결론만 궁금하신 분들은 바로 결론으로 가시면 됩니다!
프로젝트의 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
를 통해서 순서를 반드시 지정해줘야 한다.“세상에 당연한 것은 하나도 없다”
는 것이다.“이건 당연히 맞아”
라고 생각하고 문제를 해결하려고 하니 더욱 더 잘못된 길로만 가게 되었고 문제 해결과는 점점 멀어졌다.