API예외 - 스프링이 제공하는 ExceptionResolver

바그다드·2023년 5월 30일
0

예외

목록 보기
7/9

지난 포스팅에서 HandlerExceptionResolver를 직접 구현하여 API예외를 처리하는 방법에 대해서 알아보았다. HandlerExceptionResolver를 활용하면 컨트롤러 밖으로 던져진 예외를 처리해 WAS에서 에러 페이지를 재요청하는 복잡한 과정을 피할 수 있었지만, 직접 HandlerExceptionResolver를 구현하는 것이 매우 번거로웠다.

이번 포스팅에서는 스프링에서 제공하는 HandlerExceptionResolver에 대해 알아보자.

스프링이 제공하는 ExceptionResolver

스프링 부트가 제공하는 ExceptionResolver는 다음 3가지가 있다. 우선순위 순으로 보자면

  1. ExceptionHandlerExceptionResolver
    • @ExceptionHandler를 처리한다.
  2. ResponseStatusExceptionResolver
    • Http상태 코드를 지정해준다.
  3. DefaultHandlerExceptionResolver
    • 스프링 내부 기본 예외를 처리한다.
  • ExceptionHandlerExceptionResolver가 가장 중요하므로 먼저 간단한 ResponseStatusExceptionResolver부터 알아보자.

1. ResponseStatusExceptionResolver(2순위)

  • 각 예외에 따라 http상태코드를 지정해주는 역할을 하는데, 다음 두 가지 경우를 처리해준다.
  1. @ResponseStatus가 명시되어 있는 예외
  2. ResponseStatusException예외
    코드로 확인해보자

@ResponseStatus

컨트롤러 생성
    @GetMapping("/api/response-status-ex1")
    public String responseStatusEx1() {
        throw new BadRequestException();
    }
예외 정의
@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "error.bad")
public class BadRequestException extends RuntimeException{
}
  • 예외에 @ResponseStatus를 적용하면 http상태 코드를 변경해준다.
    code : 상태코드를 지정하는 파라미터
    reason : MessageSource에서 메세지를 찾아주는 기능도 있다.
    - 물론 그냥 문자열도 설정 가능하다.
MessageSource추가
  • resources/messages.properties를 생성하고 값을 설정해주자.
error.bad=잘못된 요청 오류입니다. 메세지 사용
  • 포스트맨으로 확인해보면 다음과 같이 상태코드가 404로 설정되어 응답이 돌아오는 것을 확인할 수 있다.

    참고로 message등 에러 관련 내용을 확인하고 싶다면 application.properties에 다음 값들을 추가해주자
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
server.error.include-binding-errors=always
  • 하지만 @ResponseStatus의 경우에는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다는 문제와, 어노테이션이기 때문에 조건에 따라 동적으로 처리하는 것이 어렵다는 것이다. 그렇다면 어떻게 해야할까?
    ResponseStatusException예외를 사용하자.

ResponseStatusException

    @GetMapping("/api/response-status-ex2")
    public String responseStatusEx2() {
    //  throw new ResponseStatusException("상태코드", "예외 메세지", "처리할 예외 종류");
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
    }
  • ResponseStatusException을 활용해 상태코드, 에러 메세지, 예외 종류를 직접 지정해 동적인 처리를 할 수 있다.
  • 하지만 결국 ResponseStatusExceptionResolver도 response.sendError(statusCode,
    resolvedReason)를 호출하기 때문에 WAS에서 에러 페이지 /error를 재요청한다.


2. DefaultHandlerExceptionResolver(3순위)

  • DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 처리한다.
    여러가지 예외가 있겠지만 예를들어, 앞서 확인했던 TypeMismatchException의 경우 Integer형 데이터를 받아야 하는 파라미터에 String형 데이터가 들어온다면 500에러가 발생하게 된다. 그런데 이런 경우에는 보통 서버의 문제보다는 클라이언트에서 잘못된 요청을 보냈을 가능성이 크다. 때문에 500에러가 아니라 400에러로 처리해야한다.
    @GetMapping("/api/default-handler-ex")
    public String defaultException(@RequestParam Integer data) {
        return "ok";
    }

  • 파라미터로 Integer형 파라미터 data에 String형 데이터를 넘겨 TypeMismatchException이 발생하였는데, 응답 결과를 보면 400에러가 발생한 것을 확인할 수 있다.
    -하지만 DefaultHandlerExceptionResolver도 마찬가지로 handleTypeMismatch의 경우 response.sendError(HttpServletResponse.SC_BAD_REQUEST)를 호출하기 때문에 WAS에서 에러 페이지 /error를 재요청한다.
  • 스프링 부터에서 제공하는 ExceptionResolver를 통해 http 상태코드를 변경하거나, 스프링 내부 예외의 상태코드를 변경하는 기능을 알아보았다. 하지만 이 경우에도 결국 WAS에서 재요청을 보내게 된다. 이런 문제를 ExceptionHandlerExceptionResolver를 활용해 해결할 수 있다.

* DefaultHandlerExceptionResolver에서 지원하는 예외

ExceptionHTTP Status Code
HttpRequestMethodNotSupportedException405 (SC_METHOD_NOT_ALLOWED)
HttpMediaTypeNotSupportedException415 (SC_UNSUPPORTED_MEDIA_TYPE)
HttpMediaTypeNotAcceptableException406 (SC_NOT_ACCEPTABLE)
MissingPathVariableException500 (SC_INTERNAL_SERVER_ERROR)
MissingServletRequestParameterException400 (SC_BAD_REQUEST)
MissingServletRequestPartException400 (SC_BAD_REQUEST)
ServletRequestBindingException400 (SC_BAD_REQUEST)
ConversionNotSupportedException500 (SC_INTERNAL_SERVER_ERROR)
TypeMismatchException400 (SC_BAD_REQUEST)
HttpMessageNotReadableException400 (SC_BAD_REQUEST)
HttpMessageNotWritableException500 (SC_INTERNAL_SERVER_ERROR)
MethodArgumentNotValidException400 (SC_BAD_REQUEST)
BindException400 (SC_BAD_REQUEST)
NoHandlerFoundException404 (SC_NOT_FOUND)
AsyncRequestTimeoutException503 (SC_SERVICE_UNAVAILABLE)

3. ExceptionHandlerExceptionResolver(1순위)

  • api의 경우 HandlerExceptionResolver을 사용하는 경우에는 response객체에 직접 데이터를 넣어야 하는 번거로움이 있고, 각 기능에 따라서 각각 다른 방식으로 처리해야 하는 문제가 있다. 스프링에서는 @ExceptionHandler을 제공하는데, 이게 ExceptionHandlerExceptionResolver이며 api예외 처리에 편리한 기능을 제공해준다.
  • 코드로 확인해보자

ErrorResult생성

  • 응답에 사용하기 위해 객체를 생성해주자.
@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String massage;
}

컨트롤러 생성

@Slf4j
@RestController
public class ApiExceptionV2Controller {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }
	
    // 예외를 생략할 경우 메서드 파라미터의 예외가 지정됨
    @ExceptionHandler // (UserException.class)
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] 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 exHandler(Exception e) {
        log.error("[exceptionHandler] 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에 여러 예외를 지정할 수도 있다.
      @ExceptionHandler({AException.class, BException.class})
  • 또한 @ExceptionHandler에서 지정한 예외의 경우 그 자식 클래스에 속한 예외도 같이 처리해준다.
    스프링의 경우 기본적으로 자세한 것이 높은 우선권을 가지기 때문에 부모 자식 관계에 있는 클래스의 경우 자식 클래스가 높은 우선권을 가지게 된다.

  • 위의 코드를 보면
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e)

    @ExceptionHandler
    public ResponseEntity< ErrorResult> userExHandler(UserException e)

    이처럼 @ExceptionHandler()의 파라미터로 예외를 명시하는 것과 안한 것이 있는 것을 확인할 수 있는데, @ExceptionHandler()의 파라미터를 생략할 경우 메서드 파라미터에서 지정한 예외가 @ExceptionHandler에서 처리하는 예외로 지정이 된다.

흐름

  • IllegalArgumentException이 발생할 경우,
  1. 컨트롤러 호출 중 IllegalArgumentException 발생
  2. 예외를 처리하기 위해 ExceptionResolver가 작동하는데, 우선순위가 가장 높은 ExceptionHandlerExceptionResolver가 실행
  3. 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인
  4. @ExceptionHandler가 붙은 메서드 중에 illegalExHandler()가 존재하므로 메서드 실행
    • @RestController로 설정했으므로 illegalExHandler도 응답을 json데이터로 반환
  5. @ResponseStatus(HttpStatus.BAD_REQUEST)를 명시했으므로 상태코드 400으로 응답
  • 참고로 다음과 같이 ModelAndView를 활용해 에러 화면을 응답할 수도 있다.
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
	log.info("exception e", e);
	return new ModelAndView("error");
}
  • 그런데 생각해보면 이 코드의 경우에도 AOP를 생각해보면 정상 코드와 예외 처리 코드가 하나의 컨트롤러 안에 있다. 따라서 예외 처리는 따로 분리할 필요성이 있다. @ControllerAdvice나 @RestControllerAdvice를 사용하자!!

4. 예외 코드 분리 - @ControllerAdvice

  • @ControllerAdvice의 경우 지정한 컨트롤러에 @ExceptionHandler나 @InitBinder 기능을
    부여해주는 역할을 한다.
  • 따로 대상을 지정하지 않을 경우 모든 컨트롤러에 적용된다.
  • @RestControllerAdvice는 @ControllerAdvice의 모든 메서드에 @ResponseBody가 적용된다.
    - @RestController와 유사하다고 생각하면 된다.

1. @ControllerAdvice 타겟 지정 방법

// 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 {}
  • 이중에서 패키지를 지정해서 등록하는 방법을 사용해보자.

2. @RestControllerAdvice생성

@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler // (UserException.class)
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] 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 exHandler(Exception e) {
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", "내부 오륲");
    }

}
  • @RestControllerAdvice(basePackages = "hello.exception.api")
    해당 패키지에 존재하는 모든 컨트롤러를 대상으로 지정한다.

3. 컨트롤러 수정

  • @ExceptionHandler를 제거해주자
@Slf4j
@RestController
public class ApiExceptionV3Controller {


    @GetMapping("/api3/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;
    }
}

이것으로 예외 처리에 대해서 알아보았다. 하지만 실무에서 적절하게 활용하기 위해서는 많이 사용해봐야 할 것 같다.

출처 : 김영한 스프링MVC2편

profile
꾸준히 하자!

0개의 댓글