1. 표준 예외의 한계와 커스텀 예외의 필요성
Java 는 NullPointerException, IOException 와 같이 다양한 표준 예외 클래스를 제공합니다. 하지만 실제 비즈니스 로직 구현 시에는 다음과 같은 도메인 특정 오류 상황이 자주 발생합니다.
- 상품 재고가 요청 수량보다 부족함
- 주문 상태가 변경 불가능한 단계임
- 사용자 잔액이 거래 금액보다 적음
- 접근 권한이 역할에 맞지 않음
이러한 상황을 IllegalArgumentException 등 일반적인 시스템 예외로 처리하면, 호출 측에서 오류의 정확한 원인을 파악하기 어렵고 비즈니스 규칙을 명확히 표현할 수 없습니다. 따라서 도메인 의미를 반영한 자체 예외 클래스를 정의하고, 발생 시점에 명시적으로 던지는 것이 좋습니다.
2. Java 예외 계층 구조 검토
Java 의 예외는 크게 두 가지 범주로 나뉩니다.
Checked Exception (확인 예외)
- 컴파일 단계에서 처리 여부를 확인합니다.
try-catch로 감싸거나 메서드 시그니처에throws를 선언해야 합니다.- 대표 사례:
SQLException,ClassNotFoundException
Unchecked Exception (런타임 예외)
RuntimeException을 상속받으며, 강제적인捕获 의무가 없습니다.- 대표 사례:
NullPointerException,IndexOutOfBoundsException
비즈니스 로직 예외의 경우, 코드의 가독성을 해치지 않도록 RuntimeException 을 상속받아 unchecked exception 으로 설계하는 것이 일반적입니다. 호출 측에서 필요에 따라 선택적으로 처리할 수 있기 때문입니다.
3. 커스텀 예외 클래스 구현 방법
방식 A: Checked Exception 상속
강제적으로 오류 처리를 요구해야 할 경우 Exception 을 상속합니다.
public class DataFormatValidationException extends Exception {
public DataFormatValidationException() {
super();
}
public DataFormatValidationException(String msg) {
super(msg);
}
public DataFormatValidationException(String msg, Throwable cause) {
super(msg, cause);
}
}
사용 시에는 반드시 예외 처리 블록이 필요합니다.
public void validateInput() throws DataFormatValidationException {
if (!isValid) {
throw new DataFormatValidationException("입력 데이터 형식이 잘못되었습니다.");
}
}
방식 B: RuntimeException 상속 (권장)
대부분의 비즈니스 오류는 이 방식을 따릅니다.
public class ServiceOperationFailureException extends RuntimeException {
public ServiceOperationFailureException() {
super();
}
public ServiceOperationFailureException(String msg) {
super(msg);
}
public ServiceOperationFailureException(String msg, Throwable cause) {
super(msg, cause);
}
}
호출 측에서는捕获 여부를 선택할 수 있습니다.
public void executeTask() {
if (conditionFailed) {
throw new ServiceOperationFailureException("서비스 운영 규칙에 위배됩니다.");
}
}
4. 오류 코드 포함 예외 설계
마이크로서비스나 REST API 환경에서는 클라이언트가 오류 유형을 식별할 수 있도록 오류 코드 (Error Code) 를 포함하는 것이 유용합니다.
public class ErrorCodeException extends RuntimeException {
private final String errorCode;
public ErrorCodeException(String code, String message) {
super(message);
this.errorCode = code;
}
public String getErrorCode() {
return errorCode;
}
}
throw 시 코드와 메시지를 함께 전달합니다.
throw new ErrorCodeException("ORD_001", "주문 처리가 불가능한 상태입니다.");
이를 통해 프론트엔드나 상위 시스템은 코드 값을 기반으로 적절한 UI 메시지 표시나 후속 로직 분기가 가능해집니다.
5. 설계 시 고려사항 및 베스트 프랙티스
- 명확한 네이밍:
MyException과 같이 모호한 이름 대신InsufficientStockException,InvalidOrderStatusException처럼 오류 상황을 설명하는 이름을 사용합니다. - 생성자 제공: 최소한 메시지 인자를 받는 생성자는 구현하여 오류 원인 기록을 남깁니다.
- 계층화: 대규모 시스템에서는
BaseBusinessException과 같은 공통 부모 클래스를 두고, 세부 예외들이 이를 상속하도록 하여 글로벌 핸들러에서 일괄 처리하기 쉽게 구조화합니다.
6. 실제 적용 예시: 재고 관리 시나리오
주문 처리 과정에서 재고 부족 상황을 가정하여 예외를 정의합니다.
public class InventoryShortageException extends RuntimeException {
public InventoryShortageException(String itemId, int requested) {
super("상품 ID [" + itemId + "] 의 재고가 부족합니다. 요청 수량: " + requested);
}
}
서비스 로직에서 예외를 발생시킵니다.
public void purchaseItem(String itemId, int quantity) {
int stock = getStock(itemId);
if (stock < quantity) {
throw new InventoryShortageException(itemId, quantity);
}
// 주문 처리 로직 수행
}
Spring 환경에서는 @RestControllerAdvice 를 사용하여 전역적으로 처리합니다.
@RestControllerAdvice
public class ApiErrorControllerAdvice {
@ExceptionHandler(InventoryShortageException.class)
public ResponseEntity<String> handleStockError(InventoryShortageException ex) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ex.getMessage());
}
}
이 구조를 통해 컨트롤러 층에서는 비즈니스 예외 처리 로직을 반복 작성하지 않아도 되며, 일관된 오류 응답 형식을 유지할 수 있습니다.