프리뷰 클래스 파일과 실행 규칙
프리뷰 기능을 쓴 클래스 파일에 붙는 표식을 직접 읽어 보고, 실행할 때도 --enable-preview가 필요하고 같은 JDK 릴리스에서만 실행되는 이유를 배웁니다.
앞에서 프리뷰 기능을 쓰는 소스는 컴파일할 때와 실행할 때 모두 --enable-preview를 붙여야 한다고 했습니다. 그런데 실무에서는 두 명령을 같은 사람이 같은 자리에서 실행하지 않는 경우가 많습니다. 빌드 서버가 플래그를 붙여 컴파일한 JAR을 배포 서버가 평소 명령으로 실행하면, 컴파일은 문제없이 끝났는데 실행하자마자 클래스를 불러오지 못하고 멈춥니다.
JVM은 어떻게 이 클래스가 프리뷰 기능에 기대고 있다는 것을 알까요? 답은 클래스 파일 자체에 있습니다. 이 글에서는 프리뷰 클래스 파일에 붙는 표식을 직접 읽어 보고, javac가 프리뷰 사용 위치를 어떻게 알려 주는지, 그리고 프리뷰 클래스 파일이 왜 그것을 만든 JDK 릴리스에서만 실행되는지 살펴봅니다. 예제는 모두 JDK 25 기준이며, 프리뷰 기능이나 --release 25를 쓰는 예제는 JDK 25가 있어야 실행할 수 있습니다.
실행할 때 플래그를 빠뜨리면
JDK 25에서 프리뷰인 기본형 패턴을 쓰는 아주 작은 프로그램으로 시작하겠습니다. long 값을 switch의 선택자로 쓰고 case long number로 받는 코드는 기본형 패턴과 switch에서 본 JEP 507의 기능이라 프리뷰를 켜야 컴파일됩니다.
public class PreviewPayload {
public static void main(String[] args) {
long value = 25L;
System.out.println(switch (value) {
case long number -> number;
});
}
}컴파일할 때만 --enable-preview를 붙이고, 실행할 때는 일부러 빼 보겠습니다. 아래는 명령과 그 출력을 한 터미널에서 이어서 옮긴 것이며, $로 시작하는 줄이 입력한 명령입니다.
$ javac --enable-preview --release 25 PreviewPayload.java
Note: PreviewPayload.java uses preview features of Java SE 25.
Note: Recompile with -Xlint:preview for details.
$ java PreviewPayload
Error: LinkageError occurred while loading main class PreviewPayload
java.lang.UnsupportedClassVersionError: Preview features are not enabled for PreviewPayload (class file version 69.65535). Try running with '--enable-preview'
$ java --enable-preview PreviewPayload
25컴파일은 성공하고, javac는 이 파일이 프리뷰 기능을 쓴다는 안내(Note) 두 줄만 남깁니다. 오류가 아니므로 클래스 파일은 정상적으로 만들어집니다. 하지만 플래그 없이 실행하면 JVM이 main 메서드를 찾기도 전에, 클래스를 불러오는 단계에서 UnsupportedClassVersionError를 내고 종료 코드 1로 끝납니다. 같은 클래스 파일을 --enable-preview와 함께 실행하면 그제야 25가 출력됩니다.
오류 메시지에서 눈여겨볼 부분은 class file version 69.65535입니다. 컴파일러가 이 클래스 파일에 "프리뷰 기능에 기대는 JDK 25용 클래스"라는 표시를 남겼고, JVM은 그 표시를 보고 실행을 거부한 것입니다. 컴파일할 때 붙인 플래그는 javac 프로세스가 끝나면서 사라지고, 실행하는 java 프로세스에는 클래스 파일에 새겨진 이 표시만 전달됩니다. 그래서 CI에서는 컴파일 명령과 실행 명령의 플래그를 한 빌드 설정 안에서 함께 관리해야 합니다. 프리뷰 클래스가 생성된 코드나 플러그인을 거쳐 배포용 산출물에 섞여 들어가면, 개발자 PC에서는 잘 돌던 프로그램이 배포 환경에서 이 오류로 멈춥니다.
클래스 파일 헤더에 새겨진 표식
모든 클래스 파일의 처음 8바이트는 같은 구조로 되어 있습니다. JVM 명세 4.1에 따르면 처음 4바이트는 클래스 파일임을 알리는 매직 넘버(magic) CA FE BA BE이고, 이어서 2바이트짜리 마이너 버전(minor_version)과 2바이트짜리 메이저 버전(major_version)이 옵니다. 여러 바이트로 된 값은 모두 빅 엔디언(big-endian), 즉 높은 자리 바이트가 먼저 저장됩니다.
원문 코드와 Java 25 명세에 따른 조건과 결과를 비교합니다.
클래스 헤더 · big-endian으로 읽는 4 + 2 + 2바이트
Java 25 프리뷰 클래스의 처음 8바이트
처음 8바이트는 magic(0~3), minor(4~5), major(6~7) 순서입니다. Java 25 프리뷰에 의존하는 클래스의 버전은 69.65535입니다.
magic
- 바이트 · 16진수
- CA FE BA BE
- 의미
- 클래스 파일 식별
minor
- 바이트 · 16진수
- FF FF
- 의미
- 65535 · 프리뷰 표식
major
- 바이트 · 16진수
- 00 45
- 의미
- 69 · Java 25
FF FF는 프리뷰 표식입니다. 이 헤더만으로 클래스 전체의 유효성이나 사용한 프리뷰 기능 종류까지 확인할 수는 없습니다.
메이저 버전은 클래스 파일이 어느 Java SE 릴리스용인지를 나타냅니다. Java SE 8은 52, Java SE 21은 65, Java SE 25는 69처럼 릴리스 번호에 44를 더한 값입니다. 마이너 버전은 오늘날 평소에는 0이고, 프리뷰 기능에 기대는 클래스 파일에서만 16비트를 모두 1로 채운 값, 즉 16진수 FF FF인 65535가 됩니다. 앞의 오류 메시지에 나온 69.65535는 바로 "메이저 69, 마이너 65535"라는 뜻입니다.
표식이 정말 프리뷰 기능을 쓴 클래스에만 붙는지 확인해 보겠습니다. 아래 프로그램은 프리뷰 기능(boolean 선택자 switch)을 쓰는 소스와 쓰지 않는 소스를 같은 옵션 --enable-preview --release 25로 함께 컴파일한 뒤, 두 클래스 파일의 처음 8바이트를 읽어 출력합니다. javac는 JDK에 들어 있는 javax.tools.JavaCompiler로 프로그램 안에서 호출합니다.
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
public final class PreviewClassFileHeader {
public static void main(String[] args) throws Exception {
Path directory = Files.createTempDirectory("preview-header");
Path previewSource = directory.resolve("HeaderPayload.java");
Files.writeString(previewSource, """
public class HeaderPayload {
static boolean flip(boolean value) {
return switch (value) {
case true -> false;
case false -> true;
};
}
}
""");
Path plainSource = directory.resolve("PlainPayload.java");
Files.writeString(plainSource, """
public class PlainPayload {
static boolean flip(boolean value) {
return !value;
}
}
""");
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
int exit = compiler.run(null, null, null,
"--enable-preview", "--release", "25",
"-d", directory.toString(),
previewSource.toString(), plainSource.toString());
if (exit != 0) {
throw new IllegalStateException("compile exit=" + exit);
}
for (String name : new String[] {"HeaderPayload", "PlainPayload"}) {
byte[] bytes = Files.readAllBytes(directory.resolve(name + ".class"));
ByteBuffer buffer = ByteBuffer.wrap(bytes);
int magic = buffer.getInt();
int minor = Short.toUnsignedInt(buffer.getShort());
int major = Short.toUnsignedInt(buffer.getShort());
System.out.printf("%s: magic=%08x, minor=%d, major=%d%n", name, magic, minor, major);
}
}
}이 프로그램 자체는 정식 기능만 쓰므로 플래그 없이 javac --release 25로 컴파일해 실행하면 됩니다. 실행하면 javac가 HeaderPayload.java에 대해 남기는 Note 두 줄이 표준 오류로 먼저 나오고, 표준 출력에는 다음 두 줄이 나옵니다.
HeaderPayload: magic=cafebabe, minor=65535, major=69
PlainPayload: magic=cafebabe, minor=0, major=69두 클래스는 같은 명령으로 컴파일됐지만 결과가 다릅니다. 프리뷰 기능을 쓴 HeaderPayload만 마이너 버전이 65535이고, PlainPayload는 0입니다. 즉 --enable-preview는 "프리뷰 기능을 써도 된다"는 허락일 뿐이고, 표식은 소스가 실제로 프리뷰 기능을 썼을 때만 붙습니다. 그래서 프리뷰 옵션으로 빌드한 프로젝트라도 프리뷰 기능을 쓰지 않은 클래스는 플래그 없이 실행할 수 있습니다.
코드에서 ByteBuffer는 기본으로 빅 엔디언 순서로 값을 읽으므로 명세의 바이트 순서와 맞습니다. getShort()가 돌려주는 short는 부호가 있는 타입이라 FF FF를 그대로 읽으면 −1이 됩니다. Short.toUnsignedInt는 같은 16비트를 부호 없는 값으로 해석해 255 × 256 + 255 = 65535를 돌려줍니다. byte 배열을 직접 들여다볼 때도 같은 일이 생겨서, 파일에 FF로 저장된 바이트가 Java에서는 −1로 보입니다.
이 8바이트로 알 수 있는 것은 "이 클래스는 JDK 25의 프리뷰 기능에 기댄다"는 사실까지입니다. 어떤 프리뷰 기능을 썼는지, 나머지 바이트가 올바른 클래스 파일인지는 알려 주지 않습니다. 그래도 배포 전에 산출물에 프리뷰 클래스가 섞였는지 걸러 내기에는 충분하며, 다음 글에서 이 검사를 CI에 넣습니다. 참고로 JDK 24부터는 클래스 파일 전체를 읽고 쓰는 표준 API인 Class-File API(JEP 484)가 정식 기능이 되었지만, 여기서는 헤더 8바이트만 필요하므로 ByteBuffer로 직접 읽었습니다.
더 깊이: 표식은 소스 파일 단위로 붙습니다
javac는 프리뷰 기능을 썼는지를 클래스마다가 아니라 소스 파일(컴파일 단위)마다 판단합니다. 그래서 한 .java 파일 안의 어느 한 곳에서라도 프리뷰 기능을 쓰면, 그 파일에서 나온 클래스 파일이 모두 마이너 65535를 받습니다. 프리뷰 기능을 쓰지 않은 중첩 클래스나 같은 파일에 함께 선언한 다른 최상위 클래스도 예외가 아닙니다. 프리뷰 코드를 정식 코드와 같은 파일에 두면 정식 코드까지 실행할 때 플래그를 요구하게 되므로, 프리뷰 실험은 파일부터 따로 두는 편이 안전합니다.
프리뷰 API도 같은 방식으로 다룹니다. 언어 문법이 아니라 StableValue 같은 프리뷰 API를 호출하는 소스도 프리뷰 기능을 쓴 것으로 취급되어, 컴파일할 때 --enable-preview가 필요하고 결과 클래스 파일에 같은 표식이 붙습니다.
javac가 알려 주는 프리뷰 사용 위치
처음 예제에서 javac가 남긴 Note는 "이 파일이 프리뷰 기능을 쓴다"는 요약일 뿐, 어디서 썼는지는 알려 주지 않습니다. 위치를 보려면 Note가 권하는 대로 -Xlint:preview를 붙입니다. 그러면 javac는 프리뷰 기능을 쓴 곳마다, 이 글의 기본형 패턴이라면 warning: [preview] primitive patterns are a preview feature and may be removed in a future release. 같은 경고를 파일 이름, 행 번호와 함께 출력합니다. 코드 리뷰에서 프리뷰 사용이 어디까지 퍼졌는지 확인할 때 이 경고 목록이 가장 정확한 지도가 됩니다.
CI에서는 이 경고를 사람이 로그에서 찾기보다 프로그램으로 모아 세는 편이 낫습니다. JavaCompiler에 DiagnosticCollector를 넘기면 javac가 내는 진단(diagnostic)을 출력 문자열 대신 객체로 받을 수 있습니다. 아래 프로그램은 기본형 패턴을 쓰는 소스를 -Xlint:preview와 함께 컴파일하고, 진단마다 종류와 행 번호를 출력합니다.
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileObject;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
public final class PreviewDiagnosticCapture {
public static void main(String[] args) throws Exception {
Path directory = Files.createTempDirectory("preview-diagnostics");
Path source = directory.resolve("WarningPayload.java");
Files.writeString(source, """
public class WarningPayload {
static String show(long value) {
return switch (value) {
case long number -> "value=" + number;
};
}
}
""", StandardCharsets.UTF_8);
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
try (StandardJavaFileManager files =
compiler.getStandardFileManager(diagnostics, null, StandardCharsets.UTF_8)) {
Iterable<? extends JavaFileObject> units = files.getJavaFileObjects(source);
List<String> options = List.of(
"--enable-preview", "--release", "25", "-Xlint:preview",
"-d", directory.toString());
boolean success =
compiler.getTask(null, files, diagnostics, options, null, units).call();
long warnings = diagnostics.getDiagnostics().stream()
.filter(d -> d.getKind() == Diagnostic.Kind.MANDATORY_WARNING)
.count();
System.out.println("success=" + success);
System.out.println("preview-warnings=" + warnings);
for (Diagnostic<? extends JavaFileObject> d : diagnostics.getDiagnostics()) {
System.out.println(d.getKind() + " line=" + d.getLineNumber());
}
}
}
}실행하면 먼저 success=true가 나옵니다. 프리뷰 경고는 컴파일을 막지 않기 때문입니다. 그 아래에는 프리뷰 기능을 쓴 위치마다 MANDATORY_WARNING line=... 줄이 하나씩 나옵니다. 행 번호는 텍스트 블록으로 만든 임시 소스 안에서의 번호이므로, switch (value)가 있는 3행이나 case long number가 있는 4행처럼 기본형 패턴을 쓴 줄을 가리킵니다. preview-warnings는 그 줄의 개수입니다.
MANDATORY_WARNING은 Diagnostic.Kind의 한 종류로, 도구의 명세가 반드시 알리도록 정한 경고를 뜻합니다. Java 언어 명세가 요구하는 unchecked 경고나 deprecated API 사용 경고가 여기에 속하고, 프리뷰 사용 경고도 이 종류로 나옵니다. 이 예제의 소스에는 그런 경고를 낼 다른 코드가 없으므로 이 개수가 곧 프리뷰 경고 수입니다. 반대로 -Xlint:preview를 빼고 실행하면 위치별 경고 대신 NOTE 종류의 진단 두 개가 line=-1로 나옵니다. 처음 예제에서 본 "uses preview features of Java SE 25"와 "Recompile with -Xlint:preview for details" 두 줄이 바로 이 진단이고, 파일 전체에 대한 안내라서 행 번호가 없습니다.
진단 메시지 본문은 일부러 출력하지 않았습니다. 메시지는 JDK 공급자나 버전, 로캘에 따라 문구가 달라질 수 있어서, CI 판정에 쓰기에는 종류와 위치가 더 안정적입니다. 메시지를 기록으로 남기고 싶다면 그 문구를 만든 컴파일러의 버전과 명령을 함께 저장해야 나중에 비교할 수 있습니다.
프리뷰 경고를 -Werror로 막으면 안 되나요?
-Werror는 경고가 하나라도 나오면 컴파일을 실패로 만드는 옵션입니다. -Xlint:preview와 -Werror를 함께 쓰면 프리뷰 경고도 경고이므로 error: warnings found and -Werror specified와 함께 컴파일이 실패하고 클래스 파일이 만들어지지 않습니다. 반면 -Xlint:preview 없이 -Werror만 쓰면 요약 Note는 경고가 아니어서 컴파일이 성공합니다.
그래서 프리뷰 소스 세트에 -Xlint:preview -Werror를 걸면 실험 코드 자체가 컴파일되지 않습니다. 정식 소스 세트에서는 --enable-preview 없이 컴파일하는 것만으로 프리뷰 사용이 오류가 되므로 따로 막을 필요가 없습니다. 프리뷰 소스 세트에서는 경고를 오류로 바꾸지 말고 모아서 검토하되, 프리뷰 이외의 lint 경고는 -Xlint:all,-preview -Werror처럼 계속 오류로 다룰 수 있습니다.
다른 릴리스의 JVM은 프리뷰 클래스를 받지 않는다
프리뷰 클래스 파일에는 플래그 말고도 규칙이 하나 더 있습니다. JDK 25의 JVM은 --enable-preview를 켜더라도 프리뷰 클래스 파일 가운데서는 69.65535만 받아들입니다. JDK 24에서 만든 프리뷰 클래스 파일(68.65535)도, JDK 26에서 만든 것(70.65535)도 실행하지 않습니다. 정식 클래스 파일은 낮은 버전으로 만든 것을 높은 버전의 JVM이 그대로 실행하지만, 프리뷰 클래스 파일은 만든 릴리스와 실행하는 릴리스가 정확히 같아야 합니다.
이렇게 묶어 두는 이유는 프리뷰 기능이 다음 릴리스에서 문법이나 의미가 바뀔 수 있기 때문입니다. JDK 25의 규칙으로 컴파일한 바이너리를 JDK 26이 실행하면, 그사이 바뀐 규칙 때문에 컴파일할 때와 다른 뜻으로 동작할 수 있습니다. JVM은 이런 상황을 처음부터 막으려고 다른 릴리스의 프리뷰 클래스 파일을 거부하고, 오류 메시지에 was compiled with preview features that are unsupported라는 문구와 함께 자신이 받아들이는 버전을 알려 줍니다. 예를 들어 JDK 26이라면 only recognizes preview features for class file version 70.65535라고 덧붙입니다.
따라서 프리뷰 실험을 다음 JDK로 옮길 때는 클래스 파일을 재사용할 수 없고, 소스를 새 JDK로 다시 컴파일해야 합니다. 이때 그 기능이 새 릴리스에서 바뀌었다면 소스도 고쳐야 합니다. 이 과정은 프리뷰 실험을 이어 갈지 끝낼지 정하기에서 자세히 다룹니다.
프리뷰 API를 여러 스레드에서 쓰기: StableValue
프리뷰 API인 StableValue(JEP 502)로 실험할 때도 지금까지의 규칙이 그대로 적용됩니다. 컴파일과 실행 모두 --enable-preview가 필요하고, 결과 클래스 파일에는 같은 표식이 붙습니다. 여기서는 프리뷰 API를 평가할 때 무엇을 확인해야 하는지, orElseSet을 여러 스레드에서 동시에 호출하는 예로 살펴보겠습니다.
orElseSet(supplier)는 값이 아직 없으면 supplier를 호출해 값을 정하고, 이미 있으면 저장된 값을 돌려줍니다. JDK 25 Javadoc은 공급자가 예외 없이 끝나는 한 여러 스레드가 경쟁해도 공급자는 많아야 한 번 호출된다고 명시합니다. 경쟁에서 진 스레드는 값이 정해질 때까지 기다렸다가 그 값을 받습니다. 반대로 공급자가 예외를 던지면 그 예외가 호출자에게 다시 던져지고 값은 정해지지 않은 채 남으므로, 다음 호출이 초기화를 다시 시도합니다.
아래 프로그램은 가상 스레드 20개가 같은 StableValue에 orElseSet을 호출하게 하고, 공급자가 실제로 몇 번 호출됐는지 셉니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
public final class ConcurrentStableValueProbe {
public static void main(String[] args) throws Exception {
StableValue<String> value = StableValue.of();
AtomicInteger initializers = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> results = new ArrayList<>();
for (int task = 0; task < 20; task++) {
results.add(executor.submit(() -> value.orElseSet(() -> {
initializers.incrementAndGet();
return "ready";
})));
}
for (Future<String> result : results) {
if (!result.get().equals("ready")) {
throw new AssertionError("unexpected value");
}
}
}
System.out.println("set=" + value.isSet());
System.out.println("initializer-calls=" + initializers.get());
}
}javac --enable-preview --release 25 -Xlint:preview로 컴파일하면 StableValue를 쓴 위치마다 StableValue is a preview API and may be removed in a future release.라는 [preview] 경고가 나옵니다. 이어서 실행한 결과는 다음과 같습니다.
$ java --enable-preview ConcurrentStableValueProbe
set=true
initializer-calls=120개 작업이 모두 ready를 받았고, 공급자는 한 번만 실행됐습니다. 다만 이 결과를 "20개 스레드가 동시에 초기화에 뛰어들었는데도 한 번만 실행됐다"로 읽으면 안 됩니다. 작업을 제출했다고 해서 20개가 같은 순간에 orElseSet에 들어갔다는 뜻은 아니며, 첫 작업이 값을 정한 뒤에 나머지가 도착했을 수도 있습니다. initializer-calls=1이 언제나 나오는 이유는 실행 타이밍이 아니라 API가 명시한 동작에 있습니다. 경쟁이 실제로 일어나는 상황까지 시험하려면 공급자 안에서 일부러 기다리게 하거나 CountDownLatch로 출발 시점을 맞추는 식의 장치가 따로 필요합니다.
Javadoc에는 하나가 더 적혀 있습니다. 공급자가 실행 도중 같은 StableValue의 값을 다시 정하려고 하면, 즉 초기화가 자기 자신을 재귀적으로 부르면 IllegalStateException이 납니다. 정상 경로 예제만 돌려서는 예외 경로와 재귀 경로가 어떻게 동작하는지 알 수 없으므로, 프리뷰 API를 평가할 때는 이런 경우를 따로 시험해야 합니다.
실험 결과를 남길 때 적을 것
프리뷰 실험의 결과는 다음 릴리스로 옮길 때 다시 꺼내 보게 됩니다. 그때 "동작함" 한 줄만 남아 있으면, 무엇이 어떤 조건에서 동작했는지 몰라 실험을 처음부터 다시 해야 합니다. 그래서 실험 기록에는 최소한 다음 항목을 남깁니다.
- 기능의 JEP 번호와 프리뷰 차수(예: JEP 502, 첫 번째 프리뷰)
java -version출력 전체(공급자와 업데이트 번호까지)- 실제로 실행한 컴파일 명령과 실행 명령
-Xlint:preview경고의 개수와 위치- 종료 코드와 표준 출력, 표준 오류의 핵심 줄
- 산출물 클래스 파일의 헤더(메이저.마이너)
- 동시성 실험이라면 반복 횟수와 실행 환경
정리
- 프리뷰 기능을 쓴 소스 파일에서 나온 클래스 파일은 마이너 버전이 65535입니다.
--enable-preview로 컴파일했더라도 프리뷰 기능을 쓰지 않은 파일에는 표식이 붙지 않습니다. - JVM은 클래스를 불러올 때 이 표식을 확인합니다. 실행할 때
--enable-preview가 없으면UnsupportedClassVersionError로 거부합니다. - 프리뷰 클래스 파일은 만든 릴리스의 JVM에서만 실행됩니다. JDK가 바뀌면 다시 컴파일해야 합니다.
-Xlint:preview는 프리뷰 사용 위치를 경고로 알려 주고,DiagnosticCollector로 받으면 CI에서 종류와 위치를 셀 수 있습니다.
연습 문제
- 클래스 파일의 바이트 배열을 받아 프리뷰 클래스 파일이면
true를 돌려주는isPreview를 작성하세요. 배열이 8바이트보다 짧거나 매직 넘버가CAFEBABE가 아니면 클래스 파일이 아니므로IllegalArgumentException을 던집니다. 다음 네 입력의 결과를 먼저 예상해 보세요.CA FE BA BE 00 00 00 45CA FE BA BE FF FF 00 45CA FE BA BE 00 00 00(7바이트)00 00 00 00 FF FF 00 45
- 어떤 프로젝트가 모든 소스를
--enable-preview --release 25로 컴파일했는데, 배포 서버에서 플래그 없이 실행해도 대부분의 클래스는 문제없이 동작했습니다. 왜 그럴까요? 오류가 나는 클래스는 어떤 클래스일까요? - JDK 25에서 만든 프리뷰 클래스를 JDK 26 서버에서
--enable-preview를 붙여 실행하면 어떻게 될까요? 실행되게 하려면 무엇을 해야 할까요?
해설 보기
1번은 길이를 먼저 확인하고, 매직 넘버를 확인한 다음, 마이너 버전을 부호 없는 값으로 읽어 65535와 비교합니다. 길이 검사를 빼면 어떻게 될지도 생각해 볼 만합니다. 7바이트짜리 잘린 배열도 매직 넘버와 마이너 버전까지는 읽히므로 메이저 버전이 없는데도 false라는 그럴듯한 답이 나오고, 6바이트보다 짧은 배열에서는 원인을 알기 어려운 BufferUnderflowException이 납니다. 길이를 먼저 확인해야 두 경우 모두 "클래스 파일이 아니다"라는 같은 이유로 거부할 수 있습니다.
import java.nio.ByteBuffer;
public final class PreviewClassGateSolution {
static boolean isPreview(byte[] classFile) {
if (classFile.length < 8) {
throw new IllegalArgumentException("short class file");
}
ByteBuffer buffer = ByteBuffer.wrap(classFile);
if (buffer.getInt() != 0xCAFEBABE) {
throw new IllegalArgumentException("magic");
}
int minor = Short.toUnsignedInt(buffer.getShort());
return minor == 0xFFFF;
}
public static void main(String[] args) {
byte[][] inputs = {
{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, 0x00, 0x00, 0x00, 0x45},
{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, (byte) 0xFF, (byte) 0xFF, 0x00, 0x45},
{(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, 0x00, 0x00, 0x00},
{0x00, 0x00, 0x00, 0x00, (byte) 0xFF, (byte) 0xFF, 0x00, 0x45}
};
for (byte[] input : inputs) {
try {
System.out.println("preview=" + isPreview(input));
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}
}preview=false
preview=true
rejected: short class file
rejected: magic0xCA 같은 값은 byte의 범위(−128부터 127까지)를 넘으므로 (byte)로 바꿔 넣어야 합니다. 첫 줄의 false는 "프리뷰 표식이 없다"는 뜻일 뿐이고, 이 함수는 메이저 버전이나 클래스 파일의 나머지 부분은 보지 않습니다. 앞의 두 입력도 8바이트뿐이라 완전한 클래스 파일이 아닙니다. 실제 배포 검사에서는 JAR 안의 모든 .class 항목을 이 함수에 넘겨야 하며, 그 방법은 다음 글에서 다룹니다.
2번은 표식이 소스 파일 단위로 붙기 때문입니다. 프리뷰 기능을 실제로 쓰지 않은 소스 파일에서 나온 클래스는 마이너 버전이 0인 평범한 클래스 파일이므로 플래그 없이도 실행됩니다. 오류는 프리뷰 기능을 쓴 소스 파일에서 나온 클래스(그 파일의 중첩 클래스 포함)를 처음 불러오는 순간에 납니다. 그래서 그 클래스가 쓰이는 기능을 건드리기 전까지는 문제가 드러나지 않을 수 있다는 점이 더 위험합니다.
3번은 실행되지 않습니다. JDK 26의 JVM은 70.65535 프리뷰 클래스 파일만 받아들이므로, 69.65535인 JDK 25 프리뷰 클래스는 플래그를 붙여도 거부합니다. 소스를 JDK 26으로 다시 컴파일해야 하고, 그 기능이 JDK 26에서 바뀌었다면 바뀐 명세에 맞춰 소스도 고쳐야 합니다.