[TIL] #9. API 예외 처리

kiteB·2021년 10월 4일
0

TIL-Spring4

목록 보기
15/17
post-thumbnail

시작

API 예외 처리는 어떻게 해야할까? 🤔

HTML 오류 페이지 vs API 오류 메시지

  • HTML 페이지는 각 오류에 맞는 (400, 4xx 등) 오류 페이지를 만들어서 고객에게
    오류 화면을 보여주면 된다.
  • API각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려줘야 한다.

ApiExceptionController - API 예외 컨트롤러

@Slf4j
@RestController
public class ApiExceptionController {

    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        return new MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}
  • 단순한 회원 조회 기능만 넣었다.
  • 예외 테스트를 위해 id 값이 ex인 경우 예외가 발생하도록 했다.

실행 결과

정상 호출

JSON 형식으로 데이터가 반환된다.

예외 발생 호출

JSON이 아닌, HTML 오류 페이지가 반환된다.
→ 우리가 원하는 것은 정상 요청이든, 오류 요청이든 JSON이 반환되는 것!


ErrorPageController - API 응답 추가

@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {

    log.info("API errorPage 500");

    Map<String, Object> result = new HashMap<>();
    Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
    result.put("status", request.getAttribute(ERROR_STATUS_CODE));
    result.put("message", ex.getMessage());

    Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    return new ResponseEntity(result, HttpStatus.valueOf(statusCode));
}
  • produces = MediaType.APPLICATION_JSON_VALUE: 클라이언트가 요청하는 HTTP Header의 Accept값이 application/json일 때 해당 메서드를 호출한다는 뜻

실행 결과

이제 예외가 발생했을 경우도 (요청 HTTP Header의 Accept 값이 JSON이면),
JSON 형식으로 반환되는 것을 알 수 있다!

🔗 전체 코드 확인하기


스프링 부트 기본 오류 처리

API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다.

BasicErrorController를 사용하도록
WebServerCustomizer@Component를 주석처리하자!

실행 결과

스프링부트는 BasicErrorController가 제공하는 기본 정보들을 활용해서 오류 API를 생성해준다.


HTML 페이지 vs API 오류

BasicErrorController를 확장하면 JSON 메시지도 변경할 수 있다.

  • HTML 페이지: 4xx, 5xx 등 모두 잘 처리해준다.
  • API 오류: 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해줘야 하는 경우도 있는데 BasicErrorController만으로는 부족하다😥

📌 정리

BasicErrorController는 HTML 화면을 처리할 때 사용하고,
API는 @ExceptionHandler를 사용하자!


HandlerExceptionResolver 시작

HandlerExceptionResolver

스프링MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우,
예외를 해결
하고, 동작을 새로 정의할 수 있도록 HandlerExceptionResolver를 제공한다.
(HandlerExceptionResolver를 줄여서 ExceptionResolver라고 한다.)

ExceptionResolver 적용 전

  • 예외가 발생하면 postHandler를 호출하지 않는다!

ExceptionResolver 적용 후

  • 중간에 HandlerExceptionResolver가 추가되어
    발생한 예외를 처리하고, WAS로 전달할 방식을 제어하는 과정이 추가되었다.
  • 예외를 해결해도 postHandle()은 호출❌

HandlerExceptionResolver - 인터페이스

public interface HandlerExceptionResolver {

    ModelAndView resolveException(
    HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
}
  • handler: 핸들러(컨트롤러) 정보
  • Exception ex: 핸들러(컨트롤러)에서 발생한 예외

MyHandlerExceptionResolver

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {

        try {
            if (ex instanceof IllegalArgumentException) {
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}
  • ExceptionResolverModelAndView를 반환하는데, (try~catch를 하듯이)
    Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이기 때문이다!
  • IllegalArgumentException이 발생하면 responsesendError(400)을 호출해서
    HTTP 상태 코드를 400으로 지정하고 ModelAndView를 반환한다.

✔ 반환 값에 따른 DispatcherServlet의 동작 방식

  • ModelAndView: 뷰 렌더링❌, 정상 흐름으로 서블릿이 리턴된다.
  • ModelAndView 지정: 뷰를 렌더링한다.
  • null: 다음 ExceptionResolver를 찾아서 실행한다.
    • 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고,
      기존에 발생한 예외를 서블릿 밖으로 던진다.

ExceptionResolver 활용

  • 예외 상태 코드 변환
    • 예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
    • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출
  • 뷰 템플릿 처리
    • ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링해서 고객에게 제공
  • API 응답 처리
    • HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하며, 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다.

실행 결과

  • /api/members/ex
  • /api/members/bad

HandlerExceptionResolver 활용

예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error를 호출하고.. 지금까지의 과정은 너무 복잡했다!😣

ExceptionResolver를 활용해서 이런 복잡한 과정 없이 깔끔하게 문제를 해결해보자!

코드로 확인하기

🔗 전체 코드

실행 결과

HTTP 요청 해더의 ACCEPT 값이

  • application/json → JSON으로 오류를 내려주고,
  • 그 외 경우 → error/500 에 있는 HTML 오류 페이지를 보여준다.

📌 정리

ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver에서 예외 처리를 한다!
이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다!


스프링이 제공하는 ExceptionResolver1

ExceptionResolver를 사용하면 모든 예외를 이 곳에서 처리할 수 있어서 좋다고 했다!

하지만 ExceptionResolver를 직접 구현하는 과정은 꽤 복잡했다😢
이번에는 스프링이 제공하는 ExceptionResolver에 대해 알아보자!


스프링 부트가 기본적으로 제공하는 ExceptionResolver

💡 HandlerExceptionResolverComposite에 다음 순서로 등록

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver → 우선 순위가 가장 낮다
  • ExceptionHandlerExceptionResolver: @ExceptionHandler을 처리한다.
    • API 예외 처리는 대부분 이 기능으로 해결한다.
  • ResponseStatusExceptionResolver: HTTP 상태 코드를 지정해준다.
  • DefaultHandlerExceptionResolver: 스프링 내부 기본 예외를 처리한다.

ResponseStatusExceptionResolver

예외에 따라서 HTTP 상태 코드를 지정해준다.

다음 두 가지 경우를 처리한다.

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

@ResponseStatus

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 오류 요청")
public class BadRequestException extends RuntimeException{
}
  • BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST으로 변경하고, 메시지도 담는다.
  • ResponseStatusExceptionResolverresponse.sendError(statusCode, resolvedReason)를 호출한다!

실행 결과

메시지 기능

reasonMessageSource에서 찾는 기능도 제공한다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException{
}
  • 이렇게 코드를 적어두고,
    messages.propertieserror.bad=잘못된 요청 오류입니다. 메시지 사용과 같이 메시지를 따로 뺄 수도 있다.

ResponseStatusException

  • @ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다! → 애노테이션을 직접 넣어야하기 때문!
  • 그리고 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변하는 것도 어렵다.ResponseStatusException 예외를 사용하면 된다!
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException())
}

실행 결과

🔗 전체 코드 확인하기


스프링이 제공하는 ExceptionResolver2

이번에는 DefaultHandlerExceptionResolver에 대해서 알아보자!

DefaultHandlerExceptionResolver

스프링 내부에서 발생하는 스프링 예외를 해결한다.

파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는 경우를 살펴보자!

  • 따로 처리해주지 않으면, (예외가 발생했으므로) 서블릿 컨테이너까지 오류가 올라가서 500 오류가 발생한다!
  • 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이므로, 원래는 HTTP 상태 코드 400에 해당하는 오류이다!
  • DefaultHandlerExceptionResolver이 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.

이처럼 DefaultHandlerExceptionResolver스프링 내부 오류를 어떻게 처리할지 수많은 내용이 정의되어 있으며, 이에 맞는 동작을 해준다.

ApiExceptionController 코드 추가

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

실행 결과

📌 정리

HandlerExceptionResolver는 좋기는 하지만!

  • 직접 사용하기에는 복잡하다.
  • API 오류 응답의 경우 response에 직접 데이터를 넣어야 해서 불편하다.
  • ModelAndView를 반환해야 하는 것도 API에는 잘 맞지 않는다.

그래서 스프링이 제공하는 아주 좋은 예외 처리 기능인 @ExceptionHandler을 자주 사용한다!
사실 이것이 아직 알아보지 않은 ExceptionHandlerExcetpionResolver이다. 😉


@ExceptionHandler

HTML 화면 오류 vs API 오류

  • HTML 페이지
    • 오류가 발생하면 BasicErrorController를 사용하는 것이 좋다.
    • 400, 4xx 등 관련된 오류 페이지를 만들어서 고객에게 오류 화면을 보여주면 된다.BasicErrorController가 모두 구현해놓음!
  • API 오류
    • 매우 세밀한 제어가 필요하다.
      • 시스템마다 응답 모양도 다르고, 스펙도 다르다.
      • 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다.
    • 즉, 지금까지 살펴본 BasicErrorController를 사용하거나 HandlerExceptionResolver를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다.

API 예외처리의 어려운 점

  • HandlerExceptionResolver는 API 응답에는 필요 없는,ModelAndView를 반환한다.
  • API 응답을 위해서 HttpServletResponse에 직접 응답 데이터를 넣어주는 것은 매우 불편하다.
  • 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다.

@ExceptionHandler

스프링은 API 예외 처리 문제를 해결하기 위해 ExceptionHandlerExceptionResolver을 제공한다!

💡 @ExceptionHandler

@ExceptionHandler 애노테이션을 사용하는 매우 편리한 예외 처리 기능!

  • 스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하며, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다.
  • 실무에서 API 예외 처리는 대부분 이 기능을 사용한다.

@ExceptionHandler 예외 처리 방법

  • @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정!
  • 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다.
  • 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.
    • Ex) 아래 코드처럼 지정하면, IllegalArgumentException 또는 그 하위 클래스까지 처리할 수 있다!
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
    log.error("[exceptionHandle] ex", e);
    return new ErrorResult("BAD", e.getMessage());
}

✔ 우선 순위

  • 스프링의 우선순위는 항상 자세한 것이 우선권을 갖는다!
  • @ExceptionHandler에 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다.
@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}

@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}
  • 자식예외 발생 → 부모예외처리(), 자식예외처리() 모두 호출 대상 → (자세한 것이 더 우선권을 가짐) 자식예외처리() 호출
  • 부모예외 발생 → 부모예외처리()만 호출 대상 → 부모예외처리() 호출

✔ 다양한 예외

  • 다양한 예외를 한 번에 처리할 수 있다.
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
    log.info("exception e", e);
}

✔ 예외 생략

  • @ExceptionHandler에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

✔ 파라미터와 응답

  • @ExceptionHandler에는 다양한 파라미터와 응답을 지정할 수 있다.

자세한 파라미터와 응답은 여기 참고!


코드로 확인하기

🔗 전체 코드 확인하기

IllegalArgumentException 처리

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

실행 흐름

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

실행 결과


@ControllerAdvice

스프링이 제공하는 편리한 예외 처리 기능인 @ExceptionHandler을 이용해서 예외를 깔끔하게 처리할 수 있었지만, 정상코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다.
@ControllerAdvice 또는 @RestControllerAdvice를 사용해서 분리해보자!

🔗 전체 코드 확인하기

@ControllerAdvice

  • @ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을
    부여해준다.
  • @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
  • @RestControllerAdvice@ControllerAdvice와 같고, @ResponseBody가 추가되어 있다.

대상 컨트롤러 지정 방법

// 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 {}

🔗 스프링 공식 문서 예제

  • 이처럼 특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정할 수도 있다.
  • 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다. 그리고 특정 클래스를 지정할 수도 있다.
  • 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다.
profile
🚧 https://coji.tistory.com/ 🏠

0개의 댓글