평화롭게 개발하던 어느날...
슬랙에 알람과 함께 다음과 같은 에러 로그가 날아왔다.
해당 예외는 클라이언트 측 예외라 ERROR 로그가 발생하면 안 된다.
또한 해당 예외는 ControllerAdvice에 처리하도록 했기 때문에 로그가 발생하는 것조차 이상했다.
로컬에서 여러 시도를 해본 결과 원인은, Controller의 RequestMapping으로 매핑되지 않은 경로에 접근 시 발생했다.
그리고 RequestMapping으로 설정된 경로에 접근 했을 때 매핑되는 핸들러는 HandlerMethod
타입이었고,
RequestMapping으로 설정되지 않은 경로에 접근 했을 때 매핑되는 핸들러는 ResourceHttpRequestHandler
타입임을 알아냈다.
이를 통해 예외가 ConrtrollerAdvice에서 처리되지 않은 원인은 매핑된 Handler의 타입이 HandlerMethod가 아니라서 그렇다는 가설을 세웠다.
이 가설을 검증하기 위해 스프링에서 요청이 처리되는 핵심인 DispatcherServlet
의 코드를 살펴보았다.
DispatcherServlet
의 doDispatch()
메서드를 확인하면 다음과 같은 코드를 볼 수 있다.
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
때문인 것을 확인할 수 있다.
SimpleUrlHandlerMapping
은 AbstractUrlHandlerMapping
추상 클래스를 상속하고 있다.
그리고 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이 있다.
그렇다면 최종으로 해결 방법은 pathPatternHandlerMap
에 PathPattern
이 /**
인 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
따라서 handler
가 HandlerMethod
또는 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;
}
HandlerExceptionResolverComposite
의 HandlerExceptionResolvers
필드에는 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()
메서드에서 거짓이 반환되어 결국 거짓으로 반환된다.
따라서 AbstractHandlerExceptionResolver
의 resolveException()
메서드에서 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을 파헤치며, 더 이상 파헤칠 일이 없길 빌었지만, 다시 파헤쳐버렸다. 😂😂😂
그래도 근본적인 원인을 알았으니 속은 시원하다.