본문 바로가기

Spring

[Spring MVC] DispatcherServlet 에서 발생하는 다양한 에러 핸들링하기

최근 타 결제 서비스와 결제 연동 작업을 하다가 해당 서비스가 요구하는 스펙에 맞추어 API 응답을 보내줘야 하는 경우가 생겼습니다. 외부 결제 서비스가 명시해놓은 고려해야 하는 예외 상황이

 

  1. 올바르지 않은 HTTP 메서드
  2. 올바르지 않은 media type
  3. 올바르지 않은 url path

등이 있어서, 이러한 상황에서 발생하는 예외들을 처리해주는 CustomExceptionHandler 를 만들기로 했습니다. 다만 이미 GlobalExceptionHandler가 프로젝트 내에 존재했고, 해당 서비스에 한해서만 스펙을 맞춰서 응답하면 됐기 때문에 ControllerAdvice 애너테이션에 basePackages를 설정해주었습니다.

 

package com.yoon1fe.api.xxx.controller;

@RestControllerAdvice(basePackages = "com.yoon1fe.api.xxx")
public class CustomExceptionHandler {
  ...

  @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
  public CustomApiResponse handleHttpRequestMethodNotSupportedException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
    ...

    return CustomApiResponse.builder()
               .result(CommonResult.failureResult(CustomErrorCode.METHOD_NOT_SUPPORTED))
               .build();
  }

  @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
  public CustomApiResponse handleHttpMediaTypeNotSupportedException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
    ...

    return CustomApiResponse.builder()
               .result(CommonResult.failureResult(CustomErrorCode.MEDIA_TYPE_NOT_ACCEPTABLE))
               .build();
  }

  @ExceptionHandler(NoHandlerFoundException.class)
  public CustomApiResponse handleNoHandlerFoundException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
    ...
    
    return CustomApiResponse.builder()
               .result(CommonResult.failureResult(CustomErrorCode.NO_INTERFACE_DEF))
               .build();
  }

  @ExceptionHandler(Exception.class)
  public CustomApiResponse handleException(Exception ex, HttpServletRequest request, HttpServletResponse response) {
    ...

    return CustomApiResponse.builder()
               .result(CommonResult.failureResult(CustomErrorCode.UNKNOWN_EXCEPTION))
               .build();
  }
}

 

그런데 ExceptionHandler 들을 테스트하던 중.. CustomExceptionHandler 로 들어오지 않는 케이스가 발생했습니다. 바로 HttpRequestMethodNotSupportedException 예외였는데요, 오늘은 요 예외는 왜 basePackages 가 설정된 ExceptionHandler 를 못타는지와 해결 방법, 그리고 DispatcherServlet 에서 발생하는 예외들을 핸들링하는 방법을 알아보겠습니다.

 

처음에는 해당 이슈의 원인이 단순히 basePackages 에 설정된 특정 컨트롤러에 들어가기 전(DispatcherServlet이 호출하기 전)에 예외가 발생했기 때문이라고 생각했습니다. 실제로 해당 예외는 DispatcherServlet 클래스에서 요청을 처리할 수 있는 핸들러를 찾을 수 없을 때 발생합니다(정확히는 RequestMappingInfoHandlerMapping).

 

 

먼저 예외가 발생하면 @ExceptionHandler 애너테이션이 어떻게 동작하는지 간단히 살펴봅시다. 예외가 발생하면 DispatcherServlet의processHandlerException() 메서드가 실행이 되고, 발생한 예외를 처리할 수 있는 적절한 HandlerExceptionResolver를 찾아서 예외를 처리합니다. 여러 개의 기본으로 등록된 HandlerExceptionResolver 중 ExceptionHandlerExceptionResolver가 @ControllerAdvice 애너테이션이 붙은 클래스의 범위 등(basePackages, assignableTypes, annotations..)을 고려해서 @ExceptionHandler 애너테이션이 붙은 메서드 중 예외를 처리할 메서드를 찾아서 예외를 처리하게 됩니다.

 

DispatcherServlet.java

protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
  ModelAndView exMv = null;
  Iterator var6 = this.handlerExceptionResolvers.iterator();

  while(var6.hasNext()) {
    HandlerExceptionResolver handlerExceptionResolver = (HandlerExceptionResolver)var6.next();
    exMv = handlerExceptionResolver.resolveException(request, response, handler, ex);
    if (exMv != null) {
      break;
    }
  }

  ...
}

 

이렇게 봤을 때는 새로 정의한 CustomExceptionHandler 에서 예외를 처리하지 못하는게 이해가 안되는데요, 이 문제에 대한 해답은 바로 HandlerMethod의 존재 여부에 있었습니다. HttpRequestMethodNotSupportedException 예외는 DispatcherServlet 에서 handlerMethod를 찾아오는 시점에 발생하는데요, 이 때 적절한 handlerMethod를 찾지 못해서 예외가 발생했고, 이후 예외를 처리하는 로직에서 handlerMethod 가 null 이라 우리가 원하던 처리가 안되었던 것입니다(basePackage 로 이 예외를 처리할 ExceptionHandler를 찾을 수 없음). 참고로 HttpMediaTypeNotSupportedException 예외 또한 DispatcherServlet에서 HandlerAdpater.handle() 메서드가 호출되기 전에 발생하는 예외인데요, 이 예외는 handlerMethod 를 찾고 난 이후 발생합니다. 따라서 정상적으로 CustomExceptionHandler 를 타게 됩니다.

 

 

문제 해결은 꽤나 단순하게 처리했습니다. ExceptionHandlerExceptionResolver 를 상속받은 CustomHandlerExceptionResolver를 정의했습니다. 그리고 processHandlerException() 메서드에서 호출되는 resolveException() 메서드와, 예외를 처리하는 (@ExceptionHandler) 메서드를 찾아주는 getExceptionHandlerMethod() 메서드를 재정의했습니다.

 

public class CustomHandlerExceptionResolver extends ExceptionHandlerExceptionResolver {

  @Override
  protected ServletInvocableHandlerMethod getExceptionHandlerMethod(HandlerMethod handlerMethod, Exception exception) {
    for (Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.getExceptionHandlerAdviceCache().entrySet()) {
      ControllerAdviceBean advice = entry.getKey();
      if (isCustomRequest(advice.getBeanType().getName())) {
        ExceptionHandlerMethodResolver resolver = entry.getValue();
        Method method = resolver.resolveMethod(exception);
        if (method != null) {
          return new ServletInvocableHandlerMethod(advice.resolveBean(), method);
        }
      }
    }

    return super.getExceptionHandlerMethod(handlerMethod, exception);
  }

  @Override
  public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handlerMethod, Exception ex) {
    if (!isCustomRequest(request.getRequestURI())) {
      return null;
    }

    return super.doResolveException(request, response, handlerMethod, ex);
  }

  private boolean isCustomRequest(String target) {
    ...
  }
}

 

다만 한가지 주의할 점이 있습니다. 바로 HandlerExceptionResolver 들의 순서입니다. 만약 ExceptionHandlerExceptionResolver 가 먼저 선택된다면, CustomExceptionHandler로 들어지 않고 기존에 있던 GlobalExceptionHandler로 들어가게 되겠죵… 그래서 모든 요청이 CustomHandlerExceptionResolver 로 들어오게 되는 짜치는 단점이 있습니다. 혹쉬 비슷한 상황에서 더 좋은 방법으로 해결하신 경험이 있으시면 댓글달아주심 감사하겠습니다 ㅎ.ㅎ

 

 

 

이제 모든 요구사항을 충족..했다 싶었는데 아직 아닙니다 ㅎㅎ; NoHandlerFoundException 예외를 처리하는 메서드를 작성했는데, 이번엔 또 여기로 안들어오는 겁니다.. 심지어 이 예외는 GlobalExceptoinHandler를 타지도 않았습니다. 요건 쪼금 찾아보니..

스프링의 기본 설정은 매핑된 핸들러가 없는 요청의 경우 http status 404 응답을 반환하지, NoHandlerFoundException을 던지지 않는 것이었습니다. 요청을 받아줄 핸들러가 없는 경우, DispatcherServlet에서는 throwExceptionIfNoHandlerFound 값을 보고 예외를 던지든 응답 코드를 404로 세팅하든지 합니다. 참고로 스프링 부트 3.2.0 기준 throwExceptionIfNoHandlerFound 필드의 디폴트값은 true로 바뀐 것으로 보입니다.. ㅎㅎ; 저희 팀 프로젝트는 부트도 아니라스,,~

public class DispatcherServlet extends FrameworkServlet {
  ...
  private boolean throwExceptionIfNoHandlerFound = false;

  ...
	
	
  protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {
    if (pageNotFoundLogger.isWarnEnabled()) {
      pageNotFoundLogger.warn("No mapping found for HTTP request with URI [" + getRequestUri(request) + "] in DispatcherServlet with name '" + this.getServletName() + "'");
    }

    if (this.throwExceptionIfNoHandlerFound) {
      throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request), (new ServletServerHttpRequest(request)).getHeaders());
    } else {
      response.sendError(404);
    }
  }
}

 

현재 프로젝트에선 404 응답에 대해 /error/pageNotFound 핸들러로 가도록 되어 있었습니다.

 

web.xml

	<error-page>
		<!-- 404 Internal Server Error -->
		<error-code>404</error-code>
		<location>/error/pageNotFound</location>
	</error-page>

 

해당 서버는 API 서버입니다. /error/pageNotFound 핸들러도 결국 RestController 였으므로 NoHandlerFoundException 을 던지도록 설정을 바꿔주었습니다.

 

web.xml

<servlet>
		<servlet-name>toastpay-api</servlet-name>
		<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
		<init-param>
			<param-name>throwExceptionIfNoHandlerFound</param-name>
			<param-value>true</param-value>
		</init-param>
		<load-on-startup>1</load-on-startup>
	</servlet>

 

spring boot > application.properties

spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false  // 스프링 부트 에러페이지 사용 OFF

 

이렇게 하면 매핑된 핸들러를 못 찾았을 때 NoHandlerFoundException 예외가 던져지게 되고, CustomExceptionHandler 에서 이 예외를 잘 잡아서 처리하게 됩니다~~

 

 

 

 

 

Reference

https://stackoverflow.com/questions/56459170/spring-controlleradvice-fail-to-override-handlehttprequestmethodnotsupported