Spring Boot 예외 관련 공통 기능을 구현했다.
예외가 발생했을 때 일관된 응답 형식을 제공하고, 비즈니스 로직과 예외 처리를 깔끔하게 분리하는 방법을 정리해보쟈
먼저 예외 처리 아키텍처의 핵심 구성 요소부터 살펴보자:
이렇게 구성하면 일관된 에러 처리와 응답이 가능해진다.
모든 에러 코드가 공통으로 가져야 할 속성을 정의한 인터페이스다.
public interface ErrorCode {
String getCode();
String getMessage();
int getStatus();
}
이 인터페이스를 통해 다양한 도메인의 에러 코드를 일관되게 정의할 수 있다.
공통으로 사용할 에러 코드를 정의한 열거형이다.
@Getter
public enum CommonErrorCode implements ErrorCode {
INVALID_REQUEST("COM_001", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST),
UNAUTHORIZED("COM_002", "인증되지 않은 요청입니다.", HttpStatus.UNAUTHORIZED),
FORBIDDEN("COM_003", "접근이 금지 되었습니다.", HttpStatus.FORBIDDEN),
NOT_FOUND("COM_004", "요청한 데이터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
INTERNAL_SERVER_ERROR("COM_005", "서버 에러 입니다.", HttpStatus.INTERNAL_SERVER_ERROR),
// ... 기타 공통 에러 코드들
private final String code;
private final String message;
private final int status;
CommonErrorCode(String code, String message, HttpStatus status) {
this.code = code;
this.message = message;
this.status = status.value();
}
}
이렇게 하면 에러 코드, 메시지, HTTP 상태코드를 한 곳에서 관리할 수 있다.
비즈니스 로직에서 발생하는 예외를 표현하는 커스텀 예외 클래스다.
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public static BusinessException from(ErrorCode errorCode) {
return new BusinessException(errorCode);
}
private BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
이 클래스는 ErrorCode를 캡슐화해서 예외를 좀 더 도메인적으로 표현할 수 있게 해준다.
클라이언트에게 반환할 에러 응답 형식을 정의한 DTO다.
public record ErrorResponse(String code, String message, int status, List<ValidationError> errors) {
// 다양한 factory 메서드
public static ErrorResponse of(ErrorCode errorCode) {
return new ErrorResponse(
errorCode.getCode(),
errorCode.getMessage(),
errorCode.getStatus(),
Collections.emptyList()
);
}
// 검증 에러를 위한 중첩 레코드
public record ValidationError(
String field,
String rejectedValue,
String reason
) {
// FieldError, ConstraintViolation 변환 메서드들
}
}
레코드를 사용해서 불변 객체로 만들었고, 다양한 타입의 예외에 대응할 수 있는 팩토리 메서드를 제공한다.
모든 예외를 한 곳에서 처리하는 전역 예외 처리기다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode());
return ResponseEntity.status(errorResponse.status()).body(errorResponse);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
MethodArgumentNotValidException e) {
ErrorResponse errorResponse = ErrorResponse.of(e.getBindingResult());
return ResponseEntity.status(errorResponse.status()).body(errorResponse);
}
// 기타 예외 처리 메서드들
}
@RestControllerAdvice를 사용해서 모든 컨트롤러에서 발생하는 예외를 일관되게 처리할 수 있다.
이제 실제 비즈니스 로직에서 어떻게 사용하는지 살펴보자.
// 도메인별 에러 코드 정의
@Getter
public enum StockErrorCode implements ErrorCode {
INSUFFICIENT_STOCK("STK_001", "재고가 부족합니다", HttpStatus.BAD_REQUEST),
STOCK_NOT_FOUND("STK_002", "재고 정보를 찾을 수 없습니다", HttpStatus.NOT_FOUND);
// 필드 및 생성자
}
// 서비스 레이어에서 사용
@Service
public class StockService {
public void decreaseStock(UUID productId, int quantity) {
Stock stock = stockRepository.findById(productId)
.orElseThrow(() -> BusinessException.from(StockErrorCode.STOCK_NOT_FOUND));
if (stock.getQuantity() < quantity) {
throw BusinessException.from(StockErrorCode.INSUFFICIENT_STOCK);
}
stock.decrease(quantity);
}
}
이 방식의 주요 장점은:
Spring Boot에서 이런 방식으로 예외 처리를 구현하면 일관성 있고 확장 가능한 에러 처리 전략을 만들 수 있다. 특히 마이크로서비스 환경에서는 여러 서비스 간에 일관된 에러 응답 형식을 유지하는 게 중요한데, 이 방식이 그런 요구사항을 잘 충족시켜준다.
비즈니스 로직에서는 단순히 적절한 에러 코드와 함께 BusinessException을 던지기만 하면 나머지는 GlobalExceptionHandler가 알아서 처리해주니까 개발자는 비즈니스 로직에만 집중할 수 있다.