[Spring Boot] API 는 어떻게 예외처리 해야할까 ?

mallin·2022년 8월 3일
0

spring

목록 보기
4/4
post-thumbnail

💡 해당 블로그 글은 김영한님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술
예외 처리와 오류 페이지, API 예외 처리 를 정리한 글입니다.

API 예외 처리는 중요하지만, 생각보다 예외처리 하기 어렵다.
각 예외 상황에 맞는 응답 스펙을 정하고, JSON 으로 데이터를 내려줘야 하며 .... 🤔
이것 외에 생각할 게 더욱 많다.

스프링 부트에서는 어떻게 API 예외 처리를 할 수 있을까 ?

스프링은 예외처리를 위해서
① BasicErrorController ② HandlerExceptionResolver ③ ExceptionResolver
의 총 3가지 방법을 제공한다.

그러면 본격적으로 알아보도록 하자. 🏃‍♂️

BasicErrorController

스프링 부트는 기본적으로 BasicErrorController 를 기본 오류 방식으로 제공한다. (org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController)

application/json 으로 요청했을 때 아래와 같이 리턴된다.

{
    "timestamp": "2022-06-05T07:32:47.936+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "exception": "java.lang.RuntimeException",
    "trace": "java.lang.RuntimeException: 잘못된 사용자",
    "path": "/api/members/ex"
}

내부적으로는 아래 메소드를 수행하는데, produces 에 따라 각각 다른 메소드가 호출된다.

@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);
}

HandlerExceptionResolver

예외가 발생하는 경우엔 서블릿을 넘어 WAS 까지 예외가 전달되기 때문에 HTTP 상태코드는 500 으로 노출된다. (서버 내부적으로 오류가 발생해서 예외가 전달되었다고 생각하기 때문에)

하지만, 발생하는 예외에 따라 API 별로 상태코드, 형식, 오류 페이지를 다르게 가져가고 싶다면 어떻게 처리해야 할까 ?

스프링 MVC 는 HandlerExceptionResolver 를 통해 예외를 새로 정의할 수 있는 방법을 제공한다.

인터셉터의 메소드는 다음과 같다.
① preHandle : 컨트롤러 실행 전
② postHandle : 컨트롤러 실행 후
③ afterCompletion : 모든 실행이 완료 된 후

HandlerExceptionResolver 적용하기 전 ⬇️

적용하지 않는 경우 핸들러(컨트롤러) 에서 예외가 발생하면 해당 예외를 WAS 로 전달해준다.

HandlerExceptionResolver 적용 후 ⬇️

적용한 경우 핸들러(컨트롤러) 에서 예외가 발생하면 WAS 로 예외를 전달해주는게 아니라
① 먼저 ExceptionResolver 에서 예외를 해결하기 위해 시도하고,
② 해결한 경우 정상 응답을 리턴한다.

이 경우 역시 컨트롤러가 모두 실행된게 아니기 때문에 postHandle 은 실행되지 않는다.

코드 살펴보기

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

HandlerExceptionResolver 인터페이스는 위와 같이 구성되어 있다.
이걸 implements 받아 exception 별로 설정해 줄 수 있다.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {

        // 예외가 넘어오면 정상적인 ModelAndView 로 반환
        try {
            if (ex instanceof IllegalAccessException) {
                log.info("IllegalArgumentException resolver to 400");
                // IllegalAccessException error 를 400 에러로 리턴
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                // 빈값으로 넘기면 WAS 까지 정상적인 흐름으로 리턴
                return new ModelAndView();
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        // null 로 리턴하면 예외가 그냥 날라감.
        return null;
    }

}

하지만 ExceptionHanlder 를 매번 직접 구현하는 건 너무 복잡하다.
이런 경우 스프링에서 제공하는 ExceptionHandler 를 사용할 수 있다.

스프링 ExceptionResolver

HandlerExceptionResolverComposite 에 다음 순서로 등록되어 있고, 1 > 2 > 3 번 순으로 예외 처리를 시도한다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

ExceptionHandlerExceptionResolver

API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 어노테이션을 통해 매우 편리한 예외 처리 기능을 제공한다.
해당 어노테이션이 ExceptionHandlerExceptionResolver 이다.

기본으로 제공하고, ExceptionResolver 중에서 우선순위도 가장 높다

@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e) {
    log.error("[exceptionHandler] ex",e);
    return new ErrorResult("EX", "내부 오류");
}

① 우선순위
항상 자세한 것이 우선권을 가진다.
EX) RuntimeException 과 IndexOutOfBoundsException 가 있을 때 IndexOutOfBoundsException 가 RuntimeException 보다 우선권을 가진다.

② 다양한 예외
한번에 여러 예외를 처리할 수 있다.

@ExceptionHandler(AException.class, BException.class)
public String ex(Exception e) {
        ...    
}

③ 예외 생략
@ExceptionHandler 의 예외를 생략할 수 있다

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e) {}

④ 파라미터와 응답
정말 다양한 파라미터와 응답을 지정할 수 있다.
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-exceptionhandler-args

ResponseStatusExceptionResolver

예외에 따라서 HTTP 상태 코드를 지정해주는 역할

  1. @ResponseStatus 가 달려 있는 예외
  2. ResponseStatusException 예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

DefaultHandlerExceptionResolver

스프링 내부에서 발생하는 스프링 예외를 해결

EX) 파라미터 바인딩 시점에 타입이 맞지 않으면 내부적으로 발생하는 exception 인 TypeMismatchException 의 경우 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
하지만 TypeMismatchException 은 보통 클라이언트가 요청을 잘못해서 발생하는 예외라서 400 오류로 리턴해줘야 하기 때문에 DefaultHandlerExceptionResolver 가 변경해준다.

DefaultHandlerExceptionResolver 에 doResolveException 메소드에서

if (ex instanceof TypeMismatchException) {
    return this.handleTypeMismatch((TypeMismatchException)ex, request, response, handler);
}

protected ModelAndView handleTypeMismatch(TypeMismatchException ex, HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
    response.sendError(400);
    return new ModelAndView();
}

400 으로 변경해준다.

하지만, DefaultHandlerExceptionResolver 와 ResponseStatusExceptionResolver 는 response 에 데이터를 직접 넣어줘야 하고, ModelAndView 를 반환해야 하기 때문에 매우 번거롭다. 스프링은 이 문제를 해결하기 위해 ⭐️ ExceptionHandlerExceptionResolver 를 제공한다.

@ControllerAdvice

@ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있지만, 하나의 컨트롤러에 종속되어 있다.
이럴 때 @ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 분리할 수 있다.

RestControllerAdvice

@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {

ControllerAdvice

  • @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBuilder 기능을 부여해주는 역할을 한다
  • @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다 (글로벌 적용)
  • @RestControllerAdvice 는 @ControllerAdvice 와 기능이 같고, @ResponseBody 만 추가되어 있다
// RestController 만 적용
@ControllerAdvice(annotations = RestController.class)

// org.example.controllers 패키지 하위 적용 
@ControllerAdvice("org.example.controllers")

// 부모 클래스 나 컨트롤러 지정 
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})

0개의 댓글