✔️ 예외 발생 시, API 형태로 내려주는 코드를 작성해본다.
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request,
HttpServletResponse response) {
log.info("API errorPage 500");
Map<String, Object> result = new HashMap<>();
Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
result.put("status", request.getAttribute(ERROR_STATUS_CODE));
result.put("message", ex.getMessage());
Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
produces = MediaType.APPLICATION_JSON_VALUE
: HTTP 요청 헤더의 Accept
값이 application/json
일 때 해당 메소드가 호출된다는 의미ResponseEntity
이기 때문에 메시지 컨버터가 동작하면서 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()
은 HTTP 요청 헤더의 Accept 값이 text/hrml 인 경우에 호출되고, view를 제공한다.error()
메소드가 호출되고 ResponseEntity
로 HTTP 바디에 JSON 데이터를 반환한다.💡 BasicErrorController 를 사용하면 HTML 페이지를 호출할 때는 오류에 대한 페이지를 잘 처리해준다. 하지만, API 같은 경우에는 기본적으로 JSON 데이터를 반환해주기는 하지만 서비스에 따라 응답 결과나 형태가 다른 경우가 대부분이다. 따라서 BasicErrorController를 통한 API 오류 처리보다는 @ExceptionHandler
를 사용하여 처리하는 것이 좋다.
✔️ 여기서는 예외가 발생했을 때 오류 메시지 형식 등을 API마다 다르게 처리하고 싶을 때 어떻게 해야할지 방법을 정리한다.
HandlerExceptionResolver
를 사용할 수 있다.public interface HandlerExceptionResolver {
ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex);
}
handler
: 핸들러(컨트롤러) 정보Exception ex
: 핸들러(컨트롤러)에서 발생한 예외@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
를 반환하는 이유는 Exception을 처리해서 마치 정상 흐름처럼 변경하려는 목적이다.ModelAndView
에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공response.getWriter().println("hello");
처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다//WebConfig
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
✔️ ExceptionResolver
를 사용하면 컨트롤러에서 예외가 발생해도 예외를 안에서 처리하고 서블릿 컨테이너까지 예외가 전달되지 않게할 수 있다. 아래 예제 코드를 보자.
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if (ex instanceof UserException) {
log.info("UserException resolver to 400");
String acceptHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
if ("application/json".equals(acceptHeader)) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
String result = objectMapper.writeValueAsString(errorResult);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
return new ModelAndView();
} else {
// text/html
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return null;
}
}
✔️ 스프링 부트가 제공하는 ExceptionResolver는 아래와 같고, HandlerExceptionResolverComposite
에 순서에 따라 등록한다.
1. ExceptionHandlerExceptionResolver
→ @ExceptionHandler 을 처리한다.
2. ResponseStatusExceptionResolver
→ HTTP 상태 코드를 지정해준다.
3. DefaultHandlerExceptionResolver
→ 스프링 내부 기본 예외를 처리한다.
ResponseStatusExceptionResolver
부터 알아본다.ResponseStatusExceptionResolver
는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 하며, 아래 두 가지 경우를 처리한다.//BadRequestException class
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
reason
은 MessageSource에서 메시지를 찾아서 해당 메시지로 출력해주는 기능도 있다.messages.properties
ex ) error.bad=잘못된 요청 오류입니다. 메시지 사용
ResponseStatusException
예외를 사용하면 된다.//Controller
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
DefaultHandlerExceptionResolver
스프링 내부에서 발생하는 스프링 예외를 처리한다.TypeMismatchException
이 발생하는데, 이 경우에는 따로 처리하지 않으면 서블릿 컨테이너까지 오류가 전달되고 500오류가 발생한다.DefaultHandlerExceptionResolver
은 이것을 500이 아닌 400오류로 변경해준다.response.sendError(HttpServletResponse.SC_BAD_REQUES
를 호출한다. 이 것 또한 sendError()를 호출했기 때문에 WAS에서 다시 오류 페이지(/error)를 내부 요청한다.타입을 맞지 않게 호출해보면 아래와 같이 400 오류가 찍힌다.
{
"status": 400,
"error": "Bad Request",
"exception":"org.springframework.web.method.annotation.MethodArgumentTypeMismatchException",
"message": "Failed to convert value of type 'java.lang.String' to required type 'java.lang.Integer';
nested exception is java.lang.NumberFormatException: For input string: \"hello\"",
"path": "/api/default-handler-ex"
}
✔️ API는 각 시스템 마다 오류 응답 내용이나 스펙이 모두 다르기 때문에, 지금까지 알아본 BasicErrorController를 사용하거나 HandlerExceptionResolver를 직접 구현하는 방법으로 API 예외 처리를 하려면, HttpServletResponse에 직접 응답 데이터를 넣어주고 ModelAndView를 반환하므로 복잡하고 API 응답에는 적절하지 않다.
그래서, 스프링은 API 예외 처리를 편리하게 하도록 @ExceptionHandler
라는 어노테이션을 제공하는데, 이 것이 바로 ExceptionHandlerExceptionResolver
이다.
기본으로 제공하는 ExceptionResolver
중 에서도 우선 순위가 가장 높다. 실무에서도 API 예외 처리는 대부분 이 기능을 사용한다.
✔️ 아래 예제를 통해 @ExceptionHandler를 어떻게 사용하는지 알아본다.
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(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
클래스 및 하위 클래스들도 모두 처리 가능한 것이다.@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
log.info("exception e", e);
}
@ExceptionHandler
에 예외 클래스를 생략할 수 있다. 생략하면 메소드의 파라미터로 넣어준 예외 클래스로 지정된다.@ExceptionHandler //@ExceptionHandler(UserException.class)와 동일
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}
@ExceptionHandler
에는 다양한 파라미터와 응답을 지정할 수 있다. 아래 공식 메뉴얼을 참고하자.✔️ 그럼 이제 각 예제에 대한 실행 흐름을 정리해본다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(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);
}
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
log.info("exception e", e);
return new ModelAndView("error");
}
✔️ @ControllerAdvice
또는 @RestControllerAdvice
를 사용하면 정상 처리 로직과 예외 처리 로직을 분리할 수 있다.
@ControllerAdvice
(@RestControllerAdvice)@ControllerAdvice
는 대상으로 지정한 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을 부여해주는 역할을 한다.@ControllerAdvice
에 대상 컨트롤러를 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌)@RestControllerAdvice
는 기능은 같고 @ResponseBody
가 추가된 것이다.// 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 {}