Contents
see List왜 지금 Pattern Matching인가
2026년 3월 17일 Oracle이 Java 26 LTS 후보 릴리스를 공개했다. Virtual Threads·Structured Concurrency 같은 런타임 혁신이 주목받았지만, 실제 현업 코드의 가독성을 가장 크게 바꾼 것은 Pattern Matching 3종 세트(Records, Sealed Classes, Switch Patterns)다. 여기에 Java 26에서 네 번째 프리뷰로 진화한 JEP 530 Primitive Types in Patterns가 합류하면서 if / else if / instanceof 지옥을 드디어 벗어날 수 있게 되었다.
이 글은 실제 주문 처리·결제 도메인 예제를 기준으로 Records + Sealed + Switch Pattern + Primitive Pattern을 어떻게 조합해야 유지보수 가능한 도메인 모델이 되는지, Java 26 기준 최신 문법과 함께 정리한다.
1. Records — 불변 데이터 캐리어의 표준
Records는 Java 16에서 정식화되었지만 Java 21 이후 Record Pattern과 결합되면서 가치가 폭발했다. 단순히 DTO용이 아니라 도메인 불변 객체의 첫 번째 선택지가 되어야 한다.
// 주문 도메인 - 모든 필드는 불변
public record Money(long amount, String currency) {
public Money {
if (amount < 0) throw new IllegalArgumentException("amount negative");
if (currency == null || currency.length() != 3)
throw new IllegalArgumentException("invalid currency");
}
public Money plus(Money other) {
if (!currency.equals(other.currency))
throw new IllegalStateException("currency mismatch");
return new Money(amount + other.amount, currency);
}
}
public record OrderLine(String sku, int qty, Money unitPrice) {
public Money subtotal() {
return new Money(unitPrice.amount() * qty, unitPrice.currency());
}
}핵심은 compact constructor를 통한 불변식 보장이다. 이 패턴 하나만으로 도메인 규칙이 생성자에서 강제되어, 잘못된 상태가 시스템 안으로 흘러드는 일을 원천 차단한다.
2. Sealed Classes — 타입 계층을 봉인하라
Sealed Classes(JEP 409)의 진짜 가치는 컴파일러에게 "이 계층의 서브타입은 이것이 전부다"라고 선언하는 것이다. 그러면 switch pattern이 default: 없이도 완전성(exhaustiveness)을 만족한다는 사실을 컴파일러가 증명해준다.
// 결제 결과를 sealed 인터페이스로 봉인
public sealed interface PaymentResult
permits PaymentResult.Success,
PaymentResult.Declined,
PaymentResult.Pending,
PaymentResult.Failed {
record Success(String authCode, Money charged) implements PaymentResult {}
record Declined(String reason, int retryAfterSec) implements PaymentResult {}
record Pending(String trackingId) implements PaymentResult {}
record Failed(Throwable cause) implements PaymentResult {}
}여기서 중요한 설계 원칙 두 가지.
- 서브타입을 Records로 구현하면 자동으로 Record Pattern 분해가 가능해진다.
- permits 절은 컴파일 시점 계약이다. 새 경우가 추가되면 모든 switch 블록이 컴파일 에러를 내서 누락을 잡아준다 — 런타임이 아니라 컴파일 타임에.
3. Switch Pattern — if/else 체인을 선언적 분기로
Java 21에서 정식화된 Switch Pattern은 Java 26에 와서 더욱 강력해졌다. 실제 결제 결과 처리 로직을 비교해보자.
Before — instanceof 체인
// 전형적인 Java 8 스타일 - 읽기 어렵고 오류가 잘 숨는다
public String describe(PaymentResult r) {
if (r instanceof PaymentResult.Success) {
PaymentResult.Success s = (PaymentResult.Success) r;
return "OK " + s.getAuthCode() + " " + s.getCharged();
} else if (r instanceof PaymentResult.Declined) {
PaymentResult.Declined d = (PaymentResult.Declined) r;
return "NG " + d.getReason();
} else if (r instanceof PaymentResult.Pending) {
return "PENDING";
} else {
return "UNKNOWN"; // 새 타입 추가해도 여기서 조용히 처리됨 - 버그!
}
}After — Switch Pattern + Record Pattern (Java 26)
public String describe(PaymentResult r) {
return switch (r) {
case PaymentResult.Success(String code, Money charged) ->
"OK " + code + " " + charged.amount() + charged.currency();
case PaymentResult.Declined(String reason, int retry) when retry > 60 ->
"RETRY_LATER " + reason;
case PaymentResult.Declined(String reason, var retry) ->
"RETRY_SOON " + reason;
case PaymentResult.Pending(String id) ->
"PENDING " + id;
case PaymentResult.Failed(Throwable cause) ->
"FAILED " + cause.getClass().getSimpleName();
// default 불필요! sealed + 모든 케이스 커버 = 완전성
};
}이 한 블록에 세 가지 기술이 동시에 작동한다.
- Type Pattern:
case PaymentResult.Success로 타입 검사 + 캐스팅 자동화 - Record Deconstruction:
(String code, Money charged)로 필드 분해 - Guard (when 절):
when retry > 60로 조건부 매칭
그리고 가장 중요한 건 sealed 덕분에 default가 없어도 컴파일된다는 것이다. PaymentResult에 새 케이스가 추가되는 순간 이 switch는 컴파일 에러를 내고, 개발자는 반드시 새 분기를 처리하게 된다.
4. 중첩 Record Pattern — 깊은 분해
실제 도메인은 중첩이 흔하다. Record Pattern은 임의 깊이까지 한 줄로 분해할 수 있다.
public record Order(String id, OrderLine line, PaymentResult payment) {}
public String summarize(Order order) {
return switch (order) {
case Order(String id, OrderLine(String sku, int qty, Money(long amt, String cur)),
PaymentResult.Success(String code, var charged)) ->
"[OK] " + id + " sku=" + sku + " qty=" + qty
+ " total=" + (amt * qty) + cur + " auth=" + code;
case Order(var id, var line, PaymentResult.Declined(String reason, var retry)) ->
"[NG] " + id + " reason=" + reason;
case Order(var id, var line, var p) ->
"[PENDING] " + id;
};
}3단 중첩 구조가 한 줄에 분해된다. 과거엔 order.getLine().getUnitPrice().getAmount() 같은 getter 체인이었던 것이 이제 패턴 안에서 자연스럽게 이름 바인딩된다.
5. Java 26 신규: Primitive Types in Patterns (JEP 530)
Java 26의 JEP 530은 네 번째 프리뷰로, 이전 버전 대비 무조건적 정확성(unconditional exactness) 정의를 강화하고 dominance check를 더 엄격하게 했다. 드디어 primitive 타입에도 패턴 매칭이 가능해진다.
// Java 25까지: Object를 Number로 받아 instanceof 체인
public String format(Object value) {
if (value instanceof Integer i) return "int=" + i;
if (value instanceof Long l) return "long=" + l;
if (value instanceof Double d && d.isNaN()) return "NaN";
return "other";
}
// Java 26 (preview --enable-preview): primitive 직접 매칭
public String describe(int x) {
return switch (x) {
case 0 -> "zero";
case int i when i < 0 -> "negative: " + i;
case int i when i > 1_000_000 -> "huge: " + i;
case int i -> "small positive: " + i;
};
}
// 손실 없는 변환 검사 - 값이 실제로 수용 가능한지 체크
public String narrow(long value) {
return switch (value) {
case byte b -> "fits in byte: " + b; // -128..127 범위일 때만 매칭
case short s -> "fits in short: " + s; // -32768..32767 범위
case int i -> "fits in int: " + i;
case long l -> "needs long: " + l;
};
}핵심은 "loseless conversion"이다. case byte b는 long 값이 실제로 byte 범위에 담길 수 있을 때만 매칭된다. 이전엔 if ((byte)v == v) 같은 트릭이 필요했던 로직이 선언적으로 표현된다.
6. 실전 ADT 패턴 — Result<T, E> 구현
Rust의 Result나 Kotlin의 sealed class 패턴을 Java 26에서 완전히 자연스럽게 구현할 수 있다.
public sealed interface Result<T, E> permits Result.Ok, Result.Err {
record Ok<T, E>(T value) implements Result<T, E> {}
record Err<T, E>(E error) implements Result<T, E> {}
default <R> Result<R, E> map(java.util.function.Function<T, R> f) {
return switch (this) {
case Ok<T, E>(T v) -> new Ok<>(f.apply(v));
case Err<T, E>(E e) -> new Err<>(e);
};
}
default T orElse(T fallback) {
return switch (this) {
case Ok<T, E>(T v) -> v;
case Err<T, E> e -> fallback;
};
}
}
// 사용
Result<Integer, String> parse(String s) {
try { return new Result.Ok<>(Integer.parseInt(s)); }
catch (NumberFormatException e) { return new Result.Err<>(e.getMessage()); }
}
// 체이닝
int value = parse("42").map(x -> x * 2).orElse(0); // 84과거 Java에선 Optional을 확장하거나 Vavr 라이브러리에 의존해야 했던 패턴이, 이제 언어 기본 기능만으로 표현된다.
7. 실전 베스트 프랙티스
(1) 도메인 경계엔 Records + Sealed 우선
외부 시스템과의 경계(API 응답, 메시지 페이로드, 이벤트)는 반드시 Records + Sealed로 모델링하라. 불변성 + 완전성 검사가 공짜로 따라온다.
(2) switch pattern은 default를 넣지 마라
sealed 타입에 대해서는 default:를 절대 추가하지 말 것. 새 서브타입이 추가될 때 컴파일러가 알려주는 안전망을 잃는다. 정말 필요하면 case null -> ...만 명시적으로 처리한다.
(3) when guard는 최소화
복잡한 when 체인이 나오면 이미 sealed 분리가 부족한 설계 냄새다. Declined를 DeclinedSoft·DeclinedHard로 분리하면 guard가 사라진다.
(4) Record Pattern의 var는 아끼지 마라
관심 없는 필드는 var로 두면 패턴이 짧고 읽힌다. 다만 타입이 의미 있는 문서 역할을 한다면 명시하라.
8. 마이그레이션 체크리스트
- DTO 중 필드 변경이 거의 없고 setter가 필요 없는 클래스는 전부 Record 후보다.
enum + switch로 분기하던 상태/이벤트는 sealed interface + records로 올려라. 각 케이스가 고유 필드를 가질 수 있게 된다.Visitor패턴으로 작성된 코드는 Switch Pattern으로 90% 이상 대체된다. 새 서브타입 추가 시 visitor 인터페이스 전체 수정이 필요했던 불편이 사라진다.- Java 26 Primitive Pattern은 아직 preview이므로 프로덕션은 Java 21 LTS 또는 다가오는 Java 27 LTS(2027년 9월 예상)까지 정식화를 기다리는 편이 안전하다.
맺으며
Records·Sealed Classes·Switch Pattern은 개별로 보면 문법 설탕처럼 보이지만, 세 기능이 만나는 순간 Java는 ADT(Algebraic Data Type) 기반 언어로 변신한다. 분기가 도메인 그 자체가 되고, 컴파일러가 모든 경우의 수를 증명해준다. Java 26의 Primitive Pattern까지 더해지면 이제 Java 코드에서 instanceof와 타입 캐스팅은 레거시의 흔적으로 남을 것이다.
지금 진행 중인 프로젝트의 도메인 모델 한 개만 Records + Sealed로 리팩토링해보라. NullPointerException과 분기 누락 버그가 얼마나 줄어드는지 즉시 체감할 수 있다.