Spring Web MVC 사용 중 예외가 발생했을 때 의도치 않은 정보가 클라이언트에게 제공될 수 있습니다. 이를 방지하기 위해서 예상 가능한 예외들은 미리 핸들링 하거나 @RestControllerAdvice로 핸들링하는데요. @RestControllerAdvice 로 지정한 예외들은 어떤 과정으로 핸들링 되는걸까요? 이번 글에서 알아봅시다.

지정하지 않은 예외를 핸들링하고 있다…

Untitled.png

얼마 전 JdbcTemplate를 이용하던 중 DuplicateKeyException이 발생했습니다. 해당 예외를 핸들링하는 코드는 없었죠. 또, 전역적으로 흘린 예외를 핸들링하는 @RestControllerAdvice는 다음과 같았습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(SQLException.class)
    public String handleSqlException(final SQLException exception) {
        return "쿼리가 그게 뭐냐";
    }
}

위 코드를 작성할 땐 SQLException을 처리하도록 의도했습니다. 그런데 해당 메서드가 SQLException 뿐만 아니라 DuplicateKeyException도 처리하고 있었는데요. 이게 어떻게 된 걸까요? 귀신이라도 사는걸까요?

계층 구조가 아니었다?

그래서 저는 DuplicateKeyExceptionSQLException이 부모-자식 관계를 갖고 있다고 판단했습니다. 자신만만하게 클래스 다이어그램을 조회한 결과는 어땠을까요?

Untitled.png

두 눈을 믿을 수 없었습니다. 두 예외는 unchecked exception과 checked exception으로 근본부터 달랐기 때문입니다. 그렇다면 도대체 어떻게 핸들링 할 수 있었던 것일까요?

예외를 처리할 메서드를 등록한다

이를 이해하기 위해선 예외와 예외를 처리하는 메서드 사이의 매핑 관계를 알아야합니다. Spring Web MVC는 주어진 Bean에서 @ExceptionHandler 메서드를 찾아 예외가 주어질 때 처리할 메서드를 매핑하는 ExceptionHandlerMethodResolver 를 생성합니다.

@ExceptionHandler가 붙은 메서드 가져오기

Untitled.png

우선 handlerType와 일치하는 클래스 내부에 @ExceptionHandler가 붙은 메서드를 가져옵니다. 예를 들면 다음과 같습니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
    public String handleIllegalException(final RuntimeException exception) {
        return exception.getMessage();
    }

    @ExceptionHandler()
    public String handleRuntimeException(final RuntimeException exception) {
        return exception.getMessage();
    }
}

위 경우 handleIllegalException()handleRuntimeException()를 가져옵니다.

메서드가 처리할 수 있는 Exception 정보 가져오기

Untitled.png

다음으로 메서드가 처리할 수 있는 Exception 정보를 가져옵니다. 이때 예외 정보를 가져오는 기준은 다음과 같습니다.

  1. @ExceptionHandler에 명시한 예외를 가져온다.
  2. @ExceptionHandler의 값이 비어있다면 매개 변수에 명시한 예외를 가져온다.

예제 코드와 함께 보겠습니다.

@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
public String handleIllegalException(final RuntimeException exception) {
    return exception.getMessage();
}

위 경우 메서드가 IllegalArgumentException, IllegalStateException를 처리할 수 있다는 정보를 가져옵니다.

Exception을 Method와 매핑

Untitled.png

마지막으로 가져온 예외 정보와 메서드를 매핑합니다. 매핑 정보는 필드 변수 mappedMethods에 다음과 같이 저장됩니다.

exception type method
IllegalArgumentException handleIllegalException()
IllegalStateException handleIllegalException()
RuntimeException handleRuntimeException()

예외가 발생했을 때 메서드를 찾아 처리한다

지금까지 예외가 발생했을 때 처리할 메서드를 등록하는 과정에 대해 알아보았습니다. 이제부터는 예외가 발생했을 때 어떻게 메서드를 찾는지 알아보겠습니다.

Untitled.png

아까와 동일한 ExceptionHandlerMethodResolverresolveMethod()를 호출하면 예외가 주어졌을 때 처리할 수 있는 메서드를 반환합니다.

메서드를 찾는 방법

Untitled.png

내부에서는 주어진 예외를 처리할 수 있는 메서드가 있는지 찾고 만약 존재하지 않는다면 예외의 cause가 있는지 확인합니다. 만약 cause가 존재한다면 해당 cause를 처리할 수 있는 메서드를 찾기 위해 함수를 재귀적으로 호출합니다.

호환되는 예외도 찾는다

예외를 처리할 수 있는 메서드를 찾는 과정 중 흥미로운 점이 있는데요. 정확하게 해당하는 예외를 처리할 메서드가 아니더라도 호환 가능한 예외를 처리할 수 있는 메서드가 있다면 해당 메서드를 후보 메서드로 등록한다는 점입니다.

Untitled.png

따라서 여러 개의 메서드가 처리할 메서드로 존재할 수 있고, 정렬을 통해 목표 예외와 가장 근접한 예외를 처리하는 메서드를 반환합니다.

💡 호환 되는 예외에 대해 궁금하신 분은 이 곳을, 예외를 정렬하는 방법에 대해 궁금하신 분을 이 곳을 참고해 주세요.

찾았다 귀신

여기까지의 정보를 종합하면 문제의 원인을 알 수 있습니다. 정의한 핸들러가 DuplicateKeyException을 핸들링할 수 있었던 이유는 cause로 SQLException을 갖고 있었기 때문입니다.

실제로 JdbcTemplate의 execute()의 내부 코드를 보면 다음과 같이 되어있습니다.

@Nullable
private <T> T execute(PreparedStatementCreator psc, PreparedStatementCallback<T> action, boolean closeResources)
        throws DataAccessException {

    /* 생략 */

    try {
        ps = psc.createPreparedStatement(con);
        applyStatementSettings(ps);
        T result = action.doInPreparedStatement(ps); // 쿼리 실행
        handleWarnings(ps);
        return result;
    }
    catch (SQLException ex) {
        /* 생략 */
        throw translateException("PreparedStatementCallback", sql, ex); // SQLException -> DuplicateKeyException
    }

    /* 생략 */

}

JdbcTemplate에서는 쿼리를 실행하다 발생한 SQLException을 RuntimeException으로 번역하는 작업을 수행하는데 이 과정에서 SQLException을 품은 DuplicateKeyException이 발생한 것입니다.

ExceptionHandlerMethodResolver의 생성과 사용

그렇다면 ExceptionHandlerMethodResolver는 누가 생성할까요? 이름이 매우 비슷한 ExceptionHandlerExceptionResolver 이 생성합니다. 좀 더 자세히 알아보겠습니다.

생성 조건과 생성 시점

우선 다음과 같은 메타 애너테이션을 가지고 있는 클래스에 대하여 ExceptionHandlerMethodResolver를 생성합니다. 재밌는 점은 애너테이션의 종류에 따라 생성 시점이 다르다는 것입니다.

  • @Controller: 핸들링이 발생할 때 생성하고 캐싱

    Untitled.png

  • @ControllerAdvice: Spring Bean들을 초기화할 때 바로 생성하고 캐싱

    Untitled.png

@Contoller와 @ControllerAdvice 중 누가 먼저 처리할까?

만약 @Controller@ControllerAdvice 모두에서 동일한 예외를 처리할 수 있다면 누가 먼저 처리될까요? 코드와 함께 보겠습니다.

Untitled.png

@Controller에서를 먼저 찾은 후 @ControllerAdvice에서 찾고, 없다면 null을 반환합니다.

마무리하며

등장하는 핵심 개념의 이름이 비슷해 헷갈릴 수 있으나 둘의 이름은 중요하지 않습니다. 전체적인 흐름을 이해하는 것과 Exception을 감쌀 때 반드시 cause를 함께 전달해주어야한다는 것이 중요합니다.

이번 글에서 알아본 정보를 한 번 정리해보겠습니다.

  • ExceptionHandlerMethodResolver가 예외를 처리할 메서드 매핑 정보를 갖고 있다.
  • ExceptionHandlerMethodResolver를 통해 예외를 처리할 메서드를 알 수 있다.
    • 예외를 처리할 때 호환되는 예외를 처리하는 메서드까지 후보로 등록한 후 가장 근접한 메서드를 반환한다.
    • 처리할 메서드가 없으나 cause가 존재한다면 cause에 대해 재귀적으로 수행한다.
  • ExceptionHandlerExceptionResolverExceptionHandlerMethodResolver를 생성한다.
    • 생성 대상 클래스: @Controller , @ControllerAdvice
  • 클래스 종류마다 ExceptionHandlerMethodResolver를 생성하는 시점이 다르다.
    • @Controller: 핸들링이 발생할 때 생성하고 캐싱
    • @ControllerAdvice: Spring Bean들을 초기화할 때 바로 생성하고 캐싱

이번 글을 통해 Spring Web MVC의 예외 처리에 대한 이해가 높아지셨길 바랍니다. 감사합니다!

카테고리:

업데이트:

댓글남기기