에러를 쥐잡듯이 잡아보자 - 1 🪤

상호·2024년 11월 3일
3

🌱 Spring Boot

목록 보기
5/7
post-thumbnail

현재 큐시즘에서 하고 있는 밋업 프로젝트 HitZone에서는,
전역 에러 처리 기능을 구현해서 에러를 잡고 있다.

하지만 지식이 부족하여 초기에는 에러를 제대로 처리하지 못 했는데,
이를 어떻게 개선할 수 있었는지 작성해보려고 한다.

🌍 환경
Spring Boot : 3.3.4
Java : JDK 17


🌐 전역 에러 처리

GlobalExceptionHandler

...

@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 어노테이션을 붙임으로써 컨트롤러에서 시작되어 발생하는 모든 에러를 잡으려 하였다.

여기서 RestControllerAdviceControllerAdvice의 차이점을 잠깐만 잡고 가자면,

🧑🏻‍💻 RestControllerAdviceControllerAdvice + ResponseBody로 컨트롤러에서 리턴하는 값이 응답값의 body로 세팅되어 클라이언트에게 전달된다. ControllerAdvice를 사용하고, ResponseBody 어노테이션을 붙이면 동일하게 동작한다고 하나, 그것보다는 처음부터 RestControllerAdvice를 사용하는 편이 낫다고 여겨 많이 사용한다고 한다.
자세한 내용은 링크에서 확인할 수 있다.

동작 과정

이에 대한 동작 과정을 한 번 따라간다면 조금 더 이해가 잘 될 듯하다.

BaseErrorCode

package kusitms.backend.global.code;

import kusitms.backend.global.dto.ErrorReasonDto;

public interface BaseErrorCode {
    ErrorReasonDto getReason();
    ErrorReasonDto getReasonHttpStatus();
}

인터페이스가 있다.
여기서는 ErrorReasonDto를 반환하는 메서드를 정의한다.

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 (커스텀 에러 메세지)

ChatbotErrorStatus

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만 들어가게 된다.

CustomException

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개 존재한다.

이제 지금까지 만든 클래스들로 어떻게 에러를 던지고 처리하는 지 알아보도록 하겠다.

Service 단

...

   // 가이드 챗봇 답변을 조회하는 메서드
    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를 반환하는 것이다.

GlobalExceptionHandler - handleCustomException

	// 커스텀 예외 처리
    @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 메서드를 호출한다.

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 까지 반영하기 위함이다.

에러 응답 화면

위처럼 클라이언트 측에서 응답을 확인할 수 있고, 내부적으로 로그도 기록됨을 확인할 수 있다.


지금까지 어떻게 커스텀 에러를 구성하고 응답을 반환하는 지 알아보았다.

글이 생각보다 길어져서, 여기서 추가적으로 개선한 부분은 다음 글에서 작성해보도록 하겠다! 🏃🏻

다음 글

profile
상호작용하는 백엔드 개발자

3개의 댓글

comment-user-thumbnail
2024년 11월 7일

헐 요즘 에러처리 찾아보고 있었는데~
여기서 공부할게여 ㅎㅡㅎ

2개의 답글