[Spring] doDispatch 예외 처리
최근에는 개인 프로젝트에 로깅, 모니터링에 집중하고 있습니다.
이번에는 예외가 발생하면 Slack으로 알림을 전송하기 위해 Filter, ControllerAdvice에 관련 로직을 작성하였습니다.
ControllerAdvice가 어떤 용도로 사용되는지, DispatcherServlet에서 등록된 ControllerAdvice를 사용한다 정도로만
알고 있었는데 이번 Slack 알림 전송을 추가하면서 좀 더 자세히 알게 되어 이번 포스팅에 정리합니다.
ControllerAdvice는 doDispatch에서 발생한 예외를 잡는다.
ControllerAdvice가 스케줄러, 비동기 작업에는 적용되지 않고 동기 HTTP 요청만 처리 된다고 알고 있었기에
당연히 Controller Method 이후 로직들만 catch 되는 걸로 이해하고 있었는데요.
좀 살펴보니 DispatcherServlet.doDispatch에서 발생하는 예외들은 모두 ControllerAdvice를 거칠 수 있었습니다.
Spring MVC가 요청을 처리하는 흐름은 위와 같습니다. DispatcherServlet는 HandlerMapping을 통해 요청을
처리할 수 있는 Handler를 찾고 HandlerAdapter를 통해 Handler를 실행합니다.
(흔히 RestController 사용하므로 Handler는 Controller의 Method라고 생각하면 됩니다.)
디버깅으로 따라가다보면 Handler를 찾고 실행하는 부분은 dispatcherServlet의 doDispatch 메서드임을 알 수 있습니다.
그런데 doDispatch 내부의 핵심 로직들은 전부 try catch로 감싸져있습니다.
즉 Handler를 찾고 Handler를 실행하는 과정에서 발생하는 예외는 전부 doDispatch 내부적으로 처리 됩니다.
정확히는 예외가 발생하면 dispatcherException 변수에 담아서 processDispatchResult 메서드로 넘깁니다.
doDispatch 예외 처리 흐름 따라가기
processDispatchResult는 processHandlerException 메서드를 호출합니다.
doDispatch에서 발생하는 예외를 처리하는 핵심 로직은 processHandlerException에 존재한다는 것을 알 수 있습니다.
등록된 HandlerExceptionResolver를 순회하면서 발생한 예외를 처리할 수 있는 ExceptionHandler를 찾습니다.
확인해보면 별도로 handlerExceptionResolvers를 등록하지 않는 이상 2개의 Resolver가 등록되는 것 같습니다.
먼저 DefaultErrorAttributes의 경우에는 예외를 처리하기보단 발생한 예외 정보를 저장하는 역할을 합니다.
따라서 DefaultErrorAttributes는 항상 실행되지만 예외 정보를 보관하는 역할만 하고 null을 반환해주어 계속해서 resolver들을
순회할 수 있도록 합니다.
다음으로 HandlerExceptionResolverComposite입니다. 이름 그대로 HandlerExceptionResolver를 여러개 가지고 있습니다.
HandlerExceptionResolverComposite.resolveException를 확인해보면 보유하고 있는 resolvers들을 순회합니다.
HandlerExceptionResolverComposite는 resolver를 3개 가지고 있습니다.
먼저 ExceptionHandlerExceptionResolver의 다이어그램을 확인해보면 AbstractHandlerExceptionResolver, AbstractHandlerMethodExceptionResolver 추상 클래스를 구현하고 있습니다.
AbstractHandlerExceptionResolver의 resolveException 메서드를 살펴본다면 쉽게 분석할 수 있을 것 같습니다.
내부 코드를 살펴보니 doResolveException 메서드를 따라가보면 될 것 같습니다.
그런데 doResolveException은 추상 메서드입니다. 따라서 하위 클래스에서 어디선가 구현을 하고 있다는 의미입니다.
다음 하위 클래스인 AbstractHandlerMethodExceptionResolver를 확인해보면
doResolveHandlerMethodException에게 doResolveException을 위임하는 것을 볼 수 있었습니다.
그럼 다시 제일 하위 클래스인 ExceptionHandlerExceptionResolver로 돌아와서 doResolveHandlerMethodException을
확인해보니 getExceptionHandlerMethod를 통해 ExceptionHandler를 찾는 것을 확인할 수 있었습니다.
ControllerAdvice를 순회하면서 예외를 처리하는 로직이 보입니다. 분석을 위해 좀 많이 돌아오긴 했지만 정리해보면
등록한 ControllerAdvice는 ExceptionHandlerExceptionResolver에서 처리된다는 것입니다.
순회에 사용되는 Map을 확인해보면 제가 등록한 ControllerAdvice들이 등록되어 있는 것을 볼 수 있었습니다.
다음으로 ResponseStatusExceptionResolver입니다. 말 그대로 ResponseStatusException를 처리하는 Resolver 입니다.
@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): User {
return userRepository.findById(id)
?: throw ResponseStatusException(
HttpStatus.NOT_FOUND,
"User not found with id: $id"
)
}
@PostMapping
fun createUser(@RequestBody user: User): User {
if (user.email.isBlank()) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Email is required",
IllegalArgumentException("Invalid email") // 원인 예외 포함 가능
)
}
return userRepository.save(user)
}
ResponseStatusException라는 것은 이번에 처음 알았는데요.
status 코드, reason를 직접 지정해서 예외를 던지는 방식인데 ResponseStatusExceptionResolver에서
status 코드 및 reason을 파싱해서 HTTP 응답으로 가공해주는거라고 합니다.
(reason을 응답으로 주려면 server.error.include-message 설정을 해줘야 한다는 것 같습니다.)
@Override
@Nullable
protected ModelAndView doResolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
try {
// ErrorResponse exceptions that expose HTTP response details
if (ex instanceof ErrorResponse errorResponse) {
ModelAndView mav = null;
if (ex instanceof HttpRequestMethodNotSupportedException theEx) {
mav = handleHttpRequestMethodNotSupported(theEx, request, response, handler);
}
else if (ex instanceof HttpMediaTypeNotSupportedException theEx) {
mav = handleHttpMediaTypeNotSupported(theEx, request, response, handler);
}
else if (ex instanceof HttpMediaTypeNotAcceptableException theEx) {
mav = handleHttpMediaTypeNotAcceptable(theEx, request, response, handler);
}
else if (ex instanceof MissingPathVariableException theEx) {
mav = handleMissingPathVariable(theEx, request, response, handler);
}
else if (ex instanceof MissingServletRequestParameterException theEx) {
mav = handleMissingServletRequestParameter(theEx, request, response, handler);
}
else if (ex instanceof MissingServletRequestPartException theEx) {
mav = handleMissingServletRequestPartException(theEx, request, response, handler);
}
else if (ex instanceof ServletRequestBindingException theEx) {
mav = handleServletRequestBindingException(theEx, request, response, handler);
}
else if (ex instanceof MethodArgumentNotValidException theEx) {
mav = handleMethodArgumentNotValidException(theEx, request, response, handler);
}
else if (ex instanceof HandlerMethodValidationException theEx) {
mav = handleHandlerMethodValidationException(theEx, request, response, handler);
}
else if (ex instanceof NoHandlerFoundException theEx) {
mav = handleNoHandlerFoundException(theEx, request, response, handler);
}
else if (ex instanceof NoResourceFoundException theEx) {
mav = handleNoResourceFoundException(theEx, request, response, handler);
}
else if (ex instanceof AsyncRequestTimeoutException theEx) {
mav = handleAsyncRequestTimeoutException(theEx, request, response, handler);
}
return (mav != null ? mav :
handleErrorResponse(errorResponse, request, response, handler));
}
// Other, lower level exceptions
if (ex instanceof ConversionNotSupportedException theEx) {
return handleConversionNotSupported(theEx, request, response, handler);
}
else if (ex instanceof TypeMismatchException theEx) {
return handleTypeMismatch(theEx, request, response, handler);
}
else if (ex instanceof HttpMessageNotReadableException theEx) {
return handleHttpMessageNotReadable(theEx, request, response, handler);
}
else if (ex instanceof HttpMessageNotWritableException theEx) {
return handleHttpMessageNotWritable(theEx, request, response, handler);
}
else if (ex instanceof MethodValidationException theEx) {
return handleMethodValidationException(theEx, request, response, handler);
}
else if (ex instanceof BindException theEx) {
return handleBindException(theEx, request, response, handler);
}
else if (ex instanceof AsyncRequestNotUsableException) {
return handleAsyncRequestNotUsableException(
(AsyncRequestNotUsableException) ex, request, response, handler);
}
}
catch (Exception handlerEx) {
if (logger.isWarnEnabled()) {
logger.warn("Failure while trying to resolve exception [" + ex.getClass().getName() + "]", handlerEx);
}
}
return null;
}
마지막으로 DefaultHanderExceptionResolver의 경우에는 위 코드처럼 정의된 Exception들을 처리합니다.
ServletException들도 있으며 대부분 Spring 프레임워크에서 정의 된 Exception입니다.
정리
1. doDispatch에서 handler를 찾고 실행함.
2. handler를 찾고 실행하는 과정에서 발생하는 예외는 doDispatch가 직접 처리 함
3. 발생한 예외를 처리할 수 있는 handler를 찾는 과정은 DispatherServlet의 resolver들을 순회하며 찾음
4. 일반적으로 resolver는 HandlerExceptionResolverComposite를 사용하게 됨
5. HandlerExceptionResolverComposite는 자체적으로 여러 resolver를 포함하고 있는 Composite 패턴으로 구성되어 있음
6. HandlerExceptionResolverComposite는 3개의 resolver를 가지고 있음
7. ExceptionHandlerExceptionResolver는 등록된 ControllerAdvice(ExceptionHandler)를 순회하며 예외 처리를 시도함
8. ResponseStatusExceptionResolver는 ResponseStatusException 예외를 처리함
9. DefaultHanderExceptionResolver는 일반적으로 Spring 프레임워크에서 정의된 예외를 처리함
일단 지금까지 살펴본 내용을 위와 같이 짧게 정리해보았습니다.
ControllerAdvice - Exception
@ControllerAdvice
class CoreExceptionHandler {
@ExceptionHandler(CoreException::class)
protected fun handleCustomException(e: CoreException): ResponseEntity<ErrorResponse> {
return ErrorResponse.toResponse(e)
}
}
프로젝트에서는 비즈니스 로직의 예외 상황이 발생한 경우 CoreException을 발생시킵니다.
ex) 이미 구매한 상품을 또 구매하려고 하거나, 올바르지 않은 쿠폰을 사용하려고 하는 경우 등
CoreException은 응답으로 status, message를 반환하면 되기에 ControllerAdvice를 통해 CoreException을 처리합니다.
프로젝트에서 CoreException 말고는 비즈니스 로직에서 의도적으로 예외를 던지지 않고 있습니다.
따라서 CoreExceptionHandler에서 처리되지 않는 예외들은 Exception 혹은 RuntimeException입니다.
마침 최근에 모니터링 기능들을 도입하면서 Exception, RuntimeException 예외들을 Slack Webhook을 통해 기록하기로 하였습니다.
@ControllerAdvice
class NonCoreExceptionHandler(
private val slackNotifier: ExceptionSlackNotifier
) {
@ExceptionHandler(Exception::class)
protected fun handler(e: Exception, request: HttpServletRequest): ResponseEntity<ErrorResponse> {
val transactionId = ApiTransactionContextHolder.get().transactionId
slackNotifier.sendApiException(e, transactionId, request.requestURI, request.method)
ApiTransactionContextHolder.markException()
return ErrorResponse.toResponse(CoreException(Error.INTERNAL_SERVER_ERROR))
}
}
그래서 초기에는 NonCoreExceptionHandler를 작성해서 모든 예외는 Slack으로 기록하게 하고 500에러를 반환하도록 하였습니다.
그러나 모든 Exception을 위 코드처럼 처리하게 되면 의도하지 않은 방식대로 동작하게 됩니다. (ControllerAdvice를 파보게 된 이유)
저의 경우에는 존재하지 않는 페이지에 접근하면 404가 아닌 위 핸들러로 인해 500에러로 응답이 되는 문제가 발생했습니다.
그 이유를 알아보았습니다.
먼저 doDispatch로 돌아갑니다. 요청을 처리할 수 있는 handler를 찾지 못했을 경우 마지막으로 SimpleUrlHandlerMapping을
사용하게 됩니다.
SimpleUrlHandlerMapping은 내부적으로 urlPattern을 통해 handler를 찾게 되는데 /** 패턴으로 인해 결국은 모든 요청은
static 자원 접근을 위한 ResourceHttpRequestHandler를 사용하게 됩니다.
즉 실제로 존재하지 않는 URL로 요청을 하게 되어도 결국은 ResourceHttpRequestHandler를 사용하게 됩니다.
(물론 static 자원을 특정 url prefix로 정하면 해결 됩니다.)
그리고 ResourceHttpRequestHandler가 URL에 해당하는 자원을 찾지 못하면 NoResourceFoundException를 발생시킵니다.
그런데 NoResourceFoundException는 위에서 잠깐 스쳐지나갔었습니다.
바로 exceptionResolver 중 하나였던 DefaultHandlerExceptionResolver입니다. 이 resolver는 Spring 프레임워크가 정의한
예외를 처리하는 resolver라고 표현했었습니다.
실제로 NoResoureFoundException 처리는 DefaultHandlerExceptionResolver가 하고 있습니다.
최종적으로는 DefaultHandlerExceptionResolver가 내부적으로 NoResoureFoundException를 Page Not Found로 변환합니다.
하지만 저의 경우 DefaultHandlerExceptionResolver가 사용되기 전에 이미 HandlerExceptionResolverComposite에서 NoResoureFoundException를 잡아 Slack으로 알림 전송을 하고 500 응답을 만들었습니다.
이처럼 모든 Exception을 ControllerAdvice에서 한번에 처리해버리면 Spring이 처리해야하는 exceptionResolver가 호출되지 않고
ControllerAdvice에서 전부 처리되어 의도치 않는 동작으로 진행될 수 있습니다.
HandlerExceptionResolver 직접 구현하기
이처럼 예상치 못한 예외를 알림받고 싶으나, Spring이 처리해야 하는 예외의 경우는 그냥 넘어가고 싶을 수도 있습니다.
404, 415처럼 클라이언트의 잘못된 요청으로 발생하는 오류는 스프링이 예외를 전파 하지 않고 잘 처리하기 때문입니다.
즉 Spring 조차 핸들링 하지 못한 예외, 즉 진짜 예상치 못한 예외들만 알림을 받는 것이 제일 좋을 것 같습니다.
하지만 ControllerAdvice에 ExceptionHandler를 추가하는 방식은 ExceptionHandlerExceptionResolver에 추가 됩니다.
따라서 제일 먼저 사용 가능 여부를 판단하게 됩니다. 그렇기에 Spring이 처리할 수 있는 모든 예외는 별도로 필터링 로직을 통해
피해 가야 DefaultHandlerExceptionResolver에서 처리되게 할 수 있는데 다소 복잡해질 수있다고 생각했습니다.
그렇다면 dispatcherServlet이 사용하는 HandlerExceptionResolver를 직접 생성해서 등록하고 제일 마지막으로 사용되게 한다면
어떨까 생각했습니다.
@Component
@Order(Ordered.LOWEST_PRECEDENCE)
class NonCoreExceptionResolver(private val slackNotifier: ExceptionSlackNotifier) : HandlerExceptionResolver {
private val cacheResponse =
ErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), Error.INTERNAL_SERVER_ERROR.message)
private val objectMapper = ObjectMapper().registerKotlinModule()
override fun resolveException(
request: HttpServletRequest, response: HttpServletResponse, handler: Any?, ex: java.lang.Exception
): ModelAndView {
val transactionId = ApiTransactionContextHolder.get().transactionId
slackNotifier.sendApiException(ex, transactionId, request.requestURI, request.method)
ApiTransactionContextHolder.markException()
writeResponse(response)
return ModelAndView()
}
private fun writeResponse(response: HttpServletResponse) {
response.status = HttpStatus.INTERNAL_SERVER_ERROR.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
response.writer.write(objectMapper.writeValueAsString(cacheResponse))
}
}
최종적으로 작성한 코드는 위와 같습니다.
HandlerExceptionResolver 인터페이스를 구현하도록 하고 resolveException를 작성합니다.
먼저 기존에 사용했던 Slack 알림 전송 로직을 그대로 사용합니다.
ControllerAdvice는 그냥 객체를 반환하거나 응답 객체를 리턴해주면 알아서 JSON으로 변환되어 HTTP 응답이 만들어졌지만
resolveException 내부에서 직접 응답을 만들어줘야 했습니다.
따라서 writeResponse 메서드 내부에는 status, contentType, encoding 설정을 해주고 objectMapper를 통해 JSON String을
만들어주었습니다. 또한 500 에러 응답은 전부 동일하기 때문에 미리 객체를 만들어두어 재사용하도록 했습니다.
마지막으로 해당 HandlerExceptionResolver를 Component 어노테이이션을 통해 Bean으로 등록하고 Order 어노테이션을 통해
후순위로 등록되게 합니다. 후순위로 등록되어야 제일 마지막 Resolver로 사용되기 때문입니다.
일부러 RuntimeException을 발생시키는 API에 요청을 보내 dispatcherServlet에서 잘 사용되는지 확인해보았습니다.
확인해본결과 인덱스 2번 마지막에 잘 추가되었음을 확인할 수 있었습니다.
또한 생성한 Resolver가 잘 사용되고 있음을 확인했습니다.
Slack에 알림도 제대로 오고 잘 동작하는 것 같습니다.
그리고 404 에러도 당연히 DefaultHandlerExceptionResolver에서 처리될 것이기에 문제없습니다.
정리
Slack 알림 전송이 트리거가 되어서 servletDispatcher의 예외 처리 과정을 나름 깊이 있게 살펴보게 되었고
이를 바탕으로 직접 HandlerExceptionResolver를 구현하여 원하는 것을 처리해보았습니다.
또한 디버깅을 하면서 얕게 이해하고 있던 Spring dispatcherServlet에 좀더 친숙해진 것 같기도 합니다.
얕게만 알고 사용하면 이렇게 문제가 발생할 수 있으니 꾸준히 채워나가야겠습니다.