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

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 — 여러 예외를 한 번에 처리하기

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


읽어주셔서 감사합니다. 😊