현재 진행중인 프로젝트에선 데이터를 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
어노테이션도 사용했고, 작성한 클래스는 아래와 같다.
- ErrorCode : 커스텀 에러코드와 메시지를 관리하는
EnumClass
- CustomException : RuntimeException을 상속 받고 에러코드, 메시지, 속성 값, 로깅, 출력들을 관리 할 수 있는 상위 클래스
- ErrorResponse : CustomeException을 매개변수로 받아 코드, 메시지를 담은 객체를 생성하는 클래스
- GlobalExceptionHandler : 커스텀한 도메인 Exception을 핸들링 하는 클래스
에러코드 클래스는 팀에서 지정한 방식으로 작성했다. 아래는 그중 일부이다.
@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;
}
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);
}
}
}
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());
}
}
@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();
}