BackEnd/Spring MVC

[Spring] API 예외 처리 - 2

샤아이인 2022. 3. 15.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

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에서 사용할 때는 단순히 상태코드를 변경하는 방식으로 동작합니다.

감사합니다.

 

댓글