패턴 매칭과 데이터 분기
instanceof 패턴, switch의 타입 패턴과 when 가드, record 패턴으로 값의 타입을 확인하고 구성 요소를 꺼내는 방법과 null·case 순서·빠짐없는 분기의 규칙을 배웁니다.
Object나 인터페이스 타입으로 값을 받은 코드는 실제 타입에 따라 일을 나눠야 할 때가 많습니다. 패턴 매칭이 없던 시절에는 instanceof로 타입을 묻고, 캐스팅하고, 읽기 메서드로 값을 하나씩 꺼내는 세 단계를 매번 손으로 적었습니다. 이 단계들은 서로 어긋나기 쉽습니다. String인지 확인해 놓고 Integer로 캐스팅하는 실수도 컴파일러가 막아 주지 못했습니다. 패턴(pattern)은 '이 타입이면, 이 이름으로 꺼내라'를 한 표현으로 적게 해서 검사와 캐스팅, 값 꺼내기를 한 번에 처리합니다.
instanceof와 오버라이딩에서는 instanceof 패턴의 기본을 보았고, sealed 계층과 상태 분기에서는 case Saved saved ->처럼 switch에 타입 패턴을 써 왔습니다. 이 글은 그 규칙을 정확히 정리합니다. 패턴 변수를 쓸 수 있는 범위, switch에 null이 들어올 때의 동작, when 가드와 case의 순서, record의 구성 요소를 한 번에 꺼내는 record 패턴을 차례로 살펴봅니다. instanceof 패턴은 Java 16(JEP 394), switch의 패턴 매칭과 record 패턴은 Java 21(JEP 441, JEP 440)에 정식 기능이 되었으므로, 이 글의 예제는 JDK 25에서 아무 옵션 없이 컴파일됩니다.
instanceof 패턴 변수를 쓸 수 있는 곳
value instanceof String text는 value가 String이면 true가 되면서 text라는 패턴 변수(pattern variable)에 캐스팅된 값을 담습니다. 이 변수는 선언된 줄 아래 어디서나 쓸 수 있는 것이 아니라, 컴파일러가 '여기에 오면 반드시 검사가 성공했다'고 확신할 수 있는 곳에서만 쓸 수 있습니다. 다음 프로그램의 두 메서드는 그 범위를 이용하는 두 가지 대표적인 방식입니다.
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));
}
}main은 두 메서드에 검사가 성공하는 값과 실패하는 값을 하나씩 넘깁니다. positiveInteger(-1)은 Integer이긴 하지만 && 뒤의 조건에서 걸린다는 점을 생각하며 출력을 보세요.
4
0
positive:7
not-positive-integertextLength는 검사가 실패하면 먼저 return 0으로 빠져나갑니다. 그래서 if 아래로 내려왔다면 value는 반드시 String이고, 컴파일러는 그 아래에서 text를 쓰도록 허락합니다. 첫 줄의 4는 " java "의 앞뒤 공백을 지운 "java"의 길이이고, 둘째 줄의 0은 25가 Integer라서 검사에 실패한 결과입니다.
positiveInteger는 &&를 이용합니다. &&의 오른쪽은 왼쪽이 true일 때만 평가되므로, number > 0을 평가하는 시점에는 패턴이 이미 성공해 number가 준비되어 있습니다. 7은 두 조건을 모두 만족하고, -1은 Integer이지만 양수가 아니라서 마지막 줄이 나옵니다. 반대로 ||를 쓰면 오른쪽은 왼쪽이 false일 때, 즉 패턴이 실패했을 때 평가됩니다. 그래서 value instanceof String text || text.isBlank()라고 쓰면 javac가 cannot find symbol 오류를 냅니다. 이처럼 패턴 변수의 범위는 선언 위치가 아니라 제어 흐름으로 정해지며, 이를 흐름 범위(flow scoping)라고 부릅니다.
switch에 null이 들어오면
switch의 case에도 타입 패턴을 쓸 수 있습니다. 그런데 switch는 null을 instanceof와 다르게 다룹니다. instanceof는 null에 그냥 false를 돌려주지만, switch는 case null이 없으면 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));
}
}JDK 21에서 실행한 결과는 다음과 같습니다. 스택 트레이스의 Objects.java 줄 번호는 JDK 버전에 따라 다를 수 있습니다.
Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:233)
at NullPatternSwitchFailure.describe(NullPatternSwitchFailure.java:3)
at NullPatternSwitchFailure.main(NullPatternSwitchFailure.java:11)default까지 가지도 못하고 switch 줄(3번 줄)에서 예외가 났습니다. default는 '나열하지 않은 나머지 값'을 뜻할 뿐 null을 포함하지 않기 때문입니다. 트레이스 맨 위에 Objects.requireNonNull이 보이는 것은 JDK 21의 javac가 case null이 없는 패턴 switch 앞에 null 검사 코드를 넣기 때문입니다. Java 21 이전부터 있던 문자열 switch나 enum switch도 null이 들어오면 같은 예외를 던졌으므로, 이 동작은 예전 규칙과 맞춰져 있습니다.
null을 어떻게 다룰지는 먼저 그 null이 무엇을 뜻하는지 정한 뒤에 고릅니다. '값이 아직 없음'처럼 정상적으로 일어날 수 있는 경우라면 case null로 그 뜻을 드러냅니다.
public final class NullPatternSwitchFixed {
static String describe(Object value) {
return switch (value) {
case null -> "missing";
case String text -> "text:" + text;
case Integer number -> "number:" + number;
default -> "other";
};
}
public static void main(String[] args) {
System.out.println(describe(null));
System.out.println(describe("java"));
System.out.println(describe(3.5));
}
}세 호출은 차례로 null, String, 어느 타입 패턴에도 맞지 않는 Double을 넘깁니다. 세 값이 각각 어느 case로 가는지 먼저 예상해 보세요.
missing
text:java
othernull은 case null로, "java"는 String 패턴으로, Double인 3.5는 나머지를 받는 default로 갑니다. null을 나머지 값과 똑같이 처리하고 싶다면 case null, default -> "other"처럼 둘을 한 줄에 묶을 수 있습니다. 반대로 null이 들어오는 것 자체가 버그라면 case null을 쓰지 말고, 옵션에 맞는 구현 고르기와 객체지향 설계 정리에서 본 것처럼 메서드 첫머리에서 Objects.requireNonNull(value, "value")로 거부하는 편이 낫습니다. 어느 쪽이든 아무 뜻 없는 default에 null을 섞어 버그를 덮는 것만은 피해야 합니다.
when 가드와 case의 순서
case 뒤에 when과 조건식을 붙이면, 타입이 맞고 조건까지 true일 때만 그 case가 선택됩니다. 이 조건을 가드(guard)라고 합니다. case는 위에서 아래로 차례로 검사되므로, 같은 타입에 대해서는 좁은 조건의 case를 위에, 조건 없는 case를 아래에 둡니다. 다음 프로그램은 게시판 명령을 검사해 결과 문자열을 돌려줍니다.
public final class GuardedBoardCommandSwitch {
private sealed interface Command permits AddViews, RenamePost, ResetDay {}
private record AddViews(int viewCount) implements Command {}
private record RenamePost(String title) implements Command {}
private record ResetDay() implements Command {}
static String validate(Command command) {
return switch (command) {
case AddViews add when add.viewCount() <= 0 -> "invalid-view-count";
case AddViews add when add.viewCount() > 240 -> "popular";
case AddViews add -> "add-views:" + add.viewCount();
case RenamePost rename when rename.title().isBlank() -> "blank-title";
case RenamePost rename -> "rename:" + rename.title().trim();
case ResetDay ignored -> "reset";
};
}
public static void main(String[] args) {
System.out.println(validate(new AddViews(-1)));
System.out.println(validate(new AddViews(45)));
System.out.println(validate(new AddViews(241)));
System.out.println(validate(new RenamePost(" record ")));
System.out.println(validate(new ResetDay()));
}
}가드는 타입 패턴이 맞은 뒤에만 검사되므로, 가드 안에서 add.viewCount()처럼 패턴 변수를 바로 쓸 수 있습니다. main은 AddViews에 0 이하의 값, 보통 값, 240을 넘는 값을 차례로 넣습니다.
invalid-view-count
add-views:45
popular
rename:record
resetAddViews(-1)은 첫 case의 가드 <= 0을 만족해 invalid-view-count가 됩니다. AddViews(45)는 첫 두 가드를 모두 통과하지 못하고 조건 없는 셋째 case에 도착합니다. 241은 둘째 가드 > 240을 만족해 popular이고, 240이었다면 > 240이 거짓이라 add-views:240이 되었을 것입니다. 셋째 case까지 내려왔다는 사실 자체가 '0 이하도 아니고 240 초과도 아니다'라는 뜻이 되는 셈입니다. RenamePost(" record ")는 공백만 있는 제목이 아니므로 둘째 RenamePost 분기에서 공백이 지워집니다.
순서를 거꾸로 두면 어떻게 될까요? 조건 없는 case AddViews add ->를 가드가 있는 case AddViews add when ...보다 위에 두면, 아래쪽 case는 절대 선택될 수 없습니다. 위의 case가 모든 AddViews를 먼저 가져가기 때문입니다. javac는 이런 코드를 this case label is dominated by a preceding case label 오류로 거부합니다. 앞의 case가 뒤의 case를 지배한다(dominate)는 뜻입니다. 같은 이유로 case Object o를 case String s보다 위에 둘 수도 없습니다.
다만 컴파일러가 가드끼리의 논리 관계까지 따지지는 않습니다. when add.viewCount() > 0 아래에 when add.viewCount() > 10을 두면 아래쪽은 절대 선택되지 않지만 오류 없이 컴파일됩니다. 또 가드가 붙은 case는 빠짐없는 switch를 판단할 때 '이 타입을 다 처리했다'고 인정받지 못합니다. 앞의 switch에서 조건 없는 case AddViews add ->를 지우면 the switch expression does not cover all possible input values 오류가 납니다. 두 가드 <= 0과 > 240이 모든 정수를 덮지 못하기 때문만은 아닙니다. 가드가 모든 경우를 덮더라도 컴파일러는 그 사실을 계산하지 않으므로, 타입마다 조건 없는 case를 하나 두는 것이 보통입니다.
가드에는 어떤 boolean 식이든 쓸 수 있지만 값을 바꾸는 코드는 넣지 않습니다. 가드가 평가되는 횟수와 순서에 결과가 달라지는 코드는 case의 순서를 바꾸는 작은 수정에도 동작이 바뀝니다. 그리고 빠짐없는 타입 분기가 구성 요소의 값까지 검사해 주지는 않습니다. validate(new RenamePost(null))은 타입은 맞지만 가드의 title().isBlank()에서 NullPointerException이 납니다. null 제목을 막는 일은 record 심화와 불변 스냅샷에서 본 컴팩트 생성자의 몫입니다.
record 패턴으로 구성 요소 꺼내기
타입 패턴은 값을 캐스팅해서 변수에 담아 줄 뿐이라, 구성 요소는 add.viewCount()처럼 읽기 메서드로 다시 꺼내야 했습니다. record 패턴(record pattern)은 record의 모양을 그대로 적어서 구성 요소를 바로 변수에 담습니다. case AddViews(int viewCount) ->라고 쓰면 값이 AddViews인지 확인하고, 그 구성 요소를 viewCount라는 변수에 담는 일을 한 번에 합니다. 구성 요소 자리에는 다시 record 패턴을 넣을 수 있어서, record 안의 record까지 한 번에 풀어낼 수 있습니다.
public final class NestedRecordPattern {
private record User(long id, String name) {}
private record PostMetric(User user, String title, int viewCount) {}
static String summary(Object value) {
return switch (value) {
case PostMetric(User(long id, String name), String title, int viewCount)
when viewCount >= 60 ->
"long:" + id + ":" + name + ":" + title;
case PostMetric(User ignored, String title, int viewCount) ->
"post:" + title + ":" + viewCount;
case null -> "missing";
default -> "unsupported";
};
}
public static void main(String[] args) {
System.out.println(summary(new PostMetric(new User(7, "min"), "patterns", 75)));
System.out.println(summary(new PostMetric(new User(8, "seo"), "record", 30)));
System.out.println(summary(null));
}
}summary는 Object를 받으므로, record 패턴 두 개 외에 case null과 default도 둡니다. 첫 두 호출은 조회 수가 60 이상인 값과 미만인 값이고, 셋째 호출은 null입니다.
long:7:min:patterns
post:record:30
missing첫 case는 PostMetric의 첫 구성 요소를 다시 User(long id, String name)로 풀어서, 작성자 번호와 이름, 제목, 조회 수를 네 변수에 한 번에 담습니다. 조회 수 75는 가드 >= 60을 만족하므로 첫 줄이 나옵니다. 둘째 값은 조회 수 30이 가드를 통과하지 못해 둘째 case로 가고, 셋째 줄은 case null의 결과입니다. 구성 요소의 타입을 일일이 적기 번거롭다면 PostMetric(var user, var title, var viewCount)처럼 var를 써도 됩니다. 이때 각 변수의 타입은 구성 요소의 타입으로 정해집니다. 겉모습은 달라도 record 패턴은 내부에서 읽기 메서드를 호출해 구성 요소를 꺼냅니다. 그래서 읽기 메서드를 직접 정의해 두었다면 패턴도 그 메서드의 결과를 받습니다.
null의 위치에 따라 달라지는 패턴 분기
null의 위치와 패턴 종류를 구분
null의 위치에 따라 달라지는 패턴 분기
최상위 switch의 null 입력과 record 구성 요소의 null은 다릅니다. 가드가 null 제목을 역참조하면 예외가 납니다.
상위 값 null·case null 없음
- 결과
- NullPointerException
- 이유
- default는 null 입력을 받지 않음
상위 값 null·case null 있음
- 결과
- missing
- 이유
- NestedRecordPattern의 명시적 null 분기
게시글의 user만 null
- 결과
- post:title:75
- 이유
- 중첩 User(...)는 불일치; 다음 User ignored는 null도 받음
이름 변경 명령의 title null
- 결과
- NullPointerException
- 이유
- 가드의 title().isBlank()가 null을 역참조
첫 두 행은 원문 main의 경로입니다. 마지막 두 행은 PostMetric(null, "title", 75)와 RenamePost(null)를 넣는다고 가정한 소스·언어 규칙 비교이며 새 실행 관측이 아닙니다.
record 패턴을 쓸 때는 null이 어디에 있는지를 구분해야 합니다. 위 그림의 처음 두 행은 바깥 값 자체가 null인 경우입니다. case null이 없으면 앞 절에서 본 것처럼 NullPointerException이 나고, 있으면 missing이 됩니다. 구성 요소가 null인 경우는 규칙이 다릅니다. summary(new PostMetric(null, "title", 75))처럼 작성자만 null이면, 첫 case의 중첩 패턴 User(long id, String name)에는 맞지 않습니다. record 패턴은 null에 맞지 않기 때문입니다. 하지만 둘째 case의 User ignored처럼 그 자리의 타입 전체를 덮는 타입 패턴은 구성 요소 자리의 null도 받아들이므로, 결과는 post:title:75입니다. main에 이 줄을 추가해 직접 확인해 보세요.
그림의 마지막 행은 앞 절의 RenamePost(null)입니다. 타입 패턴은 맞았지만 가드가 null 제목의 메서드를 호출해 예외가 나는 경우로, 패턴이 맞는 것과 구성 요소의 값이 올바른 것은 별개라는 점을 다시 보여 줍니다. 중첩 패턴은 데이터의 모양을 한눈에 보여 주지만 세 단계 이상 깊어지면 오히려 읽기 어려워집니다. 그럴 때는 안쪽을 풀어내는 부분을 이름 있는 메서드로 옮깁니다.
봉인된 계층과 record 패턴을 함께 쓰기
record 패턴은 봉인된 계층과 함께 쓸 때 가장 간결해집니다. 각 case가 타입 확인과 구성 요소 꺼내기를 한 번에 하고, 컴파일러는 모든 종류를 다뤘는지 검사합니다. 다음 프로그램은 게시글 이벤트를 감사 로그 한 줄로 바꿉니다.
import java.util.List;
public final class PatternBasedBoardEventProjection {
private sealed interface Event permits Started, Published, Renamed {}
private record Started(long id, String title) implements Event {}
private record Published(long id, int viewCount) 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 title) -> "START " + id + " " + title;
case Published(long id, int viewCount) -> "PUBLISH " + id + " " + viewCount;
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 Published(1, 55));
events.stream()
.map(PatternBasedBoardEventProjection::auditLine)
.forEach(System.out::println);
}
}main은 이벤트 목록을 스트림으로 돌며 auditLine이 만든 문자열을 한 줄씩 찍습니다. Renamed는 구성 요소가 셋이라 패턴에도 변수 셋을 적었습니다.
START 1 switch
RENAME 1 switch->patterns
PUBLISH 1 55세 case의 record 패턴은 가드도 default도 없이 Event의 허용 타입 셋을 하나씩 덮으므로 빠짐없는 switch가 됩니다. 출력은 목록에 넣은 순서대로, 각 이벤트가 자기 case에서 꺼낸 구성 요소로 만든 줄입니다. 나중에 Deleted 이벤트를 허용 목록에 추가하면, 앞 글에서 본 것처럼 이 switch가 컴파일 오류로 알려 줍니다.
이런 switch는 이벤트의 데이터를 바깥에서 여러 형태로 바꾸는 일에 잘 맞습니다. 감사 로그, 화면 표시, 통계처럼 같은 이벤트로 하는 일이 계속 늘어나는 경우입니다. 반면 각 타입이 자기 동작을 가져야 하고 새 타입이 자주 늘어난다면 sealed 계층과 상태 분기의 비교표에서 본 것처럼 다형성이 낫습니다. 또 record 패턴으로 꺼낼 수 있는 것은 record가 공개한 구성 요소뿐입니다. 숨겨야 할 내부 상태가 있는 객체를 억지로 record로 바꿔 패턴으로 풀어내면 캡슐화가 약해지므로, 패턴은 원래 값을 그대로 드러내는 데이터 타입에 씁니다.
더 깊이: Java 22의 이름 없는 패턴과 그 뒤의 프리뷰 기능
이 글과 앞 글의 예제에는 쓰지 않는 패턴 변수에 ignored라는 이름을 붙인 곳이 많습니다. Java 22에서 정식 기능이 된 이름 없는 변수와 패턴(JEP 456)을 쓰면 이런 자리를 밑줄 _로 비워 둘 수 있습니다. case Saved _ -> 201처럼 타입 패턴의 변수 이름 대신 쓸 수도 있고, record 패턴 안에서는 _만 적어 그 구성 요소를 아예 무시할 수도 있습니다. 이 기능은 Java 21에서 프리뷰(JEP 443)로 처음 나왔고 Java 22에서 바뀐 내용 없이 정식이 되었습니다. 따라서 JDK 25에서는 다음 코드가 아무 옵션 없이 컴파일됩니다.
public final class UnnamedPatterns {
private record User(long id, String name) {}
private record PostMetric(User user, String title, int viewCount) {}
static String summary(PostMetric metric) {
return switch (metric) {
case PostMetric(User(long id, _), String title, int viewCount)
when viewCount >= 60 -> "long:" + id + ":" + title;
case PostMetric(_, String title, _) -> "post:" + title;
};
}
public static void main(String[] args) {
System.out.println(summary(new PostMetric(new User(7, "min"), "patterns", 75)));
System.out.println(summary(new PostMetric(null, "record", 30)));
}
}둘째 호출은 작성자 자리에 null을 넘깁니다. record 패턴 User(long id, _)는 null과 맞지 않는다는 점을 생각하며 출력을 보세요.
long:7:patterns
post:record첫 case는 작성자 이름을 쓰지 않으므로 그 자리를 _로 비웠고, 둘째 case는 제목만 필요하므로 작성자와 조회 수를 모두 _로 무시합니다. _는 그 자리의 무엇에나 맞으므로 둘째 줄처럼 작성자가 null이어도 둘째 case가 선택됩니다. 이 코드를 Java 21로 컴파일하려면 --enable-preview --release 21 옵션이 필요합니다.
패턴 매칭은 그 뒤로도 확장되고 있습니다. Java 21의 record 패턴에서는 int viewCount처럼 구성 요소 타입과 정확히 같은 기본형 패턴만 쓸 수 있습니다. 모든 패턴 자리에 기본형 패턴을 쓰고 switch에서 long이나 double 값을 패턴으로 나누는 기능은 JDK 25에서도 아직 세 번째 프리뷰입니다(JEP 507). 프리뷰 기능은 --enable-preview 옵션 없이는 컴파일되지 않고 다음 버전에서 바뀔 수 있으므로, 이 교재는 기본형 패턴과 switch를 비롯한 37장에서 따로 다룹니다. 정식 기능만 쓰는 코드에 습관처럼 프리뷰 옵션을 붙여 두면 어떤 코드가 프리뷰에 기대는지 알아보기 어려워지므로, 두 종류의 코드는 나누어 관리하는 것이 좋습니다.
정리
instanceof패턴 변수는 검사가 반드시 성공한 흐름에서만 쓸 수 있습니다.&&의 오른쪽이나 실패 시 먼저return한 뒤의 코드가 그런 곳이고,||의 오른쪽은 아닙니다.switch는case null이 없으면null에서NullPointerException을 던지며,default는null을 받지 않습니다.null이 정상적인 값이면case null로, 버그라면Objects.requireNonNull로 다룹니다.case는 위에서 아래로 검사되므로 좁은 가드를 위에 둡니다. 앞의case가 뒤의case를 지배하면 컴파일 오류가 나지만, 가드끼리의 논리 관계는 검사하지 않습니다. 가드가 붙은case는 빠짐없음 판단에서 그 타입을 다 처리한 것으로 인정받지 못합니다.- record 패턴은 타입 확인과 구성 요소 꺼내기를 한 번에 하고, 중첩해서 쓸 수 있습니다. record 패턴은
null에 맞지 않지만, 구성 요소 자리의 타입 전체를 덮는 타입 패턴은 그 자리의null도 받습니다. - 봉인된 계층과 record 패턴을 함께 쓰면 가드와
default없이도 빠짐없는switch를 만들 수 있습니다.
연습 문제
- 조회 수 목표
ViewGoal과 게시글 수 목표PostCountGoal을 봉인된 인터페이스Goal의 record로 만들고, 달성했으면views-done이나posts-done을, 아니면 남은 수를 돌려주는message를 record 패턴과 가드로 작성하세요. 조회 수 목표는 60회에 현재 45회, 게시글 수 목표는 3개에 현재 3개입니다. 현재 조회 수가 60회와 61회일 때의 결과도 함께 예상하세요. - 다음
switch는 컴파일될까요? 안 된다면 어떤 오류가 나고 어떻게 고쳐야 할까요?
return switch (command) {
case AddViews add -> "add-views:" + add.viewCount();
case AddViews add when add.viewCount() <= 0 -> "invalid-view-count";
case RenamePost rename -> "rename:" + rename.title().trim();
case ResetDay ignored -> "reset";
};- 이 글의
NestedRecordPattern.summary에new PostMetric(new User(1, null), null, 75)와"text"를 넘기면 각각 무엇이 출력될지 예상하세요.
힌트
- 같은 타입에 대해 가드가 있는
case와 없는case를 하나씩 둡니다. 가드가 없는case에 도착했다면 아직 달성하지 못한 것입니다. - 첫
case가 이미 모든AddViews를 가져갑니다. - 구성 요소 자리의
String name과String title은String전체를 덮는 타입 패턴입니다.
해설
1. 목표 달성 메시지
목표마다 가드가 있는 case로 달성한 경우를 먼저 처리하고, 가드가 없는 case에서 남은 수를 계산합니다. main의 마지막 두 줄은 문제에서 함께 예상해 보라고 한 60회와 61회입니다.
public final class PatternGoalMessageSolution {
private sealed interface Goal permits ViewGoal, PostCountGoal {}
private record ViewGoal(int target, int actual) implements Goal {}
private record PostCountGoal(int target, int actual) implements Goal {}
static String message(Goal goal) {
return switch (goal) {
case ViewGoal(int target, int actual) when actual >= target -> "views-done";
case ViewGoal(int target, int actual) -> "views-left=" + (target - actual);
case PostCountGoal(int target, int actual) when actual >= target -> "posts-done";
case PostCountGoal(int target, int actual) -> "posts-left=" + (target - actual);
};
}
public static void main(String[] args) {
System.out.println(message(new ViewGoal(60, 45)));
System.out.println(message(new PostCountGoal(3, 3)));
System.out.println(message(new ViewGoal(60, 60)));
System.out.println(message(new ViewGoal(60, 61)));
}
}ViewGoal과 PostCountGoal은 구성 요소의 모양이 같지만 서로 다른 타입이라서, switch는 타입마다 두 case를 따로 둡니다. 출력의 각 줄은 main의 네 호출에 차례로 대응합니다.
views-left=15
posts-done
views-done
views-done45회는 가드 actual >= target을 만족하지 못해 둘째 case에서 60 - 45 = 15를 계산합니다. 게시글 수 3개는 목표와 같아 posts-done입니다. 60회와 61회는 모두 가드를 만족해 views-done이 되므로, 이미 넘어선 값에서 views-left=-1 같은 음수가 나오지 않습니다. 남은 수를 계산하는 case에는 '아직 미달'인 값만 도착하기 때문입니다. 이 switch는 default 없이 허용된 두 타입을 모두 다루지만, message(null)이나 음수 목표처럼 잘못된 값까지 막아 주지는 않습니다. 그런 값은 record의 컴팩트 생성자에서 검사해야 합니다.
2. 지배당한 case
컴파일되지 않습니다. 첫 case AddViews add ->에는 가드가 없어서 모든 AddViews가 여기서 선택되므로, 둘째 case는 절대 선택될 수 없습니다. javac는 둘째 case에 this case label is dominated by a preceding case label 오류를 냅니다. 고치려면 가드가 있는 case AddViews add when add.viewCount() <= 0을 위로, 가드가 없는 case AddViews add를 아래로 옮깁니다. 그러면 0 이하인 값은 첫 case가, 나머지는 둘째 case가 받습니다.
3. 구성 요소가 null인 PostMetric
new PostMetric(new User(1, null), null, 75)는 long:1:null:null을 출력합니다. 첫 case의 중첩 패턴 User(long id, String name)에서 작성자 User(1, null)은 null이 아니므로 record 패턴에 맞고, 그 안의 이름 자리 String name은 String 전체를 덮는 타입 패턴이라 null도 받습니다. 제목 자리의 String title도 같은 이유로 null을 받고, 조회 수 75는 가드를 만족합니다. 그래서 문자열을 이어 붙일 때 null이 그대로 "null"로 찍힙니다.
"text"는 PostMetric이 아니므로 두 record 패턴에 모두 맞지 않고, null도 아니므로 default로 가서 unsupported를 출력합니다. 패턴이 구성 요소의 null을 받아 준다고 해서 그 값이 올바른 것은 아니라는 점을 첫 결과가 보여 줍니다. 이름이나 제목이 반드시 있어야 한다면 그 검사는 record를 만들 때 해야 합니다.
다음 글
모듈 시스템과 ServiceLoader에서는 클래스를 넘어 패키지 묶음 단위로 무엇을 공개하고 무엇을 감출지 정하는 Java 플랫폼 모듈 시스템을 다룹니다. 앞 글의 봉인된 계층이 '구현할 수 있는 타입'을 닫았다면, 모듈은 '접근할 수 있는 패키지'를 닫습니다.