API 예외 처리
HTML 페이지의 경우 저번에 정리했던 것처럼 4xx, 5xx와 같은 오류페이지만 있으면 대부분의 문제를 해결할 수 있다.
[Spring] 서블릿 예외 처리
오늘은 스프링이 아닌 순수 서블릿 컨테이너가 예외를 어떻게 처리하는지 정리해보려고 한다. 서블릿 예외 처리서블릿은 2가지 방식으로 예외 처리를 지원한다.Exception(예외)response.sendError(HTTP
auny.tistory.com
API의 경우 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
HandlerExceptionResolver
스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.
컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver를 사용하면 된다.
(줄여서 ExceptionResolver)


🔻 HandlerExceptionResolver - 인터페이스
public interface HandlerExceptionResolver {
@Nullable
ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
}
- handler : 핸들러(컨트롤러) 정보
- Exception ex : 핸들러(컨트롤러)에서 발생한 예외
🔻 MyHandlerExceptionResolver
@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;
}
}
- ExceptionResolver가 ModelAndView를 반환하는 이유는 마치 try, catch를 하듯이, Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이다. 이름 그대로 Exception을 Resolver(해결)하는 것이 목적이다.
- 여기서는 IllegalArgumentException이 발생하면 response.sendError(400)를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다.
반환 값에 따른 동작 방식
HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식
- 빈 ModelAndView : new ModelAndView()처럼 빈 ModelAndView를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.
- ModelAndView 지정 : ModelAndView에 View, Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.
- null : null을 반환하면, 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
ExceptionResolver 활용
예외 상태 코드 변환
- 예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
- 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어 스프링 부트가 기본으로 설정한 /error가 호출
뷰 템플릿 처리
- ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
API 응답 처리
- response.getWriter().println("hello"); 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능
- 여기에 JSON으로 응답하면 API 응답 처리 가능
👉 ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 예외를 처리해 주기 때문에 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝난다. 결과적으로 WAS 입장에서는 정상 처리가 된 것이다.
하지만 ExceptionResolver를 구현하는 것은 복잡하기 때문에 스프링이 제공하는 ExceptionResolver들을 알아보자.
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver -> 우선 순위가 가장 낮음
ResponseStatusExceptionResolver
예외에 따라서 HTTP 상태 코드를 지정해 주는 역할을 한다.
@ResponseStatus가 달려있는 예외, ResponseStatusException 예외 두 가지 경우를 처리한다.
🔻 @ResponseStatus 사용
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
- BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST(400)으로 변경하고, 메시지도 담는다.
🔻 ApiExceptionController
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (애노테이션을 직접 넣어야 하는데, 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳)
추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.
🔻 ResponseStatusException 예외 사용
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
DefaultHandlerExceptionResolver
스프링 내부에서 발생하는 스프링 예외를 해결한다.
대표적으로 파라미터 바인딩 시점에서 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
그런데 파라미터 바인딩은 대부분 클라이언트가 요청 정보를 잘못 호출해서 발생하는 문제다. 따라서 HTTP에서는 이런 경우 상태 코드 400을 사용하도록 되어 있다. 결국 DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 400 오류로 변경한다.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
- Integer data에 문자를 입력하면 내부에서 TypeMismatchException이 발생해서 실행 결과 HTTP 상태 코드가 400인 것을 확인할 수 있다.
🤔 지금까지 HTTP 상태 코드를 변경하고, 스프링 내부 예외의 상태코드를 변경하는 기능을 알아봤는데, HandlerExceptionResolver를 직접 사용하기는 복잡하다. API 오류 응답의 경우 response에 직접 데이터를 넣어야 해서 번거롭다. ModelAndView를 반환하는 것도 API에는 잘 맞지 않다.
스프링은 이 문제를 해결하기 위해 @ExceptionHandler라는 예외 처리 기능을 제공한다.
그것이 ExceptionHandlerExceptionResolver이다.
ExceptionHandlerExceptionResolver
다시 한번 말하자면 지금까지 살펴본 방식으로는 API 예외를 다루기 쉽지 않다.
- HandlerExceptionResolver가 ModelAndView 반환 => API 응답에는 필요하지 않음
- API 응답을 위해 HttpServletResponse에 직접 응답 데이터를 넣어줌
- 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어려움
스프링은 이 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 편리한 예외 처리 기능을 제공한다.
스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위가 가장 높다. 실무에서 대부분 이 기능으로 사용한다.
예제를 살펴보자.
🔻 ErrorResult
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
🔻 ApiExceptionV2Controller
@Slf4j
@RestController
public class ApiExceptionV2Controller {
// IllegalArgumentException 또는 그 하위 자식 클래스까지 모두 처리
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
// 예외 생략 가능, 생략하면 메서드 파라미터의 예외가 지정
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] 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 exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
- @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
- 스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.
- 예외를 지정한 부모 클래스는 자식 클래스까지 처리할 수 있다. 따라서 자식예외가 발생하면 부모예외처리(), 자식예외처리() 둘 다 호출 대상이 된다. 둘 중 더 자세한 것이 우선권을 가지므로 자식예외처리()가 호출된다.
IllegalArgumentException 처리 과정
- 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
- 예외가 발생했으므로 ExceptionResolver가 작동한다. 그 중 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
- ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
- illegalExHandle()를 실행한다. @RestController이므로 illegalExHandle()에도 @ResponseBody가 적용된다.
- 따라서 HTTP 컨버터가 사용되고, 응답이 JSON으로 반환된다.
- {"code": "BAD", "message": "잘못된 입력 값"}
UserException 처리 과정
- @ExceptionHandler에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다.
- ResponseEntity를 사용해서 HTTP 메시지 바디에 직접 응답한다.
- ResponseEntity를 사용하면 HTTP 응답 코드를 프로그래밍해서 동적으로 변경할 수 있다. 앞서 살펴본 @ResponseStatus는 애노테이션이므로 HTTP 응답 코드를 동적으로 변경할 수 없다.
Exception 처리 과정
- throw new RuntimeException("잘못된 사용자")이 실행되면서, 컨트롤러 밖으로 RuntimeException이 던져진다.
- RuntimeException은 Exception의 자식클래스이므로 exHandle가 호출된다.
@ControllerAdvice
이제 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다.
@ControllerAdvice와 @RestControllerAdvice를 사용하면 둘을 분리할 수 있다.
🔻 @RestControllerAdvice 사용
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] 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 exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
- @RestControllerAdvice는 @ControllerAdvice와 같고, @ResponseBody가 추가되어 있다. @Controller, @RestController 차이와 같다.
🔻 ApiExceptionV2Controller 코드에 있는 @ExceptionHandler 모두 제거
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
✨👩💻✨
@ExceptionHandler와 @ControllerAdvice를 조합하면 예외를 깔끔하게 해결할 수 있다.
프로젝트 진행할 때 당연하게 사용했던 건데, 이번 기회에 왜 써야 하는지와 흐름을 정리해 볼 수 있어서 좋았다.
출처
스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술 | 김영한 - 인프런
'Study > Spring' 카테고리의 다른 글
| [Test] JUnit을 활용한 단위 테스트 (0) | 2024.10.04 |
|---|---|
| [Test] 테스트의 필요성 (0) | 2024.09.26 |
| [Spring] 서블릿 예외 처리 (0) | 2024.09.05 |
| [JPA] N+1 문제 원인 및 해결 (0) | 2024.09.03 |
| [Spring] 로그인 처리하기 - 쿠키, 세션 (0) | 2024.06.04 |