ControllerAdvice 혹은 RestControllerAdvice가 하는 일은 Controller에서 발생한 예외를 받아 처리해주는 역할을 합니다. 저희는 이것을 어노테이션을 붙여주는 것만으로 사용할 수 있습니다. 간단한 예시를 보고, 어떻게 동작하는지 살펴보겠습니다. 

 

1. ControllerAdvice 예시 

@RestControllerAdvice(basePackages = "gugus.pleco.domain")
public class ControllerAdvice {

    @ExceptionHandler(UserDuplicatedException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorDto duplicate1(UserDuplicatedException e){
        return new ErrorDto(e.getMessage(),false);
    }

    @ExceptionHandler
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorDto duplicate2(UserDuplicatedException e){
        return new ErrorDto(e.getMessage(),false);
    }
}

1-1. @RescControllerAdvice를 지정할 범위 선택

 

@RestControllerAdvice에는 적용할 범위를 정할 수 있는 여러 가지 속성이 있습니다. 생략할 경우 전역에 해당합니다. 

 

1. value, basePackaged: 해당 패키지와 하위 패키지에 적용

2. basePackageClasses: 해당 패키지 내에 클래스를 지정하여 적용

3. assignalbeTypes: 타입을 직접 지정하여 적용

4. annotations: 어노테이션이 붙은 것에 적용

 

1-2. @ExceptioHandler를 통해서 어떠한 예외를 잡는지 명시합니다. 이때 예외를 처리 하는 우선순위가 있습니다. 

 

1. 자식 클래스

2. 부모 클래스

 

자식 클래스의 예외를 처리하는 @ExceptionHandler가 있다면, 동작합니다. 

하지만 없다면 부모 클래스 예외를 처리하는 @ExceptionHandler가 동작합니다.

 

1-3. @ExceptionHandler 속성 생략

Handler로 들어오는 예외가 명시돼 있다면, @ExceptionHandler에 예외 클래스 속성을 생략할 수 있습니다.

 

2. ControllerAdvice 등록 과정

 

저희는 간편하게 어노테이션을 사용해서 ControllerAdvice를 등록할 수 있습니다. 과정을 살펴보겠습니다. 

 

2-1. @ControllerAdvice와 @RestControllerAdvice는 Bean으로 등록됩니다.

 

@ControllerAdvice는 @Component를 가지고 있기 때문입니다. 

 

 

2-2. ControllerAdviceBean에서 ControllerAdvice의 리스트를 반환하도록 합니다. 

 

컨테이너 초기화 시점에 ControllerAdviceBean에서 Bean으로 등록된 ControllerAdvivce를 찾게 됩니다.

public static List<ControllerAdviceBean> findAnnotatedBeans(ApplicationContext context) {
   ListableBeanFactory beanFactory = context;
   if (context instanceof ConfigurableApplicationContext) {
      beanFactory = ((ConfigurableApplicationContext) context).getBeanFactory();
   }
   List<ControllerAdviceBean> adviceBeans = new ArrayList<>();
   for (String name : BeanFactoryUtils.beanNamesForTypeIncludingAncestors(beanFactory, Object.class)) {
      if (!ScopedProxyUtils.isScopedTarget(name)) {
         // ControllerAdivce를 찾는 과정 
         ControllerAdvice controllerAdvice = beanFactory.findAnnotationOnBean(name, ControllerAdvice.class);
         if (controllerAdvice != null) {
            adviceBeans.add(new ControllerAdviceBean(name, beanFactory, controllerAdvice));
         }
      }
   }
   OrderComparator.sort(adviceBeans);
   return adviceBeans;
}

 

findAnnotationOnBean은 DefaultListableBeanFactory의 메서드입니다. 

ControllerAdviceBean에서 호출하는 함수는 findAnnotationsOnBean(String beanName, Class <A> annotationType)이고, 반환 값으로 내부에서 따로 호출하는 findAnnotationOnBean(String beanName, Class <A> annotationType, boolean allowFactoryBeanInit)입니다. 

@Override
@Nullable
public <A extends Annotation> A findAnnotationOnBean(String beanName, Class<A> annotationType)
      throws NoSuchBeanDefinitionException {

   return findAnnotationOnBean(beanName, annotationType, true);
}

@Override
@Nullable
public <A extends Annotation> A findAnnotationOnBean(
      String beanName, Class<A> annotationType, boolean allowFactoryBeanInit)
      throws NoSuchBeanDefinitionException {

	// 만약 ControllerAdvice 어노테이션이 붙은 Bean이라면 객체를 반환하고, 아닐 경우 null을 반환합니다.
   return findMergedAnnotationOnBean(beanName, annotationType, allowFactoryBeanInit)
         .synthesize(MergedAnnotation::isPresent).orElse(null);
}

 

2-3. ControllerAdviceBean와 @ExceptionHandler로 붙여둔 예외를 맵핑

 

List로 반환된 ControllerAdviceBean들은 @ExceptionHandler로 붙여둔 예외를 맵핑하기 위해 ExceptionHandlerMethodResolver를 통해서 선언된 예외들과 ControllerAdivce를 맵핑합니다. 

이로써 예외를 핸들할 수 있는 ControllerAdvice가 생깁니다. 

public ExceptionHandlerMethodResolver(Class<?> handlerType) {
   for (Method method : MethodIntrospector.selectMethods(handlerType, EXCEPTION_HANDLER_METHODS)) {
      for (Class<? extends Throwable> exceptionType : detectExceptionMappings(method)) {
         // 예외를 맵핑
         addExceptionMapping(exceptionType, method);
      }
   }
}
/* 예외를 맵핑해주는 메서드 파라미터 타입을 확인함으로써 맵핑합니다. 그래서 @ExceptionHandler
의 속성으로 예외를 주지 않아도 됩니다. 
*/
private List<Class<? extends Throwable>> detectExceptionMappings(Method method) {
	List<Class<? extends Throwable>> result = new ArrayList<>();
	detectAnnotationExceptionMappings(method, result);
	if (result.isEmpty()) {
		for (Class<?> paramType : method.getParameterTypes()) {
			if (Throwable.class.isAssignableFrom(paramType)) {
				result.add((Class<? extends Throwable>) paramType);
			}
		}
	}
	if (result.isEmpty()) {
		throw new IllegalStateException("No exception types mapped to " + method);
	}
	return result;
}

 

3. ControllerAdvice 동작 과정

 

3-1. 요청의 시작과 끝인 DispatcherServlet에서 예외 핸들

 

요청 시작시에 dispatchException이 null로 시작하며, 예외가 없을 시 null로 있을 시 해당 예외가 들어갑니다. 예외가 발생했다면, processDispatcherResult로 null이 아닌 예외가 들어가게 됩니다.  

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {

    /*
    중략
    */
   try {
      ModelAndView mv = null;
      Exception dispatchException = null;

    /*
    중략
    */
      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 NestedServletException("Handler dispatch failed", err);
      }
      processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
	
    /*
    중략
    */
}

 

 

예외가 null이 아니고, ModelAndViewDefinitionException이 아닌 경우 processHandlerException를 수행합니다. 예외 핸들을 위한 로직이 있습니다. 

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) {
         logger.debug("ModelAndViewDefiningException encountered", exception);
         mv = ((ModelAndViewDefiningException) exception).getModelAndView();
      }
      else {
         Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
         mv = processHandlerException(request, response, handler, exception);
         errorView = (mv != null);
      }
   }
    /*
    중략
    */
}

 

 

exMv =null로 시작해서 예외를 핸들 할 수 없다면, exMv가 null로 함수의 마지막에 다다르게 됩니다. throw ex이므로 예외를 터트리게 됩니다. 

protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
      @Nullable Object handler, Exception ex) throws Exception {

   request.removeAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);

   ModelAndView exMv = null;
   if (this.handlerExceptionResolvers != null) {
      for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
      	// 예외를 처리할 수 있는지 확인하며, 없다면 null을 반환하게 된다. 
         exMv = resolver.resolveException(request, response, handler, ex);
         if (exMv != null) {
            break;
         }
      }
   }
   if (exMv != null) {
   /*
   중략
   */
   }
    // 처리할 수 있는 예외 핸들러가 없으므로 결국에는 예외가 발생한다. 
   throw ex;
}

 

3-2. AbstractHandlerExceptionResolver에 존재하는 resolveException 실행

 

위에서 살펴본 resolveException의 경우 AbstractHandlerExceptionResolver의 메서드입니다. shouldApplyTo를 통해서 예외를 핸들 할 수 있는지, 확인하고 없다면 null을 반환합니다. 

@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) {
         if (logger.isDebugEnabled() && (this.warnLogger == null || !this.warnLogger.isWarnEnabled())) {
            logger.debug(buildLogMessage(ex, request) + (result.isEmpty() ? "" : " to " + result));
         }
         logException(ex, request);
      }
      return result;
   }
   else {
      return null;
   }
}
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();
}

 

위에서 에러를 핸들하기 위해서 실행하는 doResolveException는 AbstractHandlerMethodExceptionResolver의 메서드입니다. doResolverHandlerMethodException의 구현체는 ExceptionHandlerExceptionResolver입니다. 

@Override
@Nullable
protected final ModelAndView doResolveException(
      HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {

   HandlerMethod handlerMethod = (handler instanceof HandlerMethod ? (HandlerMethod) handler : null);
   return doResolveHandlerMethodException(request, response, handlerMethod, ex);
}

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

 

InvokeAndHandler -> InvokeForRequest -> doInvoke를 호출하는 형태로 진행됩니다.

@Override
@Nullable
protected ModelAndView doResolveHandlerMethodException(HttpServletRequest request,
      HttpServletResponse response, @Nullable HandlerMethod handlerMethod, Exception exception) {

   ServletInvocableHandlerMethod exceptionHandlerMethod = getExceptionHandlerMethod(handlerMethod, exception);
   if (exceptionHandlerMethod == null) {
      return null;
   }

    /*
    중략
    */
   try {
    /*
    중략
    */
    // 예외를 처리할 수 있는 핸들러가 있다면, ServletInvocableHandlerMethod가 있다면 invokeAndHandler호출 
	exceptionHandlerMethod.invokeAndHandle(webRequest, mavContainer, arguments);
   }
   catch (Throwable invocationEx) {
      // Any other than the original exception (or a cause) is unintended here,
      // probably an accident (e.g. failed assertion or the like).
      if (!exceptions.contains(invocationEx) && logger.isWarnEnabled()) {
         logger.warn("Failure in @ExceptionHandler " + exceptionHandlerMethod, invocationEx);
      }
      // Continue with default processing of the original exception...
      return null;
   }
    /*
    중략
    */
   }
}

 

doInvoke를 잠시 살펴보면 리플렉션 API를 이용해서 ExceptionHandler의 구현 메서드를 호출해서 에러를 처리합니다. 

@Nullable
protected Object doInvoke(Object... args) throws Exception {
   Method method = getBridgedMethod();
   try {
      if (KotlinDetector.isSuspendingFunction(method)) {
         return CoroutinesUtils.invokeSuspendingFunction(method, getBean(), args);
      }
      return method.invoke(getBean(), args);
   }
   catch (IllegalArgumentException ex) {
      assertTargetBean(method, getBean(), args);
      String text = (ex.getMessage() != null ? ex.getMessage() : "Illegal argument");
      throw new IllegalStateException(formatInvokeError(text, args), ex);
   }
   /*
   중략
   */
}

 

4. ControllerAdvice VS ResponseEntityExceptionHandler

 

이전 Validation을 처리하면서 적용했던 @RestControllerAdvice가 존재하는 ResponseEntityExceptionHandler와 ControllerAdvice 중에서 어떤 것이 선택돼 에러를 처리하는지에 대해서 궁금증이 생겨서 테스트를 진행했습니다. 

 

BeanValidation 적용 객체

@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {


    @Email(message = "email 형식으로 기입해주세요.")
    public String email;

    @Size(min = 8, max = 16, message = "password의 길이는 8~16자리로 해주세요.")
    public String password;

    @Range(min = 0, max = 100, message = "age는 0~100 사이로 입력해주세요.")
    public Long age;

}

 

ResponseEntityExceptionHandler를 상속받아 Bean Validation을 처리하는 객체

@RestControllerAdvice(basePackages = "com.example.mvc")
@RequiredArgsConstructor
public class UserValidationHandler extends ResponseEntityExceptionHandler {

    private final TypeMismatchConvertor typeMismatchConvertor;


    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
        Map<String, Object> map = new HashMap<>();
        ex.getBindingResult().getAllErrors().stream()
                .forEach(a -> {
                    String fieldName = ((FieldError)a).getField();
                    String message = a.getDefaultMessage();
                    map.put(fieldName,message);
                });
        return new ResponseEntity<>(map, HttpStatus.BAD_REQUEST);
    }

    @Override
    protected ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
        TypeMismatchConvertor.Result convert = typeMismatchConvertor.convert(ex.getMessage());
        return new ResponseEntity<>(convert,HttpStatus.BAD_REQUEST);
    }
}

 

@RestControllerAdvice가 붙은 UserControllerAdvice 객체

@RestControllerAdvice
public class UserControllerAdvice {

    @ExceptionHandler
    @ResponseStatus
    public String test(MethodArgumentNotValidException ex) {
        return "ok";
    }


    @ExceptionHandler
    @ResponseStatus
    public String test2(HttpMessageNotReadableException ex) {
        return "ok";
    }
}
Controller는 이전 예제에서 봐주시길 바랍니다. 

 

실험 예제 localhost:8080/validation/v6로 아래와 같은 요청을 보냈을 때 UserControllerAdvice가 동작한다면, "ok"를 UserValidatorHandler가 동작한다면 기존에 설정한 에러메시지가 나옵니다. 

{
    "email""hi",
    "password""bye",
    "age""z"
}

 

결과는 UserControllerAdvice가 동작됐습니다. 그 이유를 알아보자면 Bean 등록 순서와 상관이 있습니다. ComponentScan의 경우 @ComponentScan이 명시된 패키지부터 하위 패지키들을 순서대로 Bean으로 등록합니다. 이때 @SpringbootApplication이 있는 곳이 시작점이 됩니다. 

 

현재 저의 패키지 구조입니다. controller가 handler보다 위에 있으므로 먼저 등록되므로 UserControllerAdvice가 동작했습니다. 

 

 

패키지 구조를 아래와 같이 바꿨을 때의 동작을 보겠습니다. 

 

 

추측대로 UserValidationHandler가 예외를 처리했습니다. 

 

따라서 @Bean으로 등록할 위치에 따라서 결과가 달라집니다. 따라서 예외 처리 전략을 아래와 같이 정한다면, 효율적으로 다룰 수 있을 것이라고 생각합니다. 

Bean Validation의 일관적인 예외처리 -> ResponseEntityExceptionHandler

비즈니스 로직을 실행하며 발생한 예외 처리 -> ControllerAdvice