스프링에서 예외를 잘! 처리하는 방법
Intro
자바 프로그램에서 예외가 발생하면 스레드가 종료됩니다. 하지만 스프링에서는 예외가 발생해도 어플리케이션이 종료되지 않고, 예외 응답을 반환합니다. 스프링은 어떻게 예외를 처리하기에 이렇게 작동하는 것일까요?🤔 이번 글에서는 스프링에서 어떻게 예외를 처리하는지와, 어떻게해야 예외를 잘~ 처리할 수 있는지 알아보겠습니다.
🔍 스프링의 기본 예외 처리 방식
어떠한 예외처리도 하지 않았을 때, 스프링에서 어떤식으로 예외가 처리될까요?
우선 스프링의 기본 작동 방식에 대해 생각해봅시다.
스프링에 요청이 오면, WAS - Dispatcher Servlet - HandlerMapping - HandlerAdapter - Controller
를 거쳐 로직이 실 행됩니다.
이때 예외가 발생하면 예외 내용은 WAS까지 거슬러 올라갑니다.
그럼 WAS는 어플리케이션에서 처리할 수 없는 예외라 판단하여 에러 컨트롤러로 예외 내용을 전달합니다.
즉, 예외가 발생하면 WAS - Controller - WAS - ErrorController
의 흐름을 갖게 됩니다.
이 과정을 통해 예외가 발생해도 어플리케이션을 종료하지 않고, 마치 정상 요청인 것처럼 예외 응답을 반환하는 것입니다.
BasicErrorController
이때 호출되는 에러 컨트롤러가 바로 BasicErrorController
입니다.
실제로 어떠한 예외처리도 하지 않고 BasicErrorController에 BreakPoint 를 걸어두고 디버깅하니,
BasicErrorController에서 예외가 핸들링되는 것을 확인할 수 있었습니다.
BasicErrorController의 응답
BasicErrorController는 DefaultErrorAttributes
의 getErrorAttributes()
함수를 호출해서 응답할 내용을 불러옵니다.
이때 getErrorAttributes()가 기본적으로 제공하는 속성과, 설정을 통해 추가할 수 있는 속성들은 다음과 같습니다.
- timestamp: 에러가 발생한 시간
- status: 에러의 Http 상태
- error: 에러 코드
- path: 에러가 발생한 uri
- exception: 최상위 예외 클래스의 이름(설정 필요)
- message: 에러에 대한 내용(설정 필요)
- errors: BindingExecption에 의해 생긴 에러 목록(설정 필요)
- trace: 에러 스택 트레이스(설정 필요)
// 어떤 설정도 하지 않았을 때의 응답
{
"timestamp": "2024-07-21T16:16:40.463+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/reviews/999"
}
// 추가할 수 있는 속성들
server:
error:
include-message: always
include-binding-errors: always
include-stacktrace: always
include-exception: true
// 모든 속성을 추가했을 때의 응답
{
"timestamp": "2024-07-21T16:19:58.729+00:00",
"status": 500,
"error": "Internal Server Error",
"exception": "org.springframework.orm.jpa.JpaObjectRetrievalFailureException",
"trace": "org.springframework.orm.jpa.JpaObjectRetrievalFailureException\n\tat ….",
"message": "No message available",
"path": "/reviews/999"
}
BasicErrorController를 통한 예외 처리의 한계
혹시 '이것만으로도 충분히 훌륭한데?' 라는 생각이 드시나요? 하지만 이 방식에는 몇가지 한계가 존재합니다. 첫째로, 이 방식은 WAS에서 컨트롤러를 거쳐, 다시 WAS로 왔다가, 에러 컨트롤러로 가는 흐름인데요, 이 과정이 길고 복잡하게 느껴집니다. 또 이는 필터나 인터셉터를 2번 호출하는 등 다른 문제를 야기할 수 있습니다. 그리고 결정적으로, 예외에 따라 다른 HttpStatusCode와 메세지를 줄 수도 없습니다😓
🔍 스프링이 제공하는 다양한 예외 처리 방식
이런 단점을 보완하기 위해서, 스프 링에서는 기본 예외 처리 외에도 다양한 예외 처리 방식을 제공합니다.
HandlerExceptionResolver
HandlerExceptionResolver는 예외 처리 방식을 추상화한 인터페이스입니다. 이를 구현하는 구현체들은 발생한 예외를 캐치하여 응답의 Http 상태나 메세지를 설정합니다.
HandlerExceptionResolver의 구현체들
HandlerExceptionResolver의 구현체들은 HandlerExceptionResolverComposite에 우선순위 순서대로 빈으로 등록되어 관리됩니다.
그리고 예외 발생 시, 우선순위 순으로 구현체들을 순회하며 핸들링을 할 수 있는지 확인합니다.
public class HandlerExceptionResolverComposite implements HandlerExceptionResolver, Ordered {
@Override
@Nullable
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
if (this.resolvers != null) {
// 구현체들을 순회하면서 예외를 처리할 수 있는지 확인
for (HandlerExceptionResolver handlerExceptionResolver : this.resolvers) {
ModelAndView mav = handlerExceptionResolver.resolveException(request, response, handler, ex);
if (mav != null) {
return mav;
}
}
}
return null;
}
}
빈으로 등록된 HandlerExceptionResolver 구현체과 그것이 처리하는 대상은 아래와 같습니다.
- ExceptionHandlerExceptionResolver : ExceptionHandler를 처리
- ResponseStatusExceptionResolver : ResponseStatus, ResponseStatusException를 처리
- DefaultHandlerExceptionResolver : 스프링 내부의 기본 예외들을 처리
하지만 ResponseStatusExceptionResolver
만으로는 에러 응답을 수정할 수 없으며, 일괄 에러 처리를 할 수 없다는 단점이 있으므로 이번 글에서는 설명하지 않겠습니다.
🔍 HandlerExceptionResolver를 동작시키는 것들
@ExceptionHandler와 @ControllerAdvice
@ExceptionHandler는 가장 우선순위가 높은 예외 핸들러인 ExceptionHandler ExceptionResolver에서 처리됩니다. @ExceptionHandler는 예외 응답의 코드와 메세지를 자유롭게 설정할 수 있다는 장점이 있습니다. @ExceptionHandler는 컨트롤러의 메서드에 바로 사용할 수도 있으며, @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드에도 사용할 수 있습니다. 만약 컨트롤러의 메서드에도 @ExceptionHandler를 사용하고, @ControllerAdvice에도 사용한다면 전자가 우선시됩니다.
스프링 내부의 기본 예외들
스프링에서 발생할 수 있는 기본 예외들은 미리 정의되어, DefaultHandler ExceptionResolver에서 처리됩니다. 대표적인 예외로는 지원하지 않는 HttpMethod 로 요청을 보낼 때 발생하는 HttpRequestMethod NotSupportedException가 있습니다. DefaultHandler ExceptionResolver는 에러를 핸들링해서 어떤 기본 예외인지 판별하는 역할까지만 하므로, 직접 예외 응답을 반환하지는 않습니다. 따라서 최종적으로 BasicController를 통해서 예외 응답을 반환합니다.
ResponseEntityExceptionHandler
여기까지 읽으시며 이상함을 느끼셨나요?😳 ExceptionHandler를 사용하면 예외 응답 형식을 지정할 수 있습니다. 반면 스프링 기본 예외는 DefaultHandler ExceptionResolver로 처리되기 때문에 BasicController의 응답 형식을 따르게 됩니다. 따라서 ExceptionHandler로 처리되는 예외와, 기본 예외의 응답 형식이 다르다는 문제가 생기게 됩니다. 이를 해결하기 위해 스프링은 ResponseEntity ExceptionHandler 를 제공합니다.
ResponseEntity ExceptionHandler는 스프링 기본 예외에 대한 핸들링을 미리 정의해둔 추상 클래스입니다. 따라서 ControllerAdvice가 ResponseEntity ExceptionHandler를 상속하게 하는것 만으로 스프링 기본 예외를 핸들링할 수 있습니다. 또한 ResponseEntity ExceptionHandler는 모든 기본 예외를 handleExceptionInternal() 함수로 처리하고 있기 때문에, 이 함수를 오버라이딩해줌으로써, 모든 기본 예외의 응답을 커스텀 예외 응답형식으로 통일할 수 있습니다.
어떻게 해야 예외를 잘 응답할 수 있을까?
여기까지 우리는 스프링에서 예외를 처리하는 방법에 대해 알아봤습니다. 그럼 이들을 바탕으로 우리는 어떻게 예외를 잘 처리하고, 잘 응답할 수 있을까요?🤔 제가 세운 기준은 아래와 같습니다.
1. ExceptionHandler를 사용하자.
스프링의 기본 예외 처리 방식인 BasicErrorController 를 사용하는 방식은, http status code 와 예외 메세지를 지정할 수 없다는 문제가 있습니다. ExceptionHandler 를 사용해서 유연하게 예외를 핸들링하는 것이 좋습니다.
2. ControllerAdvice 안에서 ExceptionHandler를 사용하자.
ExceptionHandler를 사용해서 각 컨트롤러마다 예외를 처리하는 방식은 중복되는 코드가 많을 것입니다. 또한 어떤 컨트롤러에는 ExceptionHandler를 붙이고, 다른 컨트롤러에는 붙이지 않는 등의 누락도 생길 수 있습니다. 따라서 예외 핸들링을 전역으로 할 수 있는 ControllerAdvice 안에서 ExceptionHandler를 사용하는 것이 좋습니다.
3. 최상위 예외인 Exception를 핸들링하자.
최상위 예외에 대한 핸들링을 하지 않는다면, BasicController
를 통해 응답되는 예외가 있을 수 있습니다.
그렇다면 우리가 정의한 커스텀 예외 응답과 다른 형식이 되므로, 일관성이 깨지게 됩니다.
따라서 최상위 예외에 대해 처리를 해줘야 합니다.
4. 스프링 기본 예외의 응답을 통일하자.
마찬가지로 일관성을 위해 스프링 기본 예외의 응답도 통일할 필요가 있습니다. 따라서 ControllerAdvice에서 ResponseEntityExceptionHandler를 상속하고, handleExceptionInternal() 함수를 오버라이딩해줘야 합니다.