커스텀 예외 — 도메인에 맞는 예외 클래스 설계

Java 커스텀 예외 설계 완전 가이드 — RuntimeException vs Exception 선택 기준, ErrorCode enum 패턴, 도메인 루트 예외 계층 구조, 생성자 4종 구현, 직렬화 serialVersionUID

· 6 min read · PALDYN Team

지난 글에서 catch (A | B e) 구문으로 여러 예외를 한 번에 처리하는 방법을 살펴봤다. 이번에는 도메인에 맞는 커스텀 예외 클래스를 올바르게 설계하는 방법을 다룬다.

커스텀 예외가 필요한 이유

IllegalArgumentException이나 RuntimeException("메시지")를 직접 던지는 코드는 간단하지만 문제가 있다. 예외 타입만으로 무엇이 잘못됐는지 알 수 없고, 오류 코드·HTTP 상태 코드를 일관성 있게 매핑하기 어렵다. 커스텀 예외 계층을 잘 설계하면 예외 타입 자체가 도메인 언어가 된다.

Checked vs Unchecked — 어떤 것을 상속할까

기준Exception 상속 (Checked)RuntimeException 상속 (Unchecked)
호출자가 예외 처리 강제OX
복구 가능성높음 (IO, 네트워크 등)낮음 (프로그래밍 오류, 비즈니스 규칙)
API 유연성낮음 (throws 선언 필요)높음
현대 프레임워크 방향Spring 등은 Unchecked 선호←

도메인 예외 대부분은 RuntimeException을 상속하는 Unchecked 예외로 만드는 것이 현대적 관행이다. 복구 전략이 명확한 경우(예: 파일 없음 → 재시도)에만 Checked를 고려한다.

생성자 4종 — 표준 패턴

public class AppException extends RuntimeException {

    // 1. 메시지만
    public AppException(String message) {
        super(message);
    }

    // 2. 메시지 + 원인 예외 (예외 체이닝 필수)
    public AppException(String message, Throwable cause) {
        super(message, cause);
    }

    // 3. 원인 예외만 (메시지는 cause.toString()으로 자동)
    public AppException(Throwable cause) {
        super(cause);
    }

    // 4. 직렬화 지원용
    protected AppException(String message, Throwable cause,
                           boolean enableSuppression,
                           boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }

    // 직렬화 고유 식별자 (선언 필수)
    private static final long serialVersionUID = 1L;
}

생성자 4종을 모두 제공하지 않으면 서브클래스에서 원인 예외를 전달할 방법이 막힌다.

ErrorCode enum + 루트 예외 패턴

실무에서 가장 많이 쓰이는 패턴은 ErrorCode enum과 루트 예외를 결합하는 방식이다.

public enum ErrorCode {
    INVALID_INPUT(400, "잘못된 입력값입니다"),
    NOT_FOUND(404, "요청한 리소스를 찾을 수 없습니다"),
    DUPLICATE_RESOURCE(409, "이미 존재하는 리소스입니다"),
    INTERNAL_ERROR(500, "내부 오류가 발생했습니다");

    public final int httpStatus;
    public final String defaultMessage;

    ErrorCode(int httpStatus, String defaultMessage) {
        this.httpStatus = httpStatus;
        this.defaultMessage = defaultMessage;
    }
}
public class AppException extends RuntimeException {
    private final ErrorCode errorCode;

    public AppException(ErrorCode errorCode) {
        super(errorCode.defaultMessage);
        this.errorCode = errorCode;
    }

    public AppException(ErrorCode errorCode, Throwable cause) {
        super(errorCode.defaultMessage, cause);
        this.errorCode = errorCode;
    }

    public AppException(ErrorCode errorCode, String detail) {
        super(detail); // 상세 메시지 오버라이드
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode() { return errorCode; }

    private static final long serialVersionUID = 1L;
}

커스텀 예외 클래스 계층 설계

도메인별 구체적 예외

루트 예외를 상속해 도메인 언어로 예외 이름을 짓는다.

// 유저 도메인
public class UserNotFoundException extends AppException {
    private final long userId;

    public UserNotFoundException(long userId) {
        super(ErrorCode.NOT_FOUND, "사용자를 찾을 수 없습니다. id=" + userId);
        this.userId = userId;
    }

    public long getUserId() { return userId; }

    private static final long serialVersionUID = 1L;
}

// 주문 도메인
public class InsufficientStockException extends AppException {
    private final String productId;
    private final int required;
    private final int available;

    public InsufficientStockException(String productId, int required, int available) {
        super(ErrorCode.INVALID_INPUT,
              "재고 부족: productId=%s, 필요=%d, 가용=%d".formatted(productId, required, available));
        this.productId = productId;
        this.required = required;
        this.available = available;
    }

    private static final long serialVersionUID = 1L;
}

예외 클래스에 관련 데이터를 필드로 보관하면 로그와 오류 응답에서 활용할 수 있다.

커스텀 예외 구현 패턴

Spring 환경에서의 통합

Spring의 @ExceptionHandler 또는 @RestControllerAdvice와 결합하면 ErrorCode의 HTTP 상태 코드가 자동으로 응답에 반영된다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AppException.class)
    public ResponseEntity<ErrorResponse> handleAppException(AppException e) {
        ErrorCode code = e.getErrorCode();
        return ResponseEntity
            .status(code.httpStatus)
            .body(new ErrorResponse(code.name(), e.getMessage()));
    }
}

예외 클래스 이름과 ErrorCode 하나만 보면 HTTP 응답이 어떻게 될지 즉시 알 수 있다.

흔한 실수들

// ❌ 정보 없는 예외
throw new RuntimeException("오류"); // 무슨 오류인지 모름

// ❌ 예외 체이닝 누락
try {
    db.query(sql);
} catch (SQLException e) {
    throw new DataAccessException("DB 오류"); // cause 누락 → 원인 스택트레이스 소실
}

// ✅ 원인 예외 보존
throw new DataAccessException("DB 오류", e); // cause 전달

// ❌ serialVersionUID 누락
// 직렬화 도구(로깅 시스템, RMI 등)에서 경고 발생

// ❌ 생성자에서 비즈니스 로직 실행
public OrderException(Order o) {
    super("...");
    o.cancel(); // 생성자에서 부작용 → 예외가 잡히지 않을 때 상태 오염
}

커스텀 예외 클래스는 단순한 데이터 컨테이너여야 한다. 생성자에서는 필드 초기화와 메시지 구성만 한다.


지난 글: multi-catch — 여러 예외를 한 번에 처리하기

다음 글: 예외 체이닝 — 원인 예외를 보존하는 방법


읽어주셔서 감사합니다. 😊
#Java#커스텀 예외#RuntimeException#예외설계#ErrorCode#도메인예외