Java 비즈니스 로직 예외 처리 및 커스텀 예외 설계

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());
    }
}

이 구조를 통해 컨트롤러 층에서는 비즈니스 예외 처리 로직을 반복 작성하지 않아도 되며, 일관된 오류 응답 형식을 유지할 수 있습니다.

태그: java ExceptionHandling SpringBoot RuntimeException CustomException

10월 10일 22:17에 게시됨