스프링 MVC 2편(백엔드 웹 개발 활용 기술)

Ch09. API 예외 처리 - 스프링이 제공하는 ExceptionResolver

webmaster 2022. 3. 21. 14:42
728x90

스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.

  1. ExceptionHandlerExceptionResolver 
  2. ResponseStatusExceptionResolver 
  3. DefaultHandlerExceptionResolver -> 우선순위가 가장 낮다.

ResponseStatusExceptionResolver 

  • HTTP 상태 코드를 지정해준다.
  • 다음 두가지 경우를 처리해 준다
    • @ResponseStatus 가 달려있는 예외
    • ResponseStatusException 예외

BadRequestException

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad") //400 코드로 변경
public class BadRequestException extends RuntimeException{
    //ResponseStatusResolver에서 잡아서 상태코드를 변경해 준다
    //내부적으로 response.sendError(code, reason)으로 호출뒤 정상 호출을 반환한다.
}

ApiExceptionController

@GetMapping("/api/response-status-ex1")
public MemberDto responseStatusEx1(){
    //상태 코드가 400으로 바뀌어져 있다

    throw new BadRequestException();
}
  • BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지도 담는다.
  • ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason)를 호출하는 것을 확인할 수 있다.

ResponseStatusExceptionResolver 

내부적으로 우리가 작성했던 코드와 똑같이 실행해서 상태 코드를 바꾸고 정상흐름으로 변경해 준다.

  • 메시지 기능
    • reason을 MessageSource에서 찾는 기능도 제공한다. reason = "error.bad"

messages.properties

error.bad= 잘못된 요청 오류입니다. 메시지 사용
  • Messages.properties에 있는 값을 읽어서 메시지로 사용한다.
  • @ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.)
  • 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.

ResponseStatusException

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

DefaultHandlerExceptionResolver

ApiExceptionController

@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data){
    //Spring 이 타입오류 를 400대 에러로 변경해 준다.
    return "ok";
}

 

  • DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다.
  • 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다
    • 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다
    • DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다

DefaultHandlerExceptionResolver 

내부적으로 Type이 맞지 않을경우 400대 에러로 변경하게 되어 있다.

ExceptionHandlerExceptionResolver

  • API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다.
  • 예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다. 그리고 같은 예외라고 해도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 내려주어야 할 수 있다.
  • 한마디로 매우 세밀한 제어가 필요하다. 앞서 이야기했지만, 예를 들어서 상품 API와 주문 API는 오류가 발생했을 때 응답의 모양이 완전히 다를 수 있다
  • API 예외처리의 어려운 점
    • HandlerExceptionResolver를 떠올려 보면 ModelAndView를 반환해야 했다. 이것은 API 응답에는 필요하지 않다.
    • API 응답을 위해서 HttpServletResponse 에 직접 응답 데이터를 넣어주었다. 이것은 매우 불편하다. 스프링 컨트롤러에 비유하면 마치 과거 서블릿을 사용하던 시절로 돌아간 것 같다.
    • 정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다. 예를 들어서 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException 예외와 상품을 관리하는 컨트롤러에서 발생하는 동일한 RuntimeException 예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야 할까?

@ExceptionHandler

  • 스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver이다. 스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다. 실무에서 API 예외 처리는 대부분 이 기능을 사용한다

ErrorResult

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

ApiExceptionV2 Controller

@Slf4j
@RestController
public class ApiExceptionV2Controller {

    @ResponseStatus(HttpStatus.BAD_REQUEST) //해당 어노테이션을 붙여서 상태코드를 원하는 값으로 반환해 줄수 있다
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalException(IllegalArgumentException e) {
        //ExceptionHandlerExceptionResolver가 컨트롤러를 뒤져 해당 어노테이션이 있는지를 찾아서 해당 메소드를 호출해 준다.
        log.error("[exceptionHandler] ex", e);
        //해당 흐름을 찾아 정상 흐름으로 반환해 준다 -> 200 이 반환 된다.
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    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) {  //자식 에외를 모두 잡기 때문에 Exception을 잡게된다면 잡지않은 공통의 예외를 모두 잡아준다
        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에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다. 따라서 자식예외 가 발생하면 부모예외처리() , 자식예외처리() 둘다 호출 대상이 된다.
    • 그런데 둘 중 더 자세한 것이 우선권을 가지므로 자식예외처리() 가 호출된다. 물론 부모예외 가 호출되면 부모예외처리() 만 호출 대상이 되므로 부모예외처리() 가 호출된다
  • 다양한 예외

위와같은 예외를 한번에 처리 가능

 

Web on Servlet Stack

This part of the reference documentation covers support for Servlet stack, WebSocket messaging that includes raw WebSocket interactions, WebSocket emulation through SockJS, and publish-subscribe messaging through STOMP as a sub-protocol over WebSocket. 4.1

docs.spring.io

IllegalArgumentException 처리

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

UserException 처리

  • @ExceptionHandler에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다. 여기서는 UserException을 사용한다.
  • ResponseEntity를 사용해서 HTTP 메시지 바디에 직접 응답한다. 물론 HTTP 컨버터가 사용된다.
  • ResponseEntity 를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다. 앞서 살펴본 @ResponseStatus는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다

Exception 처리

  • throw new RuntimeException("잘못된 사용자") 이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException 이 던져진다.
  • RuntimeException 은 Exception의 자식 클래스이다. 따라서 이 메서드가 호출된다.
  • @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)로 HTTP 상태 코드를 500으로 응답한다

HTML 오류 화면

  • ModelAndView를 사용해서 오류 화면(HTML)을 응답하는 데 사용할 수도 있다.

 

728x90