스프링에서 내부적으로 Exception 을 처리하는 방법

SeungHoon·2024년 6월 23일

Spring

목록 보기
5/15
post-thumbnail

시작

Spring은 만들어질 때(1.0)부터 에러 처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 기본적으로 /error로 에러 요청을 다시 전달하도록 WAS 설정을 해두었다.

그래서 별도의 설정이 없다면 예외 발생 시에 BasicErrorController로 에러 처리 요청이 전달된다. 참고로 이는 스프링 부트의 WebMvcAutoConfiguration를 통해 자동 설정이 되는 WAS의 설정이다. 여기서 요청이 /error로 다시 전달된다는 부분에 주목해야 한다. 일반적인 요청 흐름은 다음과 같이 진행된다.

WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러

그리고 컨트롤러 하위에서 예외가 발생하였을 때, 별도의 예외 처리를 하지 않으면 WAS까지 에러가 전달된다. 그러면 WAS는 애플리케이션에서 처리를 못하는 예외라 exception이 올라왔다고 판단을 하고, 대응 작업을 진행한다.

컨트롤러(예외발생) -> 인터셉터 -> 서블릿(디스패처 서블릿) -> 필터 -> WAS(톰캣)

WAS는 스프링 부트가 등록한 에러 설정(/error)에 맞게 요청을 전달하는데, 이러한 흐름을 총 정리하면 다음과 같다.

WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러
-> 컨트롤러(예외발생) -> 인터셉터 -> 서블릿(디스패처 서블릿) -> 필터 -> WAS(톰캣)
-> WAS(톰캣) -> 필터 -> 서블릿(디스패처 서블릿) -> 인터셉터 -> 컨트롤러(BasicErrorController)

기본적인 에러 처리 방식은 결국 에러 컨트롤러를 한번 더 호출하는 것이다. 그러므로 필터나 인터셉터가 다시 호출될 수 있는데, 이를 제어하기 위해서 별도의 설정이 필요하다.

서블릿은 DispatcherType로 요청의 종류를 구분하는데, 일반적인 요청은 REQUEST이며 에러 처리 요청은 ERROR이다. 필터는 서블릿 기술이므로 필터 등록(FilterRegisterationBean) 시에 호출될 DispatcherType 을 설정할 수 있고, 별도의 설정이 없다면 REQUEST일 경우에만 필터가 호출된다. 하지만 인터셉터는 스프링 기술이므로 DispatcherType을 설정할 수 없어 URI 패턴으로 처리가 필요하다.

스프링 부트에서는 WAS까지 직접 제어하게 되면서 이러한 WAS의 에러 설정까지 가능해졌다. 또한 이는 요청이 2번 생기는 것은 아니고, 1번의 요청이 2번 전달되는 것이다. 그러므로 클라이언트는 이러한 에러 처리 작업이 진행되었는지 알 수 없다.

스프링 시큐리티에서 작성한 SecurityConfig class가 인터셉트를 구현한 클래스이다.

하지만 설정을 변경했음에도 불구하고 status는 여전히 500이며, 유의미한 에러 응답을 전달하지 못한다. (참고로 여기서 status가 500인 이유는 에러가 처리되지 않고 WAS가 에러를 전달받았기 때문이다.) 또한 흔히 사용되는 API 에러 처리 응답으로는 보다 세밀한 제어가 요구된다. 그러므로 우리는 별도의 에러 처리 전략을 통해 상황에 맞는 에러 응답을 제공해야 한다.

스프링이 제공하는 다양한 예외처리 방법

Spring은 예외 처리라는 공통 관심사를 메인 로직으로부터 분리하는 (AOP) 다양한 예외 처리 방식을 고안하였고, 예외 처리 전략을 추상화한 HandlerExceptionResolver 인터페이스를 만들었다.

public interface HandlerExceptionResolver {
    ModelAndView resolveException(HttpServletRequest request, 
            HttpServletResponse response, Object handler, Exception ex);
}

위의 Object 타입인 handler는 예외가 발생한 컨트롤러 객체이다. 예외가 던져지면 예외가 디스패처 서블릿까지 전달되는데, 적합한 예외 처리를 위해 HandlerExceptionResolver 구현체들을 빈으로 등록해서 관리한다. 그리고 적용 가능한 구현체를 찾아 예외 처리를 하는데, 우선순위대로 아래의 4가지 구현체들이 빈으로 등록되어 있다.

  • DefaultErrorAttributes : 에러 속성을 저장하며 직접 예외를 처리하지는 않는다.
  • ExceptionHandlerExceptionResolver : 에러 응답을 위한 Controller나 ControllerAdvice 에 있는 ExceptionHandler를 처리함.
  • ResponseStatusExceptionResolver : HTTP 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함.
  • DafaultHandlerExceptionResolver : 스프링 내부의 기본 예외들을 처리한다.

DefaultErrorAttributes는 직접 예외를 처리하지 않고 속성만 관리하므로 성격이 다르다. 그래서 내부적으로 DefaultErrorAttributes 을 제외하고 3가지 ExceptionResolver들을 HandlerExceptionResolverComposite로 모아서 관리한다.

Spring은 아래의 도구들로 ExceptionResolver를 동작시켜 에러를 처리할 수 있는데, 각각의 방식에 대해서 자세히 살펴보자.

  1. ResponseStatus
  2. ResponseStatusException
  3. ExceptionHandler
  4. ControllerAdvice, RestControllerAdvice

@ResponseStatus

예외 HTTP 상태를 변경하도록 도와주는 어노테이션이다. 다음과 같은 경우들에 적용할 수 있다.

  • Exception 클래스 자체
  • 메소드에 @ExceptionHandler와 함께
  • 클래스에 @RestControllerAdvice와 함께
@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class NoSuchElementFoundException extends RuntimeException {
  ...
}

다음과 같이 출력이 된다.

{
    "timestamp": "2021-12-31T03:35:44.675+00:00",
    "status": 404,
    "error": "Not Found",
    "path": "/product/5000"
}

하지만 에러 응답에서 볼 수 있듯이 이는 BasicErrorController에 의한 응답이다. 즉 @ResponseStatus를 처리하는 ResponseStatusExceptionResolver는 WAS까지 예외를 전달시키며, 복잡한 WAS의 에러 요청 전달이 진행되는 것이다. 이렇게 한계점이 존재한다.

  • 예외 응답의 내용을 수정할 수 없음. (DefaultErrorAttributes을 수정하면 되긴 함)
  • 예외 클래스와 강하게 결합되어 같은 예외는 같은 상태와 예외 메세지를 반환함.
  • 별도의 응답 상태가 필요하다면 예외 클래스를 추가해야 함.
  • WAS까지 예외가 전달되고, WAS의 에러 요청 전달이 진행됨.
  • 외부에서 정의한 Exception 클래스에는 @ResponseStatus를 붙일 수 없음.

개발자가 원하는대로 에러를 처리하는 것이 어려운 모습이다.

ResponseStatusException

Spring 5버전에서 손쉽게 에러를 제어할 수 있는 ResponseStatusException가 추가되었다. ResponseStatusExceptionHttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있고, 언체크 예외를 상속받고 있어 명시적으로 에러를 처리해주지 않아도 된다.

@GetMapping("/product/{id}")
public ResponseEntity<Product> getProduct(@PathVariable String id) {
    try {
        return ResponseEntity.ok(productService.getProduct(id));
    } catch (NoSuchElementFoundException e) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Item Not Found");
    }
}

ResponseStatusException을 사용하면 다음과 같은 이점을 얻을 수 있다.

  • 기본적인 예외처리를 빠르게 적용할 수 있으므로 손쉽게 프로토타이핑할 수 있다.
  • HttpStatus를 직접 설정하여 예외 클래스와의 결합도를 낮출수 있다.
  • 불필요하게 많은 별도의 예외 클래스를 만들지 않아도 됨.
  • 프로그래밍 방식으로 예외를 직접 생성하므로 예외를 더욱 잘 제어할 수 있음.

하지만 ResponseStatusException은 다음과 같은 단점이 있어서 @ExceptionHandler를 더 많이 사용한다.

  • 직접 예외 처리를 프로그래밍해서 일관된 예외 처리가 어려움
  • 예외 처리 코드가 중복될 수 있음
  • Spring 내부의 예외를 처리하는 것이 어려움
  • 예외가 WAS까지 전달되고, WAS의 에러 요청 전달이 진행됨.

    위 2가지 방법 모두 예외가 WAS까지 전달되고, WAS의 에러 요청 전달이 진행된다는 것을 기억하자.

@ExceptionHandler

@ExceptionHandler 은 매우 유연하게 에러를 처리할 수 있는 방법을 제공하는 기능이다. @ExceptionHandler 은 다음에 애노테이션을 추가함으로써 에러를 손쉽게 처리할 수 있다.

  • 컨트롤러의 메소드
  • @ControllerAdvice, @RestControllerAdvice 가 있는 클래스의 메소드
@RestController
@RequiredArgsConstructor
public class ProductController {

  private final ProductService productService;

  @GetMapping("/product/{id}")
  public Response getProduct(@PathVariable String id){
    return productService.getProduct(id);
  }

  @ExceptionHandler(NoSuchElementFoundException.class)
  public ResponseEntity<String> handleNoSuchElementFoundException(NoSuchElementFoundException exception) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(exception.getMessage());
  }
}

@ExceptionHandler 는 Exception 클래스들을 속성으로 받아 처리할 예외를 지정할 수 있다. 만약 @ExceptionHandler 에 예외 클래스를 지정하지 않는다면 파라미터에 설정된 에러 클래스를 처리하게 된다. 또한 @ResponseStatus 와도 결합 가능한데, 만약 ResponseEntity에서도 status를 지정하고, @ResponseStatus 도 있다면 ResponseEntity가 우선순위를 갖는다.

ExceptionHandler는 @ResponseStatus 와 달리 에러 응답을 자유롭게 다룰 수 있다는 점에서 유연하다. 예를 들어 응답을 다음과 같이 정의해서 내리면 좋을 것이다.

  • code : 어떤 종류의 에러가 발생하는지에 대한 에러 코드
  • message : 에러 메세지
  • errors : 어느 값이 잘 못되어 @Valid에 의한 검증이 실패한 것인지를 위한 에러 목록

여기서 code로 http 표준 상태와 같은 BAD_REQUEST 같은 가독성 좋은 값을 사용하는 것이 클라이언트나 서버 입장에서 좋다.

@RestController
@RequiredArgsConstructor
public class ProductController {

    ...

    @ExceptionHandler(NoSuchElementFoundException.class)
    public ResponseEntity<ErrorResponse> handleItemNotFoundException(NoSuchElementFoundException exception) {
        ...
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) {
        ...
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAllUncaughtException(Exception exception) {
        ...
    }
}

Spring은 예외가 발생하면 가장 구체적인 예외 핸들러를 먼저 찾고, 없으면 부모 예외의 핸들러를 찾는다. 예를 들어 NullPointerException이 발생했다면, 위에서는 NullPointerException 처리기가 없으므로 Exception에 대한 처리기가 찾아진다.

@ExceptionHandler 를 사용 시에 주의할 점은 @ExceptionHandler에 등록된 예외 클래스와 파라미터로 받는 예외 클래스가 동일해야 한다는 것이다. 만약 값이 다르다면 스프링은 컴파일 시점에 에러를 내지 않다가 런타임 시점에 에러를 발생시킨다.

ExceptionHandler의 파라미터로 HttpServletRequest나 WebRequest 등을 얻을 수 있으며 반환 타입으로는 ResponseEntity, String, void 등 자유롭게 활용할 수 있다.

@ExceptionHandler 는 컨트롤러에 구현하므로 특정 컨트롤러에서만 발생하는 예외만 처리된다. 하지만 컨트롤러에 에러 처리 코드가 섞이며, 에러 처리 코드가 중복될 가능성이 높다. 그래서 스프링은 전역적으로 예외를 처리할 수 있는 좋은 기술을 제공해준다.

@ControllerAdvice와@RestControllerAdvice

@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를 적용해준다. 위에서 보이듯 @Component 애노테이션 때문에 ControllerAdvice 가 선언된 클래스는 스프링 빈으로 등록된다. 그러므로 우리는 다음과 같이 전역적으로 에러를 핸들링하는 클래스를 만들어 애노테이션을 붙여주면 에러 처리를 위임할 수 있다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(NoSuchElementFoundException.class)
    protected ResponseEntity<?> handleNoSuchElementFoundException(NoSuchElementFoundException e) {
        final ErrorResponse errorResponse = ErrorResponse.builder()
                .code("Item Not Found")
                .message(e.getMessage()).build();

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
    }
}

ControllerAdvice는 전역적으로 적용되는데, 만약 특정 클래스에만 제한적으로 적용하고 싶다면 @RestControllerAdvice의 basePackages 등을 설정함으로써 제한할 수 있다.
스프링 예외에는 대표적으로 잘못된 URI를 호출하여 발생하는 NoHandlerFoundException 등이 있다. Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공하고 있다. ResponseEntityExceptionHandler에는 스프링 예외에 대한 ExceptionHandler가 모두 구현되어 있으므로 ControllerAdvice 클래스가 이를 상속받게 하면 된다.
만약 이 추상 클래스를 상속받지 않는다면 스프링 예외들은 DefaultHandlerExceptionResolver가 처리하게 되는데, 그러면 예외 처리기가 달라지므로 클라이언트가 일관되지 못한 에러 응답을 받지 못하므로 ResponseEntityExceptionHandler를 상속시키는 것이 좋다. 또한 이는 기본적으로 에러 메세지를 반환하지 않으므로, 스프링 예외에 대한 에러 응답을 보내려면 아래 메소드를 오버라이딩 해야 한다.

public abstract class ResponseEntityExceptionHandler {
    ...

    protected ResponseEntity<Object> handleExceptionInternal(
        Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request) {

        ...
    }
}

우리는 이러한 ControllerAdvice를 이용함으로써 다음과 같은 이점을 누릴 수 있다.

  • 하나의 클래스로 모든 컨트롤러에 대해 전역적으로 예외 처리가 가능하다.
  • 직접 정의한 에러 응답을 일관성있게 클라이언트에게 내려줄 수 있음.
  • 별도의 try-catch문이 없어 코드의 가독성이 높아진다.

이러한 이유로 API에 의한 예외 처리를 할 때에는 ControllerAdvice를 이용한다. 하지만 ControllerAdvice을 사용할 때 주의할 점이 있다. @Order 으로 순서를 지정하지 않는다면 Spring은 ControllerAdvice를 임의의 순서로 처리할 수 있으므로 일관된 예외 응답을 위해서는 이러한 점에 주의해야 한다.

한 프로젝트에서 여러 @ControllerAdvice가 동일한 예외를 처리하는 경우를 말한다.

  • 한 프로젝트 당 하나의 ControllerAdvice만 관리하는게 좋다.
  • 만약 여러 ControllerAdvice가 필요하다면 순서를 지정해주거나 basePackeges를 지정해야한다.
  • 직접 구현한 Exception 클래스들은 한 공간에서 관리한다.

다시 정리하는 Spring의 예외 처리 흐름

앞서 설명하였듯 다음과 같은 예외 처리기들은 스프링의 빈으로 등록되어 있고, 예외가 발생하면 순차적으로 다음의 Resolver들이 처리가능한지 판별한 후에 예외가 처리된다.

  1. ExceptionHandlerExceptionResolver : 에러 응답을 위한 Controller나 ControllerAdvice에 있는 ExceptionHandler를 처리함.
  2. ResponseStatusExceptionResolver : Http 상태 코드를 지정하는 @ResponseStatus 또는 ResponseStatusException를 처리함.
  3. DefaultHandlerExceptionResolver : 스프링 내부의 기본 예외들을 처리한다.
  1. ExceptionHandlerExceptionResolver가 동작함
    • 예외가 발생한 컨트롤러 안에 적합한 @ExceptionHandler가 있는지 검사함
    • 컨트롤러의 @ExceptionHandler에서 처리가능하다면 처리하고, 그렇지 않으면 ControllerAdvice로 넘어감
    • ControllerAdvice안에 적합한 @ExceptionHandler가 있는지 검사하고 없으면 다음 처리기로 넘어감
  2. ResponseStatusExceptionResolver가 동작함
    • @ResponseStatus가 있는지 또는 ResponseStatusException인지 검사함.
    • 맞으면 ServletResponse의 sendError()로 예외를 서블릿까지 전달되고, 서블릿이 BasicErrorController로 요청을 전달함
  3. DefaultHandlerExceptionResolver가 동작함
    • Spring의 내부 예외인지 검사하여 맞으면 에러를 처리하고 아니면 넘어감
  4. 적합한 ExceptionResolver가 없으므로 예외가 서블릿까지 전달되고, 서블릿은 SpringBoot가 진행한 자동 설정에 맞게 BasicErrorController로 요청을 다시 전달함

앞서 살펴보았듯 Spring은 BasicErrorController를 구현해두었다. ExceptionHandlerControllerAdvice처럼 직접 에러를 반환하는 경우에는 BasicErrorController를 거치지 않지만 @ResponseStatus, ResponseStatusException 등과 같이 직접 에러 응답을 반환하지 않는 경우에는 최종적으로 BasicErrorController를 거쳐 에러가 처리된다. 클라이언트 입장에서는 이를 모르지만 내부에서는 2번 컨트롤러로 요청이 전달되는 과정이 진행된다.

이 과정은 비효율적이기 때문에 ExceptionHandlerControllerAdvice 을 사용하자.

profile
공유하며 성장하는 Spring 백엔드 취준생입니다

0개의 댓글