질문, 피드백 등 모든 댓글 환영합니다.
HTTP API 요청에서 예외가 발생하면 단순히 오류페이지를 제공하는 것이 아니라 상황에 맞게 오류 응답 스펙을 정하고 관련 데이터를 JSON으로 응답해야합니다.
스프링부트가 기본으로 제공하는 BasicErrorController
를 확장하여 오류 페이지 뿐만 아니라 API 예외도 처리할 수 있습니다.
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
ResponseEntity
를 반환하여 HTTP 메시지 컨버터를 활용한 JSON 응답이 가능한 메서드를 제공하기에 BasicErrorController
를 상속 받아 기능을 확장하면 원하는 형식으로 API 예외 처리가 가능합니다.
하지만 API 예외는 요청 방법이나 형식에 따라 다른 응답을 내려주어야 할 경우가 많아 매우 복잡하고 세밀하게 설계해야 합니다. 때문에 보통 API 예외는 BasicErrorController
로 처리하지 않고 @ExceptionHandler
를 사용하여 처리합니다.
스프링은 예외가 컨트롤러 밖으로 전달된 경우 예외를 WAS까지 전달하지 않고 HandlerExceptionResolver
에서 예외를 해결하고 요청 흐름을 새로 정의할 수 있는 기능을 제공합니다.
HandlerExceptionResolver
ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);
HandlerExceptionResolver
를 상속받아 resolveException()
에서 예외를 해결하고 ModelAndView
를 반환하여 정상 흐름으로 만들 수 있습니다.
반환 값에 따라 DispatcherServlet
은 다르게 동작합니다.
ModelAndView
: 뷰를 렌더링하지 않고 정상 흐름으로 처리ModelAndView
: model과 view 정보에 따라 렌더링null
: 다음 순서의 HandlerExceptionResolver
으로 이동, 만약 마지막이라면 예외를 그대로 WAS까지 전달HandlerExceptionResolver
에서 response.getWriter()
등을 사용하여 API 형식으로 응답을 제공할 수도 있습니다.
WebMvcConfigurer
을 통해 HandlerExceptionResolver
구현체를 등록하여 사용할 수 있습니다.
하지만 막상 HandlerExceptionResolver
를 직접 구현해서 사용하려면 굉장히 복잡한 과정을 거쳐야 합니다.
try {
if(ex instance of XxxException)..
if ("application/json".equals(request.getHeader("accept")))...
response.getWriter()...
} catch .....
이 처럼 하나의 예외를 처리하기 위해 많은 코드가 필요합니다.
스프링부트는 3가지의 HandlerExceptionResolver
구현체를 제공합니다.
아래의 순서대로 등록되어 사용됩니다.
ExceptionHandlerExceptionResolver
는 아래에서 설명할 @ExceptionHandler
를 처리합니다.
ResponseStatusExceptionResolver
ResponseStatusExceptionResolver
는 예외에 HTTP 상태 코드를 지정하는 역할을 합니다.
@ResponseStatus
이 달린 예외나 ResponseStatusException
을 처리합니다.
@ResponseStatus
로 각 예외에 HTTP 상태 코드를 설정할 수 있습니다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "xxxx")
public class XxxException extends RuntimeException {
}
ResponseStatusExceptionResolver
는 @ResponseStatus
가 적용된 예외가 넘어오면 response.sendError()
를 호출하여 WAS에서 오류 페이지를 요청합니다.
@ResponseStatus
의 reason
속성은 메시지 소스에 접근할 수도 있습니다.
@ResponseStatus
는 편리하게 상태 코드를 설정할 수 있지만 개발자가 수정 가능한 예외에만 적용 가능하기에 라이브러리에서 제공하는 예외 등에는 적용할 수 없습니다.
이럴 때는 ResponseStatusException
를 사용합니다.
public ResponseStatusException(HttpStatus status)
public ResponseStatusException(HttpStatus status, @Nullable String reason)
public ResponseStatusException(HttpStatus status, @Nullable String reason, @Nullable Throwable cause)
public ResponseStatusException(int rawStatusCode, @Nullable String reason, @Nullable Throwable cause)
DefaultHandlerExceptionResolver
DefaultHandlerExceptionResolver
는 스프링 내부에서 발생하는 예외를 처리합니다.
예를 들어 HTTP 요청 시 바인딩 과정 중에 TypeMismatchException
이 발생하는데 이 경우 원래라면 500 상태 코드가 발생하지만 DefaultHandlerExceptionResolver
를 통해 400 오류로 바꿔줍니다.
HTTP 요청에서 TypeMismatchException
이 발생한다면 대부분 클라이언트가 잘못 요청한 경우이므로 HTTP 스펙에 맞게 400을 반환해야 합니다.
스프링은 일반적으로 발생하는 예외에 대한 수많은 내용을 DefaultHandlerExceptionResolver
에 등록해 두었고 별도의 설정없이 자동으로 적용되어 사용할 수 있습니다.
지금까지 HandlerExceptionResolver
에 대한 내용을 정리했는데 사실 이를 직접 구현하여 사용하기는 매우 복잡합니다. response
에 직접 API 데이터를 생성하거나 필요없는 ModelAndView
를 반환하는 것은 매우 비효율적입니다.
스프링은 @ExceptionHandler
을 통해 API 예외를 처리하는 기능을 제공합니다.
이 @ExceptionHandler
를 처리하는 리졸버가 ExceptionHandlerExceptionResolver
입니다.
@ExceptionHandler
를 사용하면 ModelAndView
도 반환하지 않고, 다른 컨트롤러에서 발생하는 같은 종류의 예외를 서로 다른 방식으로 HttpServletResponse
를 사용하지 않고 API 예외를 처리할 수 있습니다.
때문에 API 예외를 처리할 때는 거의 @ExceptionHandler
를 사용하여 처리합니다.
@ExceptionHandler
HTTP API 요청을 처리할 컨트롤러에서 @ExceptionHandler
이 지정된 메서드가 있으면 예외 발생 시 해당 메서드가 실행됩니다.
일단 글 보단 코드로 살펴 본 뒤 자세히 설명하겠습니다.
예제) /ex/{id}
로 요청 시 User를 JSON 형식으로 반환, id 1, 2는 요청 불가하며 ErrorResponse를 응답
User
@Data @AllArgsConstructor
public class User {
int id;
String name;
}
예외 시 응답 결과로 제공할 객체
@Data @AllArgsConstructor
public class ErrorResponse {
int id;
String message;
}
예외 처리 컨트롤러
@RestController
public class ExController {
@GetMapping("/ex/{id}")
public User ex(@PathVariable int id) {
if (id == 1)
throw new RuntimeException("1 예외");
if (id == 2)
throw new IllegalArgumentException("2 예외");
return new User(id, "user "+id);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(RuntimeException.class)
public ErrorResponse ex1(RuntimeException e) {
return new ErrorResponse(1, e.getMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResponse ex2(IllegalArgumentException e) {
return new ErrorResponse(2, e.getMessage());
}
}
@RestController는 @Controller와 @ResponseBody를 합친 어노테이션
응답 결과
/ex/1
{
"id": 1,
"message": "1 예외"
}
/ex/2
{
"id": 2,
"message": "2 예외"
}
/ex/3
{
"id": 3,
"name": "user 3"
}
@ExceptionHandler 사용법
@ExceptionHandler
@ExceptionHandler(Exception.class)
@ExceptionHandler({AException.class, BException.class})
@ExceptionHandler
에서 지정된 예외가 발생하면 해당 메서드가 실행됩니다.
해당 예외와 그 자식 타입의 예외를 모두 해결할 수 있고 만약 둘 다 각각 지정되어 있다면 자식 타입의 예외 발생 시 부모 타입의 예외가 지정된 메서드는 실행되지 않습니다.
위의 예제처럼 @ExceptionHandler
에 RuntimeException
과 IllegalArgumentException
을 지정했을 때 각각 따로 예외 처리가 가능합니다.
(IllegalArgumentException
은 RuntimeException
을 상속 받았기에 RuntimeException
만 지정해도 두 예외를 모두 처리할 수 있지만 IllegalArgumentException
을 따로 지정하면 우선 순위에 따라 더 자세한 IllegalArgumentException
이 호출됩니다.)
한 @ExceptionHandler
에 여러 예외를 지정하여 한 번에 처리할 수도 있습니다.
예외를 지정하지 않으면 해당 메서드의 파라미터로 지정됩니다.
@ExceptionHandler
public ErrorResponse ex(RuntimeException e)
면 RuntimeException
을 예외로 지정합니다.
컨트롤러에서 다양한 파라미터와 반환 값 사용이 가능하듯이 @ExceptionHandler
에서도 여러 파라미터와 반환 값 사용이 가능합니다.
만약 예외를 처리할 때 HTTP 상태 코드를 동적으로 사용하고 싶다면 ErrorResponse
대신 ResponseEntity<ErrorResponse>
를 반환하여 사용합니다.
@ExceptionHandler
를 이용해서 컨트롤러에 예외 처리 로직을 작성하게 되면 해당 파일에서 정상 처리 로직과 예외 처리 로직이 공존하여 복잡도가 올라갑니다.
때문에 일반적으로 이를 분리해서 사용하는데 @ControllerAdvice
를 이용하여 쉽게 분리 가능합니다.
위의 예제를 기반으로
@RestController
public class ExController {
@GetMapping("/ex/{id}")
public User ex(@PathVariable int id) {
if (id == 1)
throw new RuntimeException("1 예외");
if (id == 2)
throw new IllegalArgumentException("2 예외");
return new User(id, "user "+id);
}
}
예외 처리 로직 분리
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler(RuntimeException.class)
public ErrorResponse ex1(RuntimeException e) {
return new ErrorResponse(1, e.getMessage());
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResponse ex1(IllegalArgumentException e) {
return new ErrorResponse(2, e.getMessage());
}
}
이처럼 @ControllerAdvice
를 이용하여 정상 처리 클래스와 예외 처리 클래스를 분리할 수 있습니다. (@RestControllerAdvice
는 @ResponseBody
와 @ControllerAdvice
가 합쳐진 어노테이션입니다.)
@ControllerAdvice
는 클래스가 적용될 범위 지정이 가능합니다.
@ControllerAdvice // 1.
@ControllerAdvice(annotations = RestController.class) // 2.
@ControllerAdvice("org.example.controllers") // 3.
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class}) // 4.
@RestController
가 적용된 클래스에 적용