공부함

@RestControllerAdvice를 활용한 스프링 예외 처리 본문

스프링

@RestControllerAdvice를 활용한 스프링 예외 처리

찌땀 2023. 12. 19. 09:13

서론

프로젝트를 진행하며 예외처리에 관해 가은님과 의견차이가 있었다.

public enum ErrorCode {
    INVALID_REQUEST_BODY,
    SOLUTION_NOT_FOUND,
    PROBLEM_NOT_FOUND,
    PROBLEM_LINK_NOT_FOUND,
    COMMENT_NOT_FOUND,
    ... 이하생략
    }

현재 우리가 사용하는 ErrorCode는 위와 같다. 

public class SolutionNotFoundException extends NotFoundException {
    public SolutionNotFoundException() {
        super(SOLUTION_NOT_FOUND, "풀이를 찾을 수 없습니다.");
    }
}

커스텀한 예외 클래스에서 예외코드를 사용할 때는 위와 같이 예외메세지를 전달하고 있다. 

나는 SOLUTION_NOT_FOUND와 "풀이를 찾을 수 없습니다"는 서로 관련있는 값이므로 예외메세지도 enum으로 함께 관리해야 한다고 주장했다.

public enum ErrorCode {
    INVALID_REQUEST_BODY("어쩌구.."),
    SOLUTION_NOT_FOUND("풀이를 찾을 수 없습니다"),
    PROBLEM_NOT_FOUND("저쩌구.."),
    ... 이하생략
    }

이런 식으로 말이다. 이 편이 관리하기도 더 수월하다고 생각했다. 

@Getter
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;

    public CustomException(final ErrorCode errorCode, final String message) {
        super(message);
        this.errorCode = errorCode;
    }
}

현재 우리가 선언한 커스텀예외들은 모두 CustomException을 상속한다. CustomException에서는 생성자에서 에러메세지도 받는다. 내가 주장하는 대로 수정하면 

@Getter
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;

    public CustomException(final ErrorCode errorCode) {
        super(errorCode.getMessage);
        this.errorCode = errorCode;
    }
}

생성자는 이렇게 수정될 것이다. 

public class SolutionNotFoundException extends NotFoundException {
    public SolutionNotFoundException() {
        super(SOLUTION_NOT_FOUND);
    }
}

SolutionNotFoundException에서도 이렇게 수정될 것이다. 가은님은 이렇게 되면 구체적인 예외 클래스에서 에러메세지를 바로 확인할 수 없는 단점이 있다고 하셨다. 일종의 depth가 증가하는 효과라고 하셨고 불필요한 depth를 증가시키는 것은 좋지 않다고 하셨다. 이것도 맞는 말 같았다.. 그리고 가은님도 두 방법 중 뭘 써야 할지 고민된다고 하셨다. 그래서 우리는 다른 개발자들은 Spring에서 예외코드 관리를 어떻게 하는지 알아보고 토론하기로 했다. 

https://mangkyu.tistory.com/205

 

[Spring] @RestControllerAdvice를 이용한 Spring 예외 처리 방법 - (2/2)

예외 처리는 robust한 애플리케이션을 만드는데 매우 중요한 부분을 차지한다. Spring 프레임워크는 매우 다양한 에러 처리 방법을 제공하는데, 앞선 포스팅에서 @RestControllerAdvice를 사용해야 하는

mangkyu.tistory.com

나는 위 글을 참고했다. 위 글은 망나니개발자님이 작성한 글로, 1,2편 중 2편이다. 1편은 여러가지 예외처리 방법을 소개하는 글이다. API예외처리에는 결론적으로 @RestControllerAdvice를 사용하는 것이 좋다고 하셨고 2편은 그렇다면 @RestControllerAdvice를 어떻게 적용해야 하는지에 관한 내용이다. 다행히 우리도 @RestControllerAdvice를 사용하고 있다.

ControllerAdvice(RestControllerAdvice)를 사용할 때의 장점은 이렇다.

  • 하나의 클래스에서 모든 컨트롤러에 대해 전역적으로 예외 처리 가능 
  • 직접 정의한 예외 응답을 일관성있게 클라이언트에게 반환 가능 
  • 별도의 try-catch문을 사용하지 않아도 되어서 코드 가독성이 좋아짐 

@RestControllerAdvice 적용방법

그럼 본격적으로 적용방법에 대해 알아보자. 망나니개발자님이 소개하는 방법과 우리가 사용하던 방법을 비교해보자.

 에러 코드 정의

public interface ErrorCode {

    String name();
    HttpStatus getHttpStatus();
    String getMessage();

}
출처: https://mangkyu.tistory.com/205 [MangKyu's Diary:티스토리]

 

위와 같이 에러코드 인터페이스를 먼저 선언한다. 그리고 전역적으로 사용할 CommonErrorCode와 도메인에 대해 구체적인 ~~ErrorCode로 나눈다. 

@Getter
@RequiredArgsConstructor
public enum CommonErrorCode implements ErrorCode {

    INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "Invalid parameter included"),
    RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not exists"),
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Internal server error"),
    ;

    private final HttpStatus httpStatus;
    private final String message;
}

@Getter
@RequiredArgsConstructor
public enum UserErrorCode implements ErrorCode {

    INACTIVE_USER(HttpStatus.FORBIDDEN, "User is inactive"),
    ;

    private final HttpStatus httpStatus;
    private final String message;
}
출처: https://mangkyu.tistory.com/205 [MangKyu's Diary:티스토리]

구현한 enum은 위와 같다. @Getter로 ErrorCode 인터페이스의 getHttpStatus()와 getMessage()를 오버라이딩한다. ErrorCode의 name()은 따로 오버라이딩 하지 않았는데, enum 자체 메서드로 name()을 갖는다. name()은 enum의 열거형 이름을 반환한다. 즉 INVALID_PARAMETER를 String 형으로 반환한다. 

예외 클래스 정의

사용할 예외 클래스를 정의한다.

@Getter
@RequiredArgsConstructor
public class RestApiException extends RuntimeException {

    private final ErrorCode errorCode;

}
출처: https://mangkyu.tistory.com/205 [MangKyu's Diary:티스토리]

정의한 ErrorCode를 예외 클래스에서 필드로 갖게 한다. 

그리고 RuntimeException을 상속해 컴파일러가 검사하지 않는 언체크 예외로 만든다. 체크 예외로 만들면 컴파일러가 try-catch를 하지 않거나 throws를 하지 않으면 컴파일에러를 발생시키기 때문이다. 최근에는 거의 모든 경우에 언체크 예외를 사용한다고 한다. 

@Getter
public class CustomException extends RuntimeException {

    private final ErrorCode errorCode;

    public CustomException(final ErrorCode errorCode, final String message) {
        super(message);
        this.errorCode = errorCode;
    }
}

우리가 사용한 CustomException도 우리가 정의한 ErrorCode를 필드로 갖는다. 다만 에러메세지, Http 상태코드, 에러코드를 함께 관리하지 않는 차이가 있다.

 예외 응답 클래스 정의

클라이언트에게 응답할 예외 응답 클래스를 정의한다.

@Getter
@Builder
@RequiredArgsConstructor
public class ErrorResponse {

    private final String code;
    private final String message;

    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private final List<ValidationError> errors;

    @Getter
    @Builder
    @RequiredArgsConstructor
    public static class ValidationError {
    
        private final String field;
        private final String message;

        public static ValidationError of(final FieldError fieldError) {
            return ValidationError.builder()
                    .field(fieldError.getField())
                    .message(fieldError.getDefaultMessage())
                    .build();
        }
    }
}
출처: https://mangkyu.tistory.com/205 [MangKyu's Diary:티스토리]

클라이언트에게 예외시 응답할 포멧에 맞춰 예외 응답 클래스를 만든다. 

static inner class로 ValidationError를 선언했다. 이것은 @Valid 사용시 에러가 발생한 경우 어느 필드에서 에러가 발생했는지 응답을 위한 클래스다. 

List<ValidationError> 클래스에는 @JsonInclude 애노테이션이 붙어있다. 객체를 직렬화 할 때 조건에 따라 출력하고 싶지 않다면 사용하는 애노테이션이다. NON_EMPTY의 경우 null, absent, Collection이 isEmpty인 경우, length가 0인 array, length가 0인 string등을 제외한다. 

즉 errors 필드는 @Valid 에러가 발생한 경우에만 에러에 대한 정보들을 담아 직렬화된다. 

https://velog.io/@seulpace/JsonInclude-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98

 

@JsonInclude 어노테이션

@JsonInclude를 처음 접하고 정리했던 내용

velog.io

@JsonInclude에 관해서는 위 글을 참고하자

@RestControllerAdvice 구현 

Spring은 스프링 예외를 미리 처리해둔 ResponseEntityExceptionHandler를 추상 클래스로 제공한다. ResponseEntityExceptionHandler에서는 스프링 예외에 대한 ExceptionHandler가 모두 구현되어 있다. ControllerAdvice에서 ResponseEntityExceptionHandler를 상속하게 하면 된다. 

public abstract class ResponseEntityExceptionHandler {
    ...

    protected ResponseEntity<Object> handleExceptionInternal(
        Exception ex, @Nullable Object body, HttpHeaders headers, HttpStatus status, WebRequest request){
            
        ...
    }
}
출처: https://mangkyu.tistory.com/205 [MangKyu's Diary:티스토리]

에러 메세지는 반환하지 않기 때문에 handleExceptionInternal을 오버라이딩 해서 원하는 에러 응답 형태로 반환할 수 있다. 

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(RestApiException.class)
    public ResponseEntity<Object> handleCustomException(RestApiException e) {
        ErrorCode errorCode = e.getErrorCode();
        return handleExceptionInternal(errorCode);
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Object> handleIllegalArgument(IllegalArgumentException e) {
        log.warn("handleIllegalArgument", e);
        ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
        return handleExceptionInternal(errorCode, e.getMessage());
    }

    @Override
    public ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException e,
            HttpHeaders headers,
            HttpStatus status,
            WebRequest request) {
        log.warn("handleIllegalArgument", e);
        ErrorCode errorCode = CommonErrorCode.INVALID_PARAMETER;
        return handleExceptionInternal(e, errorCode);
    }

    @ExceptionHandler({Exception.class})
    public ResponseEntity<Object> handleAllException(Exception ex) {
        log.warn("handleAllException", ex);
        ErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR;
        return handleExceptionInternal(errorCode);
    }

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode) {
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(makeErrorResponse(errorCode));
    }

    private ErrorResponse makeErrorResponse(ErrorCode errorCode) {
        return ErrorResponse.builder()
                .code(errorCode.name())
                .message(errorCode.getMessage())
                .build();
    }

    private ResponseEntity<Object> handleExceptionInternal(ErrorCode errorCode, String message) {
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(makeErrorResponse(errorCode, message));
    }

    private ErrorResponse makeErrorResponse(ErrorCode errorCode, String message) {
        return ErrorResponse.builder()
                .code(errorCode.name())
                .message(message)
                .build();
    }

    private ResponseEntity<Object> handleExceptionInternal(BindException e, ErrorCode errorCode) {
        return ResponseEntity.status(errorCode.getHttpStatus())
                .body(makeErrorResponse(e, errorCode));
    }

    private ErrorResponse makeErrorResponse(BindException e, ErrorCode errorCode) {
        List<ErrorResponse.ValidationError> validationErrorList = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(ErrorResponse.ValidationError::of)
                .collect(Collectors.toList());

        return ErrorResponse.builder()
                .code(errorCode.name())
                .message(errorCode.getMessage())
                .errors(validationErrorList)
                .build();
    }
}
출처: https://mangkyu.tistory.com/205 [MangKyu's Diary:티스토리]

처리하길 원하는 에러마다 @ExceptionHandler를 정의해준다. @Valid 예외의 경우 getBindingResult로 필드에러 목록을 얻어와서 ErrorResponse에 선언한 errors 필드에 넣어준다. 

우리는 ResponseEntityExceptionHandler를 상속하지 않았는데 참고해서 상속하면 좋을 것 같다.


 

ResponseEntityExceptionHandler를 상속해야 하는 이유

https://velog.io/@appti/ResponseEntityExceptionHandler%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%95%BC-%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0

 

ResponseEntityExceptionHandler를 사용해야 하는 이유

ResponseEntityExceptionHandler를 사용해야 하는 이유

velog.io

일단 위 내용까지 적어가서 가은님한테 얘기를 했는데, 에러코드에 Http 상태코드와 메세지를 합해서 관리하는 건 동의하셨는데, 왜 ResponseEntityExceptionHandler를 상속해야 되는지 모르겠다고 하셨다. 그냥 원하는 예외를 GlobalExceptionHandler에 @ExceptionHandler로 명시해서 처리하면 되는거 아니냐고 하셔서 더 찾아봤다.

그래서 위 글을 통해 상속해야 하는 이유를 어느 정도 알 수 있게 되었다. 

 

@ControllerAdvice+@ExceptionHandler를 사용하면(ResponseEntityExceptionHandler를 상속하지 않고) 예외가 ExceptionHandlerExceptionResolver에 의해 처리된다. 이 때 ControllerAdvice에 명시하지 않은 예외 중 Spring MVC 예외(위에서 말한 스프링 기본 예외)는 DefaultHandlerExceptionResolver에 의해 처리된다. 이렇게 되면 예외를 전역적으로 하나의 클래스에서 처리하지 못한다. 또한 예외 응답을 커스텀하지 못한다. 

 

명시한 예외 외에 다른 예외들을 묶어서 Exception으로 처리할 수 있다. 

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Object> exception(Exception exception){
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("error");
    }
    ... 이하생략 ... 
}

 

이렇게 처리하면 명시한 예외의 예외들도 묶어서 처리할 수 있지만 단점이 있다. 첫번째로 클라이언트의 잘못인 상황에서도 500에러로 묶어서 처리된다.(물론 상태코드를 바꿀 수 있지만 제대로 파악하지 못해서 INTERNAL_SERVER_ERROR로 묶어서 처리한다는 가정이다)원인이 명확하지만 명시하지 않은 예외들도 여기 묶여서 처리된다. 

 

그렇다면 GlobalExceptionHandler에서 Spring MVC 예외를 모두 명시해서 처리해줄수도 있다. 하지만 Spring MVC 예외는 종류가 매우 많고 자주 발생하지 않는 예외도 있다. 이것을 일일이 명시하기에는 불편하고 비효율적이다. 

그래서 사용할 수 있는 방법이 ResponseEntityExceptionHandler를 상속하는 것이다. 추상 클래스인ResponseEntityExceptionHandler는 Spring mvc에서 발생하는 모든 예외를 처리한다.(추상 클래스를 확인해보면 @ExceptionHandler로 Spring mvc 예외를 처리한다) 따라서 이 클래스를 GlobalExceptionHandler에서 상속하면 모든 예외를 관리할 수 있다. handle예외명() 메서드가 각 예외를 처리하고, return문에서 handleExceptionInternal() 메서드로 ResponseEntity 형태로 응답을 만든다.

따라서 특정 예외 종류에 따라 다르게 처리하고 싶다면 handle예외명() 메서드를 오버라이딩 하면 된다. 그리고 응답을 커스텀하고 싶으면 handleExceptionInternal()을 커스텀하면 된다. 


위 내용까지 가은님에게 또 말씀드렸다. 가은님은 예외를 하나의 클래스에서 처리하기 위해 ResponseEntityExceptionHandler를 상속하는 것 까지는 동의하셨다.

 

그런데 handle예외명() 메서드를 오버라이딩 하는 것에는 동의하지 않으셨다.

그 이유는 첫번째로 우리가 Spring MVC 예외 종류를 모두 알고 있지 않다는 것이다. 따라서 특정 예외를 처리하려고 할 때마다 이 예외가 Spring MVC 예외인지 찾아보고 오버라이딩 해야 한다는 것이다.

두번째로 Spring MVC 예외는 @Overriding 애노테이션이 붙고, 커스텀 예외는 @ExceptionHandler 애노테이션이 붙어 통일성이 떨어진다는 것이다.

결정적으로 오버라이딩 하지 않고 Spring MVC 예외에 대해서도 @ExceptionHandler로 선언해도 @Overriding 한 것과 마찬가지로 처리 방법을 커스텀 하는 것이고 굳이 오버라이딩 할 필요성을 모르겠다는 것이다. 그리고 추상 클래스(부모 클래스)에 선언된 @ExceptionHandler와 자식 클래스에 선언한 @ExceptionHandler가 같은 예외를 가리킨다면 자식 클래스의 메서드가 우선순위를 가지므로 굳이 오버라이딩 하지 않아도 된다는 것이다. 

이것에 대해서 반박할 수가 없었고 꼭 handle예외명() 메서드를 오버라이딩 해야 할 근거를 제시하지 못했다. 이건 추후에 알게 된다면 다시 주장해 봐야겠다.

다만 handleExceptionInternal()을 오버라이딩 하는 것에는 동의하셨다. 왜냐하면 이것만 오버라이딩 해도 응답을 만드는 형태는 일관되게 커스텀 할 수 있기 때문이다. 

 

그래서 ErrorCode에 HttpStatus, message를 포함하는 형태로 수정하는 것과 ControllerAdvice 클래스에서 ResponseEntityExceptionHandler를 상속하는 것, 그리고 응답 형태를 일관되게 커스텀하기 위해 handleExceptionInternal()을 오버라이딩 하도록 수정하기로 했다. 

다른 개발자들이 handle예외명() 메서드를 오버라이딩 하는 이유도 분명히 있을텐데.. 빨리 알고 싶다.

'스프링' 카테고리의 다른 글

ApplicationContext  (0) 2024.08.28
서블릿과 MVC 패턴과 프론트 컨트롤러 패턴  (1) 2024.08.27
카카오 로그인  (0) 2024.08.02
SpringDataJpa 사용자 정의 Repository 적용  (1) 2023.12.09
Pageable  (0) 2023.12.08