스프링에서 @ControllerAdvice
를 통해서 예외를 처리할 때 내부에 @ExceptionHandler
를 등록하게 됩니다. 그런데 RuntimeException
을 상속한 특정 도메인의 예외를 만들고 처리하기 위해서 새로운 Advice
와 Handler
를 등록했는데 상위의 Handler
만 호출되는 문제를 겪었습니다.
왜 위와 같은 문제가 생겼는지, 어떻게 해결 할 수 있을지에 대해서 알아보겠습니다.
아래와 같이 TestController
가 있고 TextException
이 발생되는 test()
메소드가 있습니다.
@RestController
public class TestController {
@GetMapping("/throw")
public String test(){
throw new TestException(this.getClass().getSimpleName() + "에서 예외 발생!");
}
}
public class TestException extends RuntimeException{
public TestException(String message) {
super(message);
}
}
그리고 RuntimeException
을 처리할 수 있는 AControlllerAdvice
와 RuntimeException
의 하위 클래스인 TestException
을 처리할 수 있는 BControllerAdvice
가 있습니다.
@RestControllerAdvice
public class AControllerAdvice {
@ExceptionHandler(RuntimeException.class)
public ExceptionResponse runtimeException(RuntimeException e){
return new ExceptionResponse(this.getClass().getSimpleName() + "에서 처리!", e.getMessage());
}
}
@RestControllerAdvice
public class BControllerAdvice {
@ExceptionHandler(TestException.class)
public ExceptionResponse testException(TestException e){
return new ExceptionResponse(this.getClass().getSimpleName() + "에서 처리!", e.getMessage());
}
}
@Getter
@AllArgsConstructor
public class ExceptionResponse {
private String exceptionHandler;
private String message;
}
여기서 /throw
를 외부에서 호출하게 되면 어떤 @ExceptionHandler
가 동작하게 될까요?
AControllerAdvice
에 의해 처리된다.BControllerAdvice
에 의해 처리된다.아마 다들 "BControllerAdvice
의 TestException
이 더 상세하니까 우선순위가 있지 않을까?"하고 2번라 생각하실겁니다.
하지만 실제로는 AControllerAdvice
의 runtimeException
에 걸려 처리하게 됩니다.
// http://localhost:8080/throw
{
"exceptionHandler": "AControllerAdvice에서 처리!",
"message": "TestController에서 예외 발생!"
}
많이 당황스러우실 겁니다... 저도 처음엔 그랬으니까요..
그럼 이제 왜 이런 문제가 발생하는지 알아봅시다.
어떻게 ControllerAdvice
와 ExceptionHandler
가 선출되는지 이해해봅시다.
@ControllerAdvice
어노테이션을 이용해 등록된 Advice
들은 ExceptionHandlerExceptionResolver
의 exceptionHandlerAdviceCache
필드에 캐싱됩니다.
public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExceptionResolver
implements ApplicationContextAware, InitializingBean {
...
private final Map<ControllerAdviceBean, ExceptionHandlerMethodResolver> exceptionHandlerAdviceCache =
new LinkedHashMap<>();
...
}
디버깅으로 확인해보니 등록했던 AControllerAdvie
와 BControllerAdvice
가 보이네요. 일단 이녀석들을 찾았으니 이제 어떻게 선택되는지 찾아봅시다.
exceptionHandlerAdviceCache
필드는 내부의 멤버 메소드인 getExceptionHandlerMethod
에서 사용됩니다. 여기서 아까 위에서 등록된 ControllerAdvice
들을 순서대로 하나씩 탐색하게 됩니다.
@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;
}
디버깅으로 어떤 녀석이 맨 처음 비교되는지 확인해봅시다.
짜란~ 맨처음 비교되는 녀석은 AControllerAdvice
입니다. 코드를 보니 advice.isApplicableToBeanType(handlerType)
부분에서 true
가 되면 AControllerAdvice
가 선택이 될것으로 예상됩니다.
예상처럼 해당 조건이 true
가 반환되고 Method method = resolver.resolveMethod(exception);
부분에서 AControllerAdvice
의 runtimeException()
메소드가 반환되는것을 확인할 수 있습니다.
이제 문제점을 찾았으니 제가 사용한 해결 방안을 공유해보겠습니다.
말 그대로 AControllerAdvice
와 BControllerAdice
를 합치는 방식입니다.
단순히 ExceptionHandler
들을 한곳으로 모아서 해결하기 때문에 단순하고 쉬운 방식입니다. 하지만 ExceptionHandler
를 도메인별로 관리하기 힘든 단점이 있을 수 있습니다.
@RestControllerAdvice
public class AControllerAdvice {
@ExceptionHandler(RuntimeException.class)
public ExceptionResponse runtimeException(RuntimeException e){
return new ExceptionResponse(this.getClass().getSimpleName() + "의 runtimeException() 에서 처리!", e.getMessage());
}
@ExceptionHandler(TestException.class)
public ExceptionResponse testException(TestException e){
return new ExceptionResponse(this.getClass().getSimpleName() + "의 testException() 에서 처리!", e.getMessage());
}
}
// http://localhost:8080/throw
{
"exceptionHandler": "AControllerAdvice의 testException() 에서 처리!",
"message": "TestController에서 예외 발생!"
}
해당 방식은 AControllerAdvice
와 BControllerAdvice
의 등록 순서를 바꿔 BControllerAdvice
가 먼저 검사되도록 하는 방식입니다.
기본적으로 Advice
들은 @Order
의 default vaule
를 가지고 있습니다.
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {
int value() default Ordered.LOWEST_PRECEDENCE;
}
public interface Ordered {
int HIGHEST_PRECEDENCE = Integer.MIN_VALUE;
int LOWEST_PRECEDENCE = Integer.MAX_VALUE;
int getOrder();
}
이제 상세한 Exception
을 처리해야 하는 BControllerAdvice
의 우선순위를 높여주겠습니다.
@Order(100)
@RestControllerAdvice
public class BControllerAdvice {
@ExceptionHandler(TestException.class)
public ExceptionResponse testException(TestException e){
return new ExceptionResponse(this.getClass().getSimpleName() + "의 testException() 에서 처리!", e.getMessage());
}
}
이제 BControllerAdvice
가 AControllerAdvice
보다 먼저 등록이 되었습니다.
검사에도 먼저 BControllerAdvice가 사용되는것을 확인할 수 있습니다.
실제 호출 결과도 이제 BControllerAdvice에 의해서 처리되고 있습니다.
// http://localhost:8080/throw
{
"exceptionHandler": "BControllerAdvice의 testException() 에서 처리!",
"message": "TestController에서 예외 발생!"
}
종종 새로운 Advice
를 만들때 @Order
를 붙여서 순서를 명시해줘야 한다는 것을 까먹을 수도 있겠죠..? 저희는 사람이니까요.
그래서 Custom Annotation
으로 계층별로 등록해 사용하면 실수를 방지 할 수 있습니다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Order(100)
@RestControllerAdvice
public @interface DomainSpecificAdvice {
}
@DomainSpecificAdvice
public class BControllerAdvice {
@ExceptionHandler(TestException.class)
public ExceptionResponse testException(TestException e){
return new ExceptionResponse(this.getClass().getSimpleName() + "의 testException() 에서 처리!", e.getMessage());
}
}
위와 같이 등록하고 호출해보면 아래와 같이 똑같이 잘 처리되고 있음을 확인할 수 있습니다.
// http://localhost:8080/throw
{
"exceptionHandler": "BControllerAdvice의 testException() 에서 처리!",
"message": "TestController에서 예외 발생!"
}
예상치 못한 상황을 만났을 때 디버깅을 잘 할 수 있는 능력이 정말 중요한 것 같아요. 시스템은 언제나 예상하지 못한 행동을 하니까요. 디버깅 하는 과정이 힘든 시간이긴 했지만 그래도 한층 성장했다는 느낌을 받아서 뿌듯하네요.
하지만 아직 주니어 인지라 문제를 깔끔하게 해결했다는 느낌은 들지 않습니다만.. 저한테는 최선의 방법이였습니다 😂
위 방법 외에 더 나은 방법이 있으시면 댓글로 알려주시면 감사하겠습니다 😀
모두 개발 하는 족족 에러 없이 한 번에 컴파일되고 동작하는 개발하세요~ 그럼 이만~👋👋
덕분에 좋은 내용 잘 보고 갑니다.
정말 감사합니다.