에러 페이지를 보여줘야 할 상황과 달리 API 환경에서는 생각해야할 내용이 더 많아진다. 각 오류 상황에 맞는 오류 응답 스펙을 정해야하고 이를 json 형식으로 응답해야한다.
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(
HttpServletRequest request, HttpServletResponse response
) {
log.info("API error Page 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));
}
// rest controller
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMemberer(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
}
api/members/ex로 접근하면 런타임 예외를 발생시키는 컨트롤러를 만들었다. 이 예외는 기존의 동작처럼 WAS까지 던져질 것이다. WAS는 커스터마이저에 구축된 다음의 코드ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500"); 를 보고 error-page/500으로 에러처리를 위한 다시 한번의 내부 로직상의 요청을 쏘게 된다.
기존의 컨트롤러와 구분되도록 produces 인자를 통해 json 요청, 즉 API 에러 요청을 구분하도록 위와 같이 코드를 짠 것이다.
여기서 json 응답을 위해 이전에 배운 ResponseEntity를 활용한다. 응답 엔티티에 데이터를 넣어주기 위해 직접 Map을 구성하고 데이터를 하나하나 넣어야 했다. 코드가 굉장히 번잡해지는 것을 볼 수 있다.
앞서 스프링 부트의 기본 설정은 오류 발생시 /error를 오류 페이지로 요청했다. 자동적으로 구축되어있는BasicErrorController가 이를 처리해준다. 우리는 그 덕에 에러 페이지에 대한 템플릿만 적절한 경로에 구축했다.
BasicErrorController는 API요청 또한 처리해주는데 Accept 헤더에 따라 응답 형식을 조절해준다. BasicErrorController는 WAS가 에러 로직시 주는 기본 에러정보를 통해 json 에러 응답을 구성해서 응답한다.
굉장히 다재다능해보이지만 API 환경에서 이러한 일괄 처리는 앞서 말한 것과 같이 굉장히 한정적이다. 예로 회원과 관련된 API에서 예외가 발생하는 것과 상품과 관련된 API에서 예외가 발생하는 것은 예외에 따라 그 결과가 달라질 수 있다. 결론적으로 예외를 처리해야하는 상황이 굉장히 세밀한 것이 API이다. 이전의 오류페이지 관점에서는 오류 응답(오류 템플릿)을 받는 클라이언트는 브라우저이기에 오히려 정보를 줄이고 일관된 오류 페이지를 보여주는 것이 좋은 경우가 많았지만 API 환경의 경우 예외 응답을 받는 것은 프론트엔드 개발자일 수 있고 이것을 처리하기 위해서는 예외 응답이 세밀할 필요가 있기 때문이다.
그렇기에 BasicErrorController는 일괄적으로 오류 페이지를 응답할 때만 활용하는 것이 좋다.
서버 애플리케이션에서 발생한 예외는 이때동안 WAS까지 넘어가고 그 후 WAS에서 이를 구분해서 다시 에러 처리를 위한 로직이 실행되었다.(비효율적) WAS에서도 이를 잡지 못하고 에러 처리를 위한 로직이 진행되지 않는다면 500 Error를 발생시켰다.(500 에러는 그렇게 쉬운 현상이 아닌데!)
우리는 우선 WAS까지 예외가 올라가는 상황을 막아보고자 하며, 예외를 다형적으로 세분화하여 처리할 수 있길 원한다.(원하세요.)
HandlerExceptionResolver는 이를 시원하게 해결해준다.
HandlerExceptionResolver는 컨트롤러에서 예외 발생시 WAS까지 가지 않고 Spring의 범위에서(Dispatcher Servlet 단에서) 예외 해결을 시도한다. 예외를 해결한다 해도 컨트롤러가 정상적으로 수행된 것이 아니니 postHandle()은 실행되지 않는다.

@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);
return new ModelAndView();
}
} catch (IOException e) {
log.error("resolver ex", e);
}
// 다음 리졸버를 찾아감.
return null;
}
}
// WebConfig : api 예외처리 resolver 등록
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
위와 같이 HandlerExceptionResolver를 구현해주고 구현 리졸버를 등록해준다. 이렇게 등록할 경우
IllegalArgumentException는 400 에러로 바뀌어서 WAS로 전달된다.
컨트롤러부터 올라온 예외 IllegalArgumentException는 디스패처 서블릿에서 등록된 예외처리 리졸버에 의해 예외가 잡히게 되고 .sendError로 원하는 에러로 처리된다. 빈 ModelAndView를 반환하므로써 랜더링 템플릿이 매칭되지 않고 종료된다.
빈 ModelAndView: new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.ModelAndView 지정: ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는여전히 sendError를 사용했기 때문에 WAS에서 이를 구분해서 다시 에러 처리를 위한 로직이 실행된다.(BasicErrorController)
response.sendError()는 WAS가 에러의 존재를 인식해서 WAS만의 예외 처리 로직이 동작한다. 그러지 않도록 서블릿단에서 예외를 잡고 WAS에는 정상흐름으로 예외 정보를 전달할 수 있다.
이번에는 UserException 이라는 커스텀 예외 객체를 인식하게끔 리졸버를 구성했다.
@Slf4j
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 {
// TEST/Html
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.info("resolver ex", e);
}
return null;
}
}
서블릿 컨테이너까지 예외가 올라가는 것을 막았지만 꽤나 코드가 복잡하다. response에 json 응답을 세세하게 직접 구성해야 했다.
스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다. HandlerExceptionResolverComposite에 다음의 우선순위로 등록되어있다.
**ExceptionHandlerExceptionResolver** -> 이게 가장 좋음이 예외 처리 리졸버는 다음 두 가지 경우를 처리한다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
위의 예외에 @ResponseStatus 애노테이션으로 인해 ResponseStatusExceptionResolver가 이를 인식할 수 있다. 예외를 동적으로 구성하기도 어렵다. 하지만 애노테이션을 달아야만 인식할 수 있으므로 커스텀 예외 클래스에만 적용 가능하다.
그로 인해 ResponseStatusException를 특정 예외 객체가 출현했을 때 일으켜주면 해결 가능하다.
DefaultHandlerExceptionResolver 는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 예로 타입 미스매칭 예외와 같은 것들이 400 오류가 기본적으로 발생하는 이유는 이 리졸버 덕분이다. 이 리졸버를 스프링이 구성해놓지 않았다면 모든 내부 예외는 WAS까지 전파되어 500에러를 발생시켰을 것이다.
내부 예외 처리 용도이므로 활용하기 적합하지 않다.
직접 리졸버를 설계해서 사용하기에는 매우 복잡하다. response에 직접 오류에 관련한 데이터를 구성해야하고 ModelAndView를 반환해야하는 것도 API 환경의 관점에서 매우 이질적이다.
// IllegalArgumentException 하위까지 다 잡음.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
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) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("EX", "내부 오류");
}
컨트롤러 클래스에 위와 같이 @ExceptionHandler 메서드를 구현해주면 컨트롤러에서 발생하는 예외를 즉각 처리할 수 있다.
예외의 구분은 ExceptionHandler에 인자로 예외 클래스를 전달하거나 아니면 이를 생략하고 메서드 인자에 넘어올 예외 타입을 명시하여 매핑할 수 있다.
설정된 예외의 타입의 그 이하까지 다 잡을 수 있으며 세부적인 것이 우선된다. 가장 아래의 exHandler메서드의 경우 모든 예외의 부모인 Exception을 잡는다. 자식까지 모두 잡을 수 있으므로 만약 위에서 예외가 잡히지 않을 경우 처리되지 못한 예외는 모두 exHandler에서 처리할 수 있다.
실제로 동작은 컨트롤러에서 터진 예외가 서블릿까지 올라갔다가 서블릿이 ExceptionHandlerExceptionResolver를 통해 @ExceptionHandler를 체크하여 예외 처리 로직이 진행된다.
@ExceptionHandler를 사용하여 예외를 처리하는 것이 가장 많이 사용되는 방식이며 그에따라 당연히 스프링의 ExceptionResolver중 가장 우선순위가 높다.
@ExceptionHandler 에는 마치 스프링의 컨트롤러 처럼 작동하며 매우 유사한 형태를 가진다. @ExceptionHandler를 거쳐 처리된 에러로직은 일반적인 요청 성공처럼 기본적으로 200번대의 응답코드를 가진다. 이를 수정해서 보내기 위해서는 @ResponseStatus를 이전처럼 활용해주면 된다.
한 가지 문제가 존재한다면 컨트롤러 클래스에 예외 처리 부분과 비즈니스 로직이 섞여있다는 것이다. 이를 해결하기 위해 스프링은 ControllerAdvice를 지원한다. 이는 별도의 클래스에 예외처리로직을 보관하고 ExceptionHandlerExceptionResolver가 이 곳에서 @ExceptionHandler를 찾아 예외처리 로직을 적용할 수 있다.