스프링의 다양한 예외 처리 방법

Kuno17·2023년 5월 9일
0

CS공부

목록 보기
7/17
post-thumbnail

우선 기본저긍로 Spring은 어떻게 예외를 처리하고 있는지 알아보자.

Spring의 기본적인 예외처리 방식(Spring Boot)

Spring은 만들어질 때 부터 에러처리를 위한 BasicErrorController를 구현해두었고, 스프링 부트는 예외가 발생하면 기본적으로 /error로 에러요청을 다시 전달하도록 WAS설정을 해두었다. 그래서 별도의 설정이 없다면 예외 발생시 BasicErrorController로 에러 처리 요청이 전달된다.
팜고로 스프링 부트의 WebMvcAutoConfigration을 통해 자동설정이 되는 WAS의 설정이다.
여기서 /error로 다시 전달되는 부분에 주목해야 한다.

WAS란 무엇인가? - 참고 : WAS (Web Application Server)

일반적인 요청의 흐름은 다음과 같다.

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

그리고 컨트롤러 하위에서 예외가 발생하였을 경우 별도 처리하지 않으면 WAS까지 에러가 전달된다.

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

종합해서 흐름을 표현하면 다음과 같다.

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

기본적인 에러 처리방식은 결국 에러 컨트롤러를 한번 더 호출하는 것이다.


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

자바에서는 예외 처리를 위해 try-catch를 사용해야 하지만 try-catch를 모든 코드에 붙이는 것은 비효율적이다. Spring은 에러처리라는 공통 관심사(cross-cutting concerns)를 메인로직으로부터 분리하는 다양한 예외 처리방식을 고안했고, 예외 처리 전략을 추상화한 HandlerExceptionResolver 인터페이스를 만들었다.

대부분의 HandlerExceptionResolver는 발생한 Exception을 catch하고 HTTP상태나 응답 메세지 등을 결정한다.
그래서 WAS입장에서는 해당 요청이 정상적인 응답으로 인식되며 위에서 설명한 복잡한 WAS의 에러 전달이 진행되지 않는다.

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를 처리함
  • DefaultHandlerExceptionResolver: 스프링 내부의 기본 예외들을 처리한다.

그러나 DefaultErrorAttributes는 속성만 다루므로 제외한다.

나머지 3가지 ExceptionResolver들을 HandlerExceptionResolverComposite로 모아서 관리한다. 즉 컴포지트 패턴을 적용해 실제 예외 처리기들을 따로 관리한느 것이다.

아래외 같은 도구들로 ExceptionResolver를 동작시켜 예외처리가 가능하다.

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

@ResponseStatus

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

  1. Exception 클래스 자체
  2. 메소드에 @ExceptionHandler와 함께
  3. 클래스에 @RestControllerAdvice와 함께

예를 들어서 우리가 만든 클래스에 다음과 같이 응답 상태를 지정해줄 수 있다.

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class NoSuchElementFoundException extends RuntimeException {
  ...
}

Response

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

그러나 이는 BasicErrorController에 의한 응답이다. 즉 복잡한 WAS의 에러요청이 진행된다는 것이다. 이러한 점이 바로 한계점이다.


ResponseStatusException

외부 라이브러리에서 정의한 코드는 우리가 수정할 수 없으므로 @ResponseStatus를 붙여줄 수 없다.
@ResponseStatus의 프로그래밍적 대안으로써 손쉽게 에러를 반환할 수 있는 ResponseStatusException가 추가되었다. ResponseStatusException는 HttpStatus와 함께 선택적으로 reason과 cause를 추가할 수 있고, 언체크 예외을 상속받고 있어 명시적으로 에러를 처리해주지 않아도 된다. 이러한 ResponseStatusException은 다음과 같이 사용할 수 있다.

@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");
    }
}

@ResponseStatus와 동일하게 예외가 발생하면 ResponseStatusExceptionResolver가 에러를 처리한다. ResponseStatusException를 사용하면 다음과 같은 이점을 누릴 수 있다.

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

그러나 다음과 같은 한계점들을 가지고 있다.

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

이러한 한계점들 때문에 ExceptionHandler를 사용하는 방식이 더 많이 사용된다.


@ExceptionHandler

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

  • 컨트롤러의 메소드
  • @ControllerAdvice나 @RestControllerAdvice가 있는 클래스의 메소드

예를 들어 다음과 같이 컨트롤러의 메소드에 @ExceptionHandler를 추가함으로써 에러를 처리할 수 있다. @ExceptionHandler에 의해 발생한 예외는 ExceptionHandlerExceptionResolver에 의해 처리가 된다.

@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와 달리 에러 응답(payload)을 자유롭게 다룰 수 있다는 점에서 유연하다. 예를 들어 응답을 다음과 같이 정의해서 내려준다면 좋을 것이다.

  • code: 어떠한 종류의 에러가 발생하는지에 대한 에러 코드
  • message: 왜 에러가 발생했는지에 대한 설명
  • erros: 어느 값이 잘못되어 @Valid에 의한 검증이 실패한 것인지를 위한 에러 목록

여기서 code로 E001, E002 등과 같이 내부적으로 정의한 값을 사용하는 것보다 BAD_REQUEST와 같은 Http 표준 상태와과 같이 가독성 좋은 값을 사용하는 것이 클라이언트의 입장에서도 대응하기 좋고, 유지보수하는 입장에서도 좋다.

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

ExceptionHandler의 파라미터로 HttpServletRequest나 WebRequest 등을 얻을 수 있으며 반환 타입으로는 ResponseEntity, String, void 등 자유롭게 활용할 수 있다. (더 많은 입력/반환 타입을 위해서는 공식 문서를 참고하도록 하자.)

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


@ControllerAdvice와 @RestControllerAdvice

전역적으로 @ExceptionHandler를 적용할 수 있는 @ControllerAdvice와 @RestControllerAdvice 어노테이션을 각각 Spring3.2, Spring4.3부터 제공하고 있다. 두 개의 차이는 @Controller와 RestController와 같이 @ResponseBody가 붙어 있어 응답을 Json으로 내려준다는 점에서 다르다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ControllerAdvice
@ResponseBody
public @interface RestControllerAdvice {
    ...
}

ControllerAdvice는 여러 컨트롤러에 대해 전역적으로 ExceptionHandler를 적용해준다. 위에서 보이듯 ControllerAdvice 어노테이션에는 @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);
    }
}
profile
자바 스터디 정리 - 하단 홈 버튼 참조.

0개의 댓글