JAVA

Java Record와 Sealed Class로 도메인 모델을 단단하게 만들기 (패턴 매칭까지)

IT Lab 2026. 2. 27. 10:00

Java 17의 record로 데이터 클래스를 간소화하고, sealed class로 타입 계층을 제한해 안정적인 모델링을 만드는 방법을 패턴 매칭 관점에서 정리합니다.

도입 (문제 상황)

DTO나 이벤트 객체를 만들 때 equals/hashCode/toString를 매번 생성하거나, 누락 때문에 버그를 겪은 적 있으실 거예요. 또 “이 타입은 이 하위 타입들만 올 수 있다”를 코드로 강제하고 싶은데, 문서나 컨벤션에만 의존하면 금방 깨지기도 합니다. Java 17의 RecordSealed Class는 이런 반복과 불확실성을 언어 차원에서 정리해 줍니다.

핵심 개념: Java Record와 Sealed Class가 중요한 이유

Record는 데이터 표현을 간소화하고 Sealed는 타입 계층을 제한해 패턴 매칭과 연결되는 흐름도

Record와 Sealed Class는 단순히 “신기능”이 아니라, 모델을 더 명확하고 안전하게 만드는 도구입니다. 비유하자면, Record는 “데이터 전용 박스”를 표준 규격으로 제공하고, Sealed는 “박스 종류를 미리 정해 둔 카탈로그”를 강제하는 느낌이에요.

Java Record: 데이터 클래스 간소화 + 불변 기본값

record데이터를 담는 목적이 분명한 타입에 최적화되어 있습니다.

  • 필드(정확히는 record component)가 자동으로 private final로 생성됩니다.
  • equals/hashCode/toString가 컴포넌트 기반으로 자동 생성됩니다.
  • canonical constructor(모든 컴포넌트를 받는 생성자)도 기본 제공됩니다.
  • “완전한 불변”은 아니지만(컴포넌트가 가변 객체면 내부는 변할 수 있음), 불변 설계를 유도합니다.
    → 이전 글(#29 불변 객체와 방어적 복사)에서 다룬 내용이 여기서 그대로 연결됩니다.

Record를 쓸 때의 핵심은 “아무 클래스나 record로 바꾸기”가 아니라, 의도가 데이터 운반인지를 먼저 확인하는 것입니다. 도메인 엔티티처럼 정체성과 라이프사이클이 중요한 타입은 일반 클래스가 더 적합할 때가 많습니다.

Java Sealed Class: 타입 계층 제한으로 “가능한 경우의 수”를 줄이기

sealed는 상속/구현 가능한 타입을 제한합니다.

  • sealed 타입은 permits로 허용할 하위 타입을 명시합니다.
  • 허용된 하위 타입은 반드시 final, sealed, non-sealed 중 하나로 마무리해야 합니다.
  • 결과적으로 “이 인터페이스를 구현한 타입은 이 목록뿐”을 컴파일 타임에 보장할 수 있습니다.

이게 실무에서 특히 중요한 이유는, 타입 계층이 커질수록 “어떤 구현체가 들어올지 모르는” 불안이 커지기 때문입니다. Sealed로 경계를 닫아두면, 리뷰/테스트/운영에서 예상 가능한 범위가 확 줄어듭니다.

패턴 매칭과의 연계: 분기 로직이 더 안전해지는 방향

Sealed 타입은 패턴 매칭(특히 switch 패턴 매칭)과 궁합이 좋습니다. 컴파일러가 “모든 하위 타입을 다 처리했는지(exhaustiveness)”를 더 잘 판단할 수 있기 때문입니다.

Java 17에서는 switch 패턴 매칭이 정식이 아니고(프리뷰/버전별 상이), 본격적인 활용은 Java 21에서 더 자연스럽습니다.
다음 글(#31)에서 switch 패턴 매칭 & 향상된 문법을 다룰 때, sealed 계층이 왜 강력한지 더 체감하실 거예요.

아래 표는 “언제 record / sealed를 선택하면 좋은지”를 빠르게 정리한 가이드입니다.

기능 해결하는 문제 적합한 대상 주의점
record 보일러플레이트 제거, 값 기반 동등성 DTO, 요청/응답, 이벤트, 값 객체(VO) 컴포넌트가 가변이면 깊은 불변이 아님(방어적 복사 고려)
sealed 타입 계층의 확장 범위 통제 결제수단/상태/결과 같은 “닫힌 집합” 모델 라이브러리 확장 포인트에는 오히려 불편할 수 있음
record + sealed 모델의 경우의 수 + 데이터 표현을 함께 고정 ADT(대수적 데이터 타입) 스타일 모델링 패턴 매칭 switch는 버전별 지원 확인 필요
flowchart TD
  A[""record"""] --> B[""값(데이터) 표현을 간결하게"""]
  C[""sealed"""] --> D[""타입 계층(가능한 하위 타입) 제한"""]
  D --> E[""패턴 매칭에서 분기 누락 방지"""]
  B --> F[""도메인/DTO 코드 품질 향상"""]
  E --> F

Record는 데이터 표현을, Sealed는 타입의 경우의 수를 고정해 모델을 더 안전하게 만듭니다.

코드 예제: Record + Sealed로 “닫힌” 도메인 모델 만들기 (Java 17)

아래 예제는 “결제 결과”를 sealed interface로 닫고, 각 결과를 record로 표현합니다. 또한 record의 compact constructor에서 간단한 검증을 넣고, 가변 컬렉션은 방어적으로 복사합니다(이전 글 #29와 연결).

파일 1개로 복붙 실행 가능 (Java 17)

import java.time.Instant;
import java.util.List;

public class Main {

    // Sealed로 "결제 결과"의 가능한 타입을 닫습니다.
    sealed interface PaymentResult permits PaymentResult.Approved, PaymentResult.Declined, PaymentResult.Error {

        // 승인: 승인번호와 승인시각을 갖는 "데이터"이므로 record가 잘 맞습니다.
        record Approved(String approvalCode, Instant approvedAt) implements PaymentResult {
            public Approved {
                if (approvalCode == null || approvalCode.isBlank()) {
                    throw new IllegalArgumentException("approvalCode must not be blank");
                }
                if (approvedAt == null) {
                    throw new IllegalArgumentException("approvedAt must not be null");
                }
            }
        }

        // 거절: 사유 코드/메시지
        record Declined(String reasonCode, String message) implements PaymentResult {
            public Declined {
                if (reasonCode == null || reasonCode.isBlank()) {
                    throw new IllegalArgumentException("reasonCode must not be blank");
                }
                if (message == null) message = ""; // record 컴포넌트도 생성자에서 정규화 가능
            }
        }

        // 에러: 재시도 가능 여부 + 진단용 태그 목록(가변 컬렉션은 방어적 복사)
        record Error(boolean retryable, List<String> tags) implements PaymentResult {
            public Error {
                tags = (tags == null) ? List.of() : List.copyOf(tags); // 불변 리스트로 복사
            }
        }
    }

    public static void main(String[] args) {
        PaymentResult r1 = new PaymentResult.Approved("APR-20240226-0001", Instant.now());
        PaymentResult r2 = new PaymentResult.Declined("LIMIT_EXCEEDED", "카드 한도 초과");
        PaymentResult r3 = new PaymentResult.Error(true, List.of("timeout", "pgw"));

        System.out.println(describe(r1));
        System.out.println(describe(r2));
        System.out.println(describe(r3));

        // record는 equals/hashCode/toString이 컴포넌트 기반으로 자동 생성됩니다.
        System.out.println(new PaymentResult.Declined("A", "x").equals(new PaymentResult.Declined("A", "x"))); // true
    }

    // Java 17에서는 switch 패턴 매칭이 정식 기능이 아니므로, 여기서는 instanceof 패턴 매칭으로 안전하게 분기합니다.
    static String describe(PaymentResult result) {
        if (result instanceof PaymentResult.Approved a) {
            return "APPROVED: code=" + a.approvalCode() + ", at=" + a.approvedAt();
        }
        if (result instanceof PaymentResult.Declined d) {
            return "DECLINED: reason=" + d.reasonCode() + ", msg=" + d.message();
        }
        if (result instanceof PaymentResult.Error e) {
            return "ERROR: retryable=" + e.retryable() + ", tags=" + e.tags();
        }
        // sealed라면 이 지점은 사실상 도달 불가여야 합니다.
        // 다만 컴파일러가 항상 100% 증명하진 못하므로, 방어적으로 예외를 둡니다.
        throw new IllegalStateException("Unknown PaymentResult: " + result);
    }
}

실무 팁

💡 실무에서는: record에 “검증/정규화”를 compact constructor로 몰아넣어 보세요
record는 필드가 적고 목적이 명확한 만큼, 생성 시점에 규칙을 강제하는 게 유지보수에 유리합니다. 예를 들어 공백 문자열을 ""로 정규화하거나, List.copyOf로 컬렉션을 불변으로 만들어 두면 “나중에 누가 바꿨지?” 같은 디버깅 시간을 크게 줄일 수 있어요. (단, 무거운 로직/IO는 생성자에 넣지 않는 편이 좋습니다.)

💡 실무에서는: sealed는 “확장 포인트”가 아닌 “닫힌 도메인”에 쓰는 게 효과적입니다
결제 상태, 주문 상태, 인증 결과처럼 케이스가 늘면 오히려 위험한 영역에서 sealed가 빛을 봅니다. 반대로 외부 팀/플러그인이 구현체를 추가해야 하는 SPI 성격이라면 sealed가 제약이 될 수 있으니, 그때는 일반 interface + 문서/테스트로 계약을 관리하는 편이 낫습니다.

핵심 요약

  • record는 데이터 전용 타입의 보일러플레이트를 제거하고 값 기반 모델링을 쉽게 해줍니다.
  • sealed는 하위 타입을 제한해 “가능한 경우의 수”를 컴파일 타임에 줄입니다.
  • 둘을 함께 쓰면 패턴 매칭과 결합해 분기 누락/예상치 못한 타입 유입을 줄일 수 있습니다.

다음 글: [#31 switch 패턴 매칭 & 향상된 문법]