스프링 MVC-스프링 API 예외처리

API 예외

웹에서의 예외 처리는 간단했다. 각 오류 코드에 맞춰 오류 페이지를 웹에 띄우기만 하면 끝이었다.
하지만 API의 경우는 얘기가 달라진다. API에 따라 오류 응답의 스펙도 다르고, JSON으로의 형식 변환도 필요하다.
또한 컨트롤러에 따라서 주문에서 터진 예외와 등록에서 터진 예외가 다르게 처리되어야 하는 경우도 있다.
스프링에서 이러한 API 예외를 어떻게 처리하는지에 대해 알아보자.

API 예외 처리

아무런 예외 처리 없이 기본 값으로 API에 응답하면 예외 발생 시에 JSON이 아닌 html 형식으로 응답이 나간다. 클라이언트는 정상응답이든 예외던 JSON 형식으로 받기를 바라므로 이 부분을 처리해야 한다.

가장 먼저 원시적인 방법으로는 이전 포스팅에서 사용했던 오류 페이지 컨트롤러를 JSON을 반환하도록 수정하는 것이다.

  
    @RequestMapping(value = "/error-page/500",produces = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<Map<String, Object>> errorPage500Api(
            HttpServletRequest request, HttpServletResponse response) {
        log.info("api 에러페이지 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(ERROR_STATUS_CODE);
        return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
    }

@RequestMapping에서 매개변수를 추가로 넣어 Http 헤더의 Accept가 application/json일 때에는 그에 맞게 json으로 변환된 값을 반환하도록 할 수 있다.

이전 포스팅에서 사용하던 BasicErrorController를 확장하여 처리할 수도 있다.

  
    @RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
    public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}

    @RequestMapping
    public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}

위 코드와 같이 BasicErrorController는 Http 헤더의 Accept가 text/html이면 view를 반환하고 그 외의 경우에는 ResponseEntity로 JSON 형식을 반환하는 로직이 이미 존재한다.
따라서 위의 코드를 확장하고 application.properties에서 설정을 통해 API 예외 처리가 가능하지만, 더 나은 방법이 존재하므로 거의 사용되지 않는다.

ExceptionHandler

스프링 MVC는 WAS까지 예외가 전달되기 전에 예외를 처리할 수 있는 ExceptionResolver를 제공한다.
작동 순서는 다음과 같다.

ExceptionHandler

ExceptionHandler에서 예외를 잡아 처리했다면 WAS에는 정상 응답이 전달된다.
이 ExceptionHandler를 어떻게 사용하는지 알아보자.

  
public interface HandlerExceptionResolver {
    ModelAndView resolveException(
    HttpServletRequest request, HttpServletResponse response,
    Object handler, Exception ex);
}

ExceptionHandler는 정확히는 HandlerExceptionHandler로 인터페이스를 구현하여 사용한다.

  
@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, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

기본적으로 서버 내에서 어떠한 예외가 발생하면 Internal Server Error 이므로 코드 500을 반환한다.
예외에 따라 다른 코드를 반환하길 원하여 ExceptionHandler를 구현하여 만든 코드로 IllegalArgumentException이 발생하면 에러코드를 400으로 바꿔 전달하도록 구현하였다.
ExceptionHandler의 반환값에 따른 작동 방식은 다음과 같다.

  • 빈 ModelAndView: new ModelAndView() 처럼 빈 ModelAndView 를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
  • ModelAndView 지정: ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
  • null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.

위 코드에서는 빈 ModelAndView를 반환했으므로 정상적으로 WAS가 response.sendError(400)을 받고 400코드 에러가 발생한 것과 같이 동작할 것이다.

ExceptionResolver를 활용하면 위와 같이 에러코드를 변경하는 것 뿐만 아니라 바로 뷰 템플릿을 반환하여 오류 처리를 끝낼 수도 있고 response.getWriter()를 통해 json 값을 반환할 수도 있다.

ExceptionResolver는 WebMvcConfigurer에서 메서드를 오버라이드하여 등록해서 사용해야 한다.

  
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
    resolvers.add(new MyHandlerExceptionResolver());
}

아래의 코드는 예외를 WAS까지 전달하지 않고 ExceptionResolver에서 해결한 후 정상응답을 보내는 코드의 예시이다.

  
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
    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 accept = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

                if("application/json".equals(accept)) {
                    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 {
                    return new ModelAndView("error/500");
                }
            }

        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

요청의 accept 헤더가 json이라면 body에 json 타입 데이터를 담아 반환하고, 아니라면 html을 ModelAndView에 담아 정상응답을 반환한다.
WAS는 정상응답을 받았으므로 에러 페이지를 띄우는 등의 절차를 실행하지 않는다.

그런데 이 ExceptionResolver를 직접 구현하는 것은 번거로울 뿐더러, API 응답을 구현하는데는 더더욱 불편하고 번거롭다.
이제 어떤 식으로 작동하는지 로직을 알았으니 스프링 부트에서 기본적으로 몇 가지 제공하는 ExceptionResolver를 활용하면 된다.

스프링의 ExceptionResolver

스프링에서는 다음의 3가지 ExceptionResolver를 제공한다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

숫자는 각 ExceptionResolver의 우선순위이다. 가장 강력하고 자주 쓰이는 ExceptionHandlerExceptionResolver는 나중에 설명하고, 2번의 ResponseStatusExceptionResolver부터 알아보자.

ResponseStatusExceptionResolver

ResponseStatusExceptionResolver는 다음 두 가지 경우를 처리한다.

  • @ResponseStatus 어노테이션이 달려있는 예외
  • ResponseStatusException 예외

예시 코드를 보자.

  
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

위에서 ExceptionResolver를 구현하여 에러 코드를 바꾸었던 것이 기억나는가? 해당 기능을 이제는 어노테이션을 붙여주면 ResponseStatusExceptionResolver가 처리해준다.

이 BadRequestException 예외가 컨트롤러를 넘어가면 ResponseStatusExceptionResolver가 어노테이션을 확인하고 매개변수의 에러코드와 메세지로 변경하여 WAS로 넘겨준다.

만약 어노테이션을 붙일 수 없는 라이브러리의 코드같은 경우에는 ResponseStatusException 예외를 사용하면 된다.

  
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
    throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}

참고로 에러 메세지는 MessageSource에 있는 것을 사용할 수 있다. 위의 코드에서 error.bad가 그것이다.

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부의 예외를 처리한다. 이전 스프링 내부에서의 예외는 어떤 방식으로 예외가 발생했던 간에 서버 내부의 에러이므로 Internal Server Error인 500코드로 처리된다고 했던 것을 기억할 것이다.
이전에는 ExceptionResolver를 직접 구현하여 에러코드를 변경해주는 식으로 처리했지만, 원래는 DefaultHandlerExceptionResolver가 이러한 스프링 내부 예외들을 처리한다.

예를 들어 클라이언트가 값을 잘못 입력하여 TypeMismatchException 예외가 발생했을 때, 클라이언트가 요청을 잘못 입력한 것이므로 원래는 400 코드가 떠야하지만, 스프링 내부에서 500 에러코드가 뜨는 것을 DefaultHandlerExceptionResolver가 400으로 바꿔 내보내준다.

ExceptionHandlerExceptionResolver

ExceptionHandlerExceptionResolver는 가장 자주 쓰이는, 가장 강력한 기능이다. 스프링의 여타 강력한 기능들과 마찬가지로 어노테이션을 통해 예외를 처리할 수 있게 해주는데, 특히나 여타 방법들과 달리 다루기 까다로운 API 예외를 간단하게 처리할 수 있다.

ExceptionHandlerExceptionResolver의 사용방법은 간단하다. @ExceptionHandler 어노테이션을 선언하고, 매개변수로 해당 컨트롤러 내부에서 처리하고싶은 예외를 입력해주면 된다.
예를 들어 다음의 예제는 컨트롤러 내부의 IllegalArgumentException 예외를 처리한다.

  
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
    log.error("[exceptionHandle] ex", e);
    return new ErrorResult("BAD", e.getMessage());
}

코드의 실행 흐름은 다음과 같다.

  • 컨트롤러 내부에서 IllegalArgumentException 예외가 발생한다.
  • 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 먼저 실행되고, 컨트롤러 내부에 처리할 수 있는 @ExceptionHandler 어노테이션이 있는지 확인한다.
  • 어노테이션을 확인하고 illegalExHandle()를 실행한다.
  • @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 HTTP 상태코드 400으로 응답한다.

만약 어노테이션의 매개변수 값이 없다면, 메서드의 매개변수 값을 확인하고 해당 예외를 처리한다. 자세한 것이 우위에 있는 스프링의 특성 상 자식 클래스 대상을 우선으로 처리하고, 없다면 부모 클래스를 대상으로 하는 메서드를 실행한다.

@ExceptionHandler 어노테이션이 붙은 메서드는 여러 타입을 반환할 수 있다. 기본적인 ErrorResult부터 시작해서, ResponseEntity로 JSON 타입 응답을 손쉽게 처리할 수도 있고, ModelAndView를 반환하여 API 예외처리 뿐 아니라 html 응답에도 사용할 수 있다.

@ControllerAdvice

예외 처리는 모두 구현했지만 컨트롤러 내부에 컨트롤러 로직과 예외 처리 로직이 혼재해있는 것이 마음에 들지 않는다.
@ControllerAdvice 어노테이션을 사용하면 예외 처리 파트를 컨트롤러에서 분리해낼 수 있다.

  
@RestControllerAdvice
@Slf4j
public class ExControllerAdvice {
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExceptionHandler(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", "내부 오류");
    }
}

이전 컨트롤러에 있던 예외 처리 파트들을 분리한 코드이다. @RestControllerAdvice는 @ResponseBody가 추가된 @ControllerAdvice이다.
@ControllerAdvice는 매개변수로 대상을 지정하지 않으면 모든 컨트롤러를 대상으로 글로벌하게 적용된다.
대상을 지정하는 방법은 여러 가지가 있는데 다음과 같다.

  
//특정 어노테이션이 붙은 컨트롤러를 대상으로 지정
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

//특정 패키지와 그 하위의 컨트롤러를 대상으로 지정
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

//특정 클래스를 대상으로 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}

정리

웹 애플리케이션의 예외 처리와 달리 API의 예외 처리는 어떻게 다른지, 어떤 어려움이 따르는 지를 배울 수 있었다.
예외를 WAS까지 가지 않고 처리할 수 있는 ExceptionResolver의 원리와 사용, 그리고 스프링에서 제공하는 ExceptionResolver의 기능들을 알 수 있었다.
결론적으로 @ExceptionHandler, @ControllerAdvice를 조합하여 예외를 깔끔하게 처리할 수 있다는 것을 배웠다.
꾸준히 공부를 하고는 있지만 역시 알아야할 게 너무 많고 망망대해를 헤메는 느낌이 조금은 든다.
다음 포스팅에서는 스프링 타입 컨버터에 대해 알아보도록 하겠다.