기술 스택
- JDK 17
- Spring boot 3.3.5
- thymeleaf
1. 개요
HTML 페이지의 경우 /resources/templates/error 폴더에 4xx.html, 5xx.html 와 같은 오류 페이지만 있으면 BasicErrorController가 대부분의 문제를 해결해준다.
그런데 API의 경우 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만 API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고 JSON으로 데이터를 내려주어야 한다.
지금부터 API의 경우 어떻게 예외처리를 하면 좋은지 알아보겠다.
2. 코드 기본 세팅
Member.java
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Member{
private String memberId;
private String username;
public Member(String memberId) {
this.memberId = memberId;
this.username = "테스트 계정";
}
}
ControllerV1.java
@Slf4j
@RestController
public class ControllerV1 {
@GetMapping("/api/v1/{memberId}")
public Member getMemberId(@PathVariable String memberId){
if (memberId.equals("ex")){
throw new RuntimeException("잘못된 사용자");
}
if (memberId.equals("bad")){
throw new IllegalArgumentException("잘못된 사용자");
}
return new Member(memberId);
}
}
코드를 이렇게 작성해보고 Postman으로 호출 해보자
아무것도 처리를 안했는데도 JSON형태로 응답했다. 이것은 BasicErrorController가 JSON 형태로 오류 응답을 만들어준다.
BasicErrorController의 코드를 보면 알 수 있다. 한번 확인해보자.
@Controller
@RequestMapping({"${server.error.path:${error.path:/error}}"})
public class BasicErrorController extends AbstractErrorController {
private final ErrorProperties errorProperties;
@RequestMapping(produces = {"text/html"})
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
// 이하 생략...
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
// 이하 생략...
}
}
HTTP Request Header의 Accept값이 'text/html' 인 경우에는 errorHtml( )을 호출해서 view를 제공한다.
그외인 경우 error( )을 호출해서 ResponseEntity로 HttpBody에 JSON 데이터를 반환한다.
스프링 부트가 제공하는 BasicErrorController는 HTML 페이지를 제공하는 경우에는 error폴더에 html파일만 넣어주면 되니 매우 편리하다. 하지만 API 오류 처리는 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다. 예를들어 회원과 상품의 API에서 발생하는 예외에 따라 응답이 달라질 수 있다.
이제 복잡한 API 오류는 어떻게 처리해야 하는지 알아보자.
3. HandlerExceptionResolver 시작
예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 HTTP 상태 코드가 500으로 처리된다.
발생하는 예외마다 400, 404 등등 다른 상태코드로 처리하고 싶을때 어떻게 해야할까?
예를 들자면 IllegalArgumentException을 처리하지 못해 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리하고 싶으면 어떻게 해야할까?
스프링 MVC는 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고 새롭게 동작을 할 수 있는 방법을 제공한다. 바로 HandlerExcpetionResolver을 사용하면 된다.
인터페이스인 HandlerExceptionResolver의 Custom 구현체를 만들었다.
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
log.info("resolver error", e);
}
return null;
}
}
그리고 WebConfig에 등록만 하면 된다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
HandlerExceptionResolver가 ModelAndView를 반환하는 이유는 Exception을 처리해서 정상 흐름으로 변경하는 것이 목적이다. 빈 ModelAndView (new ModelAndView())를 반환하면 뷰를 렌더링하지 않고 정상 흐름으로 서블릿이 리턴된다.
당연히 뷰를 지정을 하면 그 뷰를 찾아 렌더링한다.
4. 스프링이 제공하는 ExceptionResolver
스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.
- ExceptionHandlerExceptionResolver
- ResponseStatusExceptionResolver
- DefaultHandlerExceptionResolver
ExceptionHandlerExceptionResolver
- @ExceptionHandler를 처리한다. API 예외 처리는 대부분 이기능으로 해결한다. 조금 뒤에 자세히 설명하겠다.
ResponseStatusExceptionResolver
- HTTP 상태 코드를 지정해준다.
DefaultHandlerExceptionResolver
- 스프링 내부 기본 예외를 처리한다.
ResponseStatusExceptionResolver
ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.
ResponseStatusExceptionResolver는 두가지 예외를 처리하는데
- @ResponseStatus가 달려있는 예외
- ResponseStatusException 예외
를 처리한다. 코드를 통해 알아보겠다.
BadRequestException.java
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 오류 입니다.")
public class BadRequestException extends RuntimeException {
public BadRequestException() {
super();
}
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
public BadRequestException(Throwable cause) {
super(cause);
}
}
ControllerV1 - 추가
@GetMapping("/api/responseStatusEx1")
public String responseStatusEx1(){
throw new BadRequestException();
}
그러면 요청이 다음과 같이 오는 것을 볼 수 있다.
ResponseStatusException
@ResponseStatus는 개발자가 직접 변경할 수 없는 예외는 적용할 수 없다. 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.
ControllerV1 - 추가
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
404 에러가 응답오는 것을 알 수 있다.
DefaultHandlerExceptionResolver
DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고 결과적으로 500 오류가 발생한다. 그런데 이런 경우 HTTP 상태 코드400을 사용하도록 되어 있다.
DefaultHandlerExceptionResolver가 500 오류가 아닌 400 오류로 변경해준다. 스프링 내부 오류를 어떻게 처리할지 수 많은 내용이 정의되어 있다.
ControllerV1 - 추가
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
실행 결과를 보면 HTTP 상태 코드가 400인 것을 확인할 수 있다.
5. @ExceptionHandler
HandlerExceptionResolver를 사용하면 ModelAndView를 반환해야 했다. 이것은 API 응답에 필요하지 않다.
또 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다. 각각의 다른 컨트롤러에서 RumtimeException을 다른 방식으로 처리하고 싶다면 어떻게 해야할까?
스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데 이것이 바로 ExceptionHandlerExceptionResolver이다. 스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하고 기본으로 제공하는 ExceptionResolver중에 우선 순위도 가장 높다.
코드를 통해 알아보자
예외가 발생했을 때 API 응답으로 사용하는 객체 정의
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
ControllerV1 - 추가
@ResponseStatus(HttpStatus.BAD_GATEWAY)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalHandler(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.
ErrorResult가 응답이 오는것을 확인할 수 있다.
6. @ControllerAdvice
@ExceptionHandler를 사용해서 예외를 깔끔하게 처리할 수 있게 됐지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여있다. 이럴때 @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 둘을 분리할 수 있다.
각각 컨트롤러에 언제 다 @ExceptionHandler을 선언하고 있냐,,,,,
ExControllerAdvice.java
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(RuntimeException.class)
public ErrorResult runtimeExceptionHandler(RuntimeException e) {
log.error("ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
public ResponseEntity<ErrorResult> illegalExceptionHandler(IllegalArgumentException e) {
log.error("ex", e);
return new ResponseEntity<>(new ErrorResult("BAD", e.getMessage()), HttpStatus.BAD_REQUEST);
}
}
@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다. @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
@RestControllerAdvice는 @ControllerAdviced에 @ResponseBody가 추가되어 있다.
@ControllerAdvice 대상 지정 방법
// 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 {}
특정 애노테이션이 있는 컨트롤러를 지정할 수 있고, 특정 패키지를 직접 지정할 수도 있다. 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 된다. 그리고 특정 클래스를 지정할 수도 있다.
ControllerAdvice가 더 궁금하다면 스프링공식문서-Controller Advice 를 클릭하면 된다.
'Spring Framework' 카테고리의 다른 글
[Spring] 예외 처리와 오류 페이지 (0) | 2024.11.12 |
---|---|
[Spring] Bean Validation (검증) - 2 (0) | 2024.11.04 |
[Spring] Bean Validation (검증) - 1 (4) | 2024.10.31 |
[Spring] 스프링 메시지, 국제화 (1) | 2024.10.29 |
[Spring] Argument Resolver 란? (0) | 2024.10.24 |