현재 큐시즘에서 하고 있는 밋업 프로젝트 HitZone에서는,
전역 에러 처리 기능을 구현해서 에러를 잡고 있다.
하지만 지식이 부족하여 초기에는 에러를 제대로 처리하지 못 했는데,
이를 어떻게 개선할 수 있었는지 작성해보려고 한다.
🌍 환경
Spring Boot : 3.3.4
Java : JDK 17
...
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// 커스텀 예외 처리
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleCustomException(CustomException e) {
logError(e.getMessage(), e);
return ApiResponse.onFailure(e.getErrorCode());
}
// Security 인증 관련 처리
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleSecurityException(SecurityException e) {
logError(e.getMessage(), e);
return ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED);
}
...
우리는 위와 같이 GlobalExceptionHandler 클래스를 두고,
@RestControllerAdvice
어노테이션을 붙임으로써 컨트롤러에서 시작되어 발생하는 모든 에러를 잡으려 하였다.
여기서 RestControllerAdvice
와 ControllerAdvice
의 차이점을 잠깐만 잡고 가자면,
🧑🏻💻
RestControllerAdvice
는ControllerAdvice + ResponseBody
로 컨트롤러에서 리턴하는 값이 응답값의 body로 세팅되어 클라이언트에게 전달된다.ControllerAdvice
를 사용하고,ResponseBody
어노테이션을 붙이면 동일하게 동작한다고 하나, 그것보다는 처음부터RestControllerAdvice
를 사용하는 편이 낫다고 여겨 많이 사용한다고 한다.
자세한 내용은 링크에서 확인할 수 있다.
이에 대한 동작 과정을 한 번 따라간다면 조금 더 이해가 잘 될 듯하다.
package kusitms.backend.global.code;
import kusitms.backend.global.dto.ErrorReasonDto;
public interface BaseErrorCode {
ErrorReasonDto getReason();
ErrorReasonDto getReasonHttpStatus();
}
인터페이스가 있다.
여기서는 ErrorReasonDto
를 반환하는 메서드를 정의한다.
package kusitms.backend.global.dto;
import lombok.Builder;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
@Builder
public class ErrorReasonDto {
private HttpStatus httpStatus;
private final boolean isSuccess;
private final String code;
private final String message;
}
에러가 발생했을 때의 응답값이다.
1) HTTP Status (400, 404 등..)
2) isSuccess (성공여부)
3) code (커스텀 에러 코드)
4) message (커스텀 에러 메세지)
package kusitms.backend.chatbot.status;
import kusitms.backend.global.code.BaseErrorCode;
import kusitms.backend.global.dto.ErrorReasonDto;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@Getter
@RequiredArgsConstructor
public enum ChatbotErrorStatus implements BaseErrorCode {
_NOT_FOUND_GUIDE_CHATBOT_ANSWER(HttpStatus.NOT_FOUND, "CHATBOT-001", "챗봇 답변을 찾을 수 없습니다."),
_IS_NOT_VALID_CATEGORY_NAME(HttpStatus.BAD_REQUEST, "CHATBOT-002", "올바른 카테고리명이 아닙니다.")
;
private final HttpStatus httpStatus;
private final String code;
private final String message;
@Override
public ErrorReasonDto getReason() {
return ErrorReasonDto.builder()
.isSuccess(false)
.code(code)
.message(message)
.build();
}
@Override
public ErrorReasonDto getReasonHttpStatus() {
return ErrorReasonDto.builder()
.isSuccess(false)
.httpStatus(httpStatus)
.code(code)
.message(message)
.build();
}
}
위에서 정의한 BaseErrorCode 인터페이스를 구현하는 곳이다.
클래스명은 {도메인}ErrorStatus
로 만들고 있다. Auth
라면 AuthErrorStatus
와 같은 방식이다.
Enum 클래스로, 보이는 것처럼 내부에 다양한 값들을 커스텀할 수 있다.
HTTP Status, 코드, 메세지를 가능한 한 친절하게 구성한다.
isSuccess 필드에는 당연하게도 false
만 들어가게 된다.
package kusitms.backend.global.exception;
import kusitms.backend.global.code.BaseErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
@Getter
@RequiredArgsConstructor
public class CustomException extends RuntimeException {
private final BaseErrorCode errorCode;
@Override
public String getMessage() {
return errorCode.getReasonHttpStatus().getMessage();
}
public String getCode() {
return errorCode.getReasonHttpStatus().getCode();
}
public HttpStatus getHttpStatus() {
return errorCode.getReasonHttpStatus().getHttpStatus();
}
}
기본적으로 정해져 있는 에러를 제외한,
우리 프로젝트의 로직에서 발생할 수 있는 에러들은 모두 CustomException
으로 지정한다.
기존에는 AuthException
, ChatbotException
과 같이 도메인 별로 나누었었는데,
클래스만 늘어날 뿐 효용성을 느끼지 못 해서 하나로 통일하게 되었다.
이를 먼저 생각해서 만들어 준 팀원 준형이형 👍🏻
CustomException
내에는 불변성을 띄는 BaseErrorCode
필드가 1개 존재한다.
이제 지금까지 만든 클래스들로 어떻게 에러를 던지고 처리하는 지 알아보도록 하겠다.
...
// 가이드 챗봇 답변을 조회하는 메서드
public GetGuideChatbotAnswerResponse getGuideChatbotAnswer(String stadiumName, String categoryName, int orderNumber) {
switch (categoryName) {
case "stadium":
return getAnswersByOrderAndStadium(orderNumber, stadiumName, StadiumGuideAnswer.values());
case "baseball":
return getAnswersByOrderAndStadium(orderNumber, stadiumName, BaseballGuideAnswer.values());
case "manner":
return getAnswersByOrderAndStadium(orderNumber, stadiumName, MannerGuideAnswer.values());
case "facility":
return getAnswersByOrderAndStadium(orderNumber, stadiumName, FacilityGuideAnswer.values());
case "parking":
return getAnswersByOrderAndStadium(orderNumber, stadiumName, ParkingGuideAnswer.values());
default:
throw new CustomException(ChatbotErrorStatus._IS_NOT_VALID_CATEGORY_NAME);
}
}
...
서비스 로직으로, 파라미터에 맞게 가이드 챗봇 답변을 반환하는 메서드이다.
categoryName이 case 구문에서 걸리지 않는다면, 에러를 반환하는 모습을 볼 수 있다.
throw new CustomException(ChatbotErrorStatus._IS_NOT_VALID_CATEGORY_NAME);
자세히 보면 CustomException 내에 ChatbotErrorStatus를 넣어 던지고 있다.
_IS_NOT_VALID_CATEGORY_NAME(HttpStatus.BAD_REQUEST, "CHATBOT-002", "올바른 카테고리명이 아닙니다.")
위에서 말한 것처럼 각 상황에 맞는 커스텀 에러 Status를 반환하는 것이다.
// 커스텀 예외 처리
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiResponse<ErrorReasonDto>> handleCustomException(CustomException e) {
logError(e.getMessage(), e);
return ApiResponse.onFailure(e.getErrorCode());
}
여기서 던져진 에러는 GlobalExceptionHandler가 잡아서 처리하게 된다.
이제 이 메서드를 하나씩 뜯어 보자.
@ExceptionHandler(CustomException.class)
해당 어노테이션은 handleCustomException 메서드가 CustomException을 처리하도록 지정한다.
logError(e.getMessage(), e);
이는 에러 로그를 기록하는 메서드를 호출하는 부분이다.
// 로그 기록 메서드
private void logError(String message, Object errorDetails) {
log.error("{}: {}", message, errorDetails);
}
위 메서드를 호출하여 내부적으로 에러 로그를 기록하고, 개발자가 보고 해결할 수 있도록 도움을 준다.
return ApiResponse.onFailure(e.getErrorCode());
ApiResponse
클래스의 onFailure
메서드를 호출한다.
package kusitms.backend.global.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import kusitms.backend.global.code.BaseCode;
import kusitms.backend.global.code.BaseErrorCode;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
@Getter
@RequiredArgsConstructor
public class ApiResponse<T> {
private final Boolean isSuccess;
private final String code;
private final String message;
@JsonInclude(JsonInclude.Include.NON_NULL)
private final T payload;
public static <T> ResponseEntity<ApiResponse<T>> onSuccess(BaseCode code, T payload) {
ApiResponse<T> response = new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), payload);
return ResponseEntity.status(code.getReasonHttpStatus().getHttpStatus()).body(response);
}
public static <T> ResponseEntity<ApiResponse<T>> onSuccess(BaseCode code) {
ApiResponse<T> response = new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), null);
return ResponseEntity.status(code.getReasonHttpStatus().getHttpStatus()).body(response);
}
public static <T> ResponseEntity<ApiResponse<T>> onFailure(BaseErrorCode code) {
ApiResponse<T> response = new ApiResponse<>(false, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), null);
return ResponseEntity.status(code.getReasonHttpStatus().getHttpStatus()).body(response);
}
ApiResponse
는 모든 응답 형식을 통일하기 위한 클래스이다.
1) isSuccess (성공여부)
2) code (커스텀 코드)
3) message (커스텀 메세지)
4) payload (데이터 값)
으로 구성되어 있고,
성공했을 때는 onSuccess
를 실패했을 때는 onFailure
메서드를 호출하여 응답한다.
여기서 onFailure
메서드는 BaseErrorCode를 인자로 받고,
이 안에 있는 값들을 가지고 응답을 구성해 반환하게 된다.
ResponseEntity
로 한 번 더 감싸고 보내는데,
이는 HTTP Status 까지 반영하기 위함이다.
위처럼 클라이언트 측에서 응답을 확인할 수 있고, 내부적으로 로그도 기록됨을 확인할 수 있다.
지금까지 어떻게 커스텀 에러를 구성하고 응답을 반환하는 지 알아보았다.
글이 생각보다 길어져서, 여기서 추가적으로 개선한 부분은 다음 글에서 작성해보도록 하겠다! 🏃🏻
헐 요즘 에러처리 찾아보고 있었는데~
여기서 공부할게여 ㅎㅡㅎ