패턴 매칭과 데이터 분기
Java 25에서 프리뷰 없이 쓰는 `instanceof`·`switch`·`record` 패턴을 실행하고 `null`, 가드, dominance와 빠짐없는 분기 규칙을 다룹니다.
instanceof 패턴은 Java 16, switch와 record 패턴은 Java 21에서 정식 기능이 됐습니다.
Java 25에서는 --enable-preview 없이 컴파일·실행할 수 있습니다.
패턴은 타입 검사와 변수 바인딩, record 구성 요소 분해를 한 표현에 묶어 반복적인 형변환 코드를 줄입니다.
핵심은 문법 축약이 아니라 입력 공간을 타입과 조건으로 빠짐없이 나누는 것입니다.
참조형 switch의 null 처리
레거시 API가 null을 반환했는데 switch에 case null이 없으면 실제 NullPointerException이 발생합니다.
default가 null까지 잡는다고 가정하면 안 됩니다.
public final class NullPatternSwitchFailure {
static String describe(Object value) {
return switch (value) {
case String text -> "text:" + text;
case Integer number -> "number:" + number;
default -> "other";
};
}
public static void main(String[] args) {
System.out.println(describe(null));
}
}원칙은 null이 도메인의 유효한 입력인지 경계에서 발생한 오류인지 결정하는 것입니다.
유효하다면 case null로 의미를 명시하고, 허용하지 않는다면 Objects.requireNonNull로 메서드 시작에서 규칙을 알립니다.
null을 아무 의미 없는 default에 섞지 않습니다.
instanceof 패턴의 범위
패턴 변수는 컴파일러가 패턴 일치를 확신할 수 있는 제어 흐름 범위에서만 사용할 수 있습니다.
가드 절에서 false 경로를 먼저 반환하면 뒤의 코드에서 바인딩한 변수를 사용할 수 있습니다.
public final class InstanceofPatternScope {
static int textLength(Object value) {
if (!(value instanceof String text)) {
return 0;
}
return text.trim().length();
}
static String positiveInteger(Object value) {
if (value instanceof Integer number && number > 0) {
return "positive:" + number;
}
return "not-positive-integer";
}
public static void main(String[] args) {
System.out.println(textLength(" java "));
System.out.println(textLength(25));
System.out.println(positiveInteger(7));
System.out.println(positiveInteger(-1));
}
}&&의 오른쪽은 패턴이 일치했을 때만 평가되므로 바인딩한 변수를 사용할 수 있습니다.
||의 오른쪽에는 패턴이 일치하지 않은 경로도 들어올 수 있어 같은 방식으로 사용할 수 없습니다.
switch 패턴과 when 가드
case는 위에서 아래로 검사되므로 구체적인 가드가 있는 패턴을 일반 패턴보다 먼저 둡니다.
앞의 case가 뒤의 case를 완전히 포함하면 컴파일러가 지배 관계 오류로 막습니다.
public final class GuardedStudyCommandSwitch {
private sealed interface Command permits AddMinutes, RenameTopic, ResetDay {}
private record AddMinutes(int minutes) implements Command {}
private record RenameTopic(String topic) implements Command {}
private record ResetDay() implements Command {}
static String validate(Command command) {
return switch (command) {
case AddMinutes add when add.minutes() <= 0 -> "invalid-minutes";
case AddMinutes add when add.minutes() > 240 -> "too-long";
case AddMinutes add -> "add:" + add.minutes();
case RenameTopic rename when rename.topic().isBlank() -> "blank-topic";
case RenameTopic rename -> "rename:" + rename.topic().trim();
case ResetDay ignored -> "reset";
};
}
public static void main(String[] args) {
System.out.println(validate(new AddMinutes(-1)));
System.out.println(validate(new AddMinutes(45)));
System.out.println(validate(new RenameTopic(" record ")));
System.out.println(validate(new ResetDay()));
}
}가드에는 임의의 boolean 조건을 사용할 수 있지만 부수 효과는 넣지 않습니다.
분기 결과가 조건식의 평가 순서에 의존하면 리팩터링이 위험해집니다.
record 패턴과 중첩 값
record 접근자를 반복 호출하지 않고 구성 요소 구조를 패턴에 적습니다.
중첩 record 패턴은 데이터 형태를 한눈에 보여 주지만 너무 깊다면 이름 있는 메서드로 추출합니다.
public final class NestedRecordPattern {
private record User(long id, String name) {}
private record StudySession(User user, String topic, int minutes) {}
static String summary(Object value) {
return switch (value) {
case StudySession(User(long id, String name), String topic, int minutes)
when minutes >= 60 ->
"long:" + id + ":" + name + ":" + topic;
case StudySession(User ignored, String topic, int minutes) ->
"session:" + topic + ":" + minutes;
case null -> "missing";
default -> "unsupported";
};
}
public static void main(String[] args) {
System.out.println(summary(new StudySession(new User(7, "min"), "patterns", 75)));
System.out.println(summary(new StudySession(new User(8, "seo"), "record", 30)));
System.out.println(summary(null));
}
}패턴 switch와 다형성
외부에서 여러 연산을 추가하는 닫힘 데이터 계층은 switch가 유리합니다.
각 하위 타입이 동작을 소유하고 새로운 하위 타입 확장이 잦으면 다형적 메서드가 낫습니다.
패턴 switch로 객체 내부 private 상태를 과도하게 해체하면 캡슐화가 약해집니다.
import java.util.List;
public final class PatternBasedStudyEventProjection {
private sealed interface Event permits Started, Completed, Renamed {}
private record Started(long id, String topic) implements Event {}
private record Completed(long id, int minutes) implements Event {}
private record Renamed(long id, String before, String after) implements Event {}
static String auditLine(Event event) {
return switch (event) {
case Started(long id, String topic) -> "START " + id + " " + topic;
case Completed(long id, int minutes) -> "COMPLETE " + id + " " + minutes;
case Renamed(long id, String before, String after) ->
"RENAME " + id + " " + before + "->" + after;
};
}
public static void main(String[] args) {
List<Event> events =
List.of(
new Started(1, "switch"),
new Renamed(1, "switch", "patterns"),
new Completed(1, 55));
events.stream()
.map(PatternBasedStudyEventProjection::auditLine)
.forEach(System.out::println);
}
}안정 기능과 프리뷰 구분
이 문서의 instanceof 패턴, 패턴 switch, when 가드, record 패턴은 JDK 25 기본 javac로 컴파일됩니다.
프리뷰 실험은 다음 장에서 --enable-preview --release 25가 필요한 기능만 별도 소스 세트로 다룹니다.
안정 코드에 프리뷰 플래그를 습관적으로 붙이면 어떤 API가 운영 규칙인지 흐려집니다.
타입 case가 많으면 패턴 switch가 항상 좋은가요?
아닙니다.
열린 계층이라면 하위 타입을 추가할 때 기존 switch가 새 타입을 모를 수 있습니다.
닫힌 값 계층과 외부 투영에는 잘 맞지만, 각 하위 타입이 동작을 소유해야 한다면 다형성이 더 응집력 있습니다.
변화 축과 모듈 소유권을 봅니다.
연습 문제
시간 목표와 횟수 목표 record를 sealed 계층으로 만들고, 완료 여부에 따른 메시지를 빠짐없이 반환하세요.
해설 보기
public final class PatternGoalMessageSolution {
private sealed interface Goal permits MinuteGoal, CountGoal {}
private record MinuteGoal(int target, int completed) implements Goal {}
private record CountGoal(int target, int completed) implements Goal {}
static String message(Goal goal) {
return switch (goal) {
case MinuteGoal(int target, int completed) when completed >= target -> "minutes-done";
case MinuteGoal(int target, int completed) -> "minutes-left=" + (target - completed);
case CountGoal(int target, int completed) when completed >= target -> "count-done";
case CountGoal(int target, int completed) -> "count-left=" + (target - completed);
};
}
public static void main(String[] args) {
System.out.println(message(new MinuteGoal(60, 45)));
System.out.println(message(new CountGoal(3, 3)));
}
}종료 기준은 minutes-left=15, count-done이며 default 없이 모든 하위 타입과 가드 대체 경로가 처리되는 것입니다.