Spring/Spring Boot

[Spring Boot] Enum을 활용해 전역에서 Runtime Exception Handling

jundyu 2024. 11. 4. 13:17

Spring Boot

 

들어가며

예전에 커뮤니티 사이트를 제작하는 프로젝트를 할 때 굉장히 초보였던터라 예외에 대한 처리가 하나도 없었습니다. 그저 기능들 구현하는데 쩔쩔 매느라 당장 눈에 보이는 예외만 처리하고 넘어갔습니다. 그래서 이번 쇼핑몰 프로젝트에선 비즈니스 로직에서 발생할 수 있는 예외들 위주로 전역에서 관리해봤습니다.

 


 

Runtime Exception(=Unchecked Exception)

우선 Runtime Exception에 대해 간단한 설명이 필요할 것 같습니다. Runtime Exception은 말 그대로 프로그램 실행 후 발생할 수 있는 예외로 개발자가 예측할 수 없는 오류를 일컫는 말입니다. Exception의 하위 클래스인 Runtime Exception은 컴파일러가 예측할 수 없기 때문에 Unchecked Exception이라고도 부릅니다. Runtime Exception은 주로 개발자의 잘못된 로직이나 예상치 못한 데이터에 의해 발생합니다.

 

대표적인 Runtime Exception으로 아래와 같은 것들이 있습니다.

  • NullPointerException
    Null인 객체를 참조할 때 발생
  • ArrayIndexOutOfBoundsException
    배열의 범위가 벗어났을 때 발생
  • ClassCastException
    잘못된 타입으로 객체를 형 변환할 때 발생
  • ArithmeticException
    0으로 나누기를 시도할 때 발생
  • IllegalArgumentException
    잘못된 매개변수 전달할 때 발생

이외에도 다양한 Runtime Exception이 있지만 저는 이번에 비즈니스 로직에서 발생할만한 예외에 대해 enum과 RestControllerAdvice 어노테이션을 활용해 전역에서 예외를 관리했습니다.

 

 

파일 구조

Directory Structure

쉬운 이해를 위해 첨부한 파일 구조입니다.

  • errorCode package
    ErrorCode 인터페이스의 구현체로 각 비즈니스 로직별로 나눠서 예외를 enum 타입으로 정의합니다.
  • BaseException
    RuntimeException을 상속 받는 커스텀 예외 처리에 대한 기본 클래스입니다.
  • ControllerAdvice
    런타임 중 컨트롤러에서 발생하는 예외를 전부 캐치해서 일관된 형식 ResponseEntity를 반환하는 클래스입니다.
  • ErrorCode
    errorCode 패키지에 정의된 enum 파일들의 인터페이스입니다. errorCode 패키지의 각 파일들은 ErrorCode 인터페이스를 구현합니다.
  • ExceptionResponse
    클라이언트에게 반환할 응답의 형식을 제공합니다.

각 파일별 자세한 목적과 역할은 아래에서 자세히 다뤄보겠습니다.

 

 

ErrorCode 인터페이스 & errorCode 패키지

1. ErrorCode 인터페이스

import org.springframework.http.HttpStatus;

public interface ErrorCode {
    String getErrorCode();

    String getMessage();

    HttpStatus getStatus();
}

ErrorCode는 각각의 비즈니스 로직에서 발생할 수 있는 예외에 대한 인터페이스입니다. errorCode 패키지에 있는 각각의 enum 파일들은 ErrorCode를 구현하는 구현체가 됩니다.

2. errorCode 패키지

import com.petpick.global.exception.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum ProductErrorCode implements ErrorCode {
    PRODUCT_NOT_FOUND("PRODUCT_001", "해당 상품 아이디는 존재하지 않습니다.", HttpStatus.NOT_FOUND),
    PRODUCT_ACCESS_DENIED("PRODUCT_002", "해당 상품에 대한 접근 권한이 없습니다.", HttpStatus.FORBIDDEN),
    INVALID_PRODUCT_STATUS("PRODUCT_003", "유효하지 않은 상품 상태입니다.", HttpStatus.BAD_REQUEST),
    PRODUCT_OUT_OF_STOCK("PRODUCT_004", "해당 상품의 재고가 부족합니다.", HttpStatus.BAD_REQUEST),
    INVALID_SORT_PARAMETER("PRODUCT_005", "유효하지 않은 정렬 조건입니다.", HttpStatus.BAD_REQUEST),
    INVALID_PAGE_PARAMETER("PRODUCT_006", "페이지를 생성하는데 실패했습니다.", HttpStatus.BAD_REQUEST),
    NO_PRODUCTS_AVAILABLE("PRODUCT_007", "조회 가능한 상품이 없습니다.", HttpStatus.NOT_FOUND),
    INVALID_TYPE_VALUE("PRODUCT_008", "상품의 타입이 존재하지 않습니다.", HttpStatus.BAD_REQUEST),
    INVALID_CATEGORY_VALUE("PRODUCT_009", "해당 카테고리가 존재하지 않습니다.", HttpStatus.BAD_REQUEST),
    ;

    private final String errorCode;
    private final String message;
    private final HttpStatus status;
}

상품에 대한 비즈니스 로직에서 발생할 수 있는 예외들을 정의했습니다. 009번까지 예외가 정해진 게 아니라 비즈니스 로직이 추가될 때마다 적절하게 확장해 나갈 수 있다는 점이 정말 편했습니다.

 

각각의 상수들에 저장된 첫번째 값에는 백엔드 개발자 측에서 디버깅하기 쉽도록 고유번호를 붙여줬습니다. 두번째로 자세한 예외 메세지를 작성했고, 세번째에는 클라이언트에게 응답하기 위한 HTTP 상태 코드를 작성했습니다. 롬복의 @AllArgsConstructor를 사용했기 때문에 상수의 모든 필드에 대한 생성자를 자동으로 생성해 필드값을 깔끔하게 할당할 수 있었습니다.

 

 

BaseException

import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
public class BaseException extends RuntimeException {
    private final String errorCode;
    private final String message;
    private final HttpStatus status;

    public BaseException(ErrorCode code) {
        errorCode = code.getErrorCode();
        message = code.getMessage();
        status = code.getStatus();
    }
}

BaseException은 비즈니스 로직에서 발생하는 런타임 예외에 대해 Unchecked Exception으로 처리하기 위해 RuntimeException을 상속 받았습니다. 비즈니스 로직에서 발생할 수 있는 상황에 BaseException을 호출해 ErrorCode의 패키지에서 개발자가 정의한 예외들을 전달합니다. 그럼 그 BaseException은 ControllerAdvice에서 캐치되고 클라이언트와 개발자에게 디버깅을 위한 응답을 반환합니다.

만약 RuntimeException을 상속 받지 않는다면?
Checked Exception으로 처리되기 때문에 BaseException을 호출할 때마다 try~catch나 throws로 예외 처리를 해야합니다. 또한, 각각의 비즈니스 로직에서 예외를 처리하기 때문에 코드의 유지보수도 어렵고 가독성도 많이 떨어지게 됩니다.

 

 

ExceptionResponse

import lombok.Getter;

import java.time.LocalDateTime;

@Getter
public class ExceptionResponse {
    private final String code;
    private final String message;
    private final LocalDateTime timeStamp;

    public ExceptionResponse(String code, String message) {
        this.code = code;
        this.message = message;
        this.timeStamp = LocalDateTime.now();
    }
}

BaseException이 백엔드 개발자의 디버깅을 위한 예외 응답이라면 ExceptionResponse는 클라이언트 측에서 확인하기 위한 예외 응답입니다. 클라이언트 측에서 필요할만한 정보인 에러 코드와 메세지 및 발생 시각을 전달합니다.

 

 

ControllerAdvice

// import 생략

@RestControllerAdvice
public class ControllerAdvice {

    /**
     * 미리 지정해놓은 에러 e 발생 시 ExceptionResponse 로 반환
     */
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<ExceptionResponse> handleBaseException(BaseException e) {
        return new ResponseEntity<>(new ExceptionResponse(e.getErrorCode(), e.getMessage()), e.getStatus());
    }
    
    // 생략 //

}

(ControllerAdvice의 전체 코드)

ControllerAdvice는 컨트롤러에서 발생하는 모든 런타임 예외를 캐치해서 중앙 집중 관리하는 클래스입니다. 개발자가 직접 정의한 ErrorCode뿐만 아니라 기존에 존재하는 RuntimeException까지 캐치해서 응답을 반환합니다.

1. @RestControllerAdvice

RestControllerAdvice의 표준 라이브러리 정의

@RestControllerAdvice는 애플리케이션의 모든 REST API에서 발생하는 예외를 처리합니다. @RestControllerAdvice@ConrollerAdvice@ResponseBody를 합친 스프링의 어노테이션으로 REST API에서 발생하는 예외를 JSON 형식으로 일관되게 반환합니다.

 

2. @ExceptionHandler

@ExceptionHandler@RestControllerAdvice 내부에서 사용되는 어노테이션으로 특정 예외가 발생했을 때 호출할 메서드를 정의합니다. 위의 예시 코드에서 첫번째로 작성된 코드를 보겠습니다.

/**
 * 미리 지정해놓은 에러 e 발생 시 ExceptionResponse 로 반환
 */
@ExceptionHandler(BaseException.class)
public ResponseEntity<ExceptionResponse> handleBaseException(BaseException e) {
    return new ResponseEntity<>(new ExceptionResponse(e.getErrorCode(), e.getMessage()), e.getStatus());
}

@ExceptionHandler에 캐치할 예외 클래스를 작성합니다. 그러면 @ExceptionHandler 아래에 정의된 메서드를 호출해 클라이언트에게 예외 응답을 반환합니다. ResponseEntity에 ExceptionResponse를 반환하는 것을 볼 수 있습니다. ExceptionResponse로 전달되는 인자는 BaseException에 정의된 멤버 변수들입니다.

 

 

Exception Response Example

Exception Response Example Image

예외 응답에 대한 예시입니다.

 

 

.

.

.

 

마치며

처음으로 전역에서 런타임 예외에 대한 핸들링을 해봤습니다. 다른 사람의 코드를 많이 참고했지만 다음 프로젝트에선 더욱 프로젝트의 성향에 맞게 바꿔서 깔끔한 AdviceController를 만들어보고 싶습니다!

 

피드백과 질문 환영합니다!