API의 경우 어떻게 예외처리를 할까❓
➡️ 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다!
BasicErrorController
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
errorHtml()
produces = MediaType.TEXT_HTML_VALUE
: 클라이언트 요청의 Accept 해더 값이 text/html
인 경우에는 errorHtml()
을 호출해서 view를 제공한다.error()
ResponseEntity
로 HTTP Body에 JSON 데이터를 반환한다.스프링 부트가 제공하는 BasicErrorController
는 HTML 페이지를 제공하는 경우에는 매우 편리하다.
➡️ 4xx
, 5xx
등등 모두 잘 처리해준다.
그런데 API 오류 처리는 다른 차원의 이야기❗️❗️
➡️ API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다.
(예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있음)
➡️ 매우 세밀하고 복잡함.
➡️ 따라서 이 방법은 HTML 화면을 처리할 때 사용하고, API는 오류 처리는 뒤에서 설명할 @ExceptionHandler
를 사용하자!!
📌
BasicErrorController
를 확장해서JSON
오류 메시지를 변경할 수 있다 정도로만 이해해두고 넘어가자!
스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.
컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver
를 사용하면 된다❗️ (줄여서 ExceptionResolver
)
📌 참고
ExceptionResolver
로 예외를 해결해도postHandle()
은 호출되지 않는다.
지금까지 살펴본 BasicErrorController
를 사용하거나 HandlerExceptionResolver
를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다☹️
HandlerExceptionResolver
를 떠올려 보면 ModelAndView
를 반환해야 했다. 이것은 API 응답에는 필요하지 않다.HttpServletResponse
에 직접 응답 데이터를 넣어주었는RuntimeException
예외와 상품을 관리하는 컨트롤러에서 발생하는 동일한 RuntimeException
예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까❓)스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler
라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver
이다❗️
ExceptionHandlerExceptionResolver
를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver
중에 우선순위도 가장 높다. ✔️ ErrorResult
객체 정의
package hello.exception.exhandler;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
✔️ ApiExceptionV2Controller
컨트롤러 생성
package hello.exception.api;
import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@Slf4j
// api 는 @RestController 사용
@RestController
public class ApiExceptionV2Controller {
// @ResponseStatus(HttpStatus.BAD_REQUEST) : 상태코드도 바꿔주고 싶을 때 사용
@ResponseStatus(HttpStatus.BAD_REQUEST)
// 서블릿 컨테이너까지 지저분하게 가지 않고, 정상 흐름으로 끝날 수 있음
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
// ResponseEntity<ErrorResult> 사용
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
// 그냥 exception 사용
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
예외 처리 방법
@ExceptionHandler
애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 📌 스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.
예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다.
✔️ 실행 결과
{
"code": "BAD",
"message": "잘못된 입력 값"
}
1️⃣ 컨트롤러를 호출한 결과 IllegalArgumentException
예외가 컨트롤러 밖으로 던져진다.
2️⃣ 예외가 발생했으로 ExceptionResolver
가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver
가 실행된다.
3️⃣ ExceptionHandlerExceptionResolver
는 해당 컨트롤러에 IllegalArgumentException
을 처리할 수 있는 @ExceptionHandler
가 있는지 확인한다.
4️⃣ illegalExHandle()
를 실행한다. @RestController
이므로 illegalExHandle()
에도 @ResponseBody
가 적용된다.
5️⃣ 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.
6️⃣ @ResponseStatus(HttpStatus.BAD_REQUEST)
를 지정했으므로 HTTP 상태 코드 400으로 응답한다.
@ExceptionHandler
를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice
또는 @RestControllerAdvice
를 사용하면 둘을 분리할 수 있다❗️
ApiExceptionV2Controller
에서 다룬 3가지 경우(@ExceptionHandler
)를 이곳에 붙여넣기 하고, 기존 코드는 삭제한다!
package hello.exception.exhandler.advice;
import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {
// @ResponseStatus(HttpStatus.BAD_REQUEST) : 상태코드도 바꿔주고 싶을 때 사용
@ResponseStatus(HttpStatus.BAD_REQUEST)
// 서블릿 컨테이너까지 지저분하게 가지 않고, 정상 흐름으로 끝날 수 있음
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
// ResponseEntity<ErrorResult> 사용
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
// 그냥 exception 사용
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
@ControllerAdvice
@ControllerAdvice
는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을 부여해주는 역할을 한다.@ControllerAdvice
에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)@RestControllerAdvice
는 @ControllerAdvice
와 같고, @ResponseBody
가 추가되어 있다.@Controller
, @RestController
의 차이와 같다.📌 대상 컨트롤러 지정 방법
// Target all Controllers annotated with @RestController @ControllerAdvice(annotations = RestController.class) public class ExampleAdvice1 {} // Target all Controllers within specific packages @ControllerAdvice("org.example.controllers") public class ExampleAdvice2 {} // Target all Controllers assignable to specific classes @ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class}) public class ExampleAdvice3 {}
자세한 부분은 [스프링 공식 문서](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- controller-advice) 참고 🌟
지금까지 알아본 것처럼, @ExceptionHandler
와 @ControllerAdvice
를 조합하면 예외를 깔끔하게 해결할 수 있다👍🏻
고지가 보인다...! 보...일까....? 아.....닌....가...^_^?