API 예외처리

Lilac-_-P·2023년 4월 20일
0

스프링 MVC

목록 보기
13/15

HTML 페이지의 경우, 예외가 발생했을 때 사용자에게 볼 수 있는 오류 페이지가 있으면 대부분의 문제를 해결할 수 있었다. 그런데 API의 경우에는 어떻게 해야할까? API는 사용자에게 직접 보여지는 HTML 페이지를 반환하는 것이 아니라, JSON 형태의 데이터를 반환해야한다. 따라서, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, 이를 JSON 형태의 데이터로 돌려주어야한다.

스프링 부트의 기본 API 예외 처리

스프링 부트에서 기본적으로 오류 페이지를 반환하는 매커니즘이 BasicErrorController에 구현되어있었던 것 처럼, API 예외를 처리하는 매커니즘 또한 BasicErrorController에 구현되어있다.

아래의 BasicErrorController 코드를 보자. 이해를 위해 필요한 부분만 갖고온 것이다.

@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {

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

'/error' 라는 동일한 경로를 처리하는 errorHtml(), error() 두 메서드를 확인할 수 있다.

errorHtml()는 produces="MediaType.TEXT_HTML_VALUE" 가 있으므로, 클라이언트의 요청의 Accept 헤더 값이 text/html인 경우에는 이 메서드를 호출하여 HTML 오류 페이지(view)를 제공한다.

error()는 그 외의 경우에 호출되고, ResponseEntity로 HTTP 메시지 바디에 Map 타입의 데이터를 전달하고 있다. 이 Map 타입의 데이터는 Jackson 라이브러리를 사용한 HttpMessageConverter에 의해 JSON 형태의 데이터로 변환될 것이다.

BasicErrorController를 확장하면 스프링 부트가 API 예외 처리의 결과로 제공하는 JSON 데이터도 변경할 수 있다. 그런데, API 예외 처리는 HTML 오류 페이지를 제공하는 것보다 매우 세밀하고 복잡하다. API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다. 쇼핑몰 서비스를 예로 들어보자. 쇼핑몰 서비스에서 회원과 관련된 API에서 예외가 발생할 때의 응답과 상품과 관련된 API에서 발생하는 예외는 서로 다른 응답 결과를 출력해야할 것이다.

따라서 스프링은 API 예외 처리를 위한 더 좋은 방법들을 제공한다. 하나씩 짚어보자.

참고.
아래에서 설명될 방법들은 API 예외 처리만을 위한 방법들이 아니다. 오류 페이지 제공을 통한 예외 처리에도 사용할 수 있는 방법이지만, 오류 페이지 제공을 통한 예외 처리는 BasicErrorController를 이용하는 것이 더 편리하다.

HandlerExceptionResolver

예외가 발생해서 서블릿을 넘어 WAS까지 전달되면, WAS는 발생한 예외를 HTTP 상태코드 500으로 처리한다.
하지만, 발생하는 예외에 따라서 400, 404 등등 다른 상태코드로 처리하고 싶을 수 있다.

스프링 MVC는 컨트롤러(핸들러)밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다. 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver를 사용하면 된다. 줄여서 ExceptionResolver라고 한다.

아래는 HandlerExceptionResolver 인터페이스의 코드이다.

public interface HandlerExceptionResolver {

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

인터페이스에 정의된 메서드가 resolveException() 하나이다. 컨트롤러에서 예외가 발생해서 밖으로 던져지는 경우, HandlerExceptionResolver를 구현한 구현체의 resolveException() 메서드가 호출되어 예외를 해결하고, 동작을 새로 정의한다. 상태코드 변경도 resolveException()에서 response.sendError(바꾸고 싶은 오류코드)와 같은 방법으로 할 수 있다.

HandlerExceptionResolver가 적용되기 전, 후의 스프링 MVC의 구조를 살펴보자. 몇몇 부분이 생략된 점을 감안하자.

그림에 있는 빨간 X표시를 예외라고 생각하면 된다.

resolveException() 메서드의 반환값은 ModelAndView이다. HandlerExceptionResolver가 ModelAndView를 반환하는 이유는 마치 자바 예외처리를 위해 try_catch 하듯이, 컨트롤러에서 던져진 예외를 처리해서 정상흐름처럼 변경하는 것이 목적이기 때문이다.

따라서, resolveException() 메서드의 반환 값에 따라 DispatcherServlet의 동작 방식이 달라진다.

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

예외처리에 있어서 HandlerExceptionResolver를 다음과 같이 활용할 수 있다.

  • 예외 상태 코드 변환 : 예외를 sendError() 호출로 변경하여 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
  • 뷰 템플릿 처리 : 예외에 따른 새로운 오류 화면 뷰를 렌더링하여 고객에게 제공
  • API 응답 처리 : 예외를 HTTP 응답 바디에 직접 JSON 데이터를 넣어줌으로써 API 응답 처리

위의 3개의 방법 모두 더 이상 예외를 전파시키지 않는다는 것을 알아두자.

참고.
HandlerExceptionResolver로 예외를 해결하더라도, 스프링 인터셉터의 postHandle은 호출되지 않는다.
HandlerExceptionResolver를 등록하려면, WebMvcConfigurer를 사용한다.

위에서 예외 상태 코드를 변환하는 방법의 경우 response.sendError() 호출로 상태 코드를 변경하고, 서블릿에게 상태 코드에 따라 오류를 처리하도록 위임한다고 하였다.

그러므로, WAS는 응답을 사용자에게 반환하기전에 sendError() 호출이 있었는지 확인할 것이고, 호출된 적이 있기 때문에 WAS는 오류 상태 코드에 따라 설정된 경로로 재요청을 할 것이다. 그런데, 굳이 이렇게 재요청을 할 필요가 있을까? HandlerExceptionResolver를 활용하면 이런 추가적인 과정 없이 예외를 처리할 수 있다.

아래의 코드 처럼 resolveException() 메서드를 구성하면 된다.

	ModelAndView resolveException(
			HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex){
    
        // 예외를 처리하여 응답으로 변환하는 코드
        ...
       
        
        // 빈 MAV를 반환하면, 뷰를 렌더링하지 않고 정상흐름으로 서블릿이 WAS에 반환됌.
        return new ModelAndView(); 
    }

여기에서 중요한 점은 resolveException() 메서드 내부에서 'sendError()' 메서드를 호출하지 않았다는 것이다. sendError() 메서드를 호출하지 않을 경우, WAS는 상태 코드에 따라 설정된 경로로 재요청을 하지 않는다.

위의 resolveException() 메서드처럼 sendError() 메서드를 호출하지 않으면서 예외를 이용하여 response에 직접 JSON 데이터를 담고 빈 ModelAndView를 반환하거나 ModelAndView에 View, Model 등의 정보를 지정해서 반환하면, 예외는 서블릿 컨테이너(WAS)까지 전달되지 않고, 재요청을 하지도 않는다. 즉, 스프링 MVC에서 예외 처리가 끝이난다. WAS 입장에서는 정상 처리가 된 것이다.

스프링 부트가 제공하는 ExceptionResolver

스프링 부트는 기본적으로 다음과 같은 3가지의 ExceptionResolver를 제공한다. 우선순위 순으로 나타낸 것이다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

하나씩 알아보자.

ResponseStatusExceptionResolver

ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.
그러므로, 다음 두가지 경우를 처리한다.

  • @ResponseStatus가 달려있는 예외
  • ResponseStatusException

코드로 살펴보자.


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

위와 같이 예외 자체에 @ResponseStatus를 이용하면, 이 예외가 컨트롤러 밖으로 던져졌을 때 ResponseStatusExceptionResolver가 @ResponseStatus annotation을 확인해서 오류 코드를 annotation에 설정해놓은 값으로 변경하고, reason과 같은 메시지도 담는다. 즉, 예외 자체에 annotation을 달아서, 이 예외는 이렇게 처리해달라고 알리는 것이다.

참고.
@ResponseStatus의 reason의 값으로 스프링이 제공하는 메시지 기능을 사용할 수 있다.

하지만, @ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. 예를 들어 외부 라이브러리에서 사용하는 예외는 개발자가 코드를 수정할 수 없는 영역이다. 또, annotation을 사용하기 때문에 조건에 따라 동적으로 변경하기도 어렵다. 그럴때는 ResponseStatusException를 사용하면 된다.

@GetMapping
public String exResponse() {
	throw new ResponseStatusException(HttpStatus.NOT_FOUND, "reason", new 외부라이브러리예외());
}

위와 같이 @ResponseStatus로 해결할 수 없는 예외를 ResponseStatusException로 한번 감싸버리면 된다. 이때, HTTP 상태코드와 메시지를 넣을 수도 있다. 이렇게 하면, 조건에 따라 예외를 동적으로 변경하기도 쉽다.

그런데, ResponseStatusExceptionResolver의 내부 코드를 확인해보면 결국 sendError() 메서드를 호출하는 것을 확인할 수 있다. 그러므로 ResponseStatusExceptionResolver에 의해 처리되는 예외는 WAS에서 내부적으로 오류 페이지를 재요청한다.

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다.
즉, 애플리케이션의 비즈니스 로직 동작 중 발생하는 예외가 아니라, 스프링 내부에서 발생하는 스프링 예외를 해결한다.

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 발생하는 TypeMismatchException이 있다. 파라미터 바인딩은 대부분 사용자가 HTTP 요청 정보를 잘못 전달해서 발생하는 문제이다. 이런 경우는 HTTP 상태 코드 400을 사용하도록 되어있다. DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 400 오류로 변경해준다. 만약 DefaultHandlerExceptionResolver가 없다면, WAS는 이런 오류들을 모두 500으로 처리할 것이다.

스프링 내부 오류를 어떻게 처리할지에 대한 수 많은 내용이 DefaultHandlerExceptionResolver에 정의되어 있다.

DefaultHandlerExceptionResolver도 내부 코드를 확인해보면 sendError() 메서드를 호출하는 것을 확인할 수 있다. 그러므로 WAS에서 내부적으로 오류 페이지를 재요청한다.

@ExceptionHandler

API는 각 시스템마다 응답의 모양도 다르고, 스펙도 모두 다르다.
그리고 API 예외처리는 HTML 페이지를 반환하는 것이 아니라 JSON 형태의 데이터를 반환해야한다.

각 시스템마다 API 응답의 모양도 다르고, 스펙도 모두 다르기 때문에 발생하는 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있고, 같은 예외라고 해도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 반환해야할 수 있어야한다. 한마디로 메우 세밀한 제어가 필요하다.

이전에 설명한 BasicErrorController나 HandlerExceptionResolver를 직접 구현하는 방식으로 API 예외처리를 다루기 쉽지 않다.

참고.
API 예외처리의 어려운 점들은 다음과 같다.
1. HandlerExceptionResolver는 ModelAndView를 반환해야하는데, 이는 API 응답에는 필요가 없다.
2. API 응답을 위해 HttpServletResponse에 직접 응답 데이터를 넣었는데, 이는 매우매우 불편하.
3. 컨트롤러 별로 동일한 예외에 대해 각각 다른 방식으로 처리하기 어렵다.

스프링은 API 예외 처리를 위해 매우 혁신적인 기능인 @ExceptionHandler annotation을 제공한다.

@ExceptionHandler는 아래의 코드와 같이 사용한다.

// 예외가 발생했을 때 API 응답으로 사용하는 클래스
public class ErrorResult {
    private String code;
    private String message;
}

@RestController
public class ExController {

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

    @ExceptionHandler // ! (UserException.class) 생략 가능
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] ex", e);
        return new ResponseEntity(new ErrorResult("USER-EX", e.getMessage()), HttpStatus.BAD_REQUEST);
    }
}

위의 코드 처럼, 메서드에 @ExceptionHandler annotation을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면된다. 해당 컨트롤러에서 지정한 예외가 발생하면, 그 메서드가 호출된다. 지정한 예외 또는 그 예외의 자식 클래스 모두 잡을 수 있다.

참고.
스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.
만약 부모예외와 자식예외를 지정한 @ExceptionHandler가 둘다 있을 경우, 자식예외는 두개의 @ExceptionHandler에 의해 처리될 수 있지만, 자세한 것이 우선권을 가지므로 자식예외를 지정한 @ExceptionHandler만 호출된다.

만약 annotation에 예외를 지정하지 않으면, 메서드의 파라미터로 받는 예외가 자동으로 지정된다.
또, @ExceptionHandler annotation에는 여러 개의 예외를 지정해서 다양한 예외를 한번에 처리할 수도 있다.

ExceptionHandlerExceptionResolver

ExceptionHandlerExceptionResolver는 위에서 설명한 @ExceptionHandler와 함께 컨트롤러에서 발생하는 예외를 처리하는 ExceptionResolver이다.

ExceptionHandlerExceptionResolver의 실행흐름은 다음과 같다.

  1. 컨트롤러에서 예외가 발생하여 컨트롤러 밖으로 던져진다.
  2. 예외가 발생했으므로, ExceptionResolver가 순차적으로 작동한다. ExceptionHandlerExceptionResolver는 가장 우선순위가 높기때문에 가장 먼저 싱행된다.
  3. ExceptionHandlerExceptionResolver는 예외를 밖으로 던진 컨트롤러에 해당 에외를 처리할 수 있는 @ExceptionHandler가 붙은 메서드가 있는지 확인한다.
  4. @ExceptionHandler가 붙은 메서드가 있다면, 해당 함수를 실행하고, 결과값을 반환한다.
    • 컨트롤러에 @ResponseBody가 적용되어 있다면, HTTP 컨버터가 사용되어 메서드의 반환값이 JSON으로 변환되어 반환된다.
    • @ResponseBody 대신 HttpEntity(ResponseEntity)를 사용해 HTTP 메시지 바디에 직접 응답할 수도 있다.
  5. @ExceptionHandler가 붙은 메서드에 @ResponseStatus도 붙어있다면, 지정된 HTTP 상태코드로 응답한다.
    • HTTP 응답 코드를 동적으로 변경하고 싶다면, ResponseEntity를 사용해야한다.

위에 있는 @ExceptionHandler를 이용한 코드를 참고하여 이해하면 좋다.

ExceptionHandlerExceptionResolver는 아래처럼 오류 페이지 제공을 위해서도 사용될 수 있다.

@ExceptionHandler
public ModelAndView ex(Exception e){
	return new ModelAndView("error");
}

참고.
위에서도 살짝 얘기했지만, ExceptionHandler를 이용한 예외 처리는 API 예외 처리만을 위한 방법들이 아니다. 오류 페이지 제공을 통한 예외 처리에도 사용할 수 있는 방법이지만, 단지 오류 페이지 제공을 통한 예외 처리는 BasicErrorController를 이용하는 것이 더 편리하다.

@ControllerAdvice

@ExceptionHandler를 사용하면 예외를 깔끔하게 처리할 수 있지만, 컨트롤러 내부에 정상 요청 처리 코드와 예외 처리 코드가 섞여있게 된다. @ControllerAdvice를 사용하면 이 둘을 분리할 수 있다.

컨트롤러에서 예외를 처리하는 코드만 그대로 분리해서 아래의 예시와 같이 새로운 클래스로 정의하면 된다.

@RestControllerAdvice
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 // ! (UserException.class) 생략 가능
    public ResponseEntity<ErrorResult> userExHandler(UserException e) {
        log.error("[exceptionHandler] ex", e);
        return new ResponseEntity(new ErrorResult("USER-EX", e.getMessage()), HttpStatus.BAD_REQUEST);
    }
}

@ControllerAdvice는 대상으로 지정한 컨트롤러에서 예외가 발생하는 경우, @ControllerAdvice가 붙은 클래스 내에서 예외를 처리할 메서드를 찾아서 실행하게 한다.

@RestControllerAdvice는 @ControllerAdvice에 @ResponseBody가 추가적으로 적용되어 있다고 생각하면 된다. API 예외의 경우 예외 응답 또한 JSON 형태로 반환해야하기 때문에 @RestControllerAdvice를 사용해야한다.

@ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. @ControllerAdvice는 특정 annotation이 있는 컨트롤러를 지정할 수 있고, 특정 패키지 또는 클래스를 지정할 수도 있다. 특정 패키지를 지정할 경우, 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다.

@ExceptionHandler와 @ControllerAdvice를 조합하면 예외를 깔끔하게 해결할 수 있다.

profile
열심히 하자

0개의 댓글