Spring/Spring MVC

API 예외 처리

민철킹 2021. 7. 28. 16:15

HTML 페이지의 경우에는 4xx, 5xx.html과 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있지만,

API의 경우에는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야한다.

 

어떻게 이를 처리할 수 있을까?

 

먼저 처음으로 돌아가 Servlet 오류 페이지 방식을 사용해보자.

api를 처리하는 컨트롤러를 만들고 내부에 static 클래스로 memberDto를 만들어 진행하겠다.(실제 작업시에는 별도로 생성해야함)

@RestController이기 때문에 id가 ex가 아니라면 json형식으로 응답이 반환될 것이고

ex라면 RuntimeException이 발생해 오류 페이지를 내부적으로 호출해 반환될 것이다.

먼저 정상 요청이다.

다음으로 런타임에러를 발생시켜본다.

현재 api통신을 목표로 하고 있기 때문에 json에 예외정보를 담아 반환해주어야한다.(이런 HTML이 아닌)

 

클라이언트는 정상 요청이든, 오류 요청이든 JSON이 반환되기를 기대하고 있다. 즉, 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정하여야한다.

 

api용 에러 컨트롤러를 만들었다. 위의 컨트롤러와 비교해보면 매핑 url 경로가 동일한 것을 확인할 수 있는데, 새로 추가한 것은 produces속성을 사용해 클라이언트 HTTP 헤더의 Accept가 "application/json"일 때 매핑되도록 설정하였다.

 

API용 스펙을 정의하여 사용하는 것이 맞지만 예시를 간단하게 하기 위해 Map을 생성하여 반환하도록 하였다.

앞서서 공부한 서블릿의 에러 페이지 과정을 이해했다면 이 역시 이해가 빠를 것이다. 서블릿이 서버 내부적으로 에러 페이지를 요청할 때 request에 에러 정보를 담은 채로 전달하기 때문에 해당 컨트롤러의 request에 그 정보가 담겨있다.

 

따라서 getAttribute를 사용하여 Exception과 상태 코드를 꺼내어 HttpEntity를 상속하는 ResponseEntity를 통해 객체화 하여 응답을 반환한 것이다.(getAttribute의 반환 타입은 Object이기 때문에 캐스팅을 하였음)

 

참고로 ResponseEntity는 응답용이고 RequestEntity도 존재한다.

아래는 ResponseEntity의 생성자들이다.

@ResponseBody와 ResponseEntity은 기능적으로 동일한 결과를 만들어내지만, ResponseEntity를 사용하는 용도는 HTTP response header의 융통성있는 추가가 가능하기 때문이다. 또한 구현방식이 틀리기 때문에 상황에 맞는 방법을 사용하는 것이 좋을 듯하다.

 

HTTP 헤더를 변경해야하는 상황을 생각해보자.

@ResponseBody를 사용할 때는 HttpServletResponse객체를 파라미터로 받아 그 객체의 헤더를 변경시키고,

ResponseEntity를 사용할 때는 이 클래스 객체를 생성하고 그 객체의 헤더를 변경하면 된다.

 

 

더보기

이제 HttpBody에 JSON형식으로 에러에 대한 정보가 담겨있는 것을 확인할 수 있다.

이를 클라이언트에서 RequestEntity@RequestBody를 사용하여 처리할 수 있게 된 것이다.

 

공부를 하다가 @ResponseBody와 ResponseEntity에 대한 의문이 생겨 질문을 하고 답변을 대기 중이다.

이 질문에 대한 답변으로 내가 생각하는 부분이 맞다고 한다. ResponseEntity는 프로그램으로 특정 조건에 따라 상태 코드를 변경하는 식으로 구현한다고 한다.

 

이 주제가지고도 글을 하나 작성할 수 있을듯ㅋㅋ

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/http/ResponseEntity.html

 

ResponseEntity (Spring Framework 5.3.9 API)

Extension of HttpEntity that adds an HttpStatus status code. Used in RestTemplate as well as in @Controller methods. In RestTemplate, this class is returned by getForEntity() and exchange(): ResponseEntity entity = template.getForEntity("https://example.co

docs.spring.io


Spring Boot 기본 오류 처리

 

API 예외 처리도 Spring Boot가 제공하는 기본 오류 방식을 사용할 수 있다.(BasicErrorController)

오류 발생시 "/error"경로 요청 ==> BasicErrorController 매핑

 

Accept를 application/json으로 설정하면 BasicErrorController가 JSON형식으로 응답을 보내준다.

 

Accept를 text/html로 요청을 보내면 이전에 만들어놓았던 5xx.html이 응답으로 반환된다.

 

아래는 실제 Spring Boot의 BasicErrorController의 내부 코드이다.

미디어타입에 대해 이미 구현이 되어 있기 때문에 위와 같이 Accept에 따라 다른 응답을 보낼 수 있는 것이다.

 

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

 

BasicErrorControllerHTML 페이지를 제공하는 경우에는 매우 편리하다.

사실 API 오류 처리는 각 API마다, 각 컨트롤러, 예외마다 서로 다른 응답 결과를 출력해야할 수도 있다.

즉, 회원 API에서의 예외에 대한 응답과 상품 API에서의 예외에 대한 응답이 다를 수도 있다. 매우 세밀하고 복잡하다는 의미이다.

 

API 오류 처리는 @ExceptionHandler를 사용해라!!

 


HandlerExceptionResolver

 

@ExceptionHandler에 대해 알아보기 위해서는 먼저 HandlerExceptionResolver를 먼저 이해해야한다.

 

컨트롤러에서 예외가 발생하여 서블릿을 넘어 WAS까지 전달되면 HTTP 상태코드가 500으로 처리된다.(예외에 상관없이)

이를 발생하는 예외에 따라 다른 상태코드로 처리해보자.(오류 메시지, 형식등을 API마다 다르게 처리)

잘못된 입력값이 전달되었다는 IllegalArgumentException을 던져 400에러(Bad Request)로 처리하고 싶지만 포스트맨으로 요청을 전달하면 여전히 HTTP 상태 코드가 500이다. (WAS 입장에서는 예외 종류의 관계없이 서버 내부 예외는 500)

 

스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고 동작 방식을 변경하기 위해 HandlerExceptionResolver를 제공한다. 이를 줄여 ExceptionResolver라고 부름

 

ExceptionResolver는 예외를 해결하기 위한 시도를 하고 만약 해결한다면 View를 호출하고 정상 응답을 WAS로 보낸다.

여하튼 예외가 발생한 것이기 때문에 postHandle()은 호출X

 

아래는 HandlerExceptionResolver 인터페이스이다.

- handler : 핸들러(컨트롤러) 정보

- Exception ex : 핸들러(컨트롤러)에서 발생한 발생한 예외

 

이를 구현해보자.

발생한 예외가 IllegalArgumentException이라면 sendError를 통해 예외가 아닌 오류가 발생했다고 Servlet 컨테이너에게 알려준다. 그 후에 "new ModelAndView()"를 반환하는데 이와 같이 빈값으로 반환되면 정상 흐름으로 WAS까지 전달된다.(빈 ModelAndView이기 떄문에 View 렌더링 하지 않고 서블릿이 리턴)

if문에 걸리지 않고 return null이 되면 예외가 계속 전달됨(ExceptionResolver는 여러개 등록가능)

 

그 뒤에 Config 클래스에 등록해주어야하는데 이는 extendHandlerExceptionResolvers 메서드를 Override한다.

configureHandlerExceptionResolvers를 Override하면 스프링이 기본적으로 등록하는 ExceptionResolver가 제거됨.

 

이제 서버를 실행해보자.

원하는대로 400 에러가 상태코드로 반환된 것을 확인할 수 있다. 이 또한 마찬가지로 400 에러가 WAS에게 전달된 것이고

오류 페이지를 찾기위해 "/error"가 호출되어 BasicErrorController가 매핑되는데 Accept가 application/json이기 때문에 위와 같이 JSON형식으로 응답이 반환된 것이다.

 

ExceptionResolver는  ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면을 렌더링해 제공할 수 있다.

 

또한 response.getWirter()같은 것을 사용하여 HTTP Response Body에 직접 데이터를 넣어줄 수도 있다. 여기에 JSON으로 응답하여 API 응답 처리도 가능!!(Jackson 라이브러리!!)

 

 

하지만 생각해보면 이 과정은 매우 비효율적이지 않은가? ExceptionResolver를 사용하여 예외를 처리했지만 WASsendError()를 통해 400에러를 전달하고 WAS는 오류 페이지를 위해 "/error"를 호출하여 BasicErrorController까지 다시 호출되는 과정말이다.

이러한 복잡한 과정없이 깔끔하게 해결할 수는 없을까?(당연히 있지)

 

ExceptionResolver를 활용하면 WAS까지 오류를 전달하지 않고 끝낼 수 있다.

 

RuntimeException을 상속하는 UserException이라는 사용자 정의 예외를 하나 새로 생성하였다.

그 후 해당 url로 접속하면 사용자 정의 예외를 던지도록 컨트롤러를 수정하였다.

현재 이 상태에서 접속을 하면 WAS까지 예외가 전달되어 BasicErrorController가 호출될 것이다.

 

BasicErrorController를 호출하는 추가 프로세스 없이 깔끔하게 처리해보자.

HandlerExceptionResolver 인터페이스를 구현한 클래스이다.

여기서는 sendError를 하는 것이 아닌 예외를 확인하고 사용자 정의 예외라면 직접 Response객체를 세팅해주는 것이다.

이 때 API 다루기 위해 accept를 확인하고 json이라면 Jackson라이브러리의 ObjectMapper를 사용하여 Map을 JSON형식으로 바꾸어 준뒤 Response Body에 담아주었다. 그 뒤에 빈 ModelAndView를 반환하기 때문에 정상응답으로 WAS에게 전달되고 거기서 끝이나게 되는 것이다.

 

Accept가 JSON이 아니면 templates/error에 있는 500.html을 ModelAndView에 넣어 View 랜더링하여 반환해준다.

 

그 외의 에러인 경우에는 return null;을 하므로 예외가 계속 던져진다.

 

Accept를 json으로 설정하고 사용자 정의 예외를 발생시키면 위와 같이 설정한대로 응답이 반환된 것을 확인할 수 있다.

즉, BasicErrorController가 호출되지 않는다!

 


Spring이 제공하는 ExceptionResolver

직접 ExceptionResolver를 구현하는 과정을 복잡하고 번거롭다. Spring이 제공하는 ExceptionResolve가 있는데 이를 알아보자.

 

Spring BootHandlerExceptionResolverComposite에 아래 순서로 등록한다.(이 순서로 예외를 처리하려고 시도함)

    1. ExceptionHandlerExceptionResolver

    2. ResponseStatusExceptionResolver

    3. DefaultHandlerExceptionResolver

 

ExceptionHandlerExceptionResolver

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

 

ResponseStatusExceptionResolver

HTTP 상태 코드를 지정 (ex: @ResponseStatus(value = HttpStatus.NOT_FOUND))

 

DefaultHandlerExceptionResolver

스프링 내부 기본 예외 처리

 

 

먼저 ResponseStatusExceptionResolver부터 알아보자.

 

🔧ResponseStatusExceptionResolver

ResponseStatusExceptionResolver는 예외에 따라 HTTP 상태 코드를 지정해주는 역할을 한다.

 

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

- @ResponseStatus 애노테이션이 붙은 예외

- ResponseStatusException 예외

 

Exception 생성

RuntimeException을 상속하는 BadRequestException을 새로 만들었다.

컨트롤러

그 후 컨트롤러에 해당 url로 매핑되면 위에서 만든 예외를 던지도록 하였다.

RuntimeException을 상속하였기 때문에 500 에러가 발생해야하지만 HTTP 상태코드가 400으로 반환된 것을 볼 수 있다.

 

어떻게 동작한 것인지 생각해보자.

먼저 컨트롤러에서 예외(BadRequestException)가 발생하고 예외가 DispatcherServlet으로 던져졌다. 

이 때 ExceptionResolver를 호출하는데 ResponseStatusExceptionResolver가 해당 예외를 처리하려고 시도한다.

ResponseStatusExceptionResolver는 해당 예외에 @ResponseStatus 애노테이션이 붙어있는지를 확인한다.

붙어있다면 code를 확인해 어떤 상태코드로 설정되었는지를 확인하고 sendError메서드를 통해 에러를 WAS로 전달한다.

 

ResponseStatusExceptionResolver의 내부 메서드

실제 내부 메서드를 확인해보면 상태코드를 받아 sendError메서드를 호출하고 빈 ModelAndView를 반환하는 것을 볼 수 있다. sendError를 호출했기 때문에 WAS에서 오류 페이지(/error)를 내부적으로 요청한다.

 

또한 @ResponseStatus에서 reason 속성에 예외 발생의 이유를 적어주게되는데 application.properties에 "server.error.include-message=always"를 추가해주면 반환 값에 reason도 함께 출력된다.

또한 이는 메시지 기능을 제공하기 때문에 MessageSource에서 reason을 찾을 수 있다.

제공되는 메시지 기능을 사용해보면 messages.properties에 메시지 코드를 추가해준다.

messages.properties

그 후 reason에 위와 같이 메시지 코드 명을 작성해준다.

메시지 기능이 정상적으로 동작하고 있는 것을 확인할 수 있다.

ResponseStatusExceptionResolverapplyStatusAndReason메서드의 else문을 보면 messageSource.getMessage를 통해 메시지 코드가 있는지 찾고 있는데 이를 통해 메시지 기능이 동작하는 것이다.(못찾으면 default값) 

 

@ResponseStatus는 개발자가 직접 만든 예외에는 적용이 가능하지만 라이브러리의 예외 코드 같은 직접 변경할 수 없는 예외에는 적용시킬 수 없다. 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다.

==> @ResponseBody와 ResponseEntity의 차이점과 비슷함

이 때는 ResponseStatusException예외를 사용하면 해결할 수 있다.

 

Spring이 제공하는 특수한 Exception을 통해 컨트롤러에서 바로 상태코드와, 메시지까지 한번에 처리가 가능하다.

이는 ResponseStatusExceptionResolverdoResolveException메서드가 처리해준다. 내부 코드를 확인해보면 이해가 쉽게 될 것이다.


 

🔧DefaultHandlerExceptionResolver

 

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

파라미터 바인딩 시점에 타입이 맞지않으면 TypeMismatchException이 발생하는데 이 예외를 그냥 두면 서블릿 컨테이너에게 예외가 전달되어 결과적으로 500 에러가 발생한다.

하지만 대부분 파라미터 바인딩은 클라이언트가 요청을 할 때 잘못 호출해서 발생하는 문제이므로 HTTP에서는

상태코드 400을 사용하도록 되어 있다.

DefaultHandlerExceptionResolver는 이를 500 에러가 아니라 400 에러로 변경한다. 스프링 내부 오류를 어떻게 처리할 지에 대한 내용이 정의되어 있다.

 

아래는 DefaultHandlerExceptionReolverhandleTypeMismatch 메서드이다. 내부에 수많은 기본 오류 처리에 대한 내용이 있음

TypeMismatchException이 발생하면 sendError()를 통해 예외를 처리하는데 400 상태코드로 WAS에게 정상흐름으로 전달하고 있다.

 

HTTP 상태 코드를 변경하고(ResponseStatusExceptionResolver) 스프링 내부 예외의 상태코드를 변경(DefaultHandlerExceptionReolver)하는 것에 대해 알아보았다.

 

하지만 HandlerExceptionResolver를 직접 사용하는 것은 복잡하고 API 오류 응답의 경우에는 response에 데이터를 넣어야하므로 매우 불편하고 번거롭다.(ModelAndView를 반환하는 것도 API 스타일과 상반됨)

 

이를 위해 스프링@ExceptionHandler라는 예외처리 기능을 제공한다. 이것이 바로 ExceptionHandlerExceptionResolver이다.


  🔧ExceptionHandlerExceptionResolver

HTML 화면을 제공할 때 오류가 발생하면 앞서 공부했던 BasicErrorController를 사용하는 것이 좋다.

 

하지만 API는 시스템마다 응답과 스펙이 모두 다르기 때문에 매우 세밀한 제어가 필요하다.

 

뭐가 어려운데? 그냥 HandlerExceptionResolver 쓰면 안되나?

  - HandlerExceptionResolverModelAndView를 반환해야했다. API 응답에서 ModelAndView는 필요없다.

  - 또한 마치 Servlet을 직접 사용하던 것처럼 Response에 직접 응답 데이터를 넣어주어야한다.

  - 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하는 세밀한 제어가 어렵다.

     ==> 동일한 RuntimeException이라도 컨트롤러에 따라 다르게 처리하고 싶은 경우

 

스프링은 이 모든 문제를 해결하기 위해 @ExceptionHandler를 제공한다.

이것이 바로 ExceptionHandlerExceptionResolver인데 기본으로 제공되는 ExceptionResolver 중에서 우선순위도 가장 높다. API 예외 처리는 대부분 이 기능을 사용하여 해결한다!! 

 

@ExceptionHandler를 직접 사용해보자.

현재 이 상태에서는 만들어놓은 ExceptionResolver가 sendError를 호출하고 WAS에 전달되어 스프링 부트의 기본 오류 페이지가 반환된다.(json 형식)

 

JSON형식으로 반환될 것이기 때문에 응답 객체를 생성하겠다.(DTO 역할)

해당 컨트롤러 클래스에 위와 같은 @ExceptionHandler를 추가한다. 이 컨트롤러에서 IllegalArgumentException이 발생하면 호출되는데 현재 @RestController이기 때문에 응답 객체인 ErrorResult를 반환하여 json형식으로 클라이언트에게 보여지게 된다.

동작하는 과정을 자세히 알아보자.

먼저 해당 url로 요청이 들어오고 IllegalArgumentException이 발생하였다.

발생한 예외가 DispatcherServlet에게 전달되고 ExceptionResolver를 통해 예외를 해결하려고 시도한다.스프링에 등록되어 있는 ExceptionResolver에서 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 제일 먼저 호출된다.

ExceptionHandlerExceptionResolver예외가 발생한 컨트롤러@ExceptionHandler가 있는지 찾고 존재한다면 그 애노테이션이 붙은 메서드가 호출된다. 따라서 위와 같이 API 응답이 반환된 것이다.

하지만 반환된 HTTP 상태 코드를 보면 200이다.

사실 예외가 처리되어 정상 응답이기 때문에 200이 맞지만 상태코드도 변경하고 싶다면, @ResponseStatus를 사용하자.

또한 @ExceptionHandler를 사용하면 앞선 다른 ExceptionResolver와는 다르게 WAS까지 에러가 전달되지 않고 여기서 끝이 난다. 정상적으로 JSON형식으로 응답이 반환된 것임.

 

API 응답을 위해 일반적으로 많이 사용되는 ResposeEntity또한 사용할 수 있다.

참고로 파라미터로 받는 Exception과 @ExceptionHandler이 어떤 Exception을 처리할지를 속성에 넣어주는

Exception이 동일하다면 위와 같이 생략가능하다.

==> @ExceptionHandler(UserException.class) == @ExceptionHandler

 

기본적으로 @ExceptionHandler는 지정된 예외를 상속하는 자식 예외까지 처리해준다. 이를 이용하여 특정 세부 예외를

처리하도록 하고 모든 예외의 부모인 Exception을 처리하는 @ExceptionHandler를 만들어 처리하지 못한 예외를 잡을 수 있다.

IllegalArgumentException과 자식 Exception, UserException과 자식 Exception를 제외한 해당 컨트롤러에서 발생한 모든 예외는 exHandler를 통해 처리된다.(스프링에서 모든 것은 항상 자세한 것이 우선권을 가짐)

 

아래와 같이 @ExceptionHandler에 여러 예외를 한번에 처리하도록 지정할 수도 있다.

@ExceptionHandler({RuntimeException.class, IllegalArgumentException})

 

@ExceptionHandler는 컨트롤러처럼 다양한 파라미터와 응답을 지정할 수 있다. 아래의 공식 문서를 참조하자.

API뿐 아니라 일반적인 MVC도 모두 처리할 수 있다.

 ==>View, ModelAndView, String을 반환해 ViewResolver가 작동하게도 가능(대신 @Controller여야함)

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-exceptionhandler-args

 

Web on Servlet Stack

Spring Web MVC is the original web framework built on the Servlet API and has been included in the Spring Framework from the very beginning. The formal name, “Spring Web MVC,” comes from the name of its source module (spring-webmvc), but it is more com

docs.spring.io

 

@ExceptionHandler는 Scope가 해당 컨트롤러에만 적용되기 때문에 컨트롤러 내부에 작성되는데 정상 코드와 예외처리 코드가 하나의 컨트롤러에 함께 존재한다.

 

이는 @ControllerAdvice 또는 @RestControllerAdvice를 사용하여 분리할 수 있다.

 

@ControllerAdvice

컨트롤러에서 예외처리 코드를 분리하여 별도의 클래스를 만든다.

@RestControllerAdvice = @ControllerAdvice + @ResponseBody

서버를 띄우고 요청을 전송해보자.

동일하게 에러처리가 동작하는 것을 확인할 수 있다. 

@ControllerAdvice는 여러 컨트롤러에서 발생하는 Exception을 처리해준다.

 

@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할

    - @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용(글로벌)

 

 

대상을 지정하는 방법은 아래와 같다.

@ControllerAdvice(annotations = RestController.class)

  - @RestController 애노테이션이 붙은 컨트롤러에 적용

 

@ControllerAdvice("org.example.controllers")

  - 해당 패키지 하위에 있는 모든 컨트롤러에 적용

 

@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})

  - 특정 클래스를 지정하여 적용

 

 

추가적인 내용은 공식문서를 참조합시다!

https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice

 

Web on Servlet Stack

Spring Web MVC is the original web framework built on the Servlet API and has been included in the Spring Framework from the very beginning. The formal name, “Spring Web MVC,” comes from the name of its source module (spring-webmvc), but it is more com

docs.spring.io

 

반응형

'Spring > Spring MVC' 카테고리의 다른 글

파일 업로드  (0) 2021.08.12
Spring Type Converter  (0) 2021.08.03
예외 처리, 오류 페이지  (0) 2021.07.24
필터, 인터셉터  (0) 2021.07.21
쿠키, 세션  (0) 2021.07.17