HTML 페이지의 경우 4xx, 5xx 같은 오류 페이지만 있으면 대부분 문제를 해결할 수 있다.
그런데 API 같은 경우에는 생각할 내용이 더 많다. 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
API요청으로 오류가 발생할 시,만들어둔 오류 페이지 HTML이 반환된다. 오류이든 정상 요청이든 JSON이 반환되기를 기대한다. 웹 브라우저가 아닌이상 HTML이 반환되어도 그것을 화면으로 만들어서 보여주지 못한다. 그저 HTML 태그들만 와버리는 것이다.
오류 페이지도 JSON으로 보낼 수 있게 해야된다.
@RequestMapping(value="/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String,Object>> errorPage500Api(HttpServletRequest request,
HttpServletResponse response) {
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 = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
Json으로 나오는 오류 메시지
{
"message": "잘못된 사용자",
"status": 500
}
기존의 메커니즘으로 API도 오류 처리가 된다. application/json으로 요청을 하기만 하면 JSON 형식으로 오류 페이지가 내려온다.
다만 오류 메시지가 우리가 만든 것이 아닌 스프링 부트에서 기본적으로 만들어 넣어준 오류 메시지들이 담겨 있다.
코드를 파 보니 실제 서블릿으로 만들었던 오류페이지 코드와 비슷하게 Application/json일때 Map형식으로 오류를 내려보내는것을 확인 할 수 있었다. error_html과 error로 타입을 확인 가능.
HandlerExceptionResolver
스프링MVC는 핸들러 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다. 바로 HandlerExceptionResolver
를 사용하면 된다 줄여서 ExceptionResolver
라 한다.
참고
ExceptionResolver
로 문제를 해결해도postHandle
는 작동 하지않음.
ExceptionResolver
는 예외를 먹어버리고, ModelAndViw를 반환한다. 정상 흐름이 되어 새로운 ModelAndView를 반환한다.
반환 값에 따른 동작 방식
HandlerExceptionResolver 의 반환 값에 따른 DispatcherServlet 의 동작 방식은 다음과 같다.
빈 ModelAndView: new ModelAndView()
처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지않고, 정상 흐름으로 서블릿이 리턴된다.
ModelAndView 지정: ModelAndView
에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
예외를 마무라하기
ExceptionResoslver
를 사용하면 그런 과정이 없이 예외가 발생했을 때 문제를 깔끔하게 해결할 수 있다.UserException이라는 커스텀 오류를 HandlereExceptionResolver로 처리하기
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response,
Object handler, Exception ex) {
try {
if (ex instanceof UserException) {
log.info("UserException resolver to 400");
String acceptHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
if ("application/json".equals(acceptHeader)) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
String result = objectMapper.writeValueAsString(errorResult);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
return new ModelAndView();
} else {
//TEXT/HTML
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
정리
ExceptionResolver 를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver 에서 예외를 처리 해버린다. 따라서 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다.
결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다.
그런데 직접 ExceptionResolver 를 구현하려고 하니 상당히 복잡하다. 지금부터 스프링이 제공하는 ExceptionResolver 들을 알아보자.
스프링 부트가 기본으로 제공하는 ExceptionResolver
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver
우선 순위가 가장 낮다.
ExceptionHandlerExceptionResolver
@ExceptionHandler 을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다. 조금 뒤에 자세히 설명한다.
ResponseStatusExceptionResolver
HTTP 상태 코드를 지정해준다.
예) @ResponseStatus(value = HttpStatus.NOT_FOUND)
DefaultHandlerExceptionResolver
스프링 내부 기본 예외를 처리한다.
ResponseStatusExceptionResolver 는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다. 다음 두 가지 경우를 처리한다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason)
를 호출하는 것을 확인할 수 있다.
sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다
reason은 MessageSource
에서 찾는 기능도 제공한다. ex) error.bad
ResponseStatusException
@ResponseStatus 는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.) 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는
ResponseStatusException 예외를 사용하면 된다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad",
newIllegalArgumentException());
}
스프링 내부에서 발생하는 스프링 예외를 해결한다.대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다. DefaultHandlerExceptionResolver 는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.
이 DefaultHandlerExceptionResolver
에는 많은 예외가 정의되어 있다. 스프링이 적절한 상태코드를 넣어 보내준다.
HTTP 상태 코드를 변경하고, 스프링 내부 예외의 상태코드를 변경하는 기능도 알아보았다.
그런데 HandlerExceptionResolver
를 직접 사용하기는 복잡하다. API 오류 응답의 경우 response 에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭다. ModelAndView
를 반환해야 하는 것도 API에는 잘 맞지 않는다.
스프링은 이 문제를 해결하기 위해 @ExceptionHandler
라는 매우 혁신적인 예외 처리 기능을 제공한다.
웹 브라우저에 HTML 화면을 제공할 때는 오류가 발생하면 BasicErrorController 를 사용하는게 편하다
그런데 API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다. 예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다. 그리고 같은 예외라고 해도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 내려주어야 할 수 있다.
예를 들어서 상품 API와 주문 API는 오류가 발생했을 때 응답의 모양이 완전히 다를 수 있다. 결국 지금까지 살펴본 BasicErrorController 를 사용하거나 HandlerExceptionResolver 를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다.
API 예외처리의 어려운 점
HandlerExceptionResolver 를 떠올려 보면 ModelAndView 를 반환해야 했다. 이것은 API 응답에는 필요하지 않다. API 응답을 위해서 HttpServletResponse 에 직접 응답 데이터를 넣어주었다. 이것은 매우 불편하다.스프링 컨트롤러에 비유하면 마치 과거 서블릿을 사용하던 시절로 돌아간 것 같다.
특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다. 예를 들어서 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException 예외와 상품을 관리하는 컨트롤러에서 발생하는 동일한 RuntimeException 예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까?
@ExceptionHandler
스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler
라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로ExceptionHandlerExceptionResolver
이다.
스프링은 ExceptionHandlerExceptionResolver
를 기본으로 제공하고, 기본으로 제공하는
ExceptionResolver 중에 우선순위도 가장 높다.
//만약 ResponseStatus를 붙이지 않으면 예외를 잡아 처리한 정상흐름이기에 상태코드가 200으로 나간다.
//따라서 꼭 ResponseStatus를 통해 상태콛드를 제정의 해주어야 한다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
//생략한 경우에는 해당 파라미터의 예외를 사용(UserException.Class)
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] 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 exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
해당 컨트롤러에서 예외가 발생하면 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.(ex : IllegalArgumentException을 정의했는데, 이때는 그 자식 예외까지 잡아 준다.)
스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.
파라미터와 응답
@ExceptionHandler
는 다양한 파라미터와 응답을 지정할 수있다. 사실 ModelAndView 반환도 가능해서 HTML로 응답도 가능하다.
여기까지 봤을때 사용하기 엄청 편리해졌는 것을 느꼇을 것이다. 다만 @ExceptionHandler
를 만들어둔 컨트롤러만 사용할 수 있으며, 컨트롤러마다 예외 처리를 위해 다 만들어 줘야될까? 공통되는 부분도 많은데다, 수정이 일어날시 컨트롤러마다 다 찾아서 수정해줘야 한다. 거기다 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다.
이런 부분을 해결하기 위해 @ControllerAdvice
, @RestControllerAdvice
라는 것이 있다.
@ControllerAdvice
는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을 부여해주는 역할을 한다.
@ControllerAdvice
에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
@RestControllerAdvice
는 @ControllerAdvice
와 같고, @ResponseBody
가 추가되어 있다. @Controller
, @RestController
의 차이와 같다.
// 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 {}
특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정할 수도 있다. 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다. 그리고 특정 클래스를 지정할 수도 있다. 대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다
Advice라는 명칭에서 알 수 있듯이 AOP의 어드바이스같은 느낌이다.
참고 : 김영한님의 스프링 강의를 공부하며 정리한 글입니다.