1. 개요
기존에 단건 조회 기능만 있던 상황에서 새롭게 복수 조회 기능을 추가했는데, 모니터링 툴 상에서 갑자기 500 에러가 급증하는 것을 보고 급히 확인에 들어갔다. 에러 로그와 API 구성(재현 코드)는 아래와 같았다.
에러 로그
org.springframework.web.bind.MissingServletRequestParameterException: Required request parameter '...' for method parameter type List is not present
at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValueInternal(RequestParamMethodArgumentResolver.java:218)
at org.springframework.web.method.annotation.RequestParamMethodArgumentResolver.handleMissingValue(RequestParamMethodArgumentResolver.java:193)
at org.springframework.web.method.annotation.AbstractNamedValueMethodArgumentResolver.resolveArgument(AbstractNamedValueMethodArgumentResolver.java:114)
at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122)
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:179)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:146)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:117)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:895)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:808)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1067)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:655)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:764)
...
컨트롤러 (재현코드)
// 단건 조회
@GetMapping("/getSomething/{id}")
fun getSingleData(@PathVariable("id") @NotBlank id: String): Data? {
...
}
// 복수 조회(신규 추가) <- 문제가 발생하는 API
@GetMapping("/getSomething")
fun getMultipleData(@RequestParam("ids") ids: List<String>): List<Data> {
...
}
파라미터가 요청에 포함되지 않은 것이 에러의 근본적인 원인이었다.
{host}/getSomething?ids=1,2,3,4,5 → 이런 식으로 요청이 와야 했는데
{host}/getSomething/ → 이렇게 요청이 들어왔다..
이와 별개로 모니터링 툴에 에러가 급증한 원인은 MissingServletRequestParmeterException이 500에러로 떨어졌기 때문이다. 스프링은 해당 예외에 대해서 기본적으로 400(Bad Request) 에러를 떨구지만, 현재 내가 진행중인 프로젝트 구조에선 익셉션 핸들러가 여러 개 정의되어 있어 직접 지정한 특정 예외에 대해서만 4XX 에러를 반환하는 형태를 취하고 있으며 500에러는 모두 모니터링 툴에 노출되는 상황이다.
2. 해결
1) Bean Validation으로 처리 → 실패
처음엔 @NotEmpty 애너테이션을 붙여 Bean Validation을 수행하면 ConstraintViolationException이 발생할 것이고, ConstraintViolationException은 현재 익셉션 핸들러에 등록되어 있으므로 4XX 에러가 떨어질 것이라고 생각했다.
컨트롤러
@Validated // 중요! RequestParam과 PathVariable에 Bean Validation을 적용하기 위해선 이걸 꼭 붙여줘야 한다
@RestController
@RequestMapping("/")
class ExampleController(...) {
// 단건 조회
@GetMapping("/getSomething/{id}")
fun getSingleData(@PathVariable("id") @NotBlank id: String): Data? {
...
}
// 복수 조회(신규 추가) <- 문제가 발생하는 API
@GetMapping("/getSomething")
fun getMultipleData(@RequestParam("ids") ids: List<String>): List<Data> {
...
}
}
익셉션 핸들러
@RestControllerAdvice
class CustomExceptionHandler {
@ExceptionHandler(value = [ConstraintViolationException::class])
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleConstraintViolationException(exception: ConstraintViolationException, webRequest: WebRequest): ErrorResponse {
return ErrorResponse(...)
}
// ...
}
하지만 여전히 MissingServletRequestParmeterException이 발생했다. 이유는 바로 RequestParam을 처리하는 RequestParamMethodArgumentResolver의 동작방식 때문이었다. RequestParamMethodArgumentResolver 클래스는 AbstractNamedValueMethodArgumentResolver 클래스를 상속받는데, 요청 파라미터의 value가 null인 경우에는 MissingServletRequestParmeterException(또는 MultipartException)을 발생시킨다.
*RequestParamMethodArgumentResolver의 동작방식
- 스프링 코드상의 RequestParam을 바탕으로 namedValueInfo와 nestedParamter 객체를 만들고 실제 Request Parameter의 value(resolveArgument.arg)를 구한다
- 이때 value가 null이면 request parameter의 옵션여부(required=false인지 여부)와 default값이 존재하는지 여부에 따라 분기하여 argument를 생성해준다
- 이때, value가 null인 경우는 쿼리 파라미터의 name이 완전히 생략된 경우이다
- ex) {host}/getSomething/
- RequestParam의 기본설정인 required = true인 경우, 쿼리 파라미터의 name이 존재하면 해당 타입의 기본값이 들어간다(String인 경우는 "", List인 경우는 [])
- ex) {host}/getSomething?ids=
- 단, required = false인 경우엔 null이 들어간다
- 이때, value가 null인 경우는 쿼리 파라미터의 name이 완전히 생략된 경우이다
- 만약 해당 파라미터가 필수(required=true)이면서 default값이 존재하지 않는다면 handleMissingValue()를 호출하여 MissingServletRequestParmeterException(또는 MultipartException)을 발생시킨다
AbstractNamedValueMethodArgumentResolver
public abstract class AbstractNamedValueMethodArgumentResolver implements HandlerMethodArgumentResolver {
//...
@Override
@Nullable
public final Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
// 파라미터명, 옵션여부, default값에 대한 정보를 가지고 있다.
NamedValueInfo namedValueInfo = getNamedValueInfo(parameter);
// 보다 구체적인 파라미터에 대한 정보(타입, 애너테이션 등)에 대한 정보를 가지고 있다.
MethodParameter nestedParameter = parameter.nestedIfOptional();
Object resolvedName = resolveEmbeddedValuesAndExpressions(namedValueInfo.name);
if (resolvedName == null) {
throw new IllegalArgumentException(
"Specified name must not resolve to null: [" + namedValueInfo.name + "]");
}
Object arg = resolveName(resolvedName.toString(), nestedParameter, webRequest);
if (arg == null) {
if (namedValueInfo.defaultValue != null) {
arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue);
}
else if (namedValueInfo.required && !nestedParameter.isOptional()) { // parameter가 필수적인지 체크
handleMissingValue(namedValueInfo.name, nestedParameter, webRequest); // 여기서 걸림!!
}
arg = handleNullValue(namedValueInfo.name, arg, nestedParameter.getNestedParameterType());
}
else if ("".equals(arg) && namedValueInfo.defaultValue != null) {
arg = resolveEmbeddedValuesAndExpressions(namedValueInfo.defaultValue);
}
//...
}
//...
}
RequestParamMethodArgumentResolver - 위의 AbstractNamedValueMethodArgumentResolver를 상속한다
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);
if (servletRequest != null) {
Object mpArg = MultipartResolutionDelegate.resolveMultipartArgument(name, parameter, servletRequest);
if (mpArg != MultipartResolutionDelegate.UNRESOLVABLE) {
return mpArg;
}
}
Object arg = null;
MultipartRequest multipartRequest = request.getNativeRequest(MultipartRequest.class);
if (multipartRequest != null) {
List<MultipartFile> files = multipartRequest.getFiles(name);
if (!files.isEmpty()) {
arg = (files.size() == 1 ? files.get(0) : files);
}
}
if (arg == null) {
String[] paramValues = request.getParameterValues(name);
if (paramValues != null) {
arg = (paramValues.length == 1 ? paramValues[0] : paramValues);
}
}
return arg;
}
@Override
protected void handleMissingValue(String name, MethodParameter parameter, NativeWebRequest request)
throws Exception {
handleMissingValueInternal(name, parameter, request, false);
}
// 최종적으로 Exception이 던져지는 메서드
protected void handleMissingValueInternal(
String name, MethodParameter parameter, NativeWebRequest request, boolean missingAfterConversion)
throws Exception {
HttpServletRequest servletRequest = request.getNativeRequest(HttpServletRequest.class);
if (MultipartResolutionDelegate.isMultipartArgument(parameter)) {
if (servletRequest == null || !MultipartResolutionDelegate.isMultipartRequest(servletRequest)) {
throw new MultipartException("Current request is not a multipart request");
}
else {
throw new MissingServletRequestPartException(name);
}
}
else {
throw new MissingServletRequestParameterException(name,
parameter.getNestedParameterType().getSimpleName(), missingAfterConversion); // 결국 여기서 걸린다!!
}
}
}
즉, Bean Validation은 ArgumentResolver를 통해 핸들러(컨트롤러)에 전달될 요청 객체가 생성된 후에 수행되는데 이 경우엔 ArgumentResolver 내부에서 에러가 발생하여 Bean Validation이 이뤄지지 않은 것이었다.
이 문제와 별개로 아래 글을 보고 컨트롤러에 @Validated가 없어도 된다고 생각해서 처음엔 붙이지 않았는데, 그렇게 하니 Validation이 적용되지 않았다(글 자체는 너무 좋은 글이다). 따라서 RequestParam이나 PathVariable에 Bean Validation을 적용할 땐 @Validated를 꼭 붙여주자. 참고로 ConstraintValidator를 사용할 때도 붙여줘야 한다. 사실상 특별한 이유가 없으면 꼭 붙여주도록 하자.
2) 익셉션 핸들러에 추가 → 성공
따라서 컨트롤러 대신 MissingServletRequestParmeterException를 핸들링하는 익셉션 핸들러를 추가하고 커스텀 에러로 변환하는 처리를 해줬다. 비록 해결방법은 간단했지만, 이번 에러를 해결하면서 스프링 MVC의 작동원리에 대해 다시금 공부할 수 있었다.
@RestControllerAdvice
class CustomExceptionHandler {
@ExceptionHandler(value = [MissingServletRequestParameterException::class])
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleMissingServletRequestParameterException(exception: MissingServletRequestParameterException, webRequest: WebRequest): ErrorResponse {
return ErrorResponse(...)
}
// ...
}