본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
37장 : 선택 심화 — Java 25 프리뷰 기능

프리뷰 실행 규칙

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

프리뷰 컴파일은 소스 문법만 허용하는 것이 아니라 클래스 파일에 프리뷰 사용 표식을 남깁니다. Java 25 프리뷰 클래스 파일은 메이저 69와 마이너 65535를 사용하며 실행할 때도 --enable-preview를 요구합니다. 경고를 끄거나 빌드 산출물에서 버리지 말고 기능·소스·JDK 버전과 함께 실험 근거로 보존합니다.

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

다음 정식 Java 기능만 사용한 확인 프로그램은 임시 프리뷰 소스를 javac --enable-preview --release 25로 컴파일한 뒤 하위 java 프로세스에는 플래그를 주지 않습니다. 하위 프로세스는 프리뷰 클래스 로딩을 거부하고 상위 프로그램은 wrong-run-exit=1을 출력합니다.

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는 소스에서 프리뷰 기능을 사용한 위치를 경고로 알려 줍니다. 다음 확인 프로그램은 정식 기능만 사용해 기본형 패턴 소스를 컴파일하고 경고 종류와 메시지를 수집합니다.

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바이트에는 매직 값, 마이너, 메이저가 있습니다. 프리뷰를 사용하는 class는 마이너가 65535입니다. 다음 안정 확인 프로그램이 직접 컴파일하고 헤더를 읽습니다.

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);
    }
}

관찰 결과는 매직 값 cafebabe, 마이너 65535, 메이저 69입니다. 헤더 확인은 산출물 유출 게이트에 유용하지만 어떤 프리뷰 기능을 사용했는지까지 알려 주지는 않습니다.

StableValue 동시 초기화

StableValue는 초기화 블록을 최대 한 번 실행하고 설정 뒤 값을 바꾸지 않습니다. 여러 가상 스레드에서 이 동작을 확인합니다.

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));
    }
}

종료 기준은 falsetrue입니다. 실제 게이트는 JAR의 모든 .class 항목을 검사하고 다중 릴리스 디렉터리도 포함합니다.