호출될 거라 예상했던 @ExceptionHandler가 일을 안 하네?

날씨는 맑음·2022년 9월 24일
1

스프링에서 @ControllerAdvice를 통해서 예외를 처리할 때 내부에 @ExceptionHandler를 등록하게 됩니다. 그런데 RuntimeException을 상속한 특정 도메인의 예외를 만들고 처리하기 위해서 새로운 AdviceHandler를 등록했는데 상위의 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을 처리할 수 있는 AControlllerAdviceRuntimeException의 하위 클래스인 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가 동작하게 될까요?

  1. AControllerAdvice에 의해 처리된다.
  2. BControllerAdvice에 의해 처리된다.

아마 다들 "BControllerAdviceTestException이 더 상세하니까 우선순위가 있지 않을까?"하고 2번라 생각하실겁니다.

하지만 실제로는 AControllerAdviceruntimeException에 걸려 처리하게 됩니다.

// http://localhost:8080/throw
{
  "exceptionHandler": "AControllerAdvice에서 처리!",
  "message": "TestController에서 예외 발생!"
}

많이 당황스러우실 겁니다... 저도 처음엔 그랬으니까요..
그럼 이제 왜 이런 문제가 발생하는지 알아봅시다.

원인 분석

어떻게 ControllerAdviceExceptionHandler가 선출되는지 이해해봅시다.

등록된 @ControllerAdvice들은 어디에 있나요

@ControllerAdvice 어노테이션을 이용해 등록된 Advice들은 ExceptionHandlerExceptionResolverexceptionHandlerAdviceCache 필드에 캐싱됩니다.

public class ExceptionHandlerExceptionResolver extends AbstractHandlerMethodExceptionResolver
		implements ApplicationContextAware, InitializingBean { 
	...
    
	private final Map<ControllerAdviceBean, ExceptionHandlerMethodResolver> exceptionHandlerAdviceCache =
			new LinkedHashMap<>();
            
    ...
}

디버깅으로 확인해보니 등록했던 AControllerAdvieBControllerAdvice가 보이네요. 일단 이녀석들을 찾았으니 이제 어떻게 선택되는지 찾아봅시다.

어디서 선택되나요

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); 부분에서 AControllerAdviceruntimeException() 메소드가 반환되는것을 확인할 수 있습니다.

해결 방안

이제 문제점을 찾았으니 제가 사용한 해결 방안을 공유해보겠습니다.

  1. 합친다.
  2. 검사 순서를 바꾼다.

1. 합친다

말 그대로 AControllerAdviceBControllerAdice를 합치는 방식입니다.

단순히 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에서 예외 발생!"
}

2. 검사 순서를 바꾼다.

해당 방식은 AControllerAdviceBControllerAdvice의 등록 순서를 바꿔 BControllerAdvice가 먼저 검사되도록 하는 방식입니다.

@Order로 상세한 처리를 하는 Advice를 앞으로 보내기

기본적으로 Advice들은 @Orderdefault 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());
    }
}

이제 BControllerAdviceAControllerAdvice보다 먼저 등록이 되었습니다.

검사에도 먼저 BControllerAdvice가 사용되는것을 확인할 수 있습니다.

실제 호출 결과도 이제 BControllerAdvice에 의해서 처리되고 있습니다.

// http://localhost:8080/throw
{
  "exceptionHandler": "BControllerAdvice의 testException() 에서 처리!",
  "message": "TestController에서 예외 발생!"
}

@Order 붙이기 귀찮은데

종종 새로운 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에서 예외 발생!"
}

회고

예상치 못한 상황을 만났을 때 디버깅을 잘 할 수 있는 능력이 정말 중요한 것 같아요. 시스템은 언제나 예상하지 못한 행동을 하니까요. 디버깅 하는 과정이 힘든 시간이긴 했지만 그래도 한층 성장했다는 느낌을 받아서 뿌듯하네요.

하지만 아직 주니어 인지라 문제를 깔끔하게 해결했다는 느낌은 들지 않습니다만.. 저한테는 최선의 방법이였습니다 😂

위 방법 외에 더 나은 방법이 있으시면 댓글로 알려주시면 감사하겠습니다 😀

모두 개발 하는 족족 에러 없이 한 번에 컴파일되고 동작하는 개발하세요~ 그럼 이만~👋👋

1개의 댓글

comment-user-thumbnail
2023년 4월 27일

덕분에 좋은 내용 잘 보고 갑니다.
정말 감사합니다.

답글 달기