티스토리 뷰
[Spring] 여러 값을 1개의 쿼리 파라미터로 처리해야하는 경우와 Spring에서 자동으로 콤마 구분자가 처리되는 이유
망나니개발자 2022. 5. 3. 10:00API를 제공하다 보면 1개의 쿼리 파라미터로 여러 값을 줘야하는 경우가 있습니다. Spring 프레임워크에서는(엄밀히는 톰캣) 여러 개의 값을 1개의 쿼리 파라미터로 줘야할 때 콤마를 사용하면 정상적으로 처리가 가능한데, 왜 가능한지 살펴보도록 하겠습니다.
궁금해서 찾아본 굉장히 불필요한 내용이니 그냥 넘어가셔도 됩니다:)
1. 여러 값을 1개의 쿼리 파라미터로 처리해야하는 경우
[ 여러 개의 값을 1개의 쿼리 파라미터로 처리해야하는 경우 ]
API를 개발하다보면 여러 개의 값을 1개의 쿼리 파라미터로 넘겨야하는 상황이 발생할 수 있다. 예를 들어 특정 id값들을 갖는 게시물들을 모두 조회해야 하는 상황이 이러한 경우가 될 수 있다. 이러한 상황을 해결하기 위한 다양한 방법들이 존재하는데, 일반적으로 쿼리 파라미터에 콤마를 구분자로 사용하곤 한다. 예를 들어 다음과 같이 API를 설계하는 것이다.
GET https://mangkyu.tistory.com/boards?ids=1,2,3,4,5
위와 같이 스프링에서 콤마를 구분자로 사용하는 방식을 사용하면 좋은 이유는 별다른 작업 없이 다음과 같이 처리 가능하기 때문이다. 엄밀히 말하면 스프링이 해주는 것은 아니고 톰캣의 파라미터 처리 방식 때문인데, 왜 아래와 같은 코드가 가능한지 자세히 살펴보도록 하자.
@RestController
@RequiredArgsConstructor
public class SizeLimitController {
@GetMapping("/boards")
public ResponseEntity<List<Long>> boardList(@RequestParam List<Long> ids) {
return ResponseEntity.ok(ids);
}
}
2. Spring에서 자동으로 콤마 구분가 처리되는 이유
[ 콤마 구분자로 처리 가능한 이유 분석 ]
Spring의 컨트롤러에서 사용되는 어노테이션들로는 크게 다음과 같은 것들이 있다.
- @RequestParam: 쿼리 파라미터 값 바인딩
- @ModelAttribute: 쿼리 파라미터 및 폼 데이터 바인딩
- @CookieValue: 쿠키값 바인딩
- @RequestHeader: 헤더값 바인딩
- @RequestBody: 바디값 바인딩
중요한 것은 위와 같은 어노테이션들이 모두 HandlerMethodArgumentResolver 인터페이스(줄여서 ArgumentResolver, 아규먼트 리졸버)의 구현체들에 의해 처리된다는 것이다. 스프링은 매우 직관적으로 해당 구현체 클래스의 이름을 네이밍해두었는데, 해당 어노테이션 이름에 MethodArgumentResolver를 붙여주면 된다.
우리는 현재 @RequestParam에 의해 어떻게 처리되는지를 보려고 하므로 RequestParamMethodArgumentResolver 클래스를 보 된다. 해당 클래스에서도 우리는 봐야하는 부분을 바로 찾아갈 수 있는데, 그 이유는 HandlerMethodArgumentResolver 인터페이스 덕분이다. 해당 인터페이스는 다음의 2가지 메소드를 가지고 있다.
public interface HandlerMethodArgumentResolver {
boolean supportsParameter(MethodParameter parameter);
@Nullable
Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception;
}
이름에서만 봐도 알 수 있듯이 supportsParameter는 해당 ArgumentResolver의 동작 여부를 식별하는 것이고, resolveArgument는 실제로 파라미터를 처리하는 것이다. 그러므로 RequestParamMethodArgumentResolver의 resolveArgument로 넘어가도록 하자.
RequestParamMethodArgumentResolver는 부모 클래스로 AbstractNamedValueMethodArgumentResolver를 가지고 있는데, 부모 클래스에 resolveArgument 메소드가 구현되어 있다. 그러므로 부모 클래스의 코드를 봐야 하는데, 다음과 같다.
public abstract class AbstractNamedValueMethodArgumentResolver implements HandlerMethodArgumentResolver {
...
@Override
@Nullable
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
...
Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
...
return arg;
}
@Nullable
protected abstract Object resolveName(String name, MethodParameter parameter, NativeWebRequest request)
throws Exception;
}
실제로는 코드가 많이 있지만 다 생략하고, 실제 파라미터를 가져오는 곳은 resolveName 부분이다. 그런데 실제 파라미터를 가져오는 부분은 구현체에 따라 달라지도록 템플릿 메소드 패턴을 적용하고 있다. 왜냐하면 AbstractNamedValueMethodArgumentResolver 클래스는 구현체로 RequestParam외에도 PathVariable, RequestHeader 등의 ArgumentResolver 구현체를 갖기 때문에 자식에 따라 다른 파라미터 조회 로직을 호출하기 위함이다. 그러므로 resolveName 메소드는 구현체에서 보도록 하자.
public class RequestParamMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver
implements UriComponentsContributor {
...
@Override
@Nullable
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
Object arg = null;
...
if (arg == null) {
String[] paramValues = request.getParameterValues(name);
if (paramValues != null) {
arg = (paramValues.length == 1 ? paramValues[0] : paramValues);
}
}
return arg;
}
...
}
이번에도 많은 부분을 생략하고 볼 부분은 그렇게 많지 않다. 위의 코드에서 request.getParameterValues를 타고 들어가면 최종적으로 어떻게 처리되는지를 볼 수 있는데, 최종적으로 String 배열을 만드는 코드는 다음과 같다.
public String[] getParameterValues(String name) {
handleQueryParameters();
// no "facade"
ArrayList<String> values = paramHashValues.get(name);
if (values == null) {
return null;
}
return values.toArray(new String[0]);
}
여기서 name에는 클라이언트가 입력으로 준 값이 그대로 들어가게 되고, 해당 값을 배열로 바꿔서 반환을 해준다.
해당 코드를 빼내서 테스트 코드로 작성하고 돌려보면 성공적으로 통과하는 것을 볼 수 있다.
@Test
public void param() {
List<String> values = Collections.singletonList("1,2,3,4,5");
String[] strings = values.toArray(new String[0]);
assertThat(strings.length).isEqualTo(5);
}
한가지 흥미로운 점은 위의 getParameterValues 메소드를 갖는 클래스가 톰캣 패키지의 Parameters 클래스라는 것이다.
즉, 이러한 작업은 스프링이 해주는 것도 아니고 톰캣의 구현 때문이다. 물론 배열을 리스트로 바인딩해주는 것은 스프링이 해주는 것이다.
이러한 이유로 다음과 같이 콤마를 쿼리 파라미터의 구분자로 주었을 때 정상적으로 동작이 된다.
그러므로 여러 값을 1개의 쿼리 파라미터로 처리해야 한다면 스프링에서는 별도의 처리가 필요없는 콤마를 구분자로 처리하면 편리하다.
그 외에 다음과 같은 방식으로도 사용이 가능하니 참고해두도록 하자.
GET https://mangkyu.tistory.com/boards?id=1&id=2&id=3&id=4&id=5
[ 결론 정리 ]
위에서 콤마로 구분되는 값이 어떻게 쿼리 파라미터로 자동으로 들어올 수 있는지 알아보았다. 이는 그다지 중요하지 않은 내용일수도 있지만 이 글을 정리한 이유는 다음과 같은 것들 때문이다.
- Spring에서 쿼리 파라미터의 구분자로 콤마를 사용한다면 톰캣에 의해 별도의 처리가 필요 없다.
- 컨트롤러에서 사용되는 어노테이션들은 ArgumentResolver에 의해 처리가 된다.
- 특정 ArgumentResolver의 구현체를 찾으려면 어노테이션의 이름에 MethodArgumentResolver를 붙여주면 된다.
물론 이 또한 그렇게 중요하지 않을 수 있지만, 그래도 모르는 것 보다는 아는게 나을 것이라는 생각에 정리를 하게 되었다. 그렇다고 엄청 중요한 부분도 아니니 가볍게 읽고 넘어가도록 하자.
최근에 스프링부트가 동작하는 코드와 디스패처 서블릿이 동작하는 코드를 살펴보았는데, 덕분에 "왜 이러한 것들이 가능한가요"라고 질문을 받았을 때 어느 코드를 봐야하는지 바로 파악할 수 있었습니다. 디스패처 서블릿이 동작하는 과정을 뜯어본 것은 꽤나 많은 도움이 되고 있는데, 다른 분들도 기회가 되면 디스패처 서블릿 코드를 디버깅하면서 따라가보기를 추천드리겠습니다.
관련된 소스 코드 분석 내용도 포스팅해두었으니 필요하시면 여기를 참고해주세요!
감사합니다:)