API 예외 처리

이신성·2023년 11월 8일

API - 예외 처리

  • HTML 페이지의 경우 4.xx, 5.xx같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있다.
    그런데 API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.

HTML 페이지 vs API 오류

  • BasicErrorController를 확장하면 JSON 메시지도 변경할 수 있다. 그런데 @ExceptionHandler가 제공하는 기능을 사용하는 것이 더 나은 방법
  • 스프링 부트가 제공하는 BasicErrorController는 HTML 페이지를 제공하는 경우에 매우 편리하다. 그런데 API 오류 처리는 API 마다 각각의 컨트롤러나 예외마다 서로 다른 응답을 출력해야 할 수도 있다. 예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다. 따라서 BasicErrorController는 HTML화면을 처리할 떄 사용하고, API 오류 처리는 @ExceptionHandler를 사용하자.

API 예외 처리 - ExceptionResolver1

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

ExceptionHandlerExceptionResolver

  • @ExceptionHandler을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다.

    ResponseStatusExceptionResolver

  • HTTP 상태 코드를 지정해준다
    예) @ResponseStatus(value = HttpStatus.NOT_FOUND)

DefaultHandlerExceptionResolver

  • 스프링 내부 기본 예외를 처리한다.

ResponseStatusExceptionResolver

  • @ResponseStatus가 달려있는 예외
  • ResponseStatusException 예외

@ResponseStatus 애노테이션을 적용하면 HTTP 상태 코드를 변경해준다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류") public class BadRequestException extends RuntimeException {
} 

ApiExceptionController - 추가

@GetMapping("/api/response-status-ex1")
 public String responseStatusEx1() {
     throw new BadRequestException();
 }

reason을 MessageSource에서 찾는 기능도 제공한다.


//@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류") @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad") public class BadRequestException extends RuntimeException {
} 

message.properties

error.bad=잘못된 요청 오류입니다. 메시지 사용 

ResponseStatusException

ApiExceptionController - 추가

@GetMapping("/api/response-status-ex2")
  public String responseStatusEx2() {
      throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
  IllegalArgumentException());
  }

@ResponseStatusException

@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. 이떄는 ResponseStatusException 예외를 사용하면 된다.

ApiExceptionController - 추가

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
IllegalArgumentException());
}

DefaultHandlerExceptionResolver

  • DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 결과적으로 500 오류가 발생한다. 그런데 파라미터 바인딩은 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.

ApiExceptionController - 추가

@GetMapping("/api/default-handler-ex")
  public String defaultException(@RequestParam Integer data) {
      return "ok";
  }
  • Integer data에 문자를 입력하면 내부에서 TypeMismatchException이 발생한다.

@ExceptionHandler

  • ExceptionHandlerExceptionResolver

ErrorResult

@Data
  @AllArgsConstructor
  public class ErrorResult {
      private String code;
      private String message;
  }

ApiExceptionV2Controller

@Slf4j
@RestController
public class ApiExceptionV2Controller {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
} 
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e); return new ErrorResult("EX", "내부 오류"); 
    }
    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자"); } 
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값"); 
        }
        if (id.equals("user-ex")) {
throw new UserException("사용자 오류"); } 
 return new MemberDto(id, "hello " + id);
      }

      @Data
      @AllArgsConstructor
      static class MemberDto {
          private String memberId;
          private String name;
      }
} 

@ExceptionHandler 예외 처리 방법

@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}

  • 다음 예제는 IllegalArgumentException 또는 그 하위 자식 클래스를 모두 처리할 수 있다.

우선 순위

@ExceptionHandler(부모예외.class) public String 부모예외처리()(부모예외 e) {} 
@ExceptionHandler(자식예외.class) public String 자식예외처리()(자식예외 e) {} 
  • @ExceptionHandler에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다.
@ExceptionHandler({AException.class, BException.class})
 public String ex(Exception e) {
     log.info("exception e", e);
 }
  • 다양한 예외를 한번에 처리할 수 있다.

IllegalArgumentException 처리


@ResponseStatus(HttpStatus.BAD_REQUEST)
  @ExceptionHandler(IllegalArgumentException.class)
  public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
  }
  • 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖을 던져진다.
  • 예외가 발생했으므로 ExceptionResolver가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
  • ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 ExceptionHandler가 있는지 확인한다.
  • illegalExHandle()를 실행한다. @RsetController이므로 illegalExHandle()에도 @ResponseBody가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.
  • @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 HTTP상태 코드 400으로 응답한다.

UserException 처리

@ExceptionHandler
  public ResponseEntity<ErrorResult> userExHandle(UserException e) {
      log.error("[exceptionHandle] ex", e);
      ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
      return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
} 
  • @ExceptionHandler에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다. 여기서는 UserException을 사용한다.
  • ResponseEntity를 사용해서 HTTP 메시지 바디에 직접 응답한다. 물론 HTTP 컨버터가 사용된다. ResponseEntity를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다.

Exception

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
  @ExceptionHandler
  public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e); return new ErrorResult("EX", "내부 오류"); 
} 
  • throw new RuntimeException(“잘못된 사용자”)이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException이 던져진다.
  • RuntimeException은 Exception의 자식 클래스이다. 따라서 이 메서드가 호출된다.
  • @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)로 HTTP 상테 코드를 500으로 응답한다.

HTMP 오류 화면

  • 다음과 같이 ModelAndView를 사용해서 오류 화면(HTML)을 응답하는데 사용할 수도 있다.
@ExceptionHandler(ViewException.class)
  public ModelAndView ex(ViewException e) {
      log.info("exception e", e);
      return new ModelAndView("error");
  }

API 예외 처리 - @ControllerAdvice

  • 정상 코드와 예외처리 코드를 분리할 수 있다.

ExcontrollerAdvice

@Slf4j
  @RestControllerAdvice
  public class ExControllerAdvice {
      @ResponseStatus(HttpStatus.BAD_REQUEST)
      @ExceptionHandler(IllegalArgumentException.class)
      public ErrorResult illegalExHandle(IllegalArgumentException e) {
          log.error("[exceptionHandle] ex", e);
          return new ErrorResult("BAD", e.getMessage());
      }
      @ExceptionHandler
      public ResponseEntity<ErrorResult> userExHandle(UserException e) {
          log.error("[exceptionHandle] ex", e);
          ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
          return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
} 
      @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
      @ExceptionHandler
      public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류"); } 
} 

ApiExceptionV2Controller

@Slf4j
  @RestController
  public class ApiExceptionV2Controller {
      @GetMapping("/api2/members/{id}")
      public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자"); 
          }
          if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값"); } 
if (id.equals("user-ex")) {
throw new UserException("사용자 오류"); 
} 
          return new MemberDto(id, "hello " + id);
      }
      @Data
      @AllArgsConstructor
      static class MemberDto {
private String memberId;
          private String name;
      }
} 

@ControllerAdvice

  • @ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @IntiBinder 기능을 부여해주는 역할을 한다.
  • @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.
  • @RestControllerAdvice는 @ControllerAdvice와 같고, @ResponseBody가 추가되어 있다.
  • @Controller, @RestController의 차이가 같다.

대상 컨트롤러 지정 방법

// Target all Controllers annotated with @RestController
 @ControllerAdvice(annotations = RestController.class)
 public class ExampleAdvice1 {}
 // Target all Controllers within specific packages
 @ControllerAdvice("org.example.controllers")
 public class ExampleAdvice2 {}
 // Target all Controllers assignable to specific classes
 @ControllerAdvice(assignableTypes = {ControllerInterface.class,
 AbstractController.class})
 public class ExampleAdvice3 {}

0개의 댓글