Java 17의 record로 데이터 클래스를 간소화하고, sealed class로 타입 계층을 제한해 안정적인 모델링을 만드는 방법을 패턴 매칭 관점에서 정리합니다.
도입 (문제 상황)
DTO나 이벤트 객체를 만들 때 equals/hashCode/toString를 매번 생성하거나, 누락 때문에 버그를 겪은 적 있으실 거예요. 또 “이 타입은 이 하위 타입들만 올 수 있다”를 코드로 강제하고 싶은데, 문서나 컨벤션에만 의존하면 금방 깨지기도 합니다. Java 17의 Record와 Sealed Class는 이런 반복과 불확실성을 언어 차원에서 정리해 줍니다.
핵심 개념: Java Record와 Sealed Class가 중요한 이유

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 패턴 매칭 & 향상된 문법]
'JAVA' 카테고리의 다른 글
| Java Virtual Thread — 경량 스레드의 시대 (Project Loom 실무 가이드) (0) | 2026.02.28 |
|---|---|
| Java switch 패턴 매칭 & 향상된 문법 — switch 표현식부터 가드 패턴까지 (0) | 2026.02.27 |
| Java 불변 객체와 방어적 복사: record로 안전한 도메인 만들기 (0) | 2026.02.26 |
| Java 실무에서 자주 쓰는 디자인 패턴 5가지 (Strategy, Factory, Builder, Singleton, Observer) (0) | 2026.02.26 |
| Java SOLID 원칙 — 코드로 이해하기 (Before/After 예제와 과도한 적용의 함정) (0) | 2026.02.25 |