Spring MVC - API 예외처리(Exception)
웹 사이트에 클라이언트에게 오류 상황을 보여줄 때에는 HTML 파일로 정적으로 예외 페이지를 보여주면 되었다. 그런데 클라이언트가 API를 잘못된 형식으로 호출하거나 서버 내부에 오류가 생기면 정적인 HTML 예외 페이지를 보여주면 안될 것이다. 이럴때 API 예외처리는 스프링 MVC는 어떻게 처리를 할까?
클라이언트가 API를 잘못된 요청으로 호출하거나 서버 내부에 오류가 있다면 서버는 이런 형식으로 API 예외를 반환해야 할 것이다.(JSON 형식으로 요청한다면)
{
"message": "잘못된 사용자",
"status": 500
}
{
"message": "잘못된 요청",
"status": 400
}
BasicErrorController
우선적으로 웹 사이트에서 클라이언트에게 오류 페이지를 보여주는 방식을 사용할 때 쓰는 스프링 부트가 제공해주는 BasicErrorController를 보면, 오류 페이지를 보여줄 뿐만아니라 위의 JSON 형식으로 예외 문구를 전달하는 방식도 제공하는 것을 볼 수 있다.
@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);
}
위의 코드는 BasicErrorController가 예외가 발생했을 때 불러오는 메서드이다. 두가지 메서드가 존재하는데 하나는 HTTP 응답으로 Accept가 HTML일 때 동작하는 메서드와 아무것도 없는 메서드를 볼 수 있다. 사실 Postman으로 Accept를 전체 미디어타입으로 해놓으면 미디어 타입이 HTML인 메서드가 동작하는 것을 확인할 수 있다. Accept에 HTML이 들어간다면 무조건 우선적으로 동작한다는 것을 알 수 있다.
따라서 HTTP 응답으로 Accept를 JSON으로 한다면 우리가 앞서 말했던 JSON 형식의 예외문구를 응답으로 받을 수 있다. 또한 모르는분들을 위해 설명하자면 HTML으로 예외 페이지를 반환하고 싶다면 스프링 부트가 제공하는 기본 예외 페이지를 사용해도 되지만, 좀 더 이쁘게 UI를 제작하고 싶으면 프로젝트 경로 'resources/templates/error/' 에 400.html, 500.html, 4xx.html을 직접 생성해서 예외 페이지를 관리할 수 있다.
HandlerExceptionResolver
앞서 우리가 말한 BasicErrorController를 사용하여 API 예외처리를 해도 상관없지만, HTML 예외 페이지 처리만 할 때 사용하는 것이 좋다. 왜냐하면 API마다 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 반환해서 전달해야 할 수 있기 때문이다. 따라서 이런 유연한 API 예외를 처리하기 위해서 HandlerExceptionResolver를 사용하는 것이 좋다.
만약에 IllegalArgumentException이 발생해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리하고 싶고, RuntimeException이 발생하면 HTTP 상태코드를 500으로 처리하고 싶으면 어떻게 할까? 만약에 HandlerExceptionResolver를 사용하지 않는다면 컨트롤러에서 IllegalArgumentException이 발생해서 WAS에는 서버 내부에대한 오류로 500 상태코드가 전달될 것이다. HandlerExceptionResolver를 사용한다면 예외에 따라서 이를 유연하게 해결하고 정상적으로 WAS에게 상태코드를 전달할 수 있다.
HandlerExceptionResolver를 사용해서 예외를 해결하지 않고 그냥 예외를 컨트롤러에서 던져버린다면 스프링의 내부 동작원리는 다음과 같다.
- 우선 Dispatcher Servlet이 호출된 핸들러(컨트롤러)를 찾기위해서 요청URL이 존재하는 컨트롤러가 있는지 prehandle을 통해 확인하고, 핸들러 어댑터를 통해서 컨트롤러를 찾고 실행한다.
- 컨트롤러에서 예외가 발생하면 서블릿에 예외가 전달되고 posthandle이 실행되지 않고 afterCompletion이 실행된다.
- 서블릿은 WAS에게 정상 응답이 아닌 예외를 전달하게 된다.
반면 HandlerExceptionResolver를 사용하여 예외를 처리하면 다음과 같은 프로세스로 진행된다.
- 우선 Dispatcher Servlet이 호출된 핸들러(컨트롤러)를 찾기위해서 요청URL이 존재하는 컨트롤러가 있는지 prehandle을 통해 확인하고, 핸들러 어댑터를 통해서 컨트롤러를 찾고 실행한다.
- 컨트롤러에서 예외가 발생하면 서블릿에 예외가 전달된다.
- 서블릿에서 HandlerExceptionResolver를 호출하여 발생한 예외를 처리할 수 있는 부분이 있으면 해결하고, 없으면 null을 반환하여HandlerExceptionResolver가 없을때와 동일하게 동작한다. 이 때, 리졸버가 예외를 해결한 후 빈 ModelAndView객체를 전달하는데, HandlerExceptionResolver는 빈 ModelAndView가 반환되면 뷰를 렌더링하지 않고 HTTP 메시지 바디에 예외와 관련된 메시지를 전달한다. 만약 비어있지 않는 ModelAndView가 전달되면 해당 뷰를 렌더링하여 보여준다.
HandlerExceptionResolver는 인터페이스인데 형태는 다음과 같다.
public interface HandlerExceptionResolver {
@Nullable
ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
}
예외를 해결하는 메서드의 인자로 요청정보, 응답정보, 핸들러정보, 예외정보를 알려준다. 또한 위 메서드에서 예외를 해결하게 되는데 다음과 같이 응답코드를 바꿔서 응답을 하지 않는다면 500 응답코드를 반환한다. 따라서 반드시 응답 상태코드를 반환해서 해결해야 예외를 우리가 원하는대로 해결할 수 있게된다.
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try{
if(ex instanceof IllegalArgumentException){
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
//새로운 ModelAndView반환
return new ModelAndView();
}
} catch(IOException e){
log.error("resolver ex", e);
}
return null;
}
이렇게 예외를 해결하는 메서드를 완성했다면 마지막으로 가장 중요한게 하나 남았다. WebMvcConfigurer에 등록해주어야 한다. 등록을 까먹고 해주지 않는다면 우리가 짠 코드가 아무 쓸모가 없어진다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
//HandlerExceptionResolver 등록
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
스프링이 제공하는 ExceptionResolver
스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.
- ExceptionHandlerExceptionResolver: 에노테이션 기반 리졸버
- ResponseStatusExceptionResolver: HTTP 상태코드를 지정해준다.
- DefaultHandlerExceptionResolver: 스프링 내부 기본 예외처리를 한다.
이 순서대로 우선순위를 매겨서 스프링 부트는 HandlerExceptionResolverComposite에 등록을 한다.
ResponseStatusExceptionResolver
해당 리졸버는 HTTP 상태코드를 지정해주는 역할을 하는데, 두가지 방법으로 처리할 수 있다.
- @ResponseStatus가 달려있는 예외
- ResponseStatusException 예외
코드를 통해서 어떻게 쓰는지 살펴보자.
//@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException{
}
애노테이션 기반의 ResponseStatusExceptionResolver는 다음과 같은데, HTTP 상태코드와 메시지를 담아서 전달할 수 있다. 또한 messages.properties, errors.properties와 같은 MessageSource에서 메시지를 찾는 기능도 제공한다.
애노테이션 기반의 ResponseStatusExceptionResolver는 사용하면 코드가 깔끔해지고 편리하지만 단점이 존재한다. 내가 직접 만든, 즉 커스텀한 클래스에만 적용이 가능하다는 점이다. 또한 조건에 따라서 상태코드와 메시지를 동적으로 변경하는것도 불가능하다. 따라서 이럴 경우에는 다음과 같은 코드를 적용해서 사용한다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2(){
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
DefaultHandlerExceptionResolver
DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결하는데, TypeMismatchException과 같이 파라미터 바인딩을 할 때 예외를 자동으로 해결해준다. 이것은 클라이언트가 잘못 전달했기 때문에 발생한 오류이므로 400오류가 발생할 것이다.
@ExceptionHandler
이전까지 썼던 HandlerExceptionResolver를 떠올리면 ModelAndView를 반환했는데, API 응답에는 뷰를 렌더링하는 일이 필요하지 않다. 또한 API 응답으로 필요한 정보를 넣어주기 위해서 HttpServletResponse에 직접 응답 데이터를 넣어주었다. 이것은 매우 불편할 것이다.
@ExceptionHandler는 ExceptionResolver중에서도 우선순위가 가장 높고 실무에서 API 예외 처리는 대부분 이것을 사용한다고 한다. 다음 소스코드로 알아보면 이해가 쉬울 것이다.
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
//컨트롤러에서 IllegalArgumentException이 발생하면 동작해서 반환값을 JSON으로 반환
@ResponseStatus(HttpStatus.BAD_REQUEST) //상태코드를 안붙이면 200 ok가 반환되기 때문에 붙여준다.
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
//UserException의 자식 예외클래스도 다 잡아준다.
@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);
}
//최상위 클래스인 Exception 예외를 정의해서 처리
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@ExceptionHandler의 인자에는 처리하고 싶은 예외 클래스명을 담아줄 수 있다. 만약 애노테이션 인자에 넣지 않는다면 메서드 인자에 넣어주면 동일하게 동작한다. @Exceptionhandler의 중요한 정보가 하나 있는데, 처리하고 싶은 예외 클래스를 넣으면 그 예외 클래스의 자식 클래스까지 모두 동일하게 처리한다. 단, 자식클래스를 처리하는 @Exceptionhandler가 있는 경우를 제외하고 말이다. 간단히 말하면 구체적인것이 우선적으로 처리한다는 뜻이다. 위의 코드를 보면 모든 예외 클래스의 부모인 Exception을 처리하는 @ExceptionHandler가 보일 것이다. 이 메서드는 위의 우리가 예외를 처리하려고 만든 UserException과 IllegalArgumentException을 제외한 예외를 처리해 줄 것이다.
'Spring Framework > Spring Web MVC' 카테고리의 다른 글
@RequestMapping(Feat. @GetMapping, @PostMapping) (0) | 2022.04.11 |
---|---|
Spring MVC - FrontController(Dispatcher Servlet) (0) | 2022.04.09 |
Spring MVC - 로그인 처리(쿠키, 세션) (0) | 2022.04.02 |
Spring MVC - Bean Validation (0) | 2022.03.29 |
Spring MVC - Validation(검증) (0) | 2022.03.28 |