HandlerExceptionResolver
이전 포스트에서 HandlerResolverException를 구현하는 구현체를 만든 후,
직접 Media Type에 따라 에러 처리에 대한 뷰 템플릿 및 데이터 반환을 진행하였다.
하지만 모든 예외에 대해서 구현체를 만드는 것은 너무 복잡하고 시간이 많이 든다.
따라서 Spring에서 제공해 주는 HandlerResolverException의 구현체를 이용하는 것이 좋다.
빈으로 등록되어 관리되는 구현체는 총 4개로 다음과 같다.
- DefaultErrorAttributes
에러 속성을 저장하며 직접 예외를 처리하지는 않는다. - ExceptionHandlerExceptionResolver
에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리 - ResponseStatusExceptionResolver
Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리 - DefaultHandlerExceptionResolver
스프링 내부의 기본 예외들을 처리
DefaultErrorAttributes는 위의 포스트의 내용과 같이 직접 예외를 처리하지 않고 속성만 관리한다.
그래서 내부적으로 DefaultErrorAttributes를 제외하고
직접 예외를 처리하는 3가지 ExceptionResolver들을
HandlerExceptionResolverComposite로 모아서 관리한다.
즉, Composite 패턴을 적용해 실제 예외 처리기들을 따로 관리한다.
Spring은 아래와 같은 도구들로 HandlerExceptionResolver를 동작시켜 에러를 처리할 수 있는데,
각각의 방식 대해 자세히 살펴보면 다음과 같다.
- ResponseStatus
- ResponseStatusException
- ExceptionHandler
- ControllerAdvice, RestControllerAdvice
ResponseStatus
어노테이션 이름에서 예측가능하듯이 @ResponseStatus는
에러 HTTP 상태를 변경하도록 도와주는 어노테이션이다.
@ResponseStatus는 다음과 같은 경우들에 적용할 수 있다.
- Exception 클래스 자체
- 메소드에 @ExceptionHandler와 함께
- 클래스에 @RestControllerAdvice와 함께
예를 들어 우리가 만든 예외 클래스에 다음과 같이 @ResponseStatus로 응답 상태를 지정해 줄 수 있다.
그러면 ResponseStatusExceptionResolver가 지정해 준 상태로 에러 응답이 내려가도록 처리한다.
@ResponseStatus(code=HttpStatus.NOT_FOUND)
public class PostException extends RuntimeException{ }
{
"timestamp": "2024-08-17T02:41:46.182+00:00",
"status": 404,
"error": "Not Found",
"exception": "com.nahc.deploytest.config.PostException",
"path": "/public/post/30"
}
하지만 에러 응답에서 볼 수 있듯이 이는 BasicErrorController에 의한 응답이다.
즉, @ResponseStatus를 처리하는 ResponseStatusExceptionResolver는
WAS까지 예외를 전달시키며, 복잡한 WAS의 에러 요청 전달이 진행되는 것이다.
이러한 @ResponseStatus는 다음과 같은 한계점들을 가지고 있다.
- 에러 응답의 내용(Payload)을 수정할 수 없음
(DefaultErrorAttributes를 수정하면 가능) - 예와 클래스와 강하게 결합되어 같은 예외는 같은 상태와 에러 메시지를 반환
- 별도의 응답 상태가 필요하다면 예외 클래스의 추가가 필요
- WAS까지 예외가 전달되고, WAS의 에러 요청 전달이 진행된다.
- 외부에서 정의한 Exception 클래스에는 @ResponseStatus를 붙여줄 수 없다.
ResponseStatusException
외부 라이브러리에서 정의한 코드는 우리가 수정할 수 없으므로 @ResponseStatus를 붙일 수 없다.
Spring5에는 @ResponseStatus의 프로그래밍적 대안으로써
손쉽게 에러를 반환할 수 있는 ResponseStatusException가 추가되었다.
ResponseStatusException는 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있고,
언체크 예외를 상속받고 있어 명시적으로 에러를 처리해주지 않아도 된다.
이러한 ResponseStatusException은 다음과 같이 사용할 수 있다.
@GetMapping("/post/{id}")
public ResponseEntity<Post> getPostProcess(@PathVariable Long id) {
if (id >= 30){
throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Not Found this Post");
}
return ResponseEntity.ok(postService.getPost(id));
}
{
"timestamp": "2024-08-17T02:45:18.397+00:00",
"status": 404,
"error": "Not Found",
"exception": "org.springframework.web.server.ResponseStatusException",
"path": "/public/post/30"
}
@ResponseStatus와 동일하게 예외가 발생하면
ResponseStatusExceptionResolver가 에러를 처리한다.
즉 @ResponseStatus와 마찬가지로 BasicErrorController에 의해 응답이 생성된다.
하지만 ResponseStatusException를 사용하면 다음과 같은 이점을 누릴 수 있다.
- 기본적인 예외 처리를 빠르게 적용할 수 있으므로 손쉽게 프로토타이핑 가능
- HttpStatus를 직접 설정하여 예외 클래스와의 결합도를 낮춤
- 불필요하게 많은 별도의 예외 클래스를 만들지 않아도 됨
- 프로그래밍 방식으로 예외를 직접 생성하므로 예외를 쉽게 제어
하지만 그럼에도 불구하고 ResponseStatusException는 다음과 같은 한계점들을 가지고 있다.
이러한 이유로 API 에러 처리를 위해서는 @ExceptionHandler를 사용하는 방식이 더 많이 사용된다.
- 직접 예외 처리를 프로그래밍하므로 일관된 예외 처리가 어려움
- 예외 처리 코드의 중복
- 예외가 WAS까지 전달되고, WAS의 에러 요청 전달이 진행됨
ExceptionHandler
@ExceptionHandler는 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 기능이다.
@ExceptionHandler는 다음에 어노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있다.
- 컨트롤러의 메소드
- @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드
예를 들어 다음과 같이 컨트롤러의 메소드에
@ExceptionHandler를 추가함으로써 에러를 처리할 수 있다.
@ExceptionHandler에 의해 발생한 예외는 ExceptionHandlerExceptionResolver에 의해 처리된다.
@RestController
@RequestMapping("/public")
@RequiredArgsConstructor
public class TestController {
private final PostService postService;
@GetMapping("/post/{id}")
public ResponseEntity<Post> getPostProcess(@PathVariable Long id) {
if (id >= 30){
throw new PostException();
}
return ResponseEntity.ok(postService.getPost(id));
}
@ExceptionHandler(PostException.class)
public ResponseEntity<String> handlePostException(PostException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
}
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handlerAllException(Exception e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(e.getMessage());
}
}
@ExceptionHandler는 Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다.
만약 어노테이션에 예외 클래스를 지정하지 않는다면, 파라미터에 설정된 에러 클래스를 처리하게 된다.
또한 @ResponseStatus와도 결합가능한데, 만약 ResponseEntity에서도 status를 지정하고,
@ResponseStatus도 있다면 ResponseEntity가 우선순위를 갖는다.
ExceptionHandler는 @ResponseStatus와 달리 에러 응답(payload)을 자유롭게 다룰 수 있다.
Spring은 예외 발생 시 가장 구체적인 예외 핸들러를 먼저 찾고, 없으면 부모 예외의 핸들러를 찾는다.
예를 들어 NullPointerException이 발생했다면, 위에서는 NullPointerException 처리기가 없으므로
Exception에 대한 처리기가 찾아진다. @ExceptionHandler를 사용 시에 주의할 점은
@ExceptionHandler에 등록된 예외 클래스와 파라미터로 받는 예외 클래스가 동일해야 한다.
만약 값이 다르다면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킨다.
@ExceptionHandler(PostException.class)
public ResponseEntity<String> handlePostException(NoSuchElementException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
}
java.lang.IllegalStateException: Could not resolve parameter [0] in public
org.springframework.http.ResponseEntity
ExceptionHandler의 파라미터로 HttpServletRequest나 WebRequest 등을 얻을 수 있으며
반환 타입으로는 ResponseEntity, String, void 등 자유롭게 활용할 수 있다.
@ExceptionHandler는 컨트롤러에 구현하므로 특정 컨트롤러에서만 발생하는 예외만 처리된다.
하지만 컨트롤러에 에러 처리 코드가 섞이며, 에러 처리 코드가 중복될 가능성이 높다.
그래서 스프링은 전역적으로 예외를 처리할 수 있는 좋은 기술을 제공해 준다.
@ControllerAdvice / @RestControllerAdvice
Spring은 전역적으로 @ExceptionHandler를 적용할 수 있는
@ControllerAdvice와 @RestControllerAdvice 어노테이션을 각각 제공하고 있다.
두 개의 차이는 @Controller와 RestController와 같이
@ResponseBody가 붙어 있어 응답을 Json으로 내려준다는 점에서 다르다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
...
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice {
...
}
ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해 준다.
위에서 보이듯 ControllerAdvice 어노테이션에는 @Component 어노테이션이 있어서
ControllerAdvice가 선언된 클래스는 스프링 빈으로 등록된다.
그러므로 우리는 다음과 같이 전역적으로 에러를 핸들링하는 클래스를 만들어
어노테이션을 붙여주면 에러 처리를 위임할 수 있다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PostException.class)
protected ResponseEntity<String> handlerPostException(PostException ex){
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
}
ControllerAdvice는 전역적으로 적용되는데,
만약 특정 클래스에만 제한적으로 적용하고 싶다면,
@RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있다.
Spring의 예외로는 Spring MVC Exception이 존재하는데,
Spring MVC 요청 처리 도중 발생할 수 있는 예외를 의미한다.
Spring MVC Exception은 그 양이 굉장히 많은데,
하나의 @ControllerAdvice, @RestControllerAdvice만을 통해 전역적으로 예외를 처리한다면,
모든 Spring MVC Exception을 명시해야만 한다. 하지만 Spring은
스프링 예외를 미리 처리해 둔 ResponseEntityExceptionHandler를 추상 클래스로 제공한다.
ResponseEntityExceptionHandler에는 스프링 예외에 대한 ExceptionHandler가
모두 구현되어 있으므로 ControllerAdvice 클래스가 이를 상속받게 하면 된다.
만약 이 추상 클래스를 상속받지 않는다면,
스프링 예외들은 DefaultHandlerExceptionResolver가 처리하게 되는데,
그러면 예외 처리기가 달라지므로 클라이언트가 일관되지 못한 에러 응답을 받지 못하므로,
ResponseEntityExceptionHandler를 상속시키는 것이 좋다.
또한 이는 기본적으로 에러 메시지를 반환하지 않으므로,
스프링 예외에 대한 에러 응답을 보내려면 아래 메소드를 오버라이딩 해야 한다.
public abstract class ResponseEntityExceptionHandler implements MessageSourceAware {
...
@Nullable
protected ResponseEntity<Object> handleExceptionInternal(
Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatusCode statusCode,
WebRequest request) {
...
}
}
즉 @ControllerAdvice, @RestControllerAdvice만을 사용할 경우,
Spring MVC Exception을 모두 처리하지 못할 수 있다.
이 경우 사실상 하나의 클래스에서 전역적인 예외 처리가 불가능하다.
ResponseEntityExceptionHandler를 상속받는다면,
Spring MVC Exception에 대한 최소한의 예외 처리를 할 수 있다.
필요한 경우 위의 경우처럼 오버라이딩을 통해 예외 처리 관련 메소드를
커스터마이징 하여 응답을 생성할 수 있다.
우리는 이러한 ControllerAdvice를 이용함으로써 다음과 같은 이점을 누릴 수 있다.
- 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능함
- 직접 정의한 에러 응답을 일관성 있게 클라이언트에게 전달
- 별도의 try-catch문이 없어 코드의 가독성이 높아짐
하지만 ControllerAdvice를 사용할 때에는 항상 다음의 내용들을 주의해야 한다.
여러 ControllerAdvice가 있을 때 @Order 어노테이션으로 순서를 지정하지 않는다면
Spring은 ControllerAdvice를 임의의 순서로 처리할 수 있다.
- 한 프로젝트당 하나의 ControllerAdvice만 관리하는 것이 좋다.
- 만약 여러 ControllerAdvice가 필요하다면 basePackages나 annotations 등을 지정해야 한다.
- 직접 구현한 Exception 클래스들은 한 공간에서 관리한다.
Spring의 예외 처리 흐름
앞서 설명하였듯 다음과 같은 예외 처리기들은 스프링의 빈으로 등록되어 있고,
예외가 발생하면 순차적으로 다음의 Resolver들이 처리가능한지 판별한 후에 예외가 처리된다.
- ExceptionHandlerExceptionResolver
에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함 - ResponseStatusExceptionResolver
Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함 - DefaultHandlerExceptionResolver
스프링 내부의 기본 예외들을 처리한다.
- ExceptionHandlerExceptionResolver가 동작
- 예외가 발생한 컨트롤러 안에 적합한 @ExceptionHandler가 있는지 검사
- 컨트롤러의 @ExceptionHandler에서 처리가능하다면 처리하고,
그렇지 않으면 ControllerAdvice로 넘어감 - ControllerAdvice안에 적합한 @ExceptionHandler가 있는지 검사하고,
없으면 다음 처리기로 넘어감
- ResponseStatusExceptionResolver가 동작
- @ResponseStatus가 있는지 또는 ResponseStatusException인지 검사
- 맞으면 ServletResponse의 sendError()로 예외를 서블릿까지 전달되고,
서블릿이 BasicErrorController로 요청을 전달
- DefaultHandlerExceptionResolver가 동작
- Spring의 내부 예외인지 검사하여 맞으면 에러를 처리하고 아니면 넘어감
- 적합한 ExceptionResolver가 없으므로 예외가 서블릿까지 전달되고,
서블릿은 SpringBoot가 진행한 자동 설정에 맞게 BasicErrorController로 요청을 다시 전달
앞서 살펴보았듯 Spring은 BasicErrorController를 제공한다.
ExceptionHandler나 ControllerAdvice처럼
직접 에러를 반환하는 경우에는 BasicErrorController를 거치지 않지만,
@ResponseStatus, ResponseStatusException 등과 같이
직접 에러 응답을 반환하지 않는 경우에는 최종적으로 BasicErrorController를 거쳐 에러가 처리된다.
클라이언트 입장에서는 이를 모르지만 내부에서는 2번 컨트롤러로 요청이 전달되는 과정이 진행된다.
Spring은 매우 다양한 예외 처리 방법을 제공하고 있어서
어떻게 에러를 처리하는 것이 최선인지 파악이 어려울 수 있다.
하지만 위의 포스팅을 통해 ControllerAdvice를 이용하는 것이 가장 좋은 방식임을 이해할 수 있다.
참고자료
https://mangkyu.tistory.com/204
https://velog.io/@daybreak312/2.-HandlerExceptionResolver%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC
'IT > Spring boot' 카테고리의 다른 글
[Spring boot] AOP (Aspect Oriented Programming) (5) | 2024.08.28 |
---|---|
[Spring boot] Spring Boot 3.2 RestClient의 등장 (0) | 2024.08.18 |
[Spring boot] HandlerExceptionResolver Custom 예외 처리 (0) | 2024.08.16 |
[Spring boot] Spring의 Default 예외 처리 (0) | 2024.08.16 |
[Spring boot] Ck Editor5 파일 업로드 최적화 과정 (0) | 2024.08.12 |