본문으로 건너뛰기

안동민 개발노트

본문 시작

프리뷰 실행 규칙

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

프리뷰 컴파일은 소스 문법만 허용하는 것이 아니라 클래스 파일에 프리뷰 사용 표식을 남깁니다.

Java 25 프리뷰 클래스 파일은 메이저 69와 마이너 65535를 사용하며 실행할 때도 --enable-preview를 요구합니다.

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

Preview evidence는 source 경고에서 classfile header와 실행 결과까지 이어진다

compile 성공 한 줄만 보존하면 어떤 flag와 JDK로 만들어졌고 runtime이 왜 거부했는지 재현할 수 없습니다.

  1. source + JEP

    feature·preview iteration

  2. javac log

    --enable-preview · -Xlint

  3. classfile header

    69.65535

  4. java command

    flag on/off

  5. exit + output

    재현 가능한 결과


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

다음 정식 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);
    }
}

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

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

Preview class loading은 runtime flag 유무에서 갈린다

preview class가 stable artifact에 섞이면 local build는 성공해도 flag 없는 production runtime이 loading을 거부합니다.

  1. 예 + Java 25

    class load·execute

  2. 아니오

    UnsupportedClassVersionError 계열 거부

  3. 다른 feature JDK

    version mismatch

  4. stable artifact에서 발견

    publish 차단


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입니다.

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

Preview 실험은 재현에 필요한 evidence를 한 묶음으로 보존한다

동작함이라는 결론만 남기면 다음 JDK에서 API 변화와 환경 차이를 구분할 수 없습니다.

  1. identity

    JEP·preview 차수·기능

  2. environment

    java -version·OS

  3. commands

    compile·run args

  4. diagnostics

    warning·stderr·exit

  5. artifacts

    header·output·JFR


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());
    }
}
Classfile header gate는 preview 표식이 publish 경계에 있는지 판정한다

header만으로 사용한 preview 기능 이름은 알 수 없지만 안정 산출물 유출은 빠르게 차단할 수 있습니다.

  1. 예 + preview output

    실험 artifact로 보관

  2. 예 + stable output

    publish fail

  3. major=69, minor=0

    Java 25 stable class

  4. unknown version

    toolchain mismatch 조사


관찰 결과 기록 형식

실험 보고서에는 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 항목을 검사하고 다중 릴리스 디렉터리도 포함합니다.