내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.
5. 스프링이 제공하는 ExceptionResolver1
스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같다.
HandlerExceptionResolverComposite 에 다음 순서로 등록되어 있다.
1. ExceptionHandlerExceptionResolver : @ExceptionHandler 를 처리한다.
2. ResponseStatusExceptionResolver : HTTP 상태코드를 지정해줄수 있다.
3. DefaultHandlerExceptionResolver 우선 순위가 가장 낮다. : 스프링 내부 기본 예외를 처리한다
1번 부터 차례로 적용시키며, 1번이 null을 반환하면 다음 2번이 적용, 2번이 null을 반환한다면 다음 3번의 ExceptionResolver가 적용된다.
● ResponseStatusExceptionResolver
ResponseStatusExceptionResolver는 예외에 따라 HTTP 상태코드를 지정해줄수 있다. 총 2가지 방식이 있는데
1) @ResponseStatus 사용하기
2) ResponseStatusException 예외
● @ResponseStatus
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver가 해당 @ResponseStatus를 확인해서 오류 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지도 담는다.
ResponseStatusExceptionResolver 내부를 확인해 보면 다음과 같다.
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason, HttpServletResponse response)
throws IOException {
if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
}
else {
String resolvedReason = (this.messageSource != null ?
this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
reason);
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}
내부적으로는 response.sendError(statusCode, resolvedReason)을 호출하는 것을 확인할수 있다.
sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다.
즉, 끝난것이 아니다, 예외 상태 코드만 변경해준것 이다. WAS는 예외를 인지하고 다시 "/error"을 호출하게 된다.
● ResponseStatusException
@ResponseStatus 는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.
(애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.)
추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다.
이럴때는 ResponseStatusException 예외를 사용하면 된다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
위와 같이 사용자가 원하는대로 설정하여 Exception을 발생시킬수가 있다.
ResponseStatusException 또한 RuntimeException을 상속받고 있다.
위와같이 ResponseStatusException 이 발생하면 ResponseStatusExceptionResolver 가 이를 해결해 준다.
protected ModelAndView doResolveException(HttpServletRequest request,
HttpServletResponse response, @Nullable Object handler, Exception ex) {
try {
if (ex instanceof ResponseStatusException) {
return resolveResponseStatusException((ResponseStatusException) ex, request, response, handler);
}
ResponseStatusExceptionResolver 내부에 보면 doResolveException이 있는데 여기서 try문 안에서 처음에 발생한 예외가 ResponseStatusException인지 확인해 준다. 이때 걸러낼 수 있다.
6. 스프링이 제공하는 ExceptionResolver2
이번에는 DefaultHandlerExceptionResolver 를 살펴보자.
DefaultHandlerExceptionResolver는 스프링 내부의 예외를 해결해준다.
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException 이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.
하지만 TypeMismatch는 대부분이 클라이언트가 잘못 입력했기때문에 발생하는 오류이다.
HTTP는 이럴때 400대의 상태코드를 사용한다.
DefaultHandlerExceptionResolver 는 이런 500오류를 400오류로 변경하여 처리해 준다.
DefaultHandlerExceptionResolver의 내부를 보면 다음과 같은 코드가 있다.
@Override
@Nullable
protected ModelAndView doResolveException(
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex) {
try {
// 생략...
else if (ex instanceof TypeMismatchException) {
return handleTypeMismatch( (TypeMismatchException) ex, request, response, handler);
}
// 생략...
} catch (Exception handlerEx) {
if (logger.isWarnEnabled()) {
logger.warn("Failure while trying to resolve exception [" + ex.getClass().getName() + "]", handlerEx);
}
}
return null;
}
다시 handleTypeMismatch를 열어보면 다음과 같다.
protected ModelAndView handleTypeMismatch(TypeMismatchException ex,
HttpServletRequest request, HttpServletResponse response, @Nullable Object handler) throws IOException {
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
return new ModelAndView();
}
response.sendError(HttpServletResponse.SC_BAD_REQUEST) (400), 결국 response.sendError() 를 통해서 문제를 해결한다.
예를 들어 다음과 같은 컨트롤러가 있다고 해보자.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
data는 Integer인데, 여기에 "qqq"같은 문자열이 들어가면 TypeMismatchException 이 발생한다.
이를 DefaultHandlerExceptionResolver 가 처리하여 400오류로 내보내주는것 이다.
7. API 예외 처리 - @ExceptionHandler
API는 각 시스템 마다 응답의 모양도 다르고, 스펙도 모두 다르다.
예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있다.
그리고 같은 예외라고 해도 어떤 컨트롤러에서 발생했는가에 따라서 다른 예외 응답을 내려주어야 할 수 있다.
한마디로 매우 세밀한 제어가 필요하다.
● API 예외 처리의 어려운점
1) HandlerExceptionResolver를 구현할때를 생각해보면, ModelAndView를 반환했다. 이는 API응답에서는 불필요하다.
2) HandlerExceptionResolver를 상속하여 구현할때를 생각해보면 response에 직접 응답 데이터를 다 처리하기에 너무 불편했다.
3) 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기는 어렵다.
● @ExceptionHandler
스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 애노테이션을 사용한다.
이를 활용한 예외 처리 기능을 ExceptionHandlerExceptionResolver 가 제공해 준다.
이 resolver는 스프링부트가 기본적으로 등록해주는 ExceptionResolver 3개중 가장 우선순위가 높다.
예제를 통해 알아가 보자.
● ErrorResult : 예외가 발생했을때 API 응답으로 나갈 객체
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
● ApiExceptionV2Controller
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e){
log.error("[exceptionHandler] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<ErrorResult>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable 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 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.
대표적으로 위 코드의 일부인 다음 ExceptionHandler를 살펴보자.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
1) 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 전달된다.
2) 예외가 발생했으니 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
3) ExceptionHandlerExceptionResolver는 해당 컨트롤러에서 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler 가 있는지 확인한다.
4) illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.
5) @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다.
위 코드는 IllegalArgumentException 이 발생한 경우, 더 나아가 IllegalArgumentException의 자식 클래스 가 발생한 경우까지 처리가 가능하다.
결과를 확인해 보자.
http://localhost:8080/api2/members/bad 으로 요청하였다.
JSON 데이터를 원하는 형태로 받을수 있었다.
또한 상태 코드도 400인데, 이는 @ResponseStatus(HttpStatus.BAD_REQUEST) 를 추가해주었기 때문이다.
원래 @ResponseStatus를 추가하지 않았다면, 200으로 정상이 나가게 된다.
왜냐하면 ExceptionHandler를 예외를 잡아서 처리하고 정상 JSON을 반환했기 때문에 WAS까지 예외가 전달되지 않기 때문이다.
따라서 200 이 송출되는것이 정상이다.
하지만 우리는 사용자 에게 400을 보여주기 위해 @ResponseStatus 를 사용한것 이다.
- 다양한 예외 처리
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
log.info("exception e", e);
}
위 코드와 같이 다양한 예외를 한번에 처리할수도 있다.
또한 @ExceptionHandler 에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있다.
8. API 예외 처리 - @ControllerAdvice
직전의 ApiExceptionV2Controller 코드는 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다.
@ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다.
● ExControllerAdvice
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e){
log.error("[exceptionHandler] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<ErrorResult>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e){
log.error("[exceptionHandler] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
● ApiExceptionV2Controller
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable 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;
}
}
기존의 ApiExceptionV2Controller 에 있던 @ExceptionHandler를 모두 분리하였다.
● @ControllerAdvice
- @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다.
- @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
- @RestControllerAdvice 는 @ControllerAdvice 와 같고, @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 {}
@ExceptionHandler 와 @ControllerAdvice 를 조합하면 예외를 깔끔하게 해결할 수 있다.
● 질문 한가지~
궁금 했던점을 물어봤는데, 답변해 주셔서서 기록해 둔다!
@ResponseStatus를 예외에서 사용할 때는 말씀드린 내용처럼 동작하고,
컨트롤러나 @ExceptionHandler에서 사용할 때는 단순히 상태코드를 변경하는 방식으로 동작합니다.
감사합니다.
'BackEnd > Spring MVC' 카테고리의 다른 글
[Spring] 스프링 타입 컨버터 - 2 (0) | 2022.03.18 |
---|---|
[Spring] 스프링 타입 컨버터 - 1 (0) | 2022.03.18 |
[Spring] API 예외 처리 - 1 (0) | 2022.03.14 |
[Spring] 예외 처리와 오류 페이지 - 2 (0) | 2022.03.14 |
[Spring] 예외 처리와 오류 페이지 - 1 (0) | 2022.03.14 |
댓글