안동민 개발노트

본문 시작

프리뷰 실행 규칙

javac 진단 정보·프리뷰 클래스 파일 마이너 버전·실행 플래그 누락 실패를 실제 하위 프로세스로 관찰하고 StableValue 동시 초기화를 확인합니다.

프리뷰 기능을 사용하는 클래스 파일에는 프리뷰 사용 표식이 남습니다. --enable-preview만 켰다고 모든 클래스가 이 표식을 갖는 것은 아닙니다.

Java 25 프리뷰 클래스는 같은 릴리스의 JVM에서 --enable-preview를 켜야 로드할 수 있습니다. 다른 릴리스용 프리뷰 바이너리는 이 플래그만으로 호환되지 않습니다.

경고를 끄거나 빌드 산출물에서 버리지 말고 기능·소스·JDK 버전과 함께 실험 근거로 보존합니다.


프리뷰 클래스와 실행 플래그

다음 정식 Java 기능만 사용한 확인 프로그램은 임시 프리뷰 소스를 javac --enable-preview --release 25로 컴파일한 뒤 하위 java 프로세스에는 플래그를 주지 않습니다.

하위 JVM은 프리뷰 클래스 로딩을 거부합니다. 상위 프로그램은 그 종료 코드와 첫 진단 줄을 수집하므로, 자식의 실패와 상위 프로그램의 정상 종료를 구분합니다.

lab/PreviewClassfileWithoutRuntimeFlagProbe.java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;

public final class PreviewClassfileWithoutRuntimeFlagProbe {
    public static void main(String[] args) throws Exception {
        Path directory = Files.createTempDirectory("preview-run-probe");
        Path source = directory.resolve("PreviewPayload.java");
        Files.writeString(
                source,
                """
            public class PreviewPayload {
                public static void main(String[] args) {
                    long value = 25L;
                    System.out.println(switch (value) {
                        case long number -> number;
                    });
                }
            }
            """,
                StandardCharsets.UTF_8);

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        int compileExit =
                compiler.run(
                        null,
                        System.out,
                        System.err,
                        "--enable-preview",
                        "--release",
                        "25",
                        "-d",
                        directory.toString(),
                        source.toString());
        if (compileExit != 0) {
            throw new IllegalStateException("preview compilation failed");
        }

        boolean windows = System.getProperty("os.name").toLowerCase().contains("win");
        Path java = Path.of(System.getProperty("java.home"), "bin", windows ? "java.exe" : "java");
        Process process =
                new ProcessBuilder(java.toString(), "-cp", directory.toString(), "PreviewPayload")
                        .redirectErrorStream(true)
                        .start();
        String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        int runExit = process.waitFor();
        System.out.println(output.lines().findFirst().orElse("no-output"));
        System.out.println("wrong-run-exit=" + runExit);
    }
}

원칙은 컴파일과 실행 명령을 한 빌드 구성으로 묶는 것입니다.

프리뷰 클래스가 플러그인이나 생성된 코드를 통해 안정 산출물에 섞이면 지역 컴파일은 성공해도 배포 실행기에서 실패할 수 있습니다.


JavaCompiler 프리뷰 진단

-Xlint:preview는 소스에서 프리뷰 기능을 사용한 위치를 경고로 알려 줍니다.

다음 확인 프로그램은 정식 기능만 사용해 기본형 패턴 소스를 컴파일하고 진단 종류와 행 번호를 출력합니다. preview-warnings는 MANDATORY_WARNING 개수이며 전체 진단 수는 아닙니다.

src/PreviewDiagnosticCapture.java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
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);
            boolean success =
                    compiler.getTask(
                                    null,
                                    files,
                                    diagnostics,
                                    java.util.List.of(
                                            "--enable-preview",
                                            "--release",
                                            "25",
                                            "-Xlint:preview",
                                            "-d",
                                            directory.toString()),
                                    null,
                                    units)
                            .call();
            long warnings =
                    diagnostics.getDiagnostics().stream()
                            .filter(
                                    diagnostic ->
                                            diagnostic.getKind()
                                                    == javax.tools.Diagnostic.Kind
                                                            .MANDATORY_WARNING)
                            .count();
            System.out.println("success=" + success);
            System.out.println("preview-warnings=" + warnings);
            diagnostics
                    .getDiagnostics()
                    .forEach(
                            diagnostic ->
                                    System.out.println(
                                            diagnostic.getKind()
                                                    + " line="
                                                    + diagnostic.getLineNumber()));
        }
    }
}

원문은 진단 메시지 본문을 출력하지 않습니다. 메시지도 보존하려면 공급자·로캘에 따른 차이를 구분해 기록합니다.

CI 로그는 컴파일러 버전과 명령도 함께 보존합니다.


클래스 파일 헤더의 프리뷰 식별

클래스 파일의 첫 8바이트에는 매직 값, 마이너, 메이저가 있습니다.

다음 안정 확인 프로그램이 직접 컴파일하고 헤더를 읽습니다.

src/PreviewClassFileHeader.java
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 source = directory.resolve("HeaderPayload.java");
        Files.writeString(
                source,
                """
            public class HeaderPayload {
                static boolean flip(boolean value) {
                    return switch (value) {
                        case true -> false;
                        case false -> true;
                    };
                }
            }
            """);

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        int exit =
                compiler.run(
                        null,
                        null,
                        null,
                        "--enable-preview",
                        "--release",
                        "25",
                        "-d",
                        directory.toString(),
                        source.toString());
        if (exit != 0) {
            throw new IllegalStateException("compile exit=" + exit);
        }

        byte[] header = Files.readAllBytes(directory.resolve("HeaderPayload.class"));
        ByteBuffer buffer = ByteBuffer.wrap(header);
        int magic = buffer.getInt();
        int minor = Short.toUnsignedInt(buffer.getShort());
        int major = Short.toUnsignedInt(buffer.getShort());
        System.out.printf("magic=%08x, minor=%d, major=%d%n", magic, minor, major);
    }
}

아래는 Java 25 클래스 파일 형식에 따른 헤더입니다. 필드의 바이트 순서는 빅 엔디언입니다.

Java 25 프리뷰 클래스의 처음 8바이트

원문 코드와 Java 25 명세에 따른 조건과 결과를 비교합니다.

Java 25 프리뷰 클래스의 처음 8바이트
필드바이트 · 16진수의미
magicCA FE BA BE클래스 파일 식별
minorFF FF65535 · 프리뷰 표식
major00 4569 · Java 25
magic
바이트 · 16진수: CA FE BA BE
의미: 클래스 파일 식별
minor
바이트 · 16진수: FF FF
의미: 65535 · 프리뷰 표식
major
바이트 · 16진수: 00 45
의미: 69 · Java 25

헤더 확인은 산출물 유출 게이트에 유용하지만 어떤 프리뷰 기능을 사용했는지까지 알려 주지는 않습니다.


StableValue 동시 초기화

StableValue.orElseSet()은 공급자가 정상 반환해 값을 설정하면 이후 그 값을 재사용합니다. 공급자가 예외를 던지면 미설정 상태가 유지되어 나중 호출이 초기화를 재시도할 수 있습니다.

원문은 정상 반환하는 공급자를 두고 가상 스레드 작업 20개의 결과를 검사합니다. 정상 완료 경로에서는 set=true, initializer-calls=1이며, 작업 제출만으로 20개가 동시에 초기화에 진입했다고 볼 수는 없습니다.

preview/ConcurrentStableValueProbe.java
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());
    }
}

관찰 결과 기록 형식

실험 보고서에는 JEP 번호·프리뷰 차수, java -version, 정확한 컴파일·실행 명령, 경고 개수, 종료 코드, 표준 출력·표준 오류 핵심, 클래스 파일 헤더, 반복 횟수를 남깁니다.

“동작함” 한 줄은 다음 릴리스 마이그레이션에 쓸 수 없습니다.

프리뷰 경고를 -Werror로 막으면 안 되나요?

프리뷰 소스 세트에서 모든 프리뷰 경고를 오류로 바꾸면 의도한 실험 자체가 컴파일되지 않을 수 있습니다.

안정 소스에는 프리뷰 사용을 금지하고, 프리뷰 작업은 경고를 수집·검토하되 별도 정책을 적용합니다.

다른 린트 경고는 오류로 유지할 수 있습니다.


연습 문제

클래스 파일 헤더의 마이너 버전이 65535이면 정식 산출물에서 거부하는 함수를 작성하세요.

해설 보기
exercise/PreviewClassGateSolution.java
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[] stable = {-54, -2, -70, -66, 0, 0, 0, 69};
        byte[] preview = {-54, -2, -70, -66, -1, -1, 0, 69};
        System.out.println("stable-preview=" + isPreview(stable));
        System.out.println("preview-preview=" + isPreview(preview));
    }
}

두 8바이트 예제의 판정은 false와 true입니다. 이 함수는 매직 값과 마이너만 검사하며 메이저나 전체 클래스의 유효성은 검사하지 않습니다.

실제 게이트에는 JAR의 모든 .class 항목과 다중 릴리스 디렉터리를 순회하는 처리가 추가로 필요합니다.