결론만 궁금하신 분들은 바로 결론으로 가시면 됩니다!
프로젝트의 vue쪽 코드를 수정하고 수정된 코드가 잘 반영되었는지, 배포 서버에서 테스트를 해보다가 RestControllerAdvice가 이상하게 동작하는 것을 발견했다.
어떻게 이상하게 동작 했는지를 설명하기 위해서는, 지금 내 프로젝트가 어떻게 구성되어 있는지를 간단하게 설명을 해야 한다.
현재 내 프로젝트는 RestControllerAdvice를 2개 사용하고 있다.
ApiControllerAdvice
BindException, EntityNotFoundException, IllegalArgumentException, BusinessException (내가 직접 만든 Custom Exception) ]log를 남기고, 클라이언트한테 해당 예외에 맞는 상태 코드와 예외 메세지를 전달한다.
@RestControllerAdvice
@Slf4j
public class ApiControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
public ApiResponse<Object> bindException(final BindException e) {
final String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
log.warn("API Controller BindException : {}", errorMessage);
return ApiResponse.of(HttpStatus.BAD_REQUEST, errorMessage, null);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(EntityNotFoundException.class)
public ApiResponse<Object> entityNotFoundException(final EntityNotFoundException e) {
final String errorMessage = e.getMessage();
log.warn("API Controller EntityNotFoundException : {}", errorMessage);
return ApiResponse.of(HttpStatus.BAD_REQUEST, errorMessage, null);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ApiResponse<Object> illegalArgumentException(final IllegalArgumentException e) {
final String errorMessage = e.getMessage();
log.warn("API Controller IllegalArgumentException : {}", errorMessage);
return ApiResponse.of(HttpStatus.BAD_REQUEST, errorMessage, null);
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BusinessException.class)
public ApiResponse<Object> businessException(final BusinessException e) {
final String errorMessage = e.getMessage();
log.warn("API Controller BusinessException : {}", errorMessage);
return ApiResponse.of(HttpStatus.BAD_REQUEST, errorMessage, null);
}
}
ServerErrorDetectControllerAdvice
Exception이 발생할 경우 처리한다. (ApiControllerAdvice 에서 처리 못하는 예외들)log를 남기고, Slack을 통해 예외 내용을 전달한다.Slack이랑 연동을 시켜뒀다.@Profile 설정을 했다.
@Profile("prod")
@RequiredArgsConstructor
@RestControllerAdvice
@Slf4j
public class ServerErrorDetectControllerAdvice {
private final SlackApi slackApi;
@ExceptionHandler(Exception.class)
public void handleException(final HttpServletRequest req, final Exception e) throws Exception {
log.error("Server Error : {}", e.getMessage(), e);
slackApi.call(buildSlackMessage(buildSlackAttachment(req, e)));
throw e;
}
//... 중략
}
내가 지금까지 스프링을 공부하면서 스프링에 대해서 깨달은 점은, 스프링은 항상 구체적인 조건들을 먼저 탐색하고 처리했었다.
그래서 이 경험을 기준으로, 여러 개의
RestControllerAdvice가 있어도 당연히 구체적인 예외를 지정한RestControllerAdvice에서 예외를 먼저 처리할 것이라고 생각했다.
내가 생각한 예외 처리 로직 순서는 다음과 같았다.
특정 예외가 발생
ApiControllerAdvice를 먼저 탐색 → 해당 RestControllerAdvice에서 처리하지 못하는 경우, 다음 RestControllerAdvice에서 예외 처리를 하도록 예외를 넘긴다.
ServerErrorDetectControllerAdvice를 탐색 → 해당 RestControllerAdvice는 모든 Exception을 처리하므로 error 로그를 남기고 Slack으로 메세지를 보낸 후 예외를 throw하고 request를 종료한다.
그런데 실제로 서버가 예외를 처리하는 로직은 무조건적으로 모든 에러가 ServerErrorDetectControllerAdvice에서 처리가 되었고, 모든 예외가 Slack으로 전달이 되었다. 😵

(※ 로그를 보면 IllegalArgumentException이 발생했는데도 ServerErrorDetectControllerAdvice가 처리하고 있다. 😭)
"Local에서 테스트 하지 않고 배포한건가?"라고 생각이 드실수도 있는데,Local에서 테스트를 여러번 했었는데 매번ApiControllerAdvice가 먼저 실행이 됐었습니다. ㅜㅜ
ApiControllerAdvice가 등록이 되지 않았고 그래서 ApiControllerAdvice가 실행이 되지 않은 줄 알았다.ApiControllerAdvice Bean이 생성될 때 log를 남기도록 코드를 수정 후 다시 배포하여 테스트를 진행ApiControllerAdvice는 정상적으로 생성이 되었지만 여전히 모든 예외는 ServerErrorDetectControllerAdvice가 처리@Order를 지정하지 않으면 기본 우선순위로 0이 잡힐 것이라고 생각했다.@Order를 지정하지 않으면, 제일 낮은 우선순위로 잡히게 됩니다. (즉, 안 붙이는 거랑 @Order(Ordered.LOWEST_PRECEDENCE)를 붙이는 거랑 같다는 의미입니다.) ]ServerErrorDetectControllerAdvice 에만 @Order(Ordered.LOWEST_PRECEDENCE)를 붙여주면 문제가 해결될 것이라고 생각했다.ServerErrorDetectControllerAdvice가 모든 예외를 처리Spring 코드를 까보면서 어떤게 문제인지 제대로 파악해보기로 했다.@Order (Java Doc)value의 기본 값은 Integer.MAX_VALUE이다. (Ordered.LOWEST_PRECEDENCE랑 같은 값이다.)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {
int value() default Integer.MAX_VALUE;
}
Ordered 인터페이스 (Java Doc)public interface Ordered {
int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
int LOWEST_PRECEDENCE = Integer.MAX_VALUE;
int getOrder();
}ControllerAdvice가 어떻게 등록되고 사용 되는지 알아보자.ControllerAdvice 등록WebMvcConfigurationSupport의 handlerExceptionResolver메서드가 실행된다.
그리고 this.addDefaultHandlerExceptionResolvers(exceptionResolvers, contentNegotiationManager); 메서드가 실행 된다.
// WebMvcConfigurationSupport.class
@Bean
public HandlerExceptionResolver handlerExceptionResolver(@Qualifier("mvcContentNegotiationManager") ContentNegotiationManager contentNegotiationManager) {
List<HandlerExceptionResolver> exceptionResolvers = new ArrayList();
this.configureHandlerExceptionResolvers(exceptionResolvers);
if (exceptionResolvers.isEmpty()) {
//해당 코드 실행됨
this.addDefaultHandlerExceptionResolvers(exceptionResolvers, contentNegotiationManager);
}
this.extendHandlerExceptionResolvers(exceptionResolvers);
HandlerExceptionResolverComposite composite = new HandlerExceptionResolverComposite();
composite.setOrder(0);
composite.setExceptionResolvers(exceptionResolvers);
return composite;
}
addDefaultHandlerExceptionResolvers()메서드 안에서 exceptionHandlerResolver 가 Init 된다.
그 후 exceptionHandlerResolver.afterPropertiesSet()에서 exceptionHandlerAdviceCache에 ControllerAdvice Bean들을 추가한다.
[ ※ 나중에 예외가 발생하게 되면, exceptionHandlerAdviceCache의 요소들을 순회하면서 예외를 처리할 ControllerAdvice를 정하게 된다. ]
// WebMvcConfigurationSupport.class
protected final void addDefaultHandlerExceptionResolvers(List<HandlerExceptionResolver> exceptionResolvers, ContentNegotiationManager mvcContentNegotiationManager) {
ExceptionHandlerExceptionResolver exceptionHandlerResolver = this.createExceptionHandlerExceptionResolver();
exceptionHandlerResolver.setContentNegotiationManager(mvcContentNegotiationManager);
exceptionHandlerResolver.setMessageConverters(this.getMessageConverters());
exceptionHandlerResolver.setCustomArgumentResolvers(this.getArgumentResolvers());
exceptionHandlerResolver.setCustomReturnValueHandlers(this.getReturnValueHandlers());
if (jackson2Present) {
exceptionHandlerResolver.setResponseBodyAdvice(Collections.singletonList(new JsonViewResponseBodyAdvice()));
}
if (this.applicationContext != null) {
exceptionHandlerResolver.setApplicationContext(this.applicationContext);
}
//afterPropertiesSet() 안에서 exceptionHandlerAdviceCache에 ControllerAdvice Bean들을 추가한다.
exceptionHandlerResolver.afterPropertiesSet();
exceptionResolvers.add(exceptionHandlerResolver);
ResponseStatusExceptionResolver responseStatusResolver = new ResponseStatusExceptionResolver();
responseStatusResolver.setMessageSource(this.applicationContext);
exceptionResolvers.add(responseStatusResolver);
exceptionResolvers.add(new DefaultHandlerExceptionResolver());
}
ExceptionHandlerExceptionResolver의 afterPropertiesSet()내부 구현을 살펴 보자.
this.initExceptionHandlerAdviceCache()를 통해서 exceptionHandlerAdviceCache에 ControllerAdvice Bean들을 추가한다.
// ExceptionHandlerExceptionResolver.class
public void afterPropertiesSet() {
//실제로 exceptionHandlerAdviceCache에 ControllerAdvice Bean들을 추가하는 메서드
this.initExceptionHandlerAdviceCache();
this.initMessageConverters();
List handlers;
if (this.argumentResolvers == null) {
handlers = this.getDefaultArgumentResolvers();
this.argumentResolvers = (new HandlerMethodArgumentResolverComposite()).addResolvers(handlers);
}
if (this.returnValueHandlers == null) {
handlers = this.getDefaultReturnValueHandlers();
this.returnValueHandlers = (new HandlerMethodReturnValueHandlerComposite()).addHandlers(handlers);
}
}
ControllerAdviceBean.findAnnotatedBeans(this.getApplicationContext()) 메서드를 통해서 ControllerAdvice에 해당 하는 Bean을 모두 찾아 온다.
// ExceptionHandlerExceptionResolver.class
// ★ Map<ControllerAdviceBean, ExceptionHandlerMethodResolver> exceptionHandlerAdviceCache = new LinkedHashMap();
private void initExceptionHandlerAdviceCache() {
if (this.getApplicationContext() != null) {
// ControllerAdvice에 해당 하는 Bean을 모두 찾는다.
List<ControllerAdviceBean> adviceBeans = ControllerAdviceBean.findAnnotatedBeans(this.getApplicationContext());
Iterator var2 = adviceBeans.iterator();
// ... 중략
}
}
}
ControllerAdviceBean의 findAnnotatedBeans 메서드는 ApplicationContext에서 ControllerAdvice Bean을 모두 찾은 후 Order에 따라 정렬을 진행한다. (OrderComparator.sort(adviceBeans))
// ControllerAdviceBean.class
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에 추가한다.
adviceBeans.add(new ControllerAdviceBean(name, (BeanFactory)beanFactory, controllerAdvice));
}
}
}
// Order에 따라 정렬한다.
OrderComparator.sort(adviceBeans);
return adviceBeans;
}
PriorityOrdered가 달려있는 Bean이 없기 때문에 getOrder 메서드를 통해 값을 받아와서 순서 비교를 하게 된다.
어떤 Bean에도 @Order를 지정해주지 않았다면 i1과 i2는 같은 값을 가지게 되고, i1이 무조건 우선순위를 가지게 된다. → 어떤 Bean이 우선 순위를 가질지 알 수 없다.
// OrderComparator.class
public int compare(@Nullable Object o1, @Nullable Object o2) {
return this.doCompare(o1, o2, (OrderSourceProvider)null);
}
private int doCompare(@Nullable Object o1, @Nullable Object o2, @Nullable OrderSourceProvider sourceProvider) {
boolean p1 = o1 instanceof PriorityOrdered;
boolean p2 = o2 instanceof PriorityOrdered;
if (p1 && !p2) {
return -1;
} else if (p2 && !p1) {
return 1;
} else {
//
int i1 = this.getOrder(o1, sourceProvider);
int i2 = this.getOrder(o2, sourceProvider);
return Integer.compare(i1, i2);
}
}
Spring Java Doc
OrderComparator항목을 보면, Non-ordered Objects 에 대해서 설명이 나와있다. (OrderComparator Java Doc)Any object that does not provide its own order value is implicitly assigned a value of
[Ordered.LOWEST_PRECEDENCE](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/core/Ordered.html#LOWEST_PRECEDENCE), thus ending up at the end of a sorted collection in arbitrary order with respect to other objects with the same order value.⇒ 즉, order를 지정하지 않으면 지정하지 않은 Bean들은 모두 동일한
LOWEST 값을 가지게 되고, 결국에는 같은order 값을 가지는 Bean 끼리는 임의의 순서를 가지게 된다고 나와있다.
찾아온 ControllerAdvice List를 순회하면서 exceptionHandlerAdviceCache에 추가한다.
[ ※ exceptionHandlerAdviceCache 는 LinkedHashMap 이라서 추가하는 순서가 중요하다. ]
private void initExceptionHandlerAdviceCache() {
if (this.getApplicationContext() != null) {
List<ControllerAdviceBean> adviceBeans = ControllerAdviceBean.findAnnotatedBeans(this.getApplicationContext());
Iterator var2 = adviceBeans.iterator();
// 받아온 adviceBeans을 순회
while(var2.hasNext()) {
ControllerAdviceBean adviceBean = (ControllerAdviceBean)var2.next();
Class<?> beanType = adviceBean.getBeanType();
if (beanType == null) {
throw new IllegalStateException("Unresolvable type for ControllerAdviceBean: " + adviceBean);
}
ExceptionHandlerMethodResolver resolver = new ExceptionHandlerMethodResolver(beanType);
if (resolver.hasExceptionMappings()) {
//exceptionHandlerAdviceCache에 추가한다.
this.exceptionHandlerAdviceCache.put(adviceBean, resolver);
}
if (ResponseBodyAdvice.class.isAssignableFrom(beanType)) {
this.responseBodyAdvice.add(adviceBean);
}
}
}
}
ControllerAdvice 사용예외가 발생하면, ExceptionHandlerExceptionResolver의 getExceptionHandlerMethod에서 해당하는 method를 찾는다.
exceptionHandlerCache는 등록한게 없기 때문에 넘어가고, 아까 위에서 열심히 등록한 exceptionHandlerAdviceCache에서 method를 찾는다.
그리고 여기서 exceptionHandlerAdviceCache에 등록된 순서가 중요하다.
(왜냐하면, 먼저 찾은 ControllerAdvice에서 해당 예외를 처리할 수 있으면, 더 이상 순회하지 않고 바로 처리하고 종료되기 때문이다.)
// ExceptionHandlerExceptionResolver.class
@Nullable
protected ServletInvocableHandlerMethod getExceptionHandlerMethod(@Nullable HandlerMethod handlerMethod, Exception exception) {
Class<?> handlerType = null;
if (handlerMethod != null) {
handlerType = handlerMethod.getBeanType();
ExceptionHandlerMethodResolver resolver = (ExceptionHandlerMethodResolver)this.exceptionHandlerCache.computeIfAbsent(handlerType, ExceptionHandlerMethodResolver::new);
Method method = resolver.resolveMethod(exception);
//등록된게 없기 때문에 method는 null이 나온다.
if (method != null) {
return new ServletInvocableHandlerMethod(handlerMethod.getBean(), method, this.applicationContext);
}
if (Proxy.isProxyClass(handlerType)) {
handlerType = AopUtils.getTargetClass(handlerMethod.getBean());
}
}
//여기서 우리가 등록한 ControllerAdvice를 순회하면서 적절한 예외 처리 메서드를 찾는다.
Iterator var9 = this.exceptionHandlerAdviceCache.entrySet().iterator();
while(var9.hasNext()) {
Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry = (Map.Entry)var9.next();
ControllerAdviceBean advice = (ControllerAdviceBean)entry.getKey();
if (advice.isApplicableToBeanType(handlerType)) {
ExceptionHandlerMethodResolver resolver = (ExceptionHandlerMethodResolver)entry.getValue();
Method method = resolver.resolveMethod(exception);
if (method != null) {
return new ServletInvocableHandlerMethod(advice.resolveBean(), method, this.applicationContext);
}
}
}
return null;
}
문제 진단 2를 통해 왜 ApiControllerAdvice를 탐색하지 않고 먼저 ServerErrorDetectControllerAdvice를 탐색하는지 알게 되었다.
(Order를 따로 지정해주지 않으면, Bean의 등록 순서에 따라 매번 임의로 순서가 정해진다.)
그렇다면 우리는 ApiControllerAdvice에 @Order(Ordered.HIGHEST_PRECEDENCE)를 붙여주면 정상적으로 코드가 동작을 할 것이다.
(Order를 조금 더 명확하게 드러내기 위해서, ServerErrorDetectControllerAdvice에도 @Order(Ordered.LOWEST_PRECEDENCE)를 붙였습니다.)
// ApiControllerAdvice.java
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
@Slf4j
public class ApiControllerAdvice {
// 중략 ...
}
// =============================================================
// ServerErrorDetectControllerAdvice.java
@Order(Ordered.LOWEST_PRECEDENCE)
@Profile("prod")
@RequiredArgsConstructor
@RestControllerAdvice
@Slf4j
public class ServerErrorDetectControllerAdvice {
// 중략 ...
}
(※ 해당 내용을 수정한 수정 커밋)
아래의 log를 보면 수정한 내용이 정상적으로 반영이 되었음을 알 수 있다.
(원래라면, 수정하기 전에는 ServerErrorDetectControllerAdvice가 떳지만, 이제는 ApiControllerAdvice가 뜬다.)

내가 글 초반에 스프링은 항상 구체적인 것을 먼저 처리한다고 이야기 했던 부분이 있는데, 이 부분을 확인 안 하고 넘어가긴 찝찝해서 이 부분에 대해서도 조금 더 찾아봤다.
ExceptionHandlerExceptionResolver의 getExceptionHandlerMethod 중에 보면 ExceptionHandlerMethodResolver 객체를 가져와서 resolveMethod 메서드를 실행하는 부분이 있다.
// ExceptionHandlerExceptionResolver.class
@Nullable
protected ServletInvocableHandlerMethod getExceptionHandlerMethod(@Nullable HandlerMethod handlerMethod, Exception exception) {
// 중략 ..
// ControllerAdvice Bean들을 순회
Iterator var9 = this.exceptionHandlerAdviceCache.entrySet().iterator();
while(var9.hasNext()) {
Map.Entry<ControllerAdviceBean, ExceptionHandlerMethodResolver> entry = (Map.Entry)var9.next();
ControllerAdviceBean advice = (ControllerAdviceBean)entry.getKey();
if (advice.isApplicableToBeanType(handlerType)) {
ExceptionHandlerMethodResolver resolver = (ExceptionHandlerMethodResolver)entry.getValue();
// resolver.resolveMethod 메서드가 중요
Method method = resolver.resolveMethod(exception);
if (method != null) {
return new ServletInvocableHandlerMethod(advice.resolveBean(), method, this.applicationContext);
}
}
}
return null;
}
resolveMethod 메서드를 타고 타고 가다 보면, getMappedMethod 메서드가 실행이 되는데, getMappedMethod 메서드가 중요하다.
// ExceptionHandlerMethodResolver.class
@Nullable
public Method resolveMethod(Exception exception) {
return this.resolveMethodByThrowable(exception);
}
@Nullable
public Method resolveMethodByThrowable(Throwable exception) {
Method method = this.resolveMethodByExceptionType(exception.getClass());
// 중략 ..
}
@Nullable
public Method resolveMethodByExceptionType(Class<? extends Throwable> exceptionType) {
Method method = (Method)this.exceptionLookupCache.get(exceptionType);
if (method == null) {
// this.getMappedMethod 메서드가 중요
method = this.getMappedMethod(exceptionType);
// 중략 ..
}
getMappedMethod는 하나의 ControllerAdvice Bean에서 현재 발생한 예외와 가장 근접한 구체적인 예외를 찾고, 해당 예외를 처리하는 메서드를 반환한다.
// 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;
}
}
getMappedMethod의 동작 로직
- 예외와 예외를 처리하는 메서드를
key,value로 가지는mappedMethods를keySet만 뽑아서 순회isAssignableFrom메소드를 사용하여,key에 있는 예외 중에 현재 발생한 예외와 같거나 상위 클래스인 예외가 있는지 검사- 검사하여
true가 나오면matches에 저장matches리스트에 하나 이상의 유형이 있으면, 이 리스트를ExceptionDepthComparator를 사용하여 정렬- 정렬을 하면 가장 구체적인 예외 (즉, 가장 근접한 예외)가 리스트의 맨 앞 부분으로 오게 됨
matches리스트의 첫 번째 요소에 해당하는 메소드를 반환 (이 메소드는 가장 구체적인 예외 유형에 매핑된@ExceptionHandler메소드)- 이 로직을 통해
ExceptionHandlerMethodResolver는 발생한 예외 중 가장 적합한@ExceptionHandler메소드를 선택따라서 더 구체적인 예외를 처리하는 메소드가 더 우선적으로 실행됨
혹시나 하고 찾아봤더니 역시나 스프링은 본인이 할 수 있는 최선을 다 하고 있었다.
(가장 구체적인 예외를 처리하는 메서드를 반환하기 위해 열심히 순회하고 있었다.)
내가 그걸 몰랐을 뿐.. 🥲
ControllerAdvice Bean 안에서는 최대한 구체적인 예외를 다루는 @ExceptionHandler 메소드가 실행된다.ControllerAdvice Bean이 있다면, 어떤 ControllerAdvice Bean이 먼저 실행될지 모르기 때문에 @Order를 통해서 순서를 반드시 지정해줘야 한다.“세상에 당연한 것은 하나도 없다”는 것이다.“이건 당연히 맞아” 라고 생각하고 문제를 해결하려고 하니 더욱 더 잘못된 길로만 가게 되었고 문제 해결과는 점점 멀어졌다.