빌드노트
Spring List 파라미터는 단일 쉼표 값과 반복 키를 다르게 받는다
Spring MVC에서 tag=a,b는 두 원소가 되지만 tag=a,b&tag=c는 첫 값의 쉼표가 남는다. 원문 배열·List·String[]·MultiValueMap의 실행 결과와 변환 경로를 비교하고, 쉼표를 데이터로 보존할 입력 계약을 고른다.
Spring MVC의 @RequestParam("tag") List<String>에 tag=a,b를 보내면 a와 b로 나뉜다. 그런데 tag=a,b&tag=c를 보내면 세 원소가 아니라 ["a,b","c"] 두 원소가 전달됐다. 요청 값이 하나면 문자열을 목록으로 변환하고, 여러 개면 문자열 배열을 목록으로 변환하기 때문이다. 쉼표가 태그 내용이라면 반복 키로 보내는 것뿐 아니라, 서버에서도 그 값의 경계를 보존하는 수신 방법을 골라야 한다.
같은 요청에서 원문 배열과 변환된 값을 비교했다
Windows의 OpenJDK 17.0.2와 Spring Framework 6.2.12에서 별도 컨트롤러를 만들고 standalone MockMvc로 실행했다. 변환 서비스는 DefaultFormattingConversionService를 사용했고 사용자 정의 Converter나 바인더는 추가하지 않았다. 실제 서버나 기존 애플리케이션을 실행한 결과는 아니다.
한 메서드에서 HttpServletRequest.getParameterValues("tag"), 이름을 지정한 @RequestParam("tag") List<String>과 String[], 이름을 지정하지 않은 @RequestParam MultiValueMap<String, String>을 함께 받았다. 아래의 raw는 Servlet이 제공하는 디코딩된 문자열 배열이다. HTTP 요청 바이트 자체라는 뜻은 아니다. 괄호 안 숫자는 원소 수이며, 큰따옴표로 원소 경계를 표시했다. 세 요청 모두 응답은 200이었다.
tag=a,b
raw(1)=["a,b"]
list(2)=["a","b"]
array(2)=["a","b"]
map(1)=["a,b"]
tag=a&tag=b
raw(2)=["a","b"]
list(2)=["a","b"]
array(2)=["a","b"]
map(2)=["a","b"]
tag=a,b&tag=c
raw(2)=["a,b","c"]
list(2)=["a,b","c"]
array(2)=["a,b","c"]
map(2)=["a,b","c"]
tag=a,b와 tag=a&tag=b는 같은 목록으로 변환됐다. 하지만 혼합 입력에서는 첫 값의 쉼표가 그대로 남았다. 목록 크기만 확인하면 이 차이를 놓친다. get(0)이 "a"인지 "a,b"인지도 확인해야 한다. String[]로 선언을 바꾸는 것 역시 이 구성에서는 보존 방법이 아니었다.
값의 개수가 변환기의 입력 타입을 바꾼다
Spring MVC의 RequestParam 문서 (새 탭에서 열림)는 배열·목록으로 같은 이름의 여러 값을 받을 수 있다고 설명한다. 다만 이것만으로 “각 값에 들어 있는 쉼표도 전부 분리한다”고 읽으면 위의 혼합 입력을 잘못 예상하게 된다.
측정한 버전의 RequestParamMethodArgumentResolver (새 탭에서 열림)는 요청 배열의 길이가 1이면 그 문자열 하나를 꺼내고, 둘 이상이면 배열을 다음 변환 단계에 넘긴다. 타입 변환 문서 (새 탭에서 열림)에서 설명하는 변환 서비스가 서로 다른 입력 타입을 받는 지점이다.
- 단일 값
"a,b"는 StringToCollectionConverter (새 탭에서 열림)의 문자열→컬렉션 경로에서 쉼표로 나뉜다. - 반복 값
["a,b","c"]는 ArrayToCollectionConverter (새 탭에서 열림)의 배열→컬렉션 경로를 따른다. 대상 원소 타입이String이므로 각 문자열이 원소가 되며, 내부 쉼표를 다시 나눠 목록에 펼치지 않는다.
배열 선언도 단일 문자열을 받을 때는 StringToArrayConverter (새 탭에서 열림)의 쉼표 분리를 거친다. 위 결과에서 List<String>과 String[]가 함께 나뉜 이유다. 참조 문서의 6.2 경로는 패치 버전이 갱신되는 문서이고, 여기의 구현 설명과 측정값은 6.2.12에 고정했다.
URL 인코딩만 바꾸어도 해결되지 않았다. MockMvc에 URI로 tag=a%2Cb를 넣자 원문 배열은 ["a,b"]였고 List와 String[]는 모두 ["a","b"]가 됐다. 인코딩된 쉼표도 디코딩 뒤에는 같은 변환 입력이었다. MockMvc 요청 문서 (새 탭에서 열림)도 URI의 쿼리 값은 디코딩되며, .param()에는 이미 디코딩된 값을 넣는다고 구분한다.
쉼표가 데이터라면 한 번의 tag를 한 값으로 받는다
태그 이름에 쉼표를 허용한다면 계약을 “tag가 한 번 등장할 때마다 태그 하나, 값 안의 쉼표는 문자 그대로”로 정할 수 있다. 이 계약에서 tag=a,b는 태그 하나이고 tag=a,b&tag=c는 태그 두 개다. List<String> 인자에만 기대면 태그가 하나인 요청에서 계약이 깨진다.
이때는 이름 없는 @RequestParam MultiValueMap<String, String>으로 받은 뒤 tag의 값 목록을 꺼낸다. 6.2.12의 Map 인자 해석 코드 (새 탭에서 열림)는 이 경로에서 Servlet 파라미터 배열의 각 값을 그대로 맵에 추가한다. @RequestParam("tag")처럼 애너테이션에 이름을 붙이는 선언과는 다르다.
다음 메서드는 @RestController 클래스 안에 넣는 진단용 예제다. MultiValueMap은 org.springframework.util, List는 java.util 타입이며, MVC 애너테이션은 org.springframework.web.bind.annotation 패키지를 사용한다. 응답은 원소 수와 값을 쉽게 보려는 문자열이며 API 응답 형식으로 제안하는 것은 아니다. tag가 없으면 빈 목록으로 처리한다.
@GetMapping(value = "/tags", produces = "text/plain")
public String tags(@RequestParam MultiValueMap<String, String> params) {
List<String> tags = params.getOrDefault("tag", List.of());
return tags.size() + ":" + String.join("|", tags);
}
기존 Spring MVC 테스트 환경에 버전을 맞춘 spring-test와 Servlet API가 준비돼 있다면 다음 코드를 테스트 메서드에서 실행할 수 있다. 위 메서드를 가진 컨트롤러 이름을 TagsController로 두었다. MockMvcBuilders는 org.springframework.test.web.servlet.setup, MockMvcRequestBuilders는 org.springframework.test.web.servlet.request, DefaultFormattingConversionService는 org.springframework.format.support, URI는 java.net에서 가져온다. 별도 실행에서는 Servlet API 6.0.0을 사용했다.
var mvc = MockMvcBuilders.standaloneSetup(new TagsController())
.setConversionService(new DefaultFormattingConversionService()).build();
for (String query : List.of("tag=a,b", "tag=a&tag=b", "tag=a,b&tag=c")) {
var response = mvc.perform(MockMvcRequestBuilders.get(URI.create("/tags?" + query)))
.andReturn().getResponse();
System.out.println(response.getStatus() + " " + response.getContentAsString());
}
실제로 실행한 출력은 다음과 같다. 첫 줄은 쉼표를 포함한 원소 하나, 마지막 줄은 "a,b"와 "c" 두 원소를 뜻한다.
200 1:a,b
200 2:a|b
200 2:a,b|c
쉼표를 목록 구분자로 쓰는 API라면 허용할 요청 형식을 먼저 정한다. 단일 쉼표 목록과 반복 키를 모두 허용하면서 혼합 입력까지 같은 방식으로 분리하려면, 원래 값 목록을 받은 뒤 각 값을 어떻게 나눌지 애플리케이션에서 명시해야 한다. 위 관찰은 Spring이 혼합 입력을 자동으로 모두 펼쳐 준다는 가정이 맞지 않음을 보여 준다.
자기 코드에서 결과가 다르면 먼저 getParameterValues("tag")의 길이와 각 원소를 비교한다. 여기부터 다르면 요청을 만드는 클라이언트나 Servlet까지의 전달 경로를 확인하고, 원문은 같은데 변환 결과가 다르면 Converter·@InitBinder 등 실제 변환 설정을 확인한다. 이번 실험은 기본 변환 서비스를 사용하는 standalone MockMvc의 결과다. Spring Boot 자동 설정, 실제 Servlet 컨테이너, 사용자 정의 변환기와 다른 Spring 버전의 결과까지 확인한 것은 아니다.