[Spring Boot] 일관된 에러 응답을 위한 Custom Exception 구현

Jae_0·2024년 3월 11일
1
post-thumbnail

[Spring Boot] 일관된 에러 응답을 위한 Custom Exception 구현


서론

현재 진행중인 프로젝트에선 데이터를 FE에 넘겨 줄 때 데이터를 아래와 같이 감싸서 보내는 BaseResponse가 구현되어있다.

"success": boolean,
"status": int,
"message": String,
"data": T

예를들어, 10분 단위의 시간으로 요청되어야 하는
1번 ID를 가진 Song의 2024-03-11T21:12:00 대한 데이터를 요청 했을때 데이터가 존재하지 않는다면 기본 설정으로 받는 에러 응답은 아래와 같을 것이다.

"timestamp": "요청한 시점",
"status": 500,
"error": "Internal Server Error",
"path": "/example/1/2024-03-11T21:12:00"

그러나 기본 설정된 에러응답을 사용하기에는 클라이언트(FE) 입장에서 썩 유용하지는 않다.
아래와 같다면 좀 더 유용한 에러 응답이 될 것이다.

"success": false,
"status": 400,
"message": "10분 단위로 요청되어야 합니다.",
"data": null

물론 아래와 같이 try - catch 구문으로 에러에 대해 핸들링 할 수 있지만, 가독성 측면에서도 좋지 않아 유지보수에도 문제가 있겠지만, 예외와 오류에 대한 구분이 안되어 있을수도 있고 모든 예외를 예측해 catch 하는 것 또한 어려운 일이다.

try {
	...
} catch(예외1) {
	...
} catch(예외2) {
	...
} catch(...) {
	...
}

따라서 발생할 수 있는 예외에 대해선 직접 처리하고, 관리하기 좋은 Exception Handler가 필요했다.

로직

스프링에서는 다양한 예외처리 방법을 제공한다. 그 중 나는
@ExceptionHandler@RestControllerAdvice를 사용했다.
추가로 로그 확인을 위해 @Sl4fj 어노테이션도 사용했고, 작성한 클래스는 아래와 같다.

  1. ErrorCode : 커스텀 에러코드와 메시지를 관리하는 EnumClass
  2. CustomException : RuntimeException을 상속 받고 에러코드, 메시지, 속성 값, 로깅, 출력들을 관리 할 수 있는 상위 클래스
  3. ErrorResponse : CustomeException을 매개변수로 받아 코드, 메시지를 담은 객체를 생성하는 클래스
  4. GlobalExceptionHandler : 커스텀한 도메인 Exception을 핸들링 하는 클래스

1. ErrorCode

에러코드 클래스는 팀에서 지정한 방식으로 작성했다. 아래는 그중 일부이다.

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public enum ErrorCode {
    INVALID_REPORT_REQUEST_TIME(400, "시간은 10분 단위로 요청되어야 합니다."),

    INTERNAL_SERVER_ERROR(500, "알 수 없는 오류가 발생했습니다. 지속시 문의 부탁드립니다.");

    private final long code;
    private final String message;
}

2. CustomException

CustomException 클래스는 많은 도메인Exception 클래스들의 상위 클래스로 정적 팩토리 메소드를 가지고 있으며 로깅, 에러가 발생한 부분의 파라미터 혹은 밸류 등을 만들어낸다.

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@ToString
@Getter
public class CustomException extends RuntimeException {

    private static final String EXCEPTION_INFO_BRACKET = "{ %s | %s }";
    private static final String CODE_MESSAGE = " Code: %d, Message: %s ";
    private static final String PROPERTY_VALUE = "Property: %s, Value: %s ";
    private static final String VALUE_DELIMITER = "/";

    private final long code;
    private final String message;
    private final Map<String, String> inputValuesByProperty;

    protected CustomException(final ErrorCode errorCode) {
        this(errorCode, Collections.emptyMap());
    }

    protected CustomException(ErrorCode errorCode, Map<String, String> inputValuesByProperty) {
        this.code = errorCode.getCode();
        this.message = errorCode.getMessage();
        this.inputValuesByProperty = inputValuesByProperty;
    }

    public static CustomException of(ErrorCode errorCode, Map<String, String> inputValuesByProperty) {
        return new CustomException(errorCode, inputValuesByProperty);
    }

    public static CustomException from(ErrorCode errorCode) {
        return new CustomException(errorCode);
    }

    public String getErrorInfoLog() {
        final String codeMessage = String.format(CODE_MESSAGE, code, message);
        final String errorPropertyValue = getErrorPropertyValue();

        return String.format(EXCEPTION_INFO_BRACKET, codeMessage, errorPropertyValue);
    }

    private String getErrorPropertyValue() {
        return inputValuesByProperty.entrySet()
                .stream()
                .map(entry -> String.format(PROPERTY_VALUE, entry.getKey(), entry.getValue()))
                .collect(Collectors.joining(VALUE_DELIMITER));
    }
}

각 도메인 Exception은 위 클래스를 상속받아 작성한다.

public class ExampleException extends CustomException {

    public ExampleException(ErrorCode errorCode) {
        super(errorCode);
    }

    public ExampleException(ErrorCode errorCode, Map<String, String> inputValuesByProperty) {
        super(errorCode, inputValuesByProperty);
    }

    public static class InvalidRequestTimeException extends ExampleException {

        public InvalidRequestTimeException() {
            super(ErrorCode.INVALID_REPORT_REQUEST_TIME);
        }

        public InvalidRequestTimeException(Map<String, String> inputValuesByProperty) {
            super(ErrorCode.INVALID_REPORT_REQUEST_TIME, inputValuesByProperty);
        }
    }
}

3. ErrorResponse

CustomException의 에러 코드와 메시지를 담을 클래스이다.

@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
public class ErrorResponse {

    private long code;
    private String message;

    public static ErrorResponse from(CustomException customException) {
        return new ErrorResponse(customException.getCode(), customException.getMessage());
    }
}

4. GlobalExceptionHandler

@Slf4j, @RestControllerAdvice, @ExceptionHandler 어노테이션을 이용해 도메인별 Exception 클래스와 HTTP 에러를 핸들링하여 제일 위에 작성한 BaseResponse에 맞게 생성한다. 아래는 예시로 작성한 클래스이다.
직접 핸들링할 에러, 예외를 제외하고 발생한 Exception은 가장 아래 internalServerError가 발생하도록 했다.

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({
            ExampleException.class,
            Example2Exception.class,
    })
    public ResponseEntity<BaseResponse<ErrorResponse>> handleGlobalNotFoundException(CustomException e) {
        log.error(e.getErrorInfoLog());

        BaseResponseConverter<ErrorResponse> converter = new BaseResponseConverter<>();
        ErrorResponse errorResponse = ErrorResponse.from(e);

        return ResponseEntity.notFound().body(
                converter.generateErrorResponse(
                        false,
                        errorResponse.getCode(),
                        errorResponse.getMessage(),
                        null
                )
        );
    }

    @ExceptionHandler({
            ExampleException.class,
    })
    public ResponseEntity<BaseResponse<ErrorResponse>> handleGlobalBadRequestException(CustomException e) {
        log.error(e.getErrorInfoLog());

        BaseResponseConverter<ErrorResponse> converter = new BaseResponseConverter<>();
        ErrorResponse errorResponse = ErrorResponse.from(e);

        return ResponseEntity.badRequest().body(
                converter.generateErrorResponse(
                        false,
                        errorResponse.getCode(),
                        errorResponse.getMessage(),
                        null
                )
        );
    }

    // 그 외의 모든 Exception
    @ExceptionHandler(Exception.class)
    public ResponseEntity<BaseResponse<ErrorResponse>> handleInternalServerException(Exception e) {
        CustomException customException = CustomException.from(
                ErrorCode.INTERNAL_SERVER_ERROR);

        BaseResponseConverter<ErrorResponse> converter = new BaseResponseConverter<>();
        ErrorResponse errorResponse = ErrorResponse.from(customException);

        log.error(e.toString());

        return ResponseEntity.internalServerError().body(
                converter.generateErrorResponse(
                        false,
                        errorResponse.getCode(),
                        errorResponse.getMessage(),
                        null
                )
        );
    }
}

실행 결과

예시로 서비스 레이어에서 테스트를 해보면 서론에 작성한대로 원하는 CustomException이 구현 되었음을 확인 할 수 있다.

	@DisplayName("요청시간이 10분 단위가 아니라면 예외가 발생한다.")
    @Test
    void invalidRequestTimeException() {
        // given
        LocalDateTime wrongTime = LocalDateTime.parse("2024-02-07T15:11:00");

        // when, then
        assertThatThrownBy(
                () -> ExampleService.serviceMethod(1, wrongTime))
                    .isInstanceOf(ExampleException.InvalidRequestTimeException.class);
        }

실제로 원하는대로 에러코드를 리턴하는지 확인해보자.

마무리

이렇게 되면 일관된 에러 응답과 커스텀이 가능하고 관리하기 용이해진다. try - catch 구문 역시 사용 안하고 예외 발생에 대해 예측이 가능하고, 불필요한 코드라인도 줄일 수 있고 가독성도 좋아진다!

if (invalidTime(minute)) {
            throw new ExampleException.InvalidRequestTimeException();
        }
profile
거대한 세상에 발자취 남기기

0개의 댓글