핸들러 메서드가 매핑되지 않고, 인터셉터가 적용되는 경로에 접근 시 예외 핸들링이 되지 않는 이슈

Glen·2024년 4월 2일
0

TroubleShooting

목록 보기
6/6

사건의 발단

평화롭게 개발하던 어느날...

슬랙에 알람과 함께 다음과 같은 에러 로그가 날아왔다.

해당 예외는 클라이언트 측 예외라 ERROR 로그가 발생하면 안 된다.

또한 해당 예외는 ControllerAdvice에 처리하도록 했기 때문에 로그가 발생하는 것조차 이상했다.

로컬에서 여러 시도를 해본 결과 원인은, Controller의 RequestMapping으로 매핑되지 않은 경로에 접근 시 발생했다.

그리고 RequestMapping으로 설정된 경로에 접근 했을 때 매핑되는 핸들러는 HandlerMethod 타입이었고,

RequestMapping으로 설정되지 않은 경로에 접근 했을 때 매핑되는 핸들러는 ResourceHttpRequestHandler 타입임을 알아냈다.

이를 통해 예외가 ConrtrollerAdvice에서 처리되지 않은 원인은 매핑된 Handler의 타입이 HandlerMethod가 아니라서 그렇다는 가설을 세웠다.

이 가설을 검증하기 위해 스프링에서 요청이 처리되는 핵심인 DispatcherServlet의 코드를 살펴보았다.

DispatcherServletdoDispatch() 메서드를 확인하면 다음과 같은 코드를 볼 수 있다.

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
    HttpServletRequest processedRequest = request;  
    HandlerExecutionChain mappedHandler = null;  
    boolean multipartRequestParsed = false;  
      
    WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);  
      
    try {  
        ModelAndView mv = null;  
        Exception dispatchException = null;  
      
        try {  
            processedRequest = checkMultipart(request);  
            multipartRequestParsed = (processedRequest != request);  
      
            // Determine handler for the current request.  
            mappedHandler = getHandler(processedRequest);  
            if (mappedHandler == null) {  
                noHandlerFound(processedRequest, response);  
                return;       
            }
            ...
        }
        ...
    }
    ...
}

여기서 getHandler()를 통해 요청을 처리할 수 있는 핸들러를 가져온다.

바로 위에서 말한 HandlerMethod, ResourceHttpRequestHandler가 결정되는 순간이다.

그런데 이상한 점이 있는데, 매핑된 핸들러가 null이면 noHandlerFound() 메서드를 호출하고 바로 return을 하는 분기 문이다.

애초에 처음부터 RequestMapping으로 매핑되지 않은 경로에 접근하면 매핑된 핸들러가 없기 때문에 mappedHandler 변수가 null이 되어야 한다.

따라서 noHandlerFound 메서드가 호출되고 return 되며 모든 일은 여기서 끝나야 한다.

하지만 RequestMapping으로 매핑된 경로에 매핑되지 않아도 ResourceHttpRequestHandler가 매핑되기 때문에 이 사단이 발생한 것이었다.

디버그를 사용하여 ResourceHttpRequestHandler 핸들러가 매핑되는 시점의 HandlerMapping을 확인해 보면 SimpleUrlHandlerMapping 때문인 것을 확인할 수 있다.

SimpleUrlHandlerMappingAbstractUrlHandlerMapping 추상 클래스를 상속하고 있다.

그리고 mapping.getHandler()를 호출할 때 AbstractUrlHandlerMapping 추상 클래스가 상속하고 있는 AbstractHandlerMapping 추상 클래스의 메서드를 사용한다.

public class AbstractHandlerMapping extends WebApplicationObjectSupport implements HandlerMapping, Ordered, BeanNameAware {
    ...
    @Override  
    @Nullable  
    public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {  
        Object handler = getHandlerInternal(request);
        ...
    }
    ...
}

여기서 getHandlerInternal() 메서드를 호출하는데, getHandlerInternal()AbstractUrlHandlerMapping에서 구현된 메서드를 사용한다.

public abstract class AbstractUrlHandlerMapping extends AbstractHandlerMapping implements MatchableHandlerMapping {
    ...
    @Override  
    @Nullable  
    protected Object getHandlerInternal(HttpServletRequest request) throws Exception {  
        String lookupPath = initLookupPath(request);  
        Object handler;  
        if (usesPathPatterns()) {  
           RequestPath path = ServletRequestPathUtils.getParsedRequestPath(request);  
           handler = lookupHandler(path, lookupPath, request);  
        }  
        else {  
           handler = lookupHandler(lookupPath, request);  
        }  
        if (handler == null) {  
           ...
        }  
        return handler;  
    }
    ...
}

여기서 usesPathPatterns() 메서드가 참일 때 분기하는 lookupHandler() 메서드를 호출할 때 ResourceHttpRequestHandler가 할당된다.

lookupHandler() 메서드는 다음과 같이 작성되어 있다.

@Nullable  
protected Object lookupHandler(  
       RequestPath path, String lookupPath, HttpServletRequest request) throws Exception {  
  
    Object handler = getDirectMatch(lookupPath, request);  
    if (handler != null) {  
       return handler;  
    }  
  
    // Pattern match?  
    List<PathPattern> matches = null;  
    for (PathPattern pattern : this.pathPatternHandlerMap.keySet()) {  
       if (pattern.matches(path.pathWithinApplication())) {  
          matches = (matches != null ? matches : new ArrayList<>());  
          matches.add(pattern);  
       }  
    }  
    if (matches == null) {  
       return null;  
    }  
    if (matches.size() > 1) {  
       matches.sort(PathPattern.SPECIFICITY_COMPARATOR);  
       if (logger.isTraceEnabled()) {  
          logger.trace("Matching patterns " + matches);  
       }  
    }  
    PathPattern pattern = matches.get(0);  
    handler = this.pathPatternHandlerMap.get(pattern);  
    if (handler instanceof String handlerName) {  
       handler = obtainApplicationContext().getBean(handlerName);  
    }  
    validateHandler(handler, request);  
    String pathWithinMapping = pattern.extractPathWithinPattern(path.pathWithinApplication()).value();  
    pathWithinMapping = UrlPathHelper.defaultInstance.removeSemicolonContent(pathWithinMapping);  
    return buildPathExposingHandler(handler, pattern.getPatternString(), pathWithinMapping, null);  
}

흐름을 간단하게 설명하면 pathPatternHandlerMap 변수의 key 값인 PathPattern에 매칭되면 matches에 해당 패턴을 추가하고, 매칭된 패턴이 1개를 초과하면 정렬 후, 첫 번째 패턴을 가져온 뒤 pathPatternHandlerMap의 value를 가져온다.

여기서 pathPatternHandlerMap의 value가 바로 ResourceHttpRequestHandler이다.

여기서 key 값에 /** 으로 되어있는 PathPattern이 있기 때문에 무조건 ResourceHttpRequestHandler 핸들러가 매핑된다.

프로젝트에서 Swagger를 사용했기 때문에 swagger 관련 PathPattern이 있다.

그렇다면 최종으로 해결 방법은 pathPatternHandlerMapPathPattern/**인 key를 제거하면 된다.

그러면 handler가 null이 되기 때문에 DispatcherServlet의 doDispatch() 에서 noHandlerFound 메서드를 호출하게 되고, 최종적으로 404 Not Found 응답을 보내게 되어 에러가 발생하지 않는다.

하지만 어떻게 PathPattern/**인 key를 제거할 수 있을까?

해결 방법

해결 방법은 스택 오버플로우에서 찾아냈는데, 간단하다.

그저 application 프로퍼티 파일에 다음과 같은 설정을 추가하면 된다.

spring:
    web:  
      resources:  
        add-mappings: false

설정 뒤 pathPatternHandlerMap을 확인해 보면 PathPattern/**인 key가 사라진 것을 볼 수 있다.

그리고 응답으로 에러가 발생하지 않고, 404 응답이 온다.

여기까지만 해도 충분히 원하는 결과를 얻었지만, 404 응답을 커스텀 하려면 약간의 디테일이 필요하다.

DispatcherServlet의 noHandlerFound() 메서드는 다음과 같이 구현되어 있는데, throwExceptionIfNoHandlerFound 변수가 참이면 NoHandlerFoundException 예외를 던진다.

protected void noHandlerFound(HttpServletRequest request, HttpServletResponse response) throws Exception {  
    if (pageNotFoundLogger.isWarnEnabled()) {  
       pageNotFoundLogger.warn("No mapping for " + request.getMethod() + " " + getRequestUri(request));  
    }  
    if (this.throwExceptionIfNoHandlerFound) {  
       throw new NoHandlerFoundException(request.getMethod(), getRequestUri(request),  
             new ServletServerHttpRequest(request).getHeaders());  
    }  
    else {  
       response.sendError(HttpServletResponse.SC_NOT_FOUND);  
    }  
}

NoHandlerFoundException 예외를 던지지 않는다면 해당 메서드 호출이 끝나고 return 되어 위와 같이 Whitelabel Error Page가 보여진다.

throwExceptionIfNoHandlerFound 변수를 참으로 만들려면 application 프로퍼티 파일에 다음과 같은 설정을 추가하면 된다.

spring:
    mvc:  
      throw-exception-if-no-handler-found: true

그러면 throwExceptionIfNoHandlerFound 변수가 참이되고, NoHandlerFoundException 예외를 던지게 된다.

그리고 발생한 NoHandlerFoundException 예외는 ControllerAdvice에 정의한 ExceptionHandler에 의해 처리된다.

이상한 점

여기서 이상한 점이 있는데, Handler가 null이면 ExceptionHandler에 의해 처리가 되고, Handler가 null이 아니면 ExceptionHandler에 처리가 되지 않는다는 것이다.

예외가 발생했을 때, DispatcherSevlet에서 예외는 다음과 같이 처리된다.

doDispatch()메서드를 보면 예외를 바로 던지지 않고 catch 하여, dispatchException 지역 변수에 할당한다.

try {
    processedRequest = checkMultipart(request);  
    multipartRequestParsed = (processedRequest != request);  

    // Determine handler for the current request.  
    mappedHandler = getHandler(processedRequest);  
    if (mappedHandler == null) {  
        noHandlerFound(processedRequest, response);  
        return;       
    }
    ...
} catch (Exception ex) {  
    dispatchException = ex;  
} catch (Throwable err) {  
    // As of 4.3, we're processing Errors thrown from handler methods as well,  
    // making them available for @ExceptionHandler methods and other scenarios.
    dispatchException = new ServletException("Handler dispatch failed: " + err, err);  
}  
processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
...

그리고 processDispatchResult()를 호출하여 최종으로 매핑된 핸들러와 예외를 처리한다.

private void processDispatchResult(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv, @Nullable Exception exception) throws Exception {  
    boolean errorView = false;  
    
    if (exception != null) {  
        if (exception instanceof ModelAndViewDefiningException mavDefiningException) {  
            logger.debug("ModelAndViewDefiningException encountered", exception);  
            mv = mavDefiningException.getModelAndView();  
        }  
        else {  
            Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);  
            mv = processHandlerException(request, response, handler, exception);  
            errorView = (mv != null);  
        }  
    }
    ...
}

여기서 딱 봐도 예외를 처리하게 생긴 메서드인 processHandlerException()을 파고들면 다음과 같은 코드가 보인다.

@Nullable  
protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,  
       @Nullable Object handler, Exception ex) throws Exception {  
  
    // Success and error responses may use different content types  
    request.removeAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);  
  
    // Check registered HandlerExceptionResolvers...  
    ModelAndView exMv = null;  
    if (this.handlerExceptionResolvers != null) {  
       for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {  
          exMv = resolver.resolveException(request, response, handler, ex);  
          if (exMv != null) {  
             break;  
          }  
       }  
    }  
    if (exMv != null) {
        ...
    }
    throw ex;

exMv 로컬 변수가 null이 아니면 예외를 처리하고, 그게 아니면 다시 던져서 예외를 처리하지 않는 것을 볼 수 있다.

exMv 변수는 HandlerExceptionResolver에서 처리할 수 있으면 할당이 되는데, handler의 타입에 따라 할당되는 exMv 변수는 다음과 같다.

HandlerMethod

ResourceHttpRequestHandler

null

따라서 handlerHandlerMethod 또는 null일 경우 예외가 처리되고, ResourceHttpRequestHandler의 경우 예외가 처리되지 않고 다시 던져진다.

그리고 예외가 발생했다면, 다시 콜스택을 타고 돌아가 DispatcherServlet의 doDispatch() 메서드에서 예외가 잡혀 triggerAfterCompletion() 메서드에서 처리된다.

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {  
    HttpServletRequest processedRequest = request;  
    HandlerExecutionChain mappedHandler = null;  
    boolean multipartRequestParsed = false;  
  
    WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);  
  
    try {  
       ModelAndView mv = null;  
       Exception dispatchException = null;  
  
       ...
       
       processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);  
    }  
    catch (Exception ex) {  
       triggerAfterCompletion(processedRequest, response, mappedHandler, ex);  
    }
    ...

결과적으로 triggerAfterCompletion() 메서드의 코드를 보면 결국 다시 예외가 던져져, DispatcherServlet의 doService()를 타고, 톰캣까지 내려가게 되어 500 예외가 발생하는 것이다.

private void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response,  
       @Nullable HandlerExecutionChain mappedHandler, Exception ex) throws Exception {  
  
    if (mappedHandler != null) {  
       mappedHandler.triggerAfterCompletion(request, response, ex);  
    }  
    throw ex;  
}

handler가 HandlerMethod 타입일 때는 Controller의 RequestMapping으로 매핑된 경로이니, ExceptionHandler로 처리가 되는 것이 당연하다고 생각된다.

하지만 handler가 null이면 ResourceHttpRequestHandler 처럼 ExceptionHandler로 처리가 되지 않아야 하는 게 아닌가?

따라서 handler의 타입에 따라 어떻게 HandlerExceptionResolver가 동작하는지 다시 메서드를 타고 들어가 보았다.

심연으로

위에서 설명했지만, HandlerExceptionResolver.resolveException() 메서드의 반환 값이 null이 아니면 예외가 처리된다.

여기서 등록된 HandlerExceptionResolver 타입은 총 2개이다.

첫 번째 인덱스의 DefaultErrorAttributes의 코드는 다음과 같다.

public class DefaultErrorAttributes implements ErrorAttributes, HandlerExceptionResolver, Ordered {
    ...
    
    @Override  
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {  
        storeErrorAttributes(request, ex);  
        return null;
    }  
      
    private void storeErrorAttributes(HttpServletRequest request, Exception ex) {  
        request.setAttribute(ERROR_INTERNAL_ATTRIBUTE, ex);  
    }
    
    ...
}

그저 request 객체의 setAttribute() 메서드를 호출하여, ERROR_INTERNAL_ATTRIBUTE를 key로 예외를 보관하는 것을 알 수 있다.

따라서 실질적으로 예외가 처리되는 것은 두 번째 인덱스의 HandlerExceptionResolverComposite에서 이뤄진다.

HandlerExceptionResolverComposite 클래스는 List<HandlerExceptionResolver> 필드를 가지고 있는, 말 그대로 Composite 디자인 패턴을 구현한 클래스이다.

public class HandlerExceptionResolverComposite implements HandlerExceptionResolver, Ordered {  
  
    @Nullable  
    private List<HandlerExceptionResolver> resolvers;
    ...
}

그리고 예외는 resolveException() 메서드를 통해 처리되는데, DispatcherServlet의 processHandlerException() 메서드와 똑같이 반환된 mav 변수가 null이 아니면 예외가 처리되는 것을 확인할 수 있다.

@Override  
@Nullable  
public ModelAndView resolveException(  
       HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {  
  
    if (this.resolvers != null) {  
       for (HandlerExceptionResolver handlerExceptionResolver : this.resolvers) {  
          ModelAndView mav = handlerExceptionResolver.resolveException(request, response, handler, ex);  
          if (mav != null) {  
             return mav;  
          }  
       }  
    }  
    return null;  
}

HandlerExceptionResolverCompositeHandlerExceptionResolvers 필드에는 3개의 ExceptionResolver가 등록되어 있다.

handler가 null이고, mav가 null이 되지 않는 시점의 HandlerExceptionResolver의 타입은 ExceptionHandlerExceptionResolver이다.

handler가 ResourceHttpRequestHandler이고, HandlerExceptionResolver의 타입이 ExceptionHandlerExceptionResolver 일때 mav 변수는 null이다.

생략했지만, 남은 2개의 ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver 에서도 예외 처리를 해주지 않는다. 따라서 결과로 null이 반환된다.

즉, ExceptionHandlerExceptionResolver이 handler의 타입에 따라 예외를 처리하는 핵심이라고 추측할 수 있다.

ExceptionHandlerExceptionResolver.resolveExcpetion() 메서드를 호출하면 ExceptionHandlerExceptionResolver클래스가 상속하고 있는 AbstractHandlerExceptionResolver 추상 클래스의 메서드가 호출된다.

public abstract class AbstractHandlerExceptionResolver implements HandlerExceptionResolver, Ordered {
    ...
    @Override  
    @Nullable  
    public ModelAndView resolveException(  
           HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {  
      
        if (shouldApplyTo(request, handler)) {  
           prepareResponse(ex, response);  
           ModelAndView result = doResolveException(request, response, handler, ex);  
           if (result != null) {  
              // Print debug message when warn logger is not enabled.  
              if (logger.isDebugEnabled() && (this.warnLogger == null || !this.warnLogger.isWarnEnabled())) {  
                 logger.debug(buildLogMessage(ex, request) + (result.isEmpty() ? "" : " to " + result));  
              }  
              // Explicitly configured warn logger in logException method.  
              logException(ex, request);  
           }  
           return result;  
        }  
        else {  
           return null;  
        }  
    }
    ...
}

그리고 if 분기의 shouldApplyTo() 메서드가 호출되는데, 자신의 shouldApplyTo() 메서드가 아닌, AbstractHandlerExceptionResolver을 구현하는 AbstractHandlerMethodExceptionResolver 추상 클래스의 shouldApplyTo() 메서드가 호출된다.

public abstract class AbstractHandlerMethodExceptionResolver extends AbstractHandlerExceptionResolver {
    ...
    @Override  
    protected boolean shouldApplyTo(HttpServletRequest request, @Nullable Object handler) {  
        if (handler == null) {  
           return super.shouldApplyTo(request, null);  
        }  
        else if (handler instanceof HandlerMethod handlerMethod) {  
           handler = handlerMethod.getBean();  
           return super.shouldApplyTo(request, handler);  
        }  
        else if (hasGlobalExceptionHandlers() && hasHandlerMappings()) {  
           return super.shouldApplyTo(request, handler);  
        }  
        else {  
           return false;  
        }  
    }
    ...
}

여기서 바로 handler의 타입에 따라 운명이 결정된다.

handler가 ResourceHttpRequestHandler 타입의 경우, 모든 분기문에서 거짓이 되어 최종적으로 거짓이 반환된다.

hasGlobalExceptionHandlers() 메서드에서 참이 반환되지만, hasHandlerMappings() 메서드에서 거짓이 반환되어 결국 거짓으로 반환된다.

따라서 AbstractHandlerExceptionResolverresolveException() 메서드에서 null이 반환된다.

만약 handler가 null일 경우 첫 번째 분기에서 참이 되어, super.shouldApplyTo(request, null) 메서드를 호출한다.

부모의 메서드를 호출하는 것이기 때문에 상속한 AbstractHandlerExceptionResolver의 메서드를 호출한다.

protected boolean shouldApplyTo(HttpServletRequest request, @Nullable Object handler) {  
    if (handler != null) {  
       if (this.mappedHandlers != null && this.mappedHandlers.contains(handler)) {  
          return true;  
       }  
       if (this.mappedHandlerClasses != null) {  
          for (Class<?> handlerClass : this.mappedHandlerClasses) {  
             if (handlerClass.isInstance(handler)) {  
                return true;  
             }  
          }  
       }  
    }  
    return !hasHandlerMappings();  
}

handler가 null이기 때문에 바로 마지막의 hasHandlerMappings() 메서드를 호출하게 된다.

protected boolean hasHandlerMappings() {  
    return (this.mappedHandlers != null || this.mappedHandlerClasses != null);  
}

hasHandlerMappings() 메서드는 handler의 타입이 ResourceHttpRequestHandler 일 때 호출되었는데, mappedHandlers, mappedHandlerClasses 변수 둘 중 하나라도 null이 아니면 참이 반환된다.

하지만 둘 다 null 값이라 거짓이 반환된다.

그런데 hasHandlerMappings() 메서드를 호출할 때 !를 사용하여 부정된 값이 반환되므로, 결과적으로 참이 반환되어 resolveException()에서 처리가 된다.

public abstract class AbstractHandlerExceptionResolver implements HandlerExceptionResolver, Ordered {
    ...
    @Override  
    @Nullable  
    public ModelAndView resolveException(  
           HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {  
      
        if (shouldApplyTo(request, handler)) {  
           prepareResponse(ex, response);  
           ModelAndView result = doResolveException(request, response, handler, ex);  
           if (result != null) {  
              // Print debug message when warn logger is not enabled.  
              if (logger.isDebugEnabled() && (this.warnLogger == null || !this.warnLogger.isWarnEnabled())) {  
                 logger.debug(buildLogMessage(ex, request) + (result.isEmpty() ? "" : " to " + result));  
              }  
              // Explicitly configured warn logger in logException method.  
              logException(ex, request);  
           }  
           return result;  
        }  
        else {  
           return null;  
        }  
    }
    ...
}

그리고 doResolveException() 추상 메서드를 호출하는데, 해당 메서드에는 다음과 같이 Javadoc으로 설명되어 있다.

public abstract class AbstractHandlerExceptionResolver implements HandlerExceptionResolver, Ordered {
    ...
    @Nullable  
    protected abstract ModelAndView doResolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
}
Actually resolve the given exception that got thrown during handler execution, returning a ModelAndView that represents a specific error page if appropriate.

즉, 해당 메서드가 핸들러에서 발생한 예외를 최종적으로 처리하는 역할을 한다.

다시 doResolveException() 메서드의 구현을 따라가보면 AbstractHandlerMethodExceptionResolver 클래스에서 구현하고 있는데, 또 다시 doResolveHandlerMethodException() 추상 메서드를 호출한다. 😂

@Override  
@Nullable  
protected final ModelAndView doResolveException(  
       HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {  
  
    HandlerMethod handlerMethod = (handler instanceof HandlerMethod hm ? hm : null);  
    return doResolveHandlerMethodException(request, response, handlerMethod, ex);  
}

@Nullable  
protected abstract ModelAndView doResolveHandlerMethodException(  
       HttpServletRequest request, HttpServletResponse response, @Nullable HandlerMethod handlerMethod, Exception ex);

doResolveHandlerMethodException() 메서드의 구현을 다시 따라가면, 이제 정말 ExceptionHandlerExceptionResolver 클래스가 구현하고 있는 doResolveHandlerMethodException() 메서드를 확인할 수 있다.

/**  
 * Find an {@code @ExceptionHandler} method and invoke it to handle the raised exception.  
 */
@Override  
@Nullable  
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request, HttpServletResponse response, @Nullable HandlerMethod handlerMethod, Exception exception) {
    ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);  
    if (exceptionHandlerMethod == null) {  
        return null;  
    }  
      
    if (this.argumentResolvers != null) {  
        exceptionHandlerMethod.setHandlerMethodArgumentResolvers(this.argumentResolvers);  
    }  
    if (this.returnValueHandlers != null) {  
        exceptionHandlerMethod.setHandlerMethodReturnValueHandlers(this.returnValueHandlers);  
    }
    ...
}

Javadoc을 보면 알겠지만 바로 이 메서드가 @ExceptionHandler 어노테이션을 붙인 예외 처리가 작동하는 부분이다.

즉, ControllerAdvice에 정의한 ExceptionHandler가 여기서 처리된다.

호출되는 getExceptionHandlerMethod() 메서드를 계속 타고 들어가보면, ControllerAdvice로 예외가 처리되는 비밀이 바로 여기에 있다.

@Nullable  
protected ServletInvocableHandlerMethod getExceptionHandlerMethod(  
       @Nullable HandlerMethod handlerMethod, Exception exception) {  
  
    Class<?> handlerType = null;  
  
    if (handlerMethod != null) {  
       // Local exception handler methods on the controller class itself.  
       // To be invoked through the proxy, even in case of an interface-based proxy.       handlerType = handlerMethod.getBeanType();  
       ExceptionHandlerMethodResolver resolver = this.exceptionHandlerCache.computeIfAbsent(  
             handlerType, ExceptionHandlerMethodResolver::new);  
       Method method = resolver.resolveMethod(exception);  
       if (method != null) {  
          return new ServletInvocableHandlerMethod(handlerMethod.getBean(), method, this.applicationContext);  
       }  
       // For advice applicability check below (involving base packages, assignable types  
       // and annotation presence), use target class instead of interface-based proxy.       if (Proxy.isProxyClass(handlerType)) {  
          handlerType = AopUtils.getTargetClass(handlerMethod.getBean());  
       }  
    }  
  
    for (Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry : this.exceptionHandlerAdviceCache.entrySet()) {  
       ControllerAdviceBean advice = entry.getKey();  
       if (advice.isApplicableToBeanType(handlerType)) {  
          ExceptionHandlerMethodResolver resolver = entry.getValue();  
          Method method = resolver.resolveMethod(exception);  
          if (method != null) {  
             return new ServletInvocableHandlerMethod(advice.resolveBean(), method, this.applicationContext);  
          }  
       }  
    }  
  
    return null;  
}

여기서 ExceptionHandlerMethodResolver 클래스의 resolveMethod() 메서드를 타고 들어가면 resolveMethodByThrowable() 메서드를 다시 호출한다.

@Nullable  
public Method resolveMethod(Exception exception) {  
    return resolveMethodByThrowable(exception);  
}

@Nullable  
public Method resolveMethodByThrowable(Throwable exception) {  
    Method method = resolveMethodByExceptionType(exception.getClass());  
    if (method == null) {  
       Throwable cause = exception.getCause();  
       if (cause != null) {  
          method = resolveMethodByThrowable(cause);  
       }  
    }  
    return method;  
}

그리고 resolveMethodByExceptionType() 메서드를 호출하고, exceptionLookupCache에서 꺼낸 값이 null이면 getMappedMethod() 메서드를 다시 호출한다.

@Nullable  
public Method resolveMethodByExceptionType(Class<? extends Throwable> exceptionType) {  
    Method method = this.exceptionLookupCache.get(exceptionType);  
    if (method == null) {  
       method = getMappedMethod(exceptionType);  
       this.exceptionLookupCache.put(exceptionType, method);  
    }  
    return (method != NO_MATCHING_EXCEPTION_HANDLER_METHOD ? method : null);  
}  
  
private Method getMappedMethod(Class<? extends Throwable> exceptionType) {  
    List<Class<? extends Throwable>> matches = new ArrayList<>();  
    for (Class<? extends Throwable> mappedException : this.mappedMethods.keySet()) {  
       if (mappedException.isAssignableFrom(exceptionType)) {  
          matches.add(mappedException);  
       }  
    }  
    if (!matches.isEmpty()) {  
       if (matches.size() > 1) {  
          matches.sort(new ExceptionDepthComparator(exceptionType));  
       }  
       return this.mappedMethods.get(matches.get(0));  
    }  
    else {  
       return NO_MATCHING_EXCEPTION_HANDLER_METHOD;  
    }  
}

그리고 mappedMethods 변수의 내부를 보면 ControllerAdvice에서 정의한 @ExceptionHandler가 붙은 메서드가 모두 들어있는 것을 볼 수 있다.

따라서 최종적으로 꺼내어진 mappedException 변수를 발생된 예외 클래스와 비교한 뒤, 그에 맞는 @ExceptionHandler 어노테이션으로 정의한 메서드를 호출하여 예외를 처리한다.

결론

이렇게 매핑된 handler의 타입에 따라 어떻게 예외가 처리되는지 심연을 통해 알아보았다.

결론으로 말하면, 매핑된 handler가 null 또는 HandlerMethod 일 때, 발생한 예외가 ControllerAdvice의 Exception Handler에 등록되었으면 처리된다.

하지만 매핑된 handler가 ResourceHttpRequestHandler일 때, 발생된 예외가 Exception Handler에 등록 되었어도, 예외를 처리할 수 없어 500 에러가 발생했다.

해결 방법은 application 프로퍼티 파일에 설정을 추가하는 것으로 매우 쉽게 해결할 수 있었지만, 그 조금의 설정이 어떤 영향을 끼치기에 예외가 처리되고 안 되는지 원인을 분석하는 것은 매우 쉽지 않았다. 😂

우테코 레벨 4 기간 중 스프링 MVC 구현하기 미션에서 DispatcherServlet을 파헤치며, 더 이상 파헤칠 일이 없길 빌었지만, 다시 파헤쳐버렸다. 😂😂😂

그래도 근본적인 원인을 알았으니 속은 시원하다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글