기본형 패턴과 switch
JDK 25의 프리뷰 기능인 기본형 패턴으로 값이 정보 손실 없이 더 작은 타입에 들어가는지 검사하고, long·double·boolean 값으로 switch를 쓰는 방법을 배웁니다.
int 값을 byte 하나로 보내야 하는 상황을 생각해 봅시다. 변수 스코프와 숫자 형변환에서 본 것처럼 (byte) 1000은 오류 없이 -24가 되어 버리므로, 형변환하기 전에 값이 byte 범위에 드는지 먼저 확인해야 합니다. 그래서 지금까지는 다음처럼 범위 검사와 형변환을 따로 썼습니다.
if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) {
byte small = (byte) value;
// small 사용
}이 방식에는 두 가지 약점이 있습니다. 검사와 형변환이 떨어져 있어서 한쪽만 고치는 실수가 생기기 쉽고, double을 int로 바꾸는 경우처럼 범위 검사만으로는 부족한 경우가 있습니다. 소수부가 있는 값은 범위 안에 있어도 정확히 바뀌지 않고, NaN은 비교식을 어떻게 쓰느냐에 따라 범위 검사를 그대로 통과하기도 하기 때문입니다. 또 switch는 long, float, double, boolean 값을 고를 수 없어서, 이런 값으로 여러 갈래를 나누려면 if 문을 이어 써야 했습니다.
JDK 25의 기본형 패턴(JEP 507)은 이 두 가지를 함께 다룹니다. 패턴 매칭에서 기본형 타입을 쓸 수 있게 하고, instanceof와 switch가 모든 기본형을 다루도록 넓힙니다. JDK 23의 JEP 455에서 처음 프리뷰로 나왔고, JDK 24의 JEP 488에 이어 JDK 25의 JEP 507이 바뀐 내용 없이 세 번째 프리뷰입니다. 따라서 이 글의 소스는 모두 프리뷰 기능 사용법과 소스 분리에서 본 것처럼 --enable-preview로 컴파일하고 실행해야 하며, JDK 25에서만 컴파일됩니다. 출력은 JEP와 API 문서에 정해진 규칙을 기준으로 적었습니다.
instanceof로 정확한 변환 검사하기
기본형 패턴의 핵심 개념은 정확한 변환(exact conversion)입니다. 어떤 값을 다른 타입으로 바꿨을 때 정보가 사라지지도 않고 예외도 생기지 않으면 그 변환은 정확하다고 말합니다. int 값 10을 byte로 바꾸면 그대로 10이므로 정확하고, 1000을 byte로 바꾸면 -24가 되어 크기와 부호가 모두 달라지므로 정확하지 않습니다. x instanceof byte b는 바로 이것을 검사합니다. 지금 x에 든 값을 byte로 정확히 바꿀 수 있을 때만 일치하고, 그 경우에만 바뀐 값을 b에 담아 줍니다.
public final class ExactBytePatternProbe {
static String encode(int value) {
if (value instanceof byte exact) {
return "byte:" + exact;
}
return "int:" + value;
}
public static void main(String[] args) {
System.out.println(encode(127));
System.out.println(encode(128));
System.out.println(encode(-128));
System.out.println(encode(-129));
}
}javac --enable-preview --release 25 -Xlint:preview ExactBytePatternProbe.java
java --enable-preview ExactBytePatternProbemain은 byte 범위의 양 끝인 127과 -128, 그리고 그 바로 바깥의 128과 -129를 넣어 봅니다. 규칙대로라면 출력은 다음과 같습니다.
byte:127
int:128
byte:-128
int:-129원문 코드와 Java 25 명세에 따른 조건과 결과를 비교합니다.
Java 25 프리뷰 · byte 범위의 양쪽 경계
원문 네 정수의 byte 패턴 판정
byte가 정확히 담는 범위는 −128부터 127까지입니다. 범위 밖의 입력은 int 분기로 남아 원래 값을 유지합니다.
int 입력
127
- byte로 정확한 변환
- 가능
- 원문의 반환값
- byte:127
int 입력
128
- byte로 정확한 변환
- 불가능
- 원문의 반환값
- int:128
int 입력
-128
- byte로 정확한 변환
- 가능
- 원문의 반환값
- byte:-128
int 입력
-129
- byte로 정확한 변환
- 불가능
- 원문의 반환값
- int:-129
byte가 정확히 담을 수 있는 값은 -128부터 127까지입니다. 127과 -128은 이 범위의 양 끝이므로 패턴이 일치해 byte: 쪽으로 갑니다. 128을 byte로 형변환하면 -128이 되어 원래 값을 잃으므로 패턴이 일치하지 않고, -129도 마찬가지입니다. 일치하지 않은 값은 int 그대로 남아 원래 값이 출력됩니다.
value instanceof byte처럼 변수 없이 검사만 할 수도 있습니다. 하지만 변수를 붙인 형태를 쓰면 검사와 형변환을 한 번에 하므로, 앞에서 본 범위 검사 코드처럼 검사와 형변환이 어긋날 여지가 없습니다. 반대 방향인 byte에서 int로의 변환은 어떤 값이든 정보를 잃지 않으므로, 이런 패턴은 언제나 일치합니다.
먼저 형변환하면 패턴도 정보를 되살리지 못한다
기본형 패턴은 자기가 받은 값만 검사합니다. 그래서 패턴에 넘기기 전에 이미 형변환으로 정보를 잃었다면 패턴은 그 사실을 알 수 없습니다. 다음 코드는 큰 long 값을 먼저 int로 형변환한 뒤 패턴 switch로 분류합니다.
public final class PrimitiveNarrowingBeforePatternBug {
static String classify(long original) {
int narrowed = (int) original;
return switch (narrowed) {
case int value when value > 0 -> "positive:" + value;
case int value -> "other:" + value;
};
}
public static void main(String[] args) {
long original = 4_294_967_297L;
System.out.println("original=" + original);
System.out.println("wrong-positive=" + classify(original).substring("positive:".length()));
}
}main은 원래 값을 출력한 뒤, 분류 결과에서 positive: 뒤의 숫자만 잘라 출력합니다. 규칙대로라면 출력은 다음과 같습니다.
original=4294967297
wrong-positive=1원래 값 4,294,967,297은 2의 32제곱에 1을 더한 수입니다. (int) 형변환은 long의 64비트 가운데 아래쪽 32비트만 남기므로, 위쪽 비트에 있던 2의 32제곱이 사라지고 1만 남습니다. 그 뒤의 switch가 받는 값은 이미 1이므로 '양수'라고 판단합니다. 이 판단 자체는 틀리지 않았지만, 1은 원래 값과 전혀 다른 수입니다.
고치는 방법은 형변환을 하지 않고 원래의 넓은 타입을 그대로 switch에 넘기는 것입니다. 그러면 case int value가 '이 값이 int에 정확히 들어가는가'를 대신 검사해 주고, 들어가지 않는 값은 long 분기로 갑니다.
static String classify(long original) {
return switch (original) {
case int value when value > 0 -> "positive int:" + value;
case long value when value > 0 -> "positive long:" + value;
case long value -> "other:" + value;
};
}이렇게 바꾸면 classify(4_294_967_297L)은 int에 들어가지 않으므로 둘째 분기로 가서 positive long:4294967297을 돌려줍니다. 정보를 잃는 지점이 사라졌기 때문입니다.
long과 boolean 값으로 switch 쓰기
기본형 패턴은 switch가 고를 수 있는 값의 타입도 넓힙니다. 이제 long, float, double, boolean 값으로도 switch를 쓸 수 있습니다. 다음 예제는 long 값을 크기에 따라 나누고, boolean 값으로 회원과 손님을 가릅니다.
public final class ExpandedPrimitiveSwitch {
static String bucket(long value) {
return switch (value) {
case 0L -> "zero";
case 1L -> "one";
case long number when number > 1_000_000_000L -> "large";
case long number when number > 0 -> "positive";
case long ignored -> "negative";
};
}
static String access(boolean loggedIn) {
return switch (loggedIn) {
case true -> "member";
case false -> "guest";
};
}
public static void main(String[] args) {
System.out.println(bucket(0L));
System.out.println(bucket(5_000_000_000L));
System.out.println(bucket(-3L));
System.out.println(access(true));
System.out.println(access(false));
}
}main은 bucket에 0과 50억, -3을 넣고, access에 true와 false를 넣습니다. 규칙대로라면 출력은 다음과 같습니다.
zero
large
negative
member
guestbucket(0L)은 첫 상수 분기에서 zero가 됩니다. 50억은 상수 분기에 해당하지 않고 첫 번째 가드 number > 1_000_000_000L을 통과하므로 large이고, -3은 두 가드를 모두 통과하지 못해 마지막 분기의 negative가 됩니다. 마지막 case long ignored는 남은 모든 long 값을 받으므로 default가 없어도 switch가 모든 경우를 다룹니다.
case 상수는 선택자와 같은 타입이어야 하므로, long 값을 고를 때는 0L, 1L처럼 long 상수를 씁니다. boolean switch는 case true와 case false를 모두 적으면 모든 경우를 다룬 것으로 인정됩니다. 분기마다 여러 문장이 필요할 때는 삼항 연산자 대신 이렇게 switch 식으로 쓸 수 있습니다.
분기 순서에도 주의해야 합니다. 패턴 매칭과 데이터 분기에서 본 것처럼 switch는 위에서부터 차례로 맞춰 봅니다. 그래서 가드 없는 case long number를 가드가 있는 분기보다 위에 두면, 아래 분기에는 절대 도달할 수 없으므로 컴파일 오류가 납니다. 모든 값을 받는 분기는 언제나 맨 아래에 둡니다.
record 패턴 안의 기본형 성분
record 패턴 안에서도 같은 규칙이 적용됩니다. 다음 Measurement의 성분은 double이지만, 패턴에 int whole이라고 쓰면 그 double 값을 int로 정확히 바꿀 수 있을 때만 일치합니다.
public final class PrimitiveRecordPatternConversion {
private record Measurement(double value) {}
static String render(Measurement measurement) {
return switch (measurement) {
case Measurement(int whole) -> "whole:" + whole;
case Measurement(double decimal) -> "decimal:" + decimal;
};
}
public static void main(String[] args) {
System.out.println(render(new Measurement(25.0)));
System.out.println(render(new Measurement(25.5)));
System.out.println(render(new Measurement(3_000_000_000.0)));
}
}세 값은 각각 정수로 딱 떨어지는 값, 소수부가 있는 값, int 범위를 넘는 값입니다. 규칙대로라면 출력은 다음과 같습니다.
whole:25
decimal:25.5
decimal:3.0E925.0은 소수부가 없고 int 범위 안에 있으므로 int 25로 정확히 바뀌어 첫 분기로 갑니다. 25.5는 소수부를 잃게 되므로, 30억은 int의 최댓값 2,147,483,647보다 크므로 둘 다 두 번째 분기로 갑니다. 세 번째 줄이 3.0E9로 찍히는 것은 double을 문자열로 바꿀 때 큰 수를 지수 표기로 나타내기 때문입니다. 두 번째 분기의 Measurement(double decimal)은 모든 Measurement와 일치하므로 이 switch도 모든 경우를 다룹니다.
double을 int로 바꿀 수 있는지 판정하는 규칙
부동소수점 값을 정수 타입으로 바꿀 때는 범위만 볼 수 없습니다. 다음 프로그램은 까다로운 값들을 모아 int 패턴에 넣어 봅니다.
public final class PrimitivePatternBoundaryTable {
static String asInt(double value) {
if (value instanceof int exact) {
return "int:" + exact;
}
return "double:" + value;
}
public static void main(String[] args) {
double[] values = {
0.0,
-0.0,
42.0,
42.25,
Integer.MAX_VALUE,
(double) Integer.MAX_VALUE + 1,
Double.NaN,
Double.POSITIVE_INFINITY
};
for (double value : values) {
System.out.println(asInt(value));
}
}
}배열에는 위에서부터 0, 음의 0, 정수, 소수, int 최댓값, 최댓값보다 1 큰 값, NaN, 양의 무한대가 들어 있습니다. 규칙대로라면 출력은 다음과 같습니다.
int:0
double:-0.0
int:42
double:42.25
int:2147483647
double:2.147483648E9
double:NaN
double:Infinity원문 코드와 Java 25 명세에 따른 조건과 결과를 비교합니다.
Java 25 프리뷰 · 값과 부호까지 보존하는 판정
double에서 int로의 정확한 변환 경계
int 범위 안의 정수라도 원래 정보를 보존해야 합니다. −0.0은 부호를 잃고, 소수·범위 초과·NaN·무한대도 int 패턴과 일치하지 않습니다.
| 원문 입력 | int 패턴 | 이유 |
|---|---|---|
| 0.0 · 42.0 | 일치 | 정수 값 보존 |
| Integer.MAX_VALUE | 일치 | int 최댓값 보존 |
| -0.0 | 불일치 | 음의 0 부호 손실 |
| 42.25 | 불일치 | 소수부 손실 |
| MAX_VALUE + 1.0 | 불일치 | int 범위 초과 |
| NaN · +Infinity | 불일치 | 정수로 정확히 표현할 수 없음 |
- 0.0 · 42.0
- int 패턴
- 일치
- 이유
- 정수 값 보존
- Integer.MAX_VALUE
- int 패턴
- 일치
- 이유
- int 최댓값 보존
- -0.0
- int 패턴
- 불일치
- 이유
- 음의 0 부호 손실
- 42.25
- int 패턴
- 불일치
- 이유
- 소수부 손실
- MAX_VALUE + 1.0
- int 패턴
- 불일치
- 이유
- int 범위 초과
- NaN · +Infinity
- int 패턴
- 불일치
- 이유
- 정수로 정확히 표현할 수 없음
0.0과 42.0, int 최댓값은 int로 바꿔도 값이 그대로이므로 일치합니다. 42.25는 소수부를, 최댓값보다 1 큰 2,147,483,648은 범위를 벗어나 정보를 잃으므로 일치하지 않습니다. NaN과 무한대는 애초에 정수로 나타낼 수 없는 값이라 역시 일치하지 않습니다.
가장 헷갈리는 것은 -0.0입니다. -0.0 == 0은 true이니 언뜻 0으로 바꿔도 될 것 같지만, int에는 음의 0이 없어서 바꾸는 순간 부호 정보가 사라집니다. 그래서 API 문서(java.lang.runtime.ExactConversionsSupport)는 부동소수점의 음의 0을 정수 타입으로 바꾸는 것을 정확하지 않은 변환으로 정해 두었고, 출력에서도 double:-0.0으로 남습니다. 기존 코드에서 (int) value == value 같은 검사로 정수인지 판단하고 있었다면, -0.0에서는 두 방식의 결과가 다르다는 점을 기억해 두세요.
패턴이 일치해도 업무 규칙은 따로 검사해야 한다
instanceof int가 일치한다는 것은 그 값을 int로 정확히 바꿀 수 있다는 뜻일 뿐입니다. 그 값이 업무상 올바른지는 전혀 다른 문제입니다. 예를 들어 조회 수가 int에 들어간다고 해서 음수가 아니라는 뜻은 아니고, 단위가 맞는지도 알려 주지 않습니다. 계산 도중 오버플로가 나서 이미 틀어진 값이나, 앞에서 본 것처럼 먼저 형변환해서 잃어버린 정보도 패턴이 되살려 주지 않습니다. 그래서 기존 코드를 기본형 패턴으로 바꾸기 전에는 다음을 따져 보면 좋습니다.
- 숫자 파서나 직렬화 코드의 입력 검사를 패턴으로 바꿔도, 실패했을 때 필요한 오류 메시지를 여전히 만들 수 있는지 확인합니다.
- 정확한 변환 검사가 기존 형변환의 잘라내기나 반올림과 다른 결과를 내는 입력이 있는지, -0.0 같은 값까지 넣어 비교합니다.
long이나booleanswitch가 기존if문보다 실제로 읽기 쉬운지 검토합니다.- 기본형 패턴은 JDK 25에서도 프리뷰이므로 정식 결과물에는 넣지 않고, 다음 JDK에서 명세가 바뀌었는지 다시 확인합니다.
정리
x instanceof byte b처럼 기본형 타입 패턴은 값을 그 타입으로 정확히, 즉 정보 손실 없이 바꿀 수 있을 때만 일치합니다.- 패턴은 받은 값만 검사하므로, 넓은 타입을 먼저 형변환하지 말고 그대로 넘겨야 합니다.
switch에long,float,double,boolean값을 쓸 수 있고,case상수는 선택자와 같은 타입이어야 합니다.double에서int로의 정확한 변환은 소수부, 범위,NaN, 무한대뿐 아니라 음의 0까지 따집니다.
연습 문제
long 조회 수를 받아 0, int 범위 안의 양수, int 범위를 넘는 양수, 음수 네 가지로 나누는 classify 메서드를 switch로 작성하세요. 원래의 long 값을 미리 형변환해서는 안 됩니다.
입력은 45, 2,147,483,648, -1입니다. 코드를 쓰기 전에 각 입력이 어느 분기로 갈지, 그 분기의 패턴 타입이 무엇인지 먼저 적어 보세요. 추가로 0과 2,147,483,647을 넣으면 어느 분기로 갈지도 예상해 보세요.
해설 보기
public final class LongViewCountClassificationSolution {
static String classify(long viewCount) {
return switch (viewCount) {
case 0L -> "zero";
case int exact when exact > 0 -> "compact:" + exact;
case long value when value > 0 -> "large:" + value;
case long value -> "invalid:" + value;
};
}
public static void main(String[] args) {
System.out.println(classify(45L));
System.out.println(classify((long) Integer.MAX_VALUE + 1));
System.out.println(classify(-1L));
}
}main은 문제의 세 입력을 차례로 넣습니다. 컴파일과 실행 모두에 프리뷰 옵션이 필요하며, 규칙대로라면 출력은 다음과 같습니다.
compact:45
large:2147483648
invalid:-145는 int에 정확히 들어가고 양수이므로 compact:45입니다. 2,147,483,648은 int 최댓값보다 1 크므로 int 패턴과 일치하지 않고, 다음 분기에서 양수 가드를 통과해 large:2147483648이 됩니다. -1은 int에 들어가지만 양수 가드를 통과하지 못하고, long 양수 가드도 통과하지 못해 마지막 분기의 invalid:-1이 됩니다.
추가 입력 0은 맨 앞의 상수 분기에서 zero가 되고, 2,147,483,647은 int에 정확히 들어가는 양수이므로 compact:2147483647이 됩니다. -1의 경우에서 보듯이 'int로 바꿀 수 있는가'와 '업무상 올바른 양수인가'는 따로 검사되는 조건입니다.
다음 글
프리뷰 클래스 파일과 실행 규칙에서는 프리뷰 기능을 쓴 클래스 파일에 어떤 표시가 남는지 헤더를 직접 읽어 확인하고, 실행 옵션을 빠뜨렸을 때 어떤 일이 생기는지 프로그램으로 살펴봅니다. 이 글의 예제를 직접 컴파일해 봤다면, 그렇게 만든 클래스 파일에도 같은 방법을 쓸 수 있습니다.