ControllerAdvice 예외 우선 순위 문제 해결하기

1. 문제 상황

프로젝트에서 에러 로깅을 어떻게 할 지 고민하는 상황이었습니다.

 

제 프로젝트는 RestControllerAdvice를 통해 예외 처리를 하고 있습니다.

 

@RestControllerAdvice("생략")
public class AttendeeExceptionAdvice {

    @ExceptionHandler(ActivatedSessionExistsException.class)
    public ResponseEntity<HttpStatus> activatedSessionExistsHandler() {
        return RESPONSE_CONFLICT;
    }

    @ExceptionHandler(CourseNotFoundException.class)
    public ResponseEntity<HttpStatus> courseIdNotFoundFoundHandler() {
        return RESPONSE_NOT_FOUND;
    }

   ...

}

 

  • 커스텀 예외들에 대해 Handling 해줍니다. 즉 예상되는 예외입니다.
  • 도메인마다 별도의 RestControllerAdvice를 두고 처리하고 있습니다. -> 이것도 고민이 많았는데 프로젝트가 커질 수록 한 곳에서 처리하는 것은 혼란을 일으킨다고 생각했습니다. 도메인마다 예외 처리의 책임도 분리하고 싶었습니다.

다만 문제는 예상하지 못한 Exception였습니다.

물론 그런 것들이 발생하지 않게 하는 것이 최우선이겠지만, 실수를 할 수도 있고 예상치 못한 상황이 벌어질 수 있다고 생각했습니다.

 

그래서 그런 에러들을 어떻게 처리할 지 고민하다가 해당 에러들은 빠르게 대처해야 한다는 판단 하에 Slack과 연동해 두었습니다.

 

@Aspect
@Component
@Profile(value = {"develop", "prod"})
public class SlackAspect {
    private final SlackApi slackApi;
    private final ThreadPoolTaskExecutor threadPoolTaskExecutor;
    private final Environment env;

    public SlackAspect(@Value("${slack.webhook.url}") String webhook,
                                   ThreadPoolTaskExecutor threadPoolTaskExecutor, Environment env) {
        this.slackApi = new SlackApi(webhook);
        this.threadPoolTaskExecutor = threadPoolTaskExecutor;
        this.env = env;
    }

    @Around("@annotation(com.waruru.areyouhere.common.annotation.SlackNotification) && args(request, e)")
    public void slackNotificate(ProceedingJoinPoint proceedingJoinPoint, HttpServletRequest request,
                                Exception e) throws Throwable {

        proceedingJoinPoint.proceed();

        RequestInfo requestInfo = new RequestInfo(request);

        threadPoolTaskExecutor.execute(() -> {
            sendSlackMessage(requestInfo, e);
        });
    }

...

}

 

  1. AOP를 활용하고자 했습니다.
  2. Local 에러는 전송되지 않도록 @Profile 설정을 했습니다.

 

그리고 커스텀 Exception이 아닌 Exception 전체를 처리하는 RestControllerAdvice두고 커스텀 Annotation을 추가하여 Slack에 연동할 수 있도록 하였습니다.

이렇게 한 것에는 다음과 같은 근거가 있었습니다.

  1. Spring을 공부했을 때 구체적인 것이 있다면 먼저 처리가 된다고 들었다.
  2. ControllerAdvice를 공부했을 때도 마찬가지이다.
  3. 그렇다면 CustomException이 Exception보다 구체적인 것은 당연하므로 당연히 CustomException에서 처리가 안될 때 전송될 것이다!!

그런데 마음 아프게도 일부 CustomException들이 Slack에 전송되기 시작했습니다.

원하지 않는 잡음들로 Slack 에러 로그 채널이 채워졌고  이는 원하는 방식이 아니었습니다.

 

2. 원인 및 해결 방법

원인

원인을 파악하기 위해 ControllerAdvice가 어떻게 동작하는지 살펴보고자 했습니다.

정확히는 여러 ControllerAdvice가 있을 때 어떻게 적재되고 우선 순위가 어떻게 되는지 알아보고자 했습니다.

 

// ControllerAdviceBean 클래스의 일부입니다.

public static List<ControllerAdviceBean> findAnnotatedBeans(ApplicationContext context) {
    ListableBeanFactory beanFactory = context;
    if (context instanceof ConfigurableApplicationContext cac) {
        beanFactory = cac.getBeanFactory();
    }

    List<ControllerAdviceBean> adviceBeans = new ArrayList();
    String[] var3 = BeanFactoryUtils.beanNamesForTypeIncludingAncestors((ListableBeanFactory)beanFactory, Object.class);
    int var4 = var3.length;

    for(int var5 = 0; var5 < var4; ++var5) {
        String name = var3[var5];
        if (!ScopedProxyUtils.isScopedTarget(name)) {
            ControllerAdvice controllerAdvice = (ControllerAdvice)((ListableBeanFactory)beanFactory).findAnnotationOnBean(name, ControllerAdvice.class);
            if (controllerAdvice != null) {
		            // Bean을 모두 추가합니다.
                adviceBeans.add(new ControllerAdviceBean(name, (BeanFactory)beanFactory, controllerAdvice));
            }
        }
    }
		
		// Order에 따라 정렬합니다.
    OrderComparator.sort(adviceBeans);
    return adviceBeans;
}

 

위 코드를 보면 다른 Bean들끼리는 Order를 통해 우선 순위를 따지는 것을 알 수 있습니다.

 

반면 같은 클래스 내에서 처리하는 로직은 다음과 같습니다.

// ExceptionHandlerMethodResolver.class

private Method getMappedMethod(Class<? extends Throwable> exceptionType) {
    List<Class<? extends Throwable>> matches = new ArrayList();
    Iterator var3 = this.mappedMethods.keySet().iterator();

    while(var3.hasNext()) {
        Class<? extends Throwable> mappedException = (Class)var3.next();
        if (mappedException.isAssignableFrom(exceptionType)) {
            matches.add(mappedException);
        }
    }

    if (!matches.isEmpty()) {
        if (matches.size() > 1) {
            matches.sort(new ExceptionDepthComparator(exceptionType));
        }

        return (Method)this.mappedMethods.get(matches.get(0));
    } else {
        return NO_MATCHING_EXCEPTION_HANDLER_METHOD;
    }
}

 

위 코드를 보면 현재 발생한 예외와 같거나 상위 클래스인 예외가 있는지 검사를 하고 해당 리스트를 정렬하여 가장 근접한 예외를 사용합니다.

 

즉 같은 ControllerAdvice 클래스 내에서는 구체적인 예외가 먼저 처리가 되지만 다른 클래스라면 Order 순위대로 먼저 처리가 됩니다.

 

하지만 저는 따로 우선순위를 처리한 적이 없으므로 사실상 랜덤으로 (정확히는 등록된 순서대로) 먼저 처리가 되었던 것입니다.

 

해결 방법

해결 방법은 어렵지 않았습니다.

우선 순위에 따라 @Order 어노테이션을 RestControllerAdvice에 매겨줍니다.

 

 

 

해결 과정에서 재밌던 점은, 각 Order의 Default 값이 중간값이지 않을까..? 했는데  LOWEST_PRECEDENCE 였다는 점입니다. 

public interface Ordered {
    int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
    int LOWEST_PRECEDENCE = Integer.MAX_VALUE;

    int getOrder();
}

 

위 코드에서 볼 수 있듯이 spring에서 제공하는 Ordered에는 중간값 자체가 있지도 않았습니다. 그래서 해당 Ordered를 상속해서 확장해서 사용하기로 했습니다 :)

public interface Ordered extends org.springframework.core.Ordered {
    int SECOND_PRECEDENCE = 2;

    int THIRD_PRECEDENCE = 3;
}