API 예외처리는 어떻게 해야할까?
html 페이지의 경우 지금까지 설명했던 것 처럼 4xx, 5xx 같은 오류 페이지만 있으면 대부분의 문제를 해결할 수 있다. (Spring 의 BasicErrorController에서 필요한 페이지들)
그런데 API의 경우에는 생각할 내용이 더많다. API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고 , JSON으로 데이터를 내려주어야 한다.
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
printErrorInfo(request);
return "error-page/500";
}
에러가 발생시에도 JSON으로 응답을 받기를 클라이언트는 기대하고 있지만 html page를 줘버리는 잘못된 예시다.
@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));
}
같은 url로 호출을 하였어도 클라이언트의 header에서 accpet의 타입에 따라서 @Controller에서 호출 되는 메소드가 달라진다.
application/json 인 경우 아래의 proudces가 명시된 메소드가 호출된다.
accpet에 아무거나(*/*)로 해버리면 html 페이지가 나와버린다.
위의 @RequestMapping 메소드(produces가 적혀있지 않은)가 호출되기 때문이다.
ERROR_EXCEPTION 같은 상수들은 위에 정의되어 있는 문자열이다.
ResponseEntity = http body에 정보를 바로 쏟는것이다.
스프링부트 기본 오류 처리
@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {
기본적으로 아무것도 설정안하면 여기로 들어가서 결정하게 된다. (/error 가 기본)
스프링이 기본으로 등록한 Controller다.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = getStatus(request);
Map<String, Object> model = Collections
.unmodifiableMap(getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = resolveErrorView(request, response, status, model);
return (modelAndView != null) ? modelAndView : new ModelAndView("error", model);
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity<>(status);
}
Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity<>(body, status);
}
text/html(TEXT_HTML_VALUE) 인 경우에는 위의 메소드가 호출되면서 ModelAndView가 호출이 되고
그 외의 경우에는 아래의 메소드가 호출이 되면서 json형식을 return 하게 된다.
ErrorPage Controller 에서 만든 것이랑 굉장히 유사하다.
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=on_param
server.error.include-binding-errors=on_param
여기서도 저번편에서 application.properties에 썼던 것을 그대로 적용시키면 좀더 많은 정보를 알려주게된다. 하지만 이런 방식은 보안상 위엄하므로 하지 말아야 한다.
API 예외처리 - HandlerExceptionResolver 시작
예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 HTTP 상태코드가 500으로 처리된다. 발생하는 예외에 따라서 400,404 등등 다른 상태코드도 처리하고 싶다.
또한 오류 메시지, 형식등을 API마다 다르게 처리하고 싶다.
스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다. 컨트롤러 밖으로 던져진 에외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다. 줄여서 ExceptionResolver 라 한다.
이런식으로 WAS까지 가게 하지 않고 중간에 예외를 해결시도를 해본다.
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
log.info("call resolver", 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;
}
}
내부에서 IllegalArgumentException 이 터진다면 여기서 해당 예외를 먹으면서 400 bad request를 보낸다.
그리고 등록을 해주어야 한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LogInterceptor())
.order(1)
.addPathPatterns("/**")
.excludePathPatterns("/css/**", "*.ico", "/error", "/error-page/**");//오류 페이지 경로
}
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
resolvers.add(new UserHandlerExceptionResolver());
}
exceptionHandler를 등록 해준다.
configureHandlerExceptionResolvers 를 쓰면 스프링이 기본으로 등록하는 ExceptionResolver가 제거된다.
위에예시 처럼 extendHandlerExceptionResolvers 를 써야한다.
HandlerExcpetionResolver 의 반환 값에 따른 DispatcherServlet 의 동작 방식은 다음과 같다.
1)빈 ModelAndView = 비어있으니 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿이 리턴된다.
|-> sendError 를 호출하긴 했으니 was는 에러페이지를 찾아보게 된다.
2)ModelAndView 지정 = ModelAndView에 View , Model 정보를 지정해서 반환하면 뷰를 렌더링 한다.
3)sendError = was 가 이것이 호출됨을 확인하고 오류페이지를 찾는다. Exception을 sendError로 바꿔치기 한것이다.
4)null = null을 반환하면 다음 ExceptionResolver를 찾아서 실행한다.
만약 처리할 수 있는 ExceptionResolver 가 없으면 예외처리가 안되고 , 기존에 발생한 예외를 서블릿 밖으로 던진다.
그냥 WAS까지 기존에 동작하는 것 처럼 날라가버린다(500으로 처리하고 이에 맞는 에러페이지 요청).
그럼 ExceptionResolver를 언제 써먹는가
예외 상태 코드 변환 = response.sendError(xxx)호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 /error 가 호출된다.
뷰 템플릿 처리 = ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
API 응답 처리 = response.getWriter().println("hello") 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. 여기에 JSON으로 응답하면 API 응답 처리를 할 수 있다.
API 예외처리 - HandlerExceptionResolver 활용
예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 '/error'를 호출하는 과정은 생각해보면 너무 복잡하다.
ExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정없이 여기에서 문제를 깔끔하게 해결할 수 있다.
public class UserException extends RuntimeException {
public UserException() {
super();
}
public UserException(String message) {
super(message);
}
public UserException(String message, Throwable cause) {
super(message, cause);
}
public UserException(Throwable cause) {
super(cause);
}
protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
@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 {
// TEXT/HTML
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
직접 만든 UserException을 HandlerExceptoinResolver로 처리하는 과정이다.
application/json 인 경우와 html 인경우를 나누어서 생각한다.
objectMapper.writeValueAsString 은 JSON 객체를 문자로 바꾸는 역할이다.
여기서 ModelAndView를 잘만들어서 마치 정상동작 한 것처럼 return 해주고 에러과정들을 여기서 끝을 내버린 것이다.
마찬가지로 WebConfig에 추가를 해줘야 동작한다.
ExceptionResolver를 내가 구현하자니 상당히 복잡하다. 지금 부터 스프링이 제공하는 ExceptionResovler를 알아보자
스프링이 제공하는 Exception Resolver
스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.
아래의 순서대로 진행된다. 첫번째에서 해결되면 더 이상 진행되지 않음
1. ExceptionHandlerExceptionResolver
@ExceptionHandler를 처리한다. 제일 중요함
API 예외처리는 대부분 이 기능으로 해결한다.
2. ResponseStatusExceptionResolver
HTTP 상태코드를 지정해준다.
@ResponseStatus(value=HttpStatus.NOT_FOUND)
response.sendError 처럼 상태코드를 다르게 변경할 때 쓴다.
해당 ExceptionResolver가 적용이 될려면 ResponseStatusException 이거나 직접 만든 Exception에 @ResponseStatus 를 붙이면 된다.
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
getmapping으로 진입하고 BadrequestException이 던져지고
@ResponseStatus annotation을 확인하는 순간 2개의 값(상태코드와 메세지)을 꺼내서 아래 코드를 따라간다.
이떄 error.bad 는 messages.properties 에 적혀있는대로 동작을 할 수 있다.
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
throws IOException {
if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
}
else {
String resolvedReason = (this.messageSource != null ?
this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
reason);
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}
내부적(ResponseStatusExceptionResolver)으로 똑같이 ModelAndView를 return 해준다. response.sendError 도 똑같다.
이 메소드가 속한 클래스도 HandlerExceptionResolver 를 구현하고 있다.
위에서 우리가 구현했던것이랑 똑같다. = Exception을 먹어버리고 정상적인 흐름으로 3)response.sendError 를 할 뿐이다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
우리가 만든 Exception인 경우에는 @ResponseStatus를 달수 있지만 시스템, 라이브러리에서 제공하는 Exception은 그럴 수 없다.
이런 경우에 사용위와같이 상태코드 + 오류메시지를 부여할 수 있다.
이것 역시 ResponseStatusExceptionResolver 가 해결해준다.
3. DefaultHandlerExceptionResolver
스프링 내부 기본 예외를 처리한다.
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데 , 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500오류가 발생한다.
그런데 파라미터 바인딩은 대부분 클라이언트가 http 요청 정보를 잘못 호출해서 발생하는 문제이다.
http 에서는 이런 경우 http 상태 코드 400을 사용하도록 되어 있다.
DefaultHandlerExceptoinResolver가 500을 400오류로 변경해준다.
API 예외 처리 - @ExceptionHandler
API예외처리의 어려운점
1. HandlerExceptionResolver의 경우에는 ModelAndView를 반환해야 했다. API 응답에는 필요 없는 것이다.
위에서 resolveException에서 내가 직접 response에 JSON 만들어서 껴줘야함
2. 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어려움
e.g) 상품관리 Controller에서 발생한 RuntimeException과 회원목록 Controller에서 발생한 RuntimeException을 다르게 처리하고 싶다.
@ExceptionHandler를 사용하자.
스프링에서 기본적으로 ExceptionHandlerExceptionResolver를 제공하고 ExceptionResolver 중에 우선순위도 가장 높음
@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {
@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 을 선언하고 , 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 딴 컨트롤러에는 영향을 미치지 않는다.
참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.
그냥 냅두면 정상흐름이라 200 이 response code로 나간다.
이때 @ResponseStatus 를 통해 바꿔줄 수 있다.
@ExceptionHandler 옆에 에러 클래스를 지정해줘도되고 2번째 메소드처럼 인자로 에러 클래스를 정해줘도 된다.
항상 자세한 것이 우선순위를 가진다.
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
Controller보다 적지만 많은 것들을 인자로 받을 수 있고 return 할 수 있다.
실행흐름
컨트롤러에서 IllegalArgumentException을 던진다
-> 예외가 발생 했으니 ExceptionResolver가 작동 이중 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 실행
-> 예외가 발생한 Controller에 IllegalArgumentException을 처리할 수있는 @ExceptionHandler가 있는지 확인
-> 해당 메소드 호출
-> @RestController이므로 HTTP 컨버터를 사용해서 응답을 JSON으로 변경
-> @ResponseStatus 확인하고 코드 변경 한후에 응답
이것을 다른 Controller에서도 쓰고프다..
@ControllerAdvice , @RestControllerAdivce
이 둘을 통해 컨트롤러와 ExceptionHandler 로직을 분리할 수 있다.
@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
basePackages 를 지정하지 않으면 모든 컨트롤러에 적용된다
'WEB > Spring MVC 2' 카테고리의 다른 글
Spring MVC 2편 파일 업로드 (0) | 2021.11.06 |
---|---|
Spring MVC 2편 스프링 타입 컨버터 (0) | 2021.10.31 |
Spring MVC 2편 예외처리와 오류페이지 (0) | 2021.10.23 |
Spring MVC 2편 로그인 처리2 - 필터, 인터셉터 (0) | 2021.10.16 |
Spring MVC 2편 로그인 처리1 쿠키 세션 (0) | 2021.10.10 |