문제 상황

티어리스트 프로젝트에서는 @RestControllerAdvice를 이용해 예외응답을 생성하고 있다. 스프링 프레임워크가 던지는 예외를 핸들링하는 GlobalExceptionHandler와 도메인 레이어에서 던지는 예외를 핸들링하는 BusinessExceptionHandler를 Bean으로 등록해 사용하고 있다.

사용자는 중복된 이메일과 중복된 닉네임으로 가입할 수 없기 때문에 중복된 이메일이나 중복된 닉네임으로 가입을 시도할 시 EmailDuplicationException이나 NicknameDuplicationException을 던진다.

이를 BusinessExceptionHandler에서 받아서 다음과 같이 처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j  
@RestControllerAdvice  
public class BusinessExceptionHandler {  

  // 생략
  
  @ExceptionHandler(DuplicationException.class)  
  public ResponseEntity<ErrorResponse> handleDuplicationException(  
      DuplicationException duplicationException) {  
    ErrorCode errorCode = duplicationException.getErrorCode();  
    return ResponseEntity.status(HttpStatus.CONFLICT)
        .body(ErrorResponse.from(errorCode));  
  }  

  // 생략
  
}

Local에서 Postman으로 이메일 중복 검사 API에 중복된 이메일로 요청하면 다음과 같이 예상한 응답이 나왔다.

하지만 개발 서버에 배포된 쿠버네티스 파드에 같은 요청을 보냈을 때 GlobalExceptionHandler에서 처리됨을 확인할 수 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j  
@RestControllerAdvice  
public class GlobalExceptionHandler {  

  // 생략
  
  @ExceptionHandler(Throwable.class)  
  public ResponseEntity<ErrorResponse> handleThrowable(Throwable e) {  
    log.error(e.getMessage(), e);  
    final ErrorCode errorCode = ErrorCode.INTERNAL_SERVER_ERROR;  
    return ResponseEntity.internalServerError()
        .body(ErrorResponse.from(errorCode));  
  }  
  
}

응답은 다음과 같다.

같은 코드임에도 불구하고 어디서 작동되냐에 따라 예외 핸들링 클래스가 달라지는 것은 이상하게 느껴졌다.

문제 원인

ControllerAdvice 선택

ControllerAdvice의 선택 과정을 디버깅 해보자. 어떤 핸들러를 선택할 것인지는 ExceptionHandlerExceptionResolvergetExceptionHandlerMethod메서드 호출 과정에서 결정된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Nullable  
protected ServletInvocableHandlerMethod getExceptionHandlerMethod(  
   @Nullable HandlerMethod handlerMethod, Exception exception) {  

  // 생략

  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;  
}

exceptionHandlerAdviceCacheentrySet에서 등록된 ControllerAdvice를 하나씩 꺼내와 순서대로 탐색한다. 던져진 예외에 일치 핸들러가 존재하는지 확인한 후 ExceptionHandler의 메서드를 반환한다.

즉, 처음 만나는 일치하는 핸들러의 메서드를 반환하게 된다.

exceptionHandlerAdviceCacheLinkedHashMap으로 구현되어 있다. LinkedHashMap은 보통 다른 설정이 없다면 기본 설정으로 삽입되는 Key값의 순서를 기반으로 정렬된다.

개발 서버에서는 GlobalExceptionHandler가 먼저 exceptionHandlerAdviceCache에 등록되었다고 추측할 수 있다.

ExceptionHandler 등록 순서

exceptionHandlerAdviceCache에 어드바이스들이 등록되는 순서를 살펴보자.

ExceptionHandlerExceptionResolverinitExceptionHandlerAdviceCache메서드를 살펴보면 exceptionHandlerAdviceCache를 초기화 함을 알 수 있다.

ControllerAdviceBean.findAnnotatedBeans(getApplicationContext())에서 어드바이스 빈을 가져와 차례로 등록시킨다.

해당 메서드를 살펴보자.

BeanFactoryUtils의 메서드를 통해 빈들을 가져온 후 마지막에 OrderComparator를 통해 해당 빈들을 정렬한다. BeanFactoryUtils의 메서드는 순서를 보장하지 않는다. 즉, 원하는 ControllerAdvice 간의 적용 순서를 보장하려면 @Order 애노테이션을 통해 순서를 강제해야한다.

로컬과 개발 서버의 유일한 설정 차이는 다음과 같다. 배포중인 이미지는 GitHub Action의 DockerX action을 통해 빌드가 이루어진다. 아마도 이 부분에서 차이가 생겼을 것이라고 추측된다.

문제 해결

위의 디버깅 결과로 @Order 애노테이션을 통해 ControllerAdvice간의 순서를 보장해 주어야 함을 알았다.

새로운 ControllerAdvice를 추가해야할 때 이를 누락하는 실수를 할 수 있기 때문에 커스텀한 ControllerAdvice 애노테이션을 만들어 사용해보자.

커스텀 @ControllerAdvice 애노테이션 구현

@GlobalControllerAdvice@BusinessControllerAdvice를 다음과 같이 구현했다.

1
2
3
4
5
6
7
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Order(100)
@RestControllerAdvice
public @interface GlobalControllerAdvice {
}
1
2
3
4
5
6
7
8
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
public @interface BusinessControllerAdvice {

}

@GlobalControllerAdvice에 낮은 우선순위를 부여하고, @BusinessControllerAdvice에 가장 높은 우선순위를 부여했다.

앞으로 추가될 ControllerAdvice에는 적당한 우선순위를 부여해주면 될 것이다. 이 두 애노테이션을 기존 ControllerAdvice에 적용했다.

결과

트러블슈팅 결과 환경에 따라 예외 응답 처리 클래스가 달라지지 않았고 우선순위가 잘 적용됨을 확인했다.

댓글남기기