들어가며
예전에 커뮤니티 사이트를 제작하는 프로젝트를 할 때 굉장히 초보였던터라 예외에 대한 처리가 하나도 없었습니다. 그저 기능들 구현하는데 쩔쩔 매느라 당장 눈에 보이는 예외만 처리하고 넘어갔습니다. 그래서 이번 쇼핑몰 프로젝트에선 비즈니스 로직에서 발생할 수 있는 예외들 위주로 전역에서 관리해봤습니다.
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 어노테이션을 활용해 전역에서 예외를 관리했습니다.
파일 구조
쉬운 이해를 위해 첨부한 파일 구조입니다.
- 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는 애플리케이션의 모든 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
예외 응답에 대한 예시입니다.
.
.
.
마치며
처음으로 전역에서 런타임 예외에 대한 핸들링을 해봤습니다. 다른 사람의 코드를 많이 참고했지만 다음 프로젝트에선 더욱 프로젝트의 성향에 맞게 바꿔서 깔끔한 AdviceController를 만들어보고 싶습니다!
피드백과 질문 환영합니다!
'Spring > Spring Boot' 카테고리의 다른 글
[Spring Boot] Google OAuth 2.0과 JWT를 통한 회원 관리 방식 이해하기 (0) | 2024.10.29 |
---|---|
[Spring Boot] Google OAuth 2.0 API 요청 시 invalid grant와 malformed token 응답 해결하기 (2) | 2024.10.26 |