개요

Java 16에서 도입된 Record와 Java 17에서 정식 출시된 Sealed Class는 도메인 모델링의 표현력을 비약적으로 높여주는 기능입니다. 불변 데이터를 간결하게 정의하고, 타입 계층의 범위를 컴파일 타임에 제한함으로써 더 안전하고 예측 가능한 코드를 작성할 수 있습니다. 이 글에서는 두 기능을 결합한 실전 도메인 모델링 패턴을 소개합니다.

핵심 개념

Record와 Sealed Class의 핵심 특성을 정리합니다.

  • Record: 불변 데이터 캐리어로, equals/hashCode/toString이 자동 생성됩니다. 모든 필드가 final이며, 생성자와 접근자가 자동으로 제공됩니다.
  • Sealed Class: permits 절로 허용된 하위 타입만 상속 가능합니다. 컴파일러가 모든 하위 타입을 알고 있어 switch에서 완전성(exhaustiveness) 검사가 가능합니다.
  • 결합 활용: Sealed Interface + Record 조합으로 대수적 데이터 타입(Algebraic Data Type)을 구현할 수 있습니다.
  • 패턴 매칭 연동: Sealed Class와 Record Pattern을 함께 사용하면 타입 안전한 분기 처리가 가능합니다.

실전 예제

결제 시스템의 도메인 모델을 Record와 Sealed Class로 설계하는 예제입니다.

// 결제 수단을 Sealed Interface로 정의
public sealed interface PaymentMethod
    permits CreditCard, BankTransfer, DigitalWallet {
}

// 각 결제 수단을 Record로 구현
public record CreditCard(
    String cardNumber,
    String holderName,
    YearMonth expiryDate,
    String cvv
) implements PaymentMethod {
    // Compact Constructor로 유효성 검사
    public CreditCard {
        if (cardNumber == null || cardNumber.length() != 16) {
            throw new IllegalArgumentException("카드번호는 16자리여야 합니다");
        }
        // 마스킹 처리
        cardNumber = cardNumber.substring(0, 4) + "****" + cardNumber.substring(12);
    }
}

public record BankTransfer(
    String bankCode,
    String accountNumber,
    String accountHolder
) implements PaymentMethod {}

public record DigitalWallet(
    String provider,    // "kakao", "naver", "toss"
    String walletId
) implements PaymentMethod {}

// 주문 상태를 Sealed Class로 모델링
public sealed interface OrderStatus {
    record Pending(LocalDateTime createdAt) implements OrderStatus {}
    record Confirmed(LocalDateTime confirmedAt, String orderId) implements OrderStatus {}
    record Shipped(String trackingNumber, LocalDateTime shippedAt) implements OrderStatus {}
    record Delivered(LocalDateTime deliveredAt) implements OrderStatus {}
    record Cancelled(String reason, LocalDateTime cancelledAt) implements OrderStatus {}
}

패턴 매칭과 결합한 비즈니스 로직 예제입니다.

// 결제 처리 - 패턴 매칭으로 분기
public PaymentResult processPayment(PaymentMethod method, Money amount) {
    return switch (method) {
        case CreditCard card -> processCreditCard(card, amount);
        case BankTransfer transfer -> processBankTransfer(transfer, amount);
        case DigitalWallet wallet -> processDigitalWallet(wallet, amount);
        // sealed이므로 default 불필요 - 컴파일러가 완전성 보장
    };
}

// 주문 상태 전이 검증
public OrderStatus transition(OrderStatus current, String action) {
    return switch (current) {
        case OrderStatus.Pending p when "confirm".equals(action)
            -> new OrderStatus.Confirmed(LocalDateTime.now(), generateOrderId());
        case OrderStatus.Confirmed c when "ship".equals(action)
            -> new OrderStatus.Shipped(generateTracking(), LocalDateTime.now());
        case OrderStatus.Shipped s when "deliver".equals(action)
            -> new OrderStatus.Delivered(LocalDateTime.now());
        case OrderStatus.Pending p when "cancel".equals(action)
            -> new OrderStatus.Cancelled("고객 요청", LocalDateTime.now());
        default -> throw new IllegalStateException(
            "잘못된 상태 전이: " + current + " -> " + action);
    };
}

활용 팁

  • Record의 Compact Constructor를 활용하면 유효성 검사를 생성 시점에 강제할 수 있어, 항상 유효한 객체만 존재하게 됩니다.
  • Sealed Interface 내부에 Record를 중첩 선언하면 관련 타입을 하나의 파일에 모아 응집도를 높일 수 있습니다.
  • DTO(Data Transfer Object)를 Record로 대체하면 보일러플레이트 코드가 극적으로 줄어듭니다.
  • Record는 직렬화에 특별한 지원을 받아 Jackson, Gson 등에서 자연스럽게 매핑됩니다.
  • Sealed Class의 permits 목록은 같은 모듈 또는 같은 패키지 내에서만 지정할 수 있음을 유의하세요.

마무리

Record와 Sealed Class의 조합은 함수형 언어에서 흔히 볼 수 있는 대수적 데이터 타입을 Java에서 구현하는 강력한 도구입니다. 불변성과 타입 안전성을 동시에 확보하며, 패턴 매칭과 결합하면 비즈니스 로직을 명확하고 간결하게 표현할 수 있습니다. 특히 DDD(Domain-Driven Design) 환경에서 Value Object와 상태 모델링에 적극 활용하기를 권장합니다.