@ExceptionHandler에서 의도치 않은 예외를 잡는다면?
Spring Web MVC 사용 중 예외가 발생했을 때 의도치 않은 정보가 클라이언트에게 제공될 수 있습니다. 이를 방지하기 위해서 예상 가능한 예외들은 미리 핸들링 하거나 @RestControllerAdvice
로 핸들링하는데요. @RestControllerAdvice
로 지정한 예외들은 어떤 과정으로 핸들링 되는걸까요? 이번 글에서 알아봅시다.
지정하지 않은 예외를 핸들링하고 있다…
얼마 전 JdbcTemplate를 이용하던 중 DuplicateKeyException
이 발생했습니다. 해당 예외를 핸들링하는 코드는 없었죠. 또, 전역적으로 흘린 예외를 핸들링하는 @RestControllerAdvice
는 다음과 같았습니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(SQLException.class)
public String handleSqlException(final SQLException exception) {
return "쿼리가 그게 뭐냐";
}
}
위 코드를 작성할 땐 SQLException
을 처리하도록 의도했습니다. 그런데 해당 메서드가 SQLException
뿐만 아니라 DuplicateKeyException
도 처리하고 있었는데요. 이게 어떻게 된 걸까요? 귀신이라도 사는걸까요?
계층 구조가 아니었다?
그래서 저는 DuplicateKeyException
과 SQLException
이 부모-자식 관계를 갖고 있다고 판단했습니다. 자신만만하게 클래스 다이어그램을 조회한 결과는 어땠을까요?
두 눈을 믿을 수 없었습니다. 두 예외는 unchecked exception과 checked exception으로 근본부터 달랐기 때문입니다. 그렇다면 도대체 어떻게 핸들링 할 수 있었던 것일까요?
예외를 처리할 메서드를 등록한다
이를 이해하기 위해선 예외와 예외를 처리하는 메서드 사이의 매핑 관계를 알아야합니다. Spring Web MVC는 주어진 Bean에서 @ExceptionHandler 메서드를 찾아 예외가 주어질 때 처리할 메서드를 매핑하는 ExceptionHandlerMethodResolver
를 생성합니다.
@ExceptionHandler가 붙은 메서드 가져오기
우선 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 정보 가져오기
다음으로 메서드가 처리할 수 있는 Exception 정보를 가져옵니다. 이때 예외 정보를 가져오는 기준은 다음과 같습니다.
- @ExceptionHandler에 명시한 예외를 가져온다.
- @ExceptionHandler의 값이 비어있다면 매개 변수에 명시한 예외를 가져온다.
예제 코드와 함께 보겠습니다.
@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
public String handleIllegalException(final RuntimeException exception) {
return exception.getMessage();
}
위 경우 메서드가 IllegalArgumentException
, IllegalStateException
를 처리할 수 있다는 정보를 가져옵니다.
Exception을 Method와 매핑
마지막으로 가져온 예외 정보와 메서드를 매핑합니다. 매핑 정보는 필드 변수 mappedMethods
에 다음과 같이 저장됩니다.
exception type | method |
---|---|
IllegalArgumentException | handleIllegalException() |
IllegalStateException | handleIllegalException() |
RuntimeException | handleRuntimeException() |
예외가 발생했을 때 메서드를 찾아 처리한다
지금까지 예외가 발생했을 때 처리할 메서드를 등록하는 과정에 대해 알아보았습니다. 이제부터는 예외가 발생했을 때 어떻게 메서드를 찾는지 알아보겠습니다.
아까와 동일한 ExceptionHandlerMethodResolver
의 resolveMethod()
를 호출하면 예외가 주어졌을 때 처리할 수 있는 메서드를 반환합니다.
메서드를 찾는 방법
내부에서는 주어진 예외를 처리할 수 있는 메서드가 있는지 찾고 만약 존재하지 않는다면 예외의 cause가 있는지 확인합니다. 만약 cause가 존재한다면 해당 cause를 처리할 수 있는 메서드를 찾기 위해 함수를 재귀적으로 호출합니다.
호환되는 예외도 찾는다
예외를 처리할 수 있는 메서드를 찾는 과정 중 흥미로운 점이 있는데요. 정확하게 해당하는 예외를 처리할 메서드가 아니더라도 호환 가능한 예외를 처리할 수 있는 메서드가 있다면 해당 메서드를 후보 메서드로 등록한다는 점입니다.
따라서 여러 개의 메서드가 처리할 메서드로 존재할 수 있고, 정렬을 통해 목표 예외와 가장 근접한 예외를 처리하는 메서드를 반환합니다.
💡 호환 되는 예외에 대해 궁금하신 분은 이 곳을, 예외를 정렬하는 방법에 대해 궁금하신 분을 이 곳을 참고해 주세요.
찾았다 귀신
여기까지의 정보를 종합하면 문제의 원인을 알 수 있습니다. 정의한 핸들러가 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: 핸들링이 발생할 때 생성하고 캐싱
-
@ControllerAdvice: Spring Bean들을 초기화할 때 바로 생성하고 캐싱
@Contoller와 @ControllerAdvice 중 누가 먼저 처리할까?
만약 @Controller
와 @ControllerAdvice
모두에서 동일한 예외를 처리할 수 있다면 누가 먼저 처리될까요? 코드와 함께 보겠습니다.
@Controller
에서를 먼저 찾은 후 @ControllerAdvice
에서 찾고, 없다면 null
을 반환합니다.
마무리하며
등장하는 핵심 개념의 이름이 비슷해 헷갈릴 수 있으나 둘의 이름은 중요하지 않습니다. 전체적인 흐름을 이해하는 것과 Exception을 감쌀 때 반드시 cause를 함께 전달해주어야한다는 것이 중요합니다.
이번 글에서 알아본 정보를 한 번 정리해보겠습니다.
ExceptionHandlerMethodResolver
가 예외를 처리할 메서드 매핑 정보를 갖고 있다.ExceptionHandlerMethodResolver
를 통해 예외를 처리할 메서드를 알 수 있다.- 예외를 처리할 때 호환되는 예외를 처리하는 메서드까지 후보로 등록한 후 가장 근접한 메서드를 반환한다.
- 처리할 메서드가 없으나 cause가 존재한다면 cause에 대해 재귀적으로 수행한다.
ExceptionHandlerExceptionResolver
가ExceptionHandlerMethodResolver
를 생성한다.- 생성 대상 클래스:
@Controller
,@ControllerAdvice
- 생성 대상 클래스:
- 클래스 종류마다
ExceptionHandlerMethodResolver
를 생성하는 시점이 다르다.@Controller
: 핸들링이 발생할 때 생성하고 캐싱@ControllerAdvice
: Spring Bean들을 초기화할 때 바로 생성하고 캐싱
이번 글을 통해 Spring Web MVC의 예외 처리에 대한 이해가 높아지셨길 바랍니다. 감사합니다!
댓글남기기