빌드노트
같은 이름의 헤더 값 두 개, @RequestHeader Map에는 하나만 남는다
Spring MVC의 전체 헤더 Map은 이름마다 첫 값만 받는다. 실제 컨트롤러 인자와 조회를 MultiValueMap 또는 HttpHeaders의 목록 조회로 바꾸는 위치를 짚고, 합성 X-Item 두 값과 쉼표 문자열 하나의 실행 결과를 비교한다.
Spring MVC 컨트롤러에서 @RequestHeader Map<String, String>으로 전체 헤더를 받으면, 같은 이름에 넣은 값이 하나만 보일 수 있다. 합성 헤더 X-Item에 alpha와 beta를 넣은 MockMvc 요청에서 Map에는 alpha만 들어갔다. 전체 헤더 이름을 받는 것과 각 이름의 모든 값을 받는 것은 다르다. 모든 값이 필요하면 인자를 MultiValueMap<String, String>이나 HttpHeaders로 바꾸고 목록을 조회한다.
이 글의 실행 버전은 Spring Framework 6.2.12다. 이 버전의 Map 바인딩은 헤더 이름마다 첫 값을 가져왔다. 뒤의 값이 앞의 값을 덮어쓴 결과가 아니며, Map에서 꺼낸 문자열을 쉼표로 나눠도 빠진 beta는 되살아나지 않는다.
컨트롤러의 인자와 조회를 함께 바꾼다
먼저 실제 메서드의 인자가 이름을 지정하지 않은 @RequestHeader Map<String, String> headers인지 확인한다. 여기서 headers.get("X-Item")은 문자열 하나를 반환한다. 첫 값 하나면 충분한 처리라면 Map을 유지할 수 있다.
모든 값을 순회해야 한다면 인자를 @RequestHeader MultiValueMap<String, String> headers로 바꾼다. 메서드 안에서는 기존의 문자열 조회 대신 다음 코드로 목록을 꺼내 처리한다. MultiValueMap은 org.springframework.util, List는 java.util의 타입이다. @RequestHeader는 org.springframework.web.bind.annotation에서 가져온다.
List<String> items = headers.getOrDefault("X-Item", List.of());
이 조회는 X-Item이 없으면 빈 목록을 준다. 기존 코드가 문자열 하나를 처리했다면 이후 처리도 items의 각 값을 사용하도록 바꿔야 한다. 전체 헤더를 받는 인자이므로 @RequestHeader에 "X-Item"을 지정하지 않는다.
이미 HttpHeaders를 쓰거나 그 타입을 선택한다면 인자는 @RequestHeader HttpHeaders headers로 두고 다음처럼 조회한다. HttpHeaders는 org.springframework.http의 타입이다.
List<String> items = headers.getOrEmpty("X-Item");
getOrEmpty()도 해당 헤더가 없으면 빈 목록을 준다. headers.get("X-Item")으로 목록을 받을 수도 있지만, 없을 때는 null일 수 있다. 반면 headers.getFirst("X-Item")은 첫 문자열 하나만 꺼낸다. 인자를 HttpHeaders로 바꾸어도 getFirst()만 사용하면 처리하는 값은 여전히 하나다. 이 조회 차이는 HttpHeaders 6.2.12 API (새 탭에서 열림)에서도 확인할 수 있다.
두 값과 쉼표 문자열 하나를 구별한다
타입을 바꾼 뒤에는 같은 이름의 값 두 개로 확인한다. 기존 Spring MVC 테스트에 버전이 맞는 spring-webmvc, spring-test, Servlet API가 필요하다. 아래는 확인할 컨트롤러가 /headers에 매핑되어 기존 MockMvc 인스턴스 mvc에 등록된 상황의 요청 부분이다. 테스트 메서드는 throws Exception을 선언하고, get은 MockMvcRequestBuilders.get을 정적 import한다. 이 클래스의 패키지는 org.springframework.test.web.servlet.request다.
mvc.perform(get("/headers").header("X-Item", "alpha", "beta")).andReturn();
이 요청은 X-Item 이름에 문자열 두 개를 넣는다. 별도의 standalone MockMvc에서 세 인자 타입을 비교했을 때, Map 조회는 alpha, MultiValueMap과 HttpHeaders의 목록 조회는 각각 원소 2개인 [alpha, beta]였다. HttpHeaders의 getFirst()는 alpha였다. 입력을 beta, alpha 순서로 바꾸자 Map은 beta를 반환했다. 헤더를 아예 넣지 않은 요청에서는 앞의 두 목록 조회가 모두 빈 목록을 반환했다.
이어서 입력만 .header("X-Item", "alpha,beta")로 바꾸었다. 이번에는 두 목록 모두 원소 1개이며 그 문자열이 alpha,beta였다. 이름 없는 @RequestHeader로 전체 헤더를 받는 경로에서는 문자열 안의 쉼표를 원소 경계로 바꾸지 않았다. 목록을 출력한 대괄호 안의 쉼표만 보지 말고 items.size()와 각 원소를 확인해야 한다.
위 관찰은 Windows, OpenJDK 17.0.2, Spring Framework 6.2.12, Jakarta Servlet API 6.0.0에서 얻었다. 인증·Cookie 없이 합성 X-Item만 사용했고 실제 HTTP 서버는 띄우지 않았다.
Map의 선택과 헤더 문자열의 해석은 다르다
RequestHeader API (새 탭에서 열림)는 Map, MultiValueMap, HttpHeaders로 전체 헤더를 받을 수 있다고 설명한다. 실행한 버전의 RequestHeaderMapMethodArgumentResolver 구현 (새 탭에서 열림)은 일반 Map에 webRequest.getHeader(name)의 문자열 하나를 넣고, 나머지 두 타입에는 webRequest.getHeaderValues(name)의 각 문자열을 추가한다. 관찰에서 Map에 첫 값만 들어간 이유다.
목록에 담긴 문자열을 더 해석할지는 해당 헤더의 문법으로 판단한다. RFC 9110의 필드 순서 규칙 (새 탭에서 열림)은 같은 이름의 필드를 반복할 수 있는 조건을 헤더 정의와 연결한다. 따옴표 문자열 문법 (새 탭에서 열림)을 사용하는 값에는 따옴표 안의 쉼표도 있을 수 있으므로 모든 헤더에 split(",")을 적용하지 않는다. 이 글의 X-Item은 컨테이너 동작 비교용이며 표준 헤더의 파싱 예제가 아니다.
Spring MVC 참조 문서 (새 탭에서 열림)의 @RequestHeader("Accept") List<String>처럼 이름을 지정한 인자의 쉼표 타입 변환도 이번 전체 헤더 바인딩과는 다른 경로다. 목록 타입이라는 이유로 같은 규칙을 적용한다고 가정하지 않는다.
이번 검증은 MockMvc 요청 객체가 제공한 값을 Spring MVC가 받는 지점까지다. 실제 Servlet 컨테이너나 프록시가 이미 여러 필드를 쉼표 문자열 하나로 합쳐 제공했다면 MultiValueMap도 문자열 하나를 받을 수 있다. 이 목록이 네트워크에서 보낸 원래 필드 줄을 복원하지는 않는다.
타입을 바꿔도 원소가 하나라면 같은 입력에 대해 HttpServletRequest.getHeaders("X-Item")의 개수와 값을 비교한다. Servlet 조회부터 하나라면 입력을 만드는 쪽과 컨테이너·프록시의 전달 경로를 확인한다. Servlet에서는 둘인데 처리 값이 하나라면 실제 인자 타입과 인자 해석기 설정, getFirst() 같은 후속 조회를 확인한다. 실제 HTTP 통신·프록시와 다른 Spring 버전의 동작은 이번 실행으로 검증하지 않았다.