개발 slecs

URL 파라미터 인코딩과 메시지 패턴 처리 안정성 개선

목차

URL 파라미터 인코딩은 잘 될 때는 존재감이 없다가 터지면 원인 파악이 한참 걸리는 종류의 버그다. 평소엔 영문자, 숫자만 들어오니까 조용한데, 어느 날 사용자가 한글이나 특수문자를 입력하는 순간 요청이 조용히 깨진다. 이번 작업은 거기서 출발했다. 운영 중 발견된 인코딩 누락 포인트를 잡으면서 연관된 메시지 패턴 처리도 같이 손봤다.

왜 URL 인코딩을 코드에서 직접 챙겨야 하나

Spring MVC가 @RequestParam으로 파라미터를 바인딩할 때, 서블릿 컨테이너가 이미 한 번 디코딩한 값이 컨트롤러로 들어온다. 여기까진 자연스러운 흐름이다. 문제는 그 값을 다시 URL로 조합할 때다. 리다이렉트 URL에 붙이거나, 내부 API 호출 쿼리스트링으로 넣는 시점에 인코딩 없이 문자열을 그냥 연결하면 &, =, +, 공백, 한글이 수신 측에서 엉뚱하게 파싱된다.

메시지 본문처럼 자유도 높은 텍스트를 파라미터로 넘길 때는 더 취약하다. 줄바꿈(\n), 큰따옴표, 슬래시 같은 문자가 섞이면 URL 구조 자체를 오염시킨다. 이번에 메시지 패턴 확장과 인코딩 처리를 같이 묶어서 작업한 이유가 거기 있다.

Java에서 URL 인코딩 방법은 크게 두 갈래다.

// 1. java.net.URLEncoder - application/x-www-form-urlencoded 기준
String encoded = URLEncoder.encode(value, StandardCharsets.UTF_8);
// 주의: 공백을 '+' 로 변환함

// 2. UriComponentsBuilder - Spring 방식
String url = UriComponentsBuilder.fromHttpUrl(baseUrl)
    .queryParam("msg", value)
    .encode()
    .toUriString();
// 공백 → '%20', RFC 3986 기준으로 처리

URLEncoder는 폼 전송 스펙 기준이라 공백을 +로 변환한다. 수신 측이 +를 다시 공백으로 풀어주는 구조면 괜찮지만, 그게 보장 안 되는 환경에서는 조용히 깨진다. Spring의 UriComponentsBuilder는 RFC 3986 기준으로 %20 처리하고 파라미터 키/값을 분리해서 넘길 수 있어서 실수가 덜 난다. 이중 인코딩이 생기는 케이스도 미리 막을 수 있고.

Spring MVC + MyBatis 구조에서 변경 범위

이번 수정 대상은 내부 클래스 두 개였다. 요청 처리와 비즈니스 로직이 한 파일 안에 내부 클래스로 구성된 형태라 변경 범위는 명확했고, 기존 흐름에 끼치는 영향을 먼저 추적하고 들어갔다.

레이어 변경 내용 주의 사항
컨트롤러(내부 클래스) 파라미터 바인딩 + 인코딩 적용 null/빈 값 사전 검증 필수
서비스(내부 클래스) 메시지 패턴 분기 처리 추가 기존 패턴과의 충돌 여부 확인
MyBatis XML 쿼리 파라미터 바인딩 확인 #{} 사용으로 SQL injection 차단
JSP 응답 렌더링 / 리다이렉트 URL 조합 이미 인코딩된 값 재사용 시 이중 인코딩 주의

MyBatis에서 ${} 쓰는 쿼리가 남아 있으면 인코딩 이슈와는 별개로 SQL injection 경로가 생긴다. 이번 작업 중 연관 쿼리도 같이 확인했고, #{}로 처리된 걸 검증하고 넘어갔다.

메시지 패턴 분기 처리는 조건이 늘어나면서 가독성이 슬슬 신경 쓰이기 시작하는 구간이다. 패턴이 3-4개 선에서는 if-else 체인이 눈에 들어오는데, 5개를 넘어서면 뭘 처리하는 코드인지 파악이 어려워진다. 이번엔 패턴 수가 아직 그 선 안쪽이라 기존 구조를 유지했다. 대신 각 분기마다 처리 의도를 주석으로 남겨뒀다. 나중에 패턴이 더 붙을 때 enum dispatch나 전략 패턴으로 전환할 여지를 주석에 표시해두는 게 팀 내에서 습관처럼 자리 잡은 방식이기도 하다.

예외 처리와 트랜잭션 범위 결정

예외 처리에서 실제로 고민한 건 단순했다. RuntimeException을 위로 전파할 것인가, 잡아서 응답으로 변환할 것인가.

판단 기준은 글로벌 @ExceptionHandler 유무다.

  • 글로벌 핸들러가 있으면 전파해도 된다. 거기서 일관된 에러 응답을 만들어준다.
  • 그게 없거나 일부 예외만 글로벌로 처리되는 구조면, 개별 컨트롤러에서 직접 잡아서 응답을 구성해야 한다.

이번 케이스는 후자에 가까웠다. 인코딩 실패나 패턴 매핑 실패는 사용자 입력 문제일 가능성이 있어서, 500 에러보다 적절한 메시지를 담은 응답을 내려주는 게 맞다고 봤다.

트랜잭션 범위는 서비스 레이어에서만 @Transactional을 명시했다. 컨트롤러까지 트랜잭션을 끌고 올라오면 HTTP 요청 처리 시간 동안 커넥션 풀을 잡고 있게 돼서 운영에서 문제가 된다. 서비스 단에서 짧게 묶는 게 기본 원칙이고, 이번엔 그 원칙 그대로 따랐다.

로그는 입력 파라미터와 처리 결과를 INFO로 남겼다. 운영에서 재현이 안 되는 버그는 로그 없이는 아예 손을 못 댄다. 다만 메시지 본문이 PII를 포함할 수 있어서 전체 내용을 찍지 않고, 길이나 패턴 매핑 여부 정도만 로깅했다.

배포 순서는 로컬 기동 확인 → 개발 DB 반영 → 배포로 처리했다. 규모가 크지 않은 작업이었지만 인코딩 처리처럼 경계 입력에 민감한 변경은 특수문자, 한글, 빈 문자열, null 이 네 케이스를 로컬에서 직접 돌려보고 올리는 게 루틴이 됐다. 이런 엣지 케이스를 사전에 확인하는 게 리뷰 라운드 줄이는 데 생각보다 효과가 있다.

댓글 0

첫 댓글 달아줘.