8 minute read

최근 sealed 키워드를 사용해서 도메인 모델링을 진행하고 있습니다. 컴파일 시점에 다양한 상태를 표현할 수 있고, 코드 가독성과 상태별 테스트 작성도 편리해지는 느낌을 받았습니다.

오랜만에 프로그래밍 자체가 재미있다고 느꼈습니다. 코드로 의도와 상태를 표현할 수 있고, 타입으로 인해 코드가 더 우아해지는 경험을 했습니다. 그 생각을 바탕으로 이 글을 작성합니다.

바로 sealed부터 보면 조금 갑작스럽습니다. 초기 Java에서 상태나 타입을 표현하던 간단한 방식부터 시작해서, 문자열 상수, enum, sealed class가 각각 무엇을 해결했는지 순서대로 살펴보겠습니다.

문자열 상수는 값을 모을 뿐 타입을 만들지는 않는다

가장 흔한 시작점은 상수 클래스입니다.

public final class OrderStatusValues {
    public static final String READY = "READY";
    public static final String PAID = "PAID";
    public static final String CANCELED = "CANCELED";

    private OrderStatusValues() {
    }

    public static boolean canCancel(String status) {
        return READY.equals(status) || PAID.equals(status);
    }
}

이 방식은 간단하고 직관적입니다. 외부 API, DB 저장값, 로그 메시지처럼 결국 문자열이 필요한 영역에서는 여전히 유용합니다.

상수 클래스 안에 관련 함수까지 함께 두면 응집도도 어느 정도 유지할 수 있습니다. 모든 상태 관련 로직이 무조건 흩어진다고 말하는 것은 정확하지 않습니다.

문제는 호출부의 타입이 여전히 String이라는 점입니다.

public void changeStatus(String status) {
    // status가 실제 주문 상태인지 컴파일러는 모른다.
}

changeStatus(OrderStatusValues.PAID);
changeStatus("PAID");
changeStatus("anything");

세 호출은 모두 컴파일됩니다. 상수 클래스는 사용 가능한 값을 한곳에 모아주지만, 그 값만 사용할 수 있게 강제하지는 못합니다. 결국 잘못된 값이 들어오는 문제는 런타임 validation이나 개발자의 기억에 의존하게 됩니다.

enum은 값을 타입 시스템 안으로 올린다

enum이 들어오면 관점이 달라집니다. PAID는 더 이상 임의 문자열이 아니라 OrderStatus 타입의 값입니다.

public enum OrderStatus {
    READY,
    PAID,
    CANCELED
}

public void changeStatus(OrderStatus status) {
    // status는 OrderStatus 중 하나다.
}

이제 잘못된 문자열은 바로 넘길 수 없습니다.

changeStatus(OrderStatus.PAID);
// changeStatus("PAID"); // 컴파일되지 않는다.

Java enum은 Java 5.0에서 언어 차원에 들어왔습니다. Oracle 문서는 enum을 미리 정의된 상수 집합을 표현하는 special data type으로 설명하고, enum 선언이 class를 정의한다고 설명합니다.

이 지점이 문자열 상수와 가장 크게 다릅니다. enum은 값 목록이면서 동시에 타입입니다.

enum은 상태별 행위를 가질 수 있다

enum의 장점은 타입 안정성에서 끝나지 않습니다. enum은 필드, 생성자, 메서드를 가질 수 있습니다.

public enum OrderStatus {
    READY("결제 대기", true),
    PAID("결제 완료", true),
    CANCELED("취소 완료", false);

    private final String label;
    private final boolean cancelable;

    OrderStatus(String label, boolean cancelable) {
        this.label = label;
        this.cancelable = cancelable;
    }

    public String label() {
        return label;
    }

    public boolean isCancelable() {
        return cancelable;
    }
}

이제 상태에 대한 부가 정보와 행위를 enum 안에 둘 수 있습니다.

상태마다 계산 방식이 다르면 enum constant별로 메서드를 구현할 수도 있습니다.

public enum DiscountType {
    FIXED {
        @Override
        public int apply(int price) {
            return price - 1_000;
        }
    },
    RATE {
        @Override
        public int apply(int price) {
            return (int) (price * 0.9);
        }
    };

    public abstract int apply(int price);
}

상수 클래스도 함수를 가질 수 있지만, enum은 함수 파라미터와 반환 타입까지 OrderStatus로 제한할 수 있습니다. 값의 목록과 행위뿐 아니라, 그 값을 사용하는 코드의 경계까지 타입으로 표현할 수 있습니다.

enum이나 상태 필드만으로는 부족할 때가 있다

enum은 고정된 값 목록에는 잘 맞습니다. 주문 상태, 요일, 결제 수단, 권한 타입처럼 각 케이스의 구조가 거의 같을 때 좋습니다.

하지만 특정 공통 묶음 안에서 서로 다른 상태값을 가질 수 있고, 그 상태들을 하나의 타입으로 묶고 싶은 경우가 있습니다.

예를 들어 결제 결과는 모두 PaymentResult로 다루고 싶지만, 상태에 따라 필요한 데이터가 다를 수 있습니다.

public enum PaymentResultType {
    SUCCESS,
    FAILED,
    PENDING
}

성공 결과에는 승인 ID가 필요하고, 실패 결과에는 실패 사유와 재시도 가능 여부가 필요하고, 대기 결과에는 요청 시간이 필요할 수 있습니다.

public record Success(long paymentId, String approvedAt) {
}

public record Failed(String reason, boolean retryable) {
}

public record Pending(String requestedAt) {
}

enum 하나에 모든 필드를 nullable로 넣으면 타입은 생겼지만 모델은 흐려집니다.

public enum PaymentResult {
    SUCCESS(null, null, false),
    FAILED(null, "결제 실패", true),
    PENDING(null, null, false);

    private final Long paymentId;
    private final String reason;
    private final boolean retryable;

    PaymentResult(Long paymentId, String reason, boolean retryable) {
        this.paymentId = paymentId;
        this.reason = reason;
        this.retryable = retryable;
    }
}

이런 코드는 어떤 필드가 어떤 케이스에서 유효한지 컴파일러가 알기 어렵습니다. 사용하는 쪽에서 계속 null 체크와 규칙을 기억해야 합니다.

도메인 클래스 안에 status 필드를 두고 상태를 구분하는 방식도 가능합니다.

public enum PaymentStatus {
    READY,
    APPROVED,
    CANCELED
}

public class Payment {
    private final long id;
    private PaymentStatus status;

    public Payment(long id) {
        this.id = id;
        this.status = PaymentStatus.READY;
    }

    public void approve() {
        if (status != PaymentStatus.READY) {
            throw new IllegalStateException("READY 상태에서만 승인할 수 있습니다.");
        }

        this.status = PaymentStatus.APPROVED;
    }

    public void cancel() {
        if (status == PaymentStatus.CANCELED) {
            throw new IllegalStateException("이미 취소된 결제입니다.");
        }

        this.status = PaymentStatus.CANCELED;
    }
}

이 방식도 현실적인 선택입니다. 다만 함수마다 “이 상태에서는 사용할 수 없다”는 validation 로직이 반복될 수 있습니다. validation이 빠지면 특정 도메인의 상태가 어디에서만 가능한지 개발자의 기억에 의존하게 됩니다.

즉, enum이나 status 필드가 나쁜 것이 아니라, 상태별로 가능한 데이터와 행위를 더 강하게 표현하고 싶을 때 한계가 드러납니다.

sealed는 가능한 하위 타입을 닫는다

sealed class와 sealed interface는 이 지점에서 다른 선택지를 제공합니다. Java에서는 permits로 허용할 하위 타입을 명시할 수 있습니다.

public sealed interface PaymentResult
        permits Success, Failed, Pending {
}

public record Success(
        long paymentId,
        String approvedAt
) implements PaymentResult {
}

public record Failed(
        String reason,
        boolean retryable
) implements PaymentResult {
}

public record Pending(
        String requestedAt
) implements PaymentResult {
}

여기서 PaymentResult는 아무 타입이나 구현할 수 있는 열린 인터페이스가 아닙니다. 허용된 하위 타입의 집합이 닫혀 있습니다.

이 특징 때문에 Java 21의 pattern matching for switch와 함께 사용하면 모든 케이스를 처리했는지 컴파일러가 확인할 수 있습니다.

public String message(PaymentResult result) {
    return switch (result) {
        case Success success -> "결제 완료: " + success.paymentId();
        case Failed failed -> "결제 실패: " + failed.reason();
        case Pending pending -> "결제 대기: " + pending.requestedAt();
    };
}

default가 없어도 됩니다. 가능한 하위 타입을 컴파일러가 알고 있기 때문입니다.

공통 필드가 있으면 sealed class를 사용하자

위 예시는 sealed interface를 사용했습니다. 하지만 모든 하위 타입이 공통으로 가져야 하는 고정 필드가 있다면 sealed class가 더 자연스럽습니다.

예를 들어 결제 결과가 모두 외부 시스템에 저장할 code 값을 가져야 한다고 가정해 보겠습니다.

public sealed abstract class PaymentResult
        permits PaymentSuccess, PaymentFailed, PaymentPending {

    private final String code;

    protected PaymentResult(String code) {
        this.code = code;
    }

    public String code() {
        return code;
    }

    public static PaymentResult success(long paymentId, String approvedAt) {
        return new PaymentSuccess(paymentId, approvedAt);
    }

    public static PaymentResult failed(String reason, boolean retryable) {
        return new PaymentFailed(reason, retryable);
    }

    public static PaymentResult pending(String requestedAt) {
        return new PaymentPending(requestedAt);
    }
}

public final class PaymentSuccess extends PaymentResult {
    private final long paymentId;
    private final String approvedAt;

    public PaymentSuccess(long paymentId, String approvedAt) {
        super("SUCCESS");
        this.paymentId = paymentId;
        this.approvedAt = approvedAt;
    }

    public long paymentId() {
        return paymentId;
    }

    public String approvedAt() {
        return approvedAt;
    }
}

public final class PaymentFailed extends PaymentResult {
    private final String reason;
    private final boolean retryable;

    public PaymentFailed(String reason, boolean retryable) {
        super("FAILED");
        this.reason = reason;
        this.retryable = retryable;
    }

    public String reason() {
        return reason;
    }

    public boolean retryable() {
        return retryable;
    }
}

public final class PaymentPending extends PaymentResult {
    private final String requestedAt;

    public PaymentPending(String requestedAt) {
        super("PENDING");
        this.requestedAt = requestedAt;
    }

    public String requestedAt() {
        return requestedAt;
    }
}

이 구조에서는 모든 결제 결과가 code를 가집니다. 동시에 각 하위 타입은 자신에게 필요한 데이터만 가집니다.

public void saveResult(PaymentResult result) {
    String code = result.code();

    switch (result) {
        case PaymentSuccess success -> saveSuccess(code, success.paymentId());
        case PaymentFailed failed -> saveFailure(code, failed.reason());
        case PaymentPending pending -> savePending(code, pending.requestedAt());
    }
}

enum으로도 code 같은 공통 필드를 둘 수 있습니다. 생성 메서드를 잘 만들면 상태별 객체 생성도 어느 정도 정리할 수 있습니다.

하지만 sealed class는 애초에 상태를 타입으로 나눕니다. 공통 필드는 부모에 두고, 케이스별 데이터는 하위 타입에 둡니다. 그래서 어떤 데이터가 어떤 상태에서만 유효한지 타입 자체가 표현합니다.

상태별 테스트 작성도 쉬워진다

상태별 생성 메서드를 만들어도 테스트는 작성할 수 있습니다.

PaymentResult success = PaymentResult.success(1L, "2026-07-04T10:00:00");
PaymentResult failed = PaymentResult.failed("잔액 부족", true);

이 방식은 생성 로직을 감추고 호출부를 단순하게 만드는 장점이 있습니다. 다만 반환 타입이 모두 같은 PaymentResult라면, 테스트나 비즈니스 로직에서 현재 상태를 다시 확인해야 할 수 있습니다.

sealed class는 상태를 생성 메서드의 규칙이 아니라 타입으로 나눕니다.

@Test
void successResultIsSavedWithPaymentId() {
    PaymentResult result = new PaymentSuccess(1L, "2026-07-04T10:00:00");

    saveResult(result);

    // 성공 상태에 필요한 검증만 작성한다.
}

@Test
void failedResultIsSavedWithReason() {
    PaymentResult result = new PaymentFailed("잔액 부족", true);

    saveResult(result);

    // 실패 상태에 필요한 검증만 작성한다.
}

상태별 생성 메서드로도 해결할 수 있는 부분이 있지만, sealed를 사용하면 성공, 실패, 대기가 처음부터 서로 다른 타입으로 드러납니다. 테스트 이름, 테스트 데이터, 테스트 대상 상태가 같은 방향을 바라보게 됩니다.

이 점이 타입으로 인한 코드의 우아함과 연결됩니다. 상태를 주석이나 문자열 규칙으로 설명하는 대신, 타입 자체가 의도를 표현합니다.

sealed는 출시 이후의 상속까지 관리한다

sealed를 단순히 switch를 편하게 쓰기 위한 문법으로만 보면 의미가 작아집니다. 더 중요한 점은 상속 경계를 API 작성자가 통제할 수 있다는 데 있습니다.

일반 인터페이스나 추상 클래스는 공개된 순간 여러 곳에서 구현되거나 상속될 수 있습니다.

public interface PaymentResult {
}

이렇게 열어두면 다른 패키지나 다른 모듈에서 새로운 구현체가 계속 생길 수 있습니다. 라이브러리나 공통 모듈을 배포한 뒤에는 작성자가 모든 하위 타입을 알 수 없게 됩니다.

sealed는 이 상황을 다르게 만듭니다.

public sealed abstract class PaymentResult
        permits PaymentSuccess, PaymentFailed, PaymentPending {
}

이 선언은 “이 타입의 하위 종류는 내가 허용한 범위 안에서만 존재한다”는 의도를 코드에 남깁니다. 첫 출시 이후 아무 곳에서나 상속 타입이 늘어나는 것을 막을 수 있다는 점이 sealed의 중요한 장점입니다.

Java의 sealed class는 Java 17에서 정식화됐습니다. JEP 409는 sealed class와 interface가 어떤 클래스나 인터페이스가 자신을 extend 또는 implement할 수 있는지 제한한다고 설명합니다.

Java 21에서 정식화된 pattern matching for switch와 함께 사용하면, sealed hierarchy를 바탕으로 분기 누락을 더 잘 확인할 수 있습니다.

차이점 요약

방식 닫는 대상 장점 한계
문자열 상수 닫지 않음 외부 시스템과 주고받는 원시 코드값을 간단히 모을 수 있음 호출부가 String이면 잘못된 값도 컴파일됨
enum 가능한 값 값 목록, 필드, 행위를 하나의 타입으로 묶을 수 있음 케이스별 데이터 구조가 크게 다르면 nullable 필드나 분기 로직이 늘어남
상태 필드 객체 내부 상태 기존 도메인 객체 흐름을 유지하기 쉬움 함수마다 상태 validation이 반복되거나, 누락되면 런타임 오류로 이어짐
sealed 컴파일 시점에 허용되는 타입 상태별 데이터와 행위를 타입 자체로 나누고, 허용되지 않은 상태 사용을 컴파일 시점에 막을 수 있음 단순 값 목록에는 enum보다 구조가 무거울 수 있음

문자열 상수는 가볍습니다. enum은 값을 타입으로 올립니다. 상태 필드는 하나의 도메인 객체 안에서 현재 상태를 관리합니다. sealed는 상태를 타입 자체로 나누고, 컴파일 시점에 허용된 타입만 다루도록 강제합니다.

요약

문자열 상수에서 enum으로 넘어가는 변화는 값을 타입으로 다루기 시작했다는 점에서 큽니다. enum은 고정된 값 목록에 이름, 필드, 행위를 함께 묶을 수 있게 해줍니다.

다만 enum이나 상태 필드만으로 모든 상태 모델링이 해결되지는 않습니다. 상태별로 가능한 데이터와 행위가 달라지면 validation, null 체크, 개발자의 기억에 의존하는 코드가 늘어날 수 있습니다.

sealed는 한 단계 더 나아가 상태를 타입 자체로 표현합니다. 그래서 허용된 타입만 다루도록 컴파일 시점에 강제할 수 있고, switch에서 빠진 케이스도 컴파일 시점에 확인할 수 있습니다.

공통 필드가 있으면서도 케이스별 데이터가 달라진다면 sealed class가 특히 잘 맞습니다. 상태를 문자열 규칙이나 런타임 validation으로만 다루는 대신, 타입으로 표현하고 컴파일러가 함께 검증하게 만들 수 있습니다.

참고 자료

Comments