@RequestParam 사용 시 주의사항
요즘은 스프링 컨트롤러를 만들 때 @GetMapping, @PostMapping 으로 HTTP Method 를 명시하거나, @RequestMapping을 쓰더라도 HTTP Method 를 명시적으로 추가하는게 일반적이다.
그런데 예전 레거시 스프링 컨트롤러를 재개발 하는 상황에서 @RequestMapping 에 HTTP Method 가 명시적으로 적혀있지 않거나, 있다고 하더라도 GET, POST 둘다 받도록 하는 경우가 있어 똑같이 맞춰서 개발해야 했다.
GET + @RequestParam 인 경우는 query parameters(query string) 만 고려해서 처리하면 되서 간단한데,
POST 요청까지도 같이 받는다면 @RequestParam 이 query parameters(query string) 뿐만 아니라, form data(body로 key1=value1&key2=value2 넘어오는 케이스, content-type은 application/x-www-form-urlencoded), 그리고 multipart 요청도 받을 수 있다는걸 인지하고 있어야 한다. 이는 Spring MVC 에서 해당되며 Servlet 스펙이다.
cf) Spring Webflux 는 @RequestParam 이 query parameters(query string) 만 받아준다.
문제는 이제부터 시작인데, 이전 request body 한번만 읽어올수 있는 제약 글에서 설명한것처럼 request body를 여러번 읽기 위해 만든 CacheBodyFilter 를 통해 CachedBodyHttpServletRequest 로 감싸서 body 정보를 캐시 하는 경우
@Component
public class CacheBodyFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(new CachedBodyHttpServletRequest(request), response);
}
}
컨트롤러에서 POST + @RequestParam 일 때 form data 를 읽어오지 못한다. body 캐시를 위해 만들어진 CachedBodyHttpServletRequest 를 이용하면서 기존 로직을 안타게 되므로 충분히 그럴 수 있겠다는 생각은 들지만 정확히 어느 구간에서 발생하는 이슈인지 확인해보자.
@RequestParam 값을 가져오는데 getParameterValues 메서드를 이용한다.
public class RequestParamMethodArgumentResolver extends AbstractNamedValueMethodArgumentResolver
implements UriComponentsContributor {
...
@Override
@Nullable
protected Object resolveName(String name, MethodParameter parameter, NativeWebRequest request) throws Exception {
...
String[] paramValues = request.getParameterValues(name);
...
}
}
일반적으로 RequestParamMethodArgumentResolver 에서 request.getParameterValues(name) 을 호출하면 org.apache.catalina.connector.Request 구현체로 간다.
@Override
public String[] getParameterValues(String name) {
if (!parametersParsed) {
parseParameters();
}
return coyoteRequest.getParameters().getParameterValues(name);
}
처음이면 parametersParsed 가 false 니까 parseParameters 메서드 실행된다.
protected void parseParameters() {
...
parameters.processParameters(formData, 0, len);
...
}
여기서 formData를 갖고 org.apache.tomcat.util.http.Parameters 클래스 processParameters 메서드를 호출하는데 거기서 내부적으로 addParameter 메서드를 호출하고 paramHashValues 에 add한다.
public void addParameter( String key, String value ) throws IllegalStateException {
...
paramHashValues.put(key, values);
...
}
하지만 RequestBodyCacheFilter 를 통하는 경우, RequestParamMethodArgumentResolver 에서 request.getParameterValues(name) 을 호출하지만 내부적으로 CachedBodyHttpServletRequest 의 getParameterValues 를 찾는데 해당 메서드가 구현이 안되어 있으므로 값을 못갖고 오는것이다.
다시말해 @RequestParam 원래 스펙은 form data 정보도 읽어올 수 있지만 body 캐시로 인해 못하게 되는것이다. 자체 구현한 CachedBodyHttpServletRequest 에서 그 처리를 해주는 것도 하나의 방법이다.
아래 코드는 POST + body 일 때 추가적으로 ybs 값을 얻어오는 로직이다.
@RequestMapping("/path")
public String hello(HttpServletRequest request, @RequestParam(value = "YBS") String ybs) {
// POST + body 면 추가적으로 얻는 작업 수행해야됌
if ("POST".equalsIgnoreCase(request.getMethod()) && isRequestUsingBody(request)) {
// try-catch 생략
byte[] requestBodyBytes = new byte[request.getContentLength()];
ServletInputStream inputStream = request.getInputStream();
inputStream.read(requestBodyBytes);
// convert 로직도 특별한건 없어서 생략했다(key=value 쌍을 파싱해서 Map에 저장).
Map<String, Stringp[]> paramMap = convert(new String(requestBodyBytes, StandardCharsets.UTF_8));
ybs = paramMap.get("YBS");
}
}
private boolean isRequestUsingBody(HttpServletRequest request) {
return request.getContentLength() > 0 && isBlank(request.getQueryString());
}
cf) getParameterMap 도 내부적으로 getParameterValues 를 이용하므로, POST + @RequestParam 상황에서 form data를 못갖고 오는건 똑같다.