Spring

[SpringBoot] API 예외처리

꾸준함. 2021. 8. 8. 01:57

개요

기존에 정리한 HTML 예외처리(https://jaimemin.tistory.com/1888)와 달리 API 예외처리는 고려해야 할 요소가 상당히 많습니다.

HTML 예외처리 같은 경우 적절한 예외 페이지만 있으면 대부분의 문제를 해결할 수 있지만, API 예외처리 같은 경우 각 API마다 정해진 규약이 다르므로 각 에러 상황에 맞는 에러 응답 스펙을 정의해야 하고 JSON으로 데이터를 내려주어야 합니다.

이처럼 API마다 정해진 규약이 다르기 때문에 실무 개발을 진행하다 보면 각 프로젝트마다 API 규격 정의서를 작성해야 하는데, 규격도 양방향으로 합의한 후 정해야하기 때문에 해당 프로세스에 상당히 많은 시간을 투자해야 합니다.

 

API 규격은 회사마다 상이하고 심지어 팀 마다도 다를 수 있으므로 생략하고 이번 포스팅에서는 API 예외처리 방법에 대해 정리해보겠습니다.

 

1. API 호출 후 예외 발생 시 예외 페이지만 정의되어 있다면?

기존 게시글에서 예외 페이지를 WebServerCustomizer에 등록하고 ErrorPageController에서 예외를 처리했습니다.

이 상태에서 API 호출 후 예외가 발생한다면 응답은 JSON 타입이 아닌 500 에러 HTML 페이지가 반환될 것입니다.

API의 경우 응답이 반드시 JSON으로 반환되어야 하므로 ErrorPageController에서 API 응답하는 컨트롤러를 추가해야 합니다.

 

ErrorPageController.java

 

 

 

위 코드에서 errorPage500Api가 API 예외 처리하는 컨트롤러입니다.

  • 주목해야 할 부분은 @RequestMapping 어노테이션 내 produces = MediaType.APPLICATION_JSON_VALUE 부분인데 이는 클라이언트가 요청하는 HTTP Header의 Accept 값이 application/json일 경우에만 해당 메서드가 호출된다는 뜻
    • Accept 값이 application/json이 아닌 나머지 요청에서 예외가 발생할 경우 html로 반환
  • Jackson 라이브러리는 Map을 JSON 구조로 변환
  • 응답 객체로 ResponseEntity를 사용했기 때문에 메시지 컨버터가 동작하면서 HTTP Body에 JSON 데이터 반환
  • 정리를 하자면, produces = MediaType.APPLICATION_JSON_VALUE를 @RequestMapping 어노테이션에 포함시킨 상태에서 ResponseEntity를 반환하면 응답 값이 JSON 형태로 반환되므로 API 호출 후 예외 발생 시 예외 페이지가 아닌 JSON 형태로 반환이 됩니다.

 

1.1 BasicErrorController

  • 스프링 부트에서 제공하는 기본 에러 컨트롤러인 BasicErrorController 또한 ErrorPageController.java와 비슷하게 동작
  • 차이점이라면 BasicErrorController에서는 html 예외처리를 하는 errorHtml 메서드에 produces = MediaType.TEXT_HTML_VALUE 를 부여한 뒤 View를 제공
    • 즉, html 예외처리를 제외한 다른 예외처리는 error 메서드로 처리

 

 

 

2. HandlerExceptionResolver

  • 스프링 MVC는 컨트롤러 밖으로 exception이 던져진 경우 예외를 해결하고 새로 동작을 정의할 수 있는 방법을 제공
  • 컨트롤러 밖으로 던져진 예외를 해결하고 새로 동작을 정의하고 싶으면 HandlerExceptionResolver를 사용해야 함
  • HandlerExceptionResolver를 ExceptionResolver라고도 부름
  • 필터와 인터셉터처럼 WebConfig에 등록해야함

 

2.1 HandlerExceptionResolver 적용 전

  • HandlerExceptionResolver를 적용하기 전에는 필터, 인터셉터 정리 글(https://jaimemin.tistory.com/1887)에서 소개한 것처럼 아래와 같은 흐름을 진행이 됨
  • DispatcherServlet에서 컨트롤러를 호출하기 전 preHandle 메서드가 호출되고 컨트롤러 내에서 예외가 발생할 경우 postHandle 메서드는 호출이 되지 않고 afterCompletion 메서드가 호출됨
  • 이후 예외가 DispatcherServlet을 거쳐 WAS까지 전달이 됨
  • WAS에서 다시 예외처리를 위해 호출
    • 이 때문에, 기존 게시글에서 다룬 것처럼 예외 발생 시 아래와 같은 흐름으로 진행
    • HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 - 예외 발생 -> 인터셉터 -> 서블릿 -> 필터 -> WAS - 예외 페이지 정보 확인 -> 필터 -> 서블릿 -> 인터셉터 -> 예외 페이지 컨트롤러 -> 예외 페이지 (View)

 

 

2.2 HandlerExceptionResolver 적용 후

  • HandlerExceptionResolver 적용 후에는 예외가 WAS까지 전달되지 않고 ExceptionResolver 내에서 예외를 처리
    • try catch문에서 예외를 처리하고 정상 흐름으로 변경하는 것과 비슷
    • 정상 흐름으로 변경하기 위해 ModelAndView 반환
      • 빈 ModelAndView라도 반환하는 것은 정상 흐름으로 변경하기 위해
  • 클래스명 그대로 예외를 처리하는 것이 목적

 

 

2.3 ExceptionResolver 반환 값에 따른 동작 방식

  • Empty ModelAndView
    • new ModelAndView()처럼 빈 ModelAndView를 반환할 경우 뷰가 렌더링 되지 않고, 정상 흐름으로 서블릿이 반환됨
  • ModelAndView 지정
    • ModelAndView에 View, Model 등의 정보를 지정하고 반환할 경우 해당 뷰를 렌더링 함
    • Exception과 에러 페이지를 매핑할 때 적용
  • null
    • null 반환 시, 다음 ExceptionResolver를 찾아서 실행 (여러 ExceptionResolver 등록 가능)
    • 끝까지 처리할 수 있는 ExceptionResolver를 발견 못할 경우, 기존에 발생한 예외를 WAS까지 전달

 

2.4 ExceptionResolver 활용방안

  • 예외 상태 코드 변환
    • 예외를 response.sendError(xxx) 메서드를 호출함으로써 서블릿에서 상태 코드에 따른 오류 처리하도록 위임
    • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출
      • 예를 들자면, 스프링 부트가 기본으로 설정한 /error 호출
    • 대고객 서비스의 경우 예외가 발생할 때 4xx, 5xx 에러코드를 반환하면 보안에 문제가 있을 수 있으므로 200으로 반환하는 케이스도 있음
  • 뷰 템플릿 처리
    • ModelAndView에 값을 채워 예외에 따른 오류 화면을 매핑하여 뷰 렌더링 하여 고객에게 제공
  • API 응답처리
    • 복잡하기 때문에 추천하는 방법은 아님
    • HttpServletResponse 내 writer를 불러와 HTTP 응답 바디에 직접 데이터를 넣어주는 방식
    • JSON으로 응답하면 API 응답 처리 가능

 

CustomExceptionHandler 예시 (500 -> 400 에러 변환)

 

 

 

CustomExceptionHandler2 예시 (HTTP Header Accept 타입에 따라 분기 처리)

 

 

 

WebConfig.java (ExceptionHandler 등록)

 

 

 

3. 스프링에서 기본으로 제공하는 ExceptionResolver

앞선 예시들처럼 BasicErrorController나 커스텀 ExceptionResolver로는 세밀한 제어가 필요한 API 예외처리를 하기 힘드므로 스프링에서 세 가지의 기본 ExceptionResolver를 제공합니다.

 

API 예외처리의 어려운 점

  • 앞선 예시의 HandlerExceptionResolver의 경우 정상 흐름으로 돌리기 위해 매번 ModelAndView를 반환해야 했지만 이는 API 응답에는 사실 필요 없는 객체
  • 앞서 ExceptionResolver의 활용방안으로 HttpServletResponse에 직접 응답 데이터를 넣어도 된다고 했지만 이 것은 과거 서블릿을 사용하던 시절로 돌아가는 것과 비슷하므로 불편함
  • 같은 exception이라도 컨트롤러마다 다르게 처리하고 싶을 수 있는데 이를 매번 커스텀 ExceptionResolver로 처리하기에는 한계가 있음

 

HandlerExceptionResolverComposite에 기본으로 제공하는 ExceptionResolver가 아래의 순서대로 등록이 되어있으며 순서는 우선순위 순입니다.

  • ExceptionHandlerExceptionResolver
  • ResponseStatusExceptionResolver
  • DefaultHandlerExceptionResolver

 

3.1 ExceptionHandlerExceptionResolver

  • 스프링에서 기본으로 제공하는 ExceptionResolver 중에 우선순위가 가장 높음
  • @ExeptionHandler 어노테이션을 사용하여 처리하며 API 예외 처리의 대부분이 해당 기능으로 해결 가능
  • @ExceptionHandler 어노테이션을 선언한 뒤 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 됨
    • 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출되며 지정한 예외 또는 그 예외의 자식 클래스를 모두 처리 
    • 컨트롤러에 예외처리까지 하면 코드가 지저분해지므로 ControllerAdvice 어노테이션을 붙인 클래스를 별도로 선언하여 @ControllerAdvice 내에서 @ExceptionHandler 메서드들을 선언해도 됨 (밑에 추가 설명 예정)
  • @ExceptionHandler 어노테이션을 통해 JSON으로 응답을 보낼 수 있지만 앞선 예시처럼 html 형식으로도 응답을 보낼 수 있음
    • 즉, API 예외처리와 HTML 예외처리 둘 다 가능

 

3.1.1 예제 코드

 

ErrorResult.java (API 응답 객체)

 

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ErrorResult {

    private String code;

    private String message;
}

 

ApiExceptionController.java

 

 

 

* 주석을 보시면 ExceptionHandler 활용법을 익히실 수 있습니다.

* 예시에는 없지만, ModelAndView 반환 시 에러 페이지 렌더링 가능 (예시에는 모두 JSON으로 응답)

 

ExceptionControllerAdvice.java

  • 앞서 언급한 것처럼, ApiExceptionController.java 내 ExceptionHandler까지 있으면 코드가 깔끔해 보이지 않음
  • 따라서, ExceptionHandler들을 ExceptionControllerAdvice로 옮겨서 코드 리팩터링 가능
  • ControllerAdvice는 명시하지 않을 경우 모든 컨트롤러에서 발생하는 예외를 처리하고 별도로 명시할 경우 특정 어노테이션, 패키지, 혹은 컨트롤러에 대해서만 처리할 수 있음

 

 

 

3.1.2 ControllerAdvice 부연설명

  • @ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler 뿐만 아니라 @InitBinder 기능을 부여해주는 역할
  • 앞서 언급한 것처럼 @ControllerAdvice에 대상을 지정하지 않을 경우 글로벌하게 모든 컨트롤러에 대해 적용
    • 대상은 크게 아래와 같이 세 가지를 지정 가능
      • 어노테이션
      • 패키지
      • 컨트롤러
  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody
    • @RestController가 @Controller + @ResponseBody인 것과 비슷
  • 보다 자세한 내용은 https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice 참고

 

3.2 ResponseStatusExceptionResolver

  • @ResponseStatus 어노테이션을 통해 HTTP 상태 코드 지정
    • 앞선 ApiExceptionController 예시 코드 내 ExceptionHandler에서 사용한 바 있음
  • 즉, 정리를 하자면 ResponseStatusExceptionResolver는 예외에 따라 HTTP 상태 코드를 지정해주는 역할
  • ResponseStatusExceptionResolver는 아래의 두 가지 경우를 처리
    • @ResponseStatus가 달려있는 예외
    • ResponseStatusException 예외
  • 코드를 분석해보면 ResponseStatusExceptionResolver가 결국 HttpServletResponse의 sendError(statusCode, resolvedReason) 메서드를 호출하기 때문에 WAS에서 재정의된 HTTP 상태 코드에 대한 예외 페이지(/error)를 내부 요청하는 것을 확인 가능
  • 라이브러리에서 제공하는 예외 코드 같은 경우 개발자가 커스텀하게 @ResponseStatus 어노테이션을 부여하기 힘드므로 이 때는 ResponseStatusException 예외를 사용

 

3.2.1 @ResponseStatus 예제 코드

 

BadRequestException (커스텀 exception)

  • ResponseStatus 어노테이션을 통해 500 -> 400 에러로 변환

 

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

 

ApiExceptionController.java

 

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

 

* ResponseStatusExceptionResolver가 결국 HttpServletResponse의 sendError(statusCode, resolvedReason) 메서드를 호출하기 때문에 WAS에서 재정의된 HTTP 상태코드에 대한 예외 페이지(/error)를 내부 요청하는 것을 확인 가능

 

에시 응답

 

{
    "status": 400,
    "error": "Bad Request",
    "exception": "com.tistory.jaimemin.exception.BadRequestException",
    "message": "잘못된 요청",
    "path": "/api/response-status-exception1"
}

 

* 응답으로 message까지 표기하기 위해서는 application.properties 혹은 application.yml 내 server.error.include-message=always 설정 필요

 

3.2.2 ResponseStatusException 예제 코드

 

ApiExceptionController.java

 

@GetMapping("/api/response-status-exception2")
public String responseStatusException2() {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND
      , "error.bad" // messages.properties에서 메시지 불러올 수 있음
      , new IllegalArgumentException());
}

 

* IllegalArgumentException 에러코드를 404로 변환하고 message를 MessageSource 객체를 이용해 messages.properties에서 불러올 수 있음

 

예시 응답

 

{
    "status": 404,
    "error": "Not Found",
    "exception": "org.springframework.web.server.ResponseStatusException",
    "message": "잘못된 요청",
    "path": "/api/response-status-exception2"
}

 

3.3 DefaultHandlerExceptionResolver

  • 스프링 내부에서 발생하는 스프링 예외 해결
  • 스프링 프레임워크 내 DefaultHandlerExceptionResolver 클래스를 확인하면 보통 4XX, 5XX로 처리하는 수많은 400대 500대 에러에 대해서도 처리한 것을 확인할 수 있음
  • 코드를 보면 결국, HttpServletResponse 내 sendError 메서드를 통해 알맞은 HTTPStatusCode로 변경하는 것을 확인할 수 있음
  • HTTP 응답 코드를 적절하게 변경할 수 있지만 ResponseStatusExceptionResolver와 달리 message에 원하는 내용 넣기 힘듦


 

출처

인프런 스프링 MVC 2편 (김영한 강사님)

반응형