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

안동민 개발노트

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

안정·프리뷰 CI 분리

프리뷰 클래스가 안정 산출물에 섞이는 문제를 재현하고 소스 세트별 플래그, 캐시 키, 기초 실행 검사, JAR 게이트를 설계합니다.

프리뷰 소스 디렉터리만 나눠도 산출물, 의존성, 테스트 실행 환경, 캐시, 패키징이 다시 합쳐지면 격리가 깨집니다. CI 그래프는 안정과 프리뷰를 두 독립 파이프라인으로 만들고 안정 빌드 산출물 작업은 프리뷰 산출물을 입력으로 받지 않아야 합니다. 프리뷰 실패는 실험 결과로 보고하되 운영 배포 게이트와 의미를 구분합니다.

프리뷰 클래스의 안정 산출물 유입

아래 확인 프로그램은 실제 프리뷰 소스를 컴파일한 뒤 클래스 파일을 정식 산출물에 복사합니다. 헤더 검사는 wrong-preview-in-stable=true를 출력합니다. 소스 폴더가 분리돼도 패키징 입력을 잘못 연결하면 프리뷰 코드가 배포 산출물에 들어갑니다.

lab/PreviewArtifactLeakBug.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 PreviewArtifactLeakBug {
    static boolean preview(byte[] bytes) {
        ByteBuffer buffer = ByteBuffer.wrap(bytes);
        if (buffer.getInt() != 0xcafebabe) {
            throw new IllegalArgumentException("class magic");
        }
        return Short.toUnsignedInt(buffer.getShort()) == 0xffff;
    }

    public static void main(String[] args) throws Exception {
        Path root = Files.createTempDirectory("artifact-leak");
        Path previewOutput = Files.createDirectories(root.resolve("preview"));
        Path stableOutput = Files.createDirectories(root.resolve("stable"));
        Path source = root.resolve("PreviewOnly.java");
        Files.writeString(
                source,
                """
            public class PreviewOnly {
                static String show(long value) {
                    return switch (value) { case long number -> "n=" + number; };
                }
            }
            """);

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

        Path previewClass = previewOutput.resolve("PreviewOnly.class");
        Path leakedClass = stableOutput.resolve("PreviewOnly.class");
        Files.copy(previewClass, leakedClass);
        System.out.println("wrong-preview-in-stable=" + preview(Files.readAllBytes(leakedClass)));
    }
}

원칙은 안정 패키징 작업이 안정 산출물만 입력으로 선언하고 마지막 빌드 산출물 검사로 마이너 버전 65535 클래스를 거부하는 것입니다. 파일 이름이나 패키지 이름만으로 프리뷰 여부를 판단하지 않습니다.

두 컴파일 파이프라인

CI 구성을 코드로 모델링하면 각 작업의 입력·산출물·플래그 차이가 명확합니다. 안정 작업에는 프리뷰 플래그가 절대 없고 프리뷰 작업은 컴파일·실행 양쪽에 플래그를 둡니다.

src/SeparatedPreviewPipelines.java
import java.nio.file.Path;
import java.util.List;

public final class SeparatedPreviewPipelines {
    private record Pipeline(
            String name,
            Path sourceRoot,
            Path output,
            List<String> compileFlags,
            List<String> runtimeFlags,
            boolean publishable) {}

    public static void main(String[] args) {
        Pipeline stable =
                new Pipeline(
                        "stable",
                        Path.of("src/main/java"),
                        Path.of("build/classes/stable"),
                        List.of("--release", "25", "-Xlint:all"),
                        List.of(),
                        true);
        Pipeline preview =
                new Pipeline(
                        "preview",
                        Path.of("src/preview/java"),
                        Path.of("build/classes/preview"),
                        List.of("--enable-preview", "--release", "25", "-Xlint:preview"),
                        List.of("--enable-preview"),
                        false);
        System.out.println(stable);
        System.out.println(preview);
    }
}

프리뷰 산출물을 정식 기능 테스트의 클래스 경로에 추가하면 해당 테스트도 프리뷰 실행 플래그를 요구할 수 있습니다. 의존성은 프리뷰 테스트가 정식 main 코드를 읽을 수 있지만 정식 main 코드와 테스트는 프리뷰 코드를 읽지 못하도록 단방향으로 둡니다.

캐시 키와 프리뷰 상태

같은 소스 해시라도 JDK 25 프리뷰 클래스와 정식 기능만 사용한 클래스는 바이너리 규칙이 다릅니다. 캐시 키에 JDK 공급자·전체 버전, 릴리스 대상, 프리뷰 활성화 여부, 컴파일러 플래그, 의존성 잠금 해시를 포함합니다.

src/PreviewBuildCacheKey.java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;

public final class PreviewBuildCacheKey {
    static String key(String sourceHash, boolean preview, List<String> flags) throws Exception {
        String input =
                String.join(
                        "|",
                        sourceHash,
                        System.getProperty("java.vendor"),
                        Runtime.version().toString(),
                        Boolean.toString(preview),
                        String.join(",", flags));
        byte[] digest =
                MessageDigest.getInstance("SHA-256").digest(input.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(digest);
    }

    public static void main(String[] args) throws Exception {
        String stable = key("abc123", false, List.of("--release", "25"));
        String preview = key("abc123", true, List.of("--enable-preview", "--release", "25"));
        System.out.println("different=" + !stable.equals(preview));
        System.out.println("stable=" + stable.substring(0, 12));
        System.out.println("preview=" + preview.substring(0, 12));
    }
}

JDK 패치 갱신이 프리뷰 구현을 바꿀 수 있으므로 단순 메이저 25만 쓰지 않습니다. 캐시 적중 뒤에도 빌드 산출물 헤더 게이트를 수행합니다.

프리뷰 기초 실행 검사

CI 프리뷰 테스트 작업은 하위 JVM 명령을 명시적으로 기록하고 프리뷰 클래스의 main을 실행합니다. 빌드 도구 데몬의 플래그가 테스트 작업의 JVM에 자동으로 전파된다고 가정하지 않습니다.

src/PreviewRuntimeCommand.java
import java.nio.file.Path;
import java.util.List;

public final class PreviewRuntimeCommand {
    static List<String> command(Path java, Path classes, String mainClass, boolean preview) {
        java.util.ArrayList<String> values = new java.util.ArrayList<>();
        values.add(java.toString());
        if (preview) {
            values.add("--enable-preview");
        }
        values.add("-cp");
        values.add(classes.toString());
        values.add(mainClass);
        return List.copyOf(values);
    }

    public static void main(String[] args) {
        Path java = Path.of(System.getProperty("java.home"), "bin", "java");
        System.out.println(command(java, Path.of("build/classes/stable"), "App", false));
        System.out.println(command(java, Path.of("build/classes/preview"), "PreviewLab", true));
    }
}

Windows 실행기에서는 java.exe 경로를 사용하거나 도구 체인 추상화에서 실행 파일을 받습니다. 명령을 셸 문자열로 합치지 않고 인수 목록으로 실행해 인용 처리와 주입 문제를 줄입니다.

JAR 항목 전수 검사

디렉터리뿐 아니라 팻 JAR, 중첩 라이브러리, 다중 릴리스 META-INF/versions 항목을 검사해야 합니다. 다음 함수는 클래스 바이트 목록에서 프리뷰 클래스 개수를 계산하는 핵심부입니다.

src/PreviewArtifactScanner.java
import java.nio.ByteBuffer;
import java.util.List;

public final class PreviewArtifactScanner {
    static boolean isPreview(byte[] bytes) {
        if (bytes.length < 8) {
            return false;
        }
        ByteBuffer header = ByteBuffer.wrap(bytes);
        return header.getInt() == 0xcafebabe && Short.toUnsignedInt(header.getShort()) == 0xffff;
    }

    static long count(List<byte[]> classes) {
        return classes.stream().filter(PreviewArtifactScanner::isPreview).count();
    }

    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("preview-count=" + count(List.of(stable, preview, preview)));
    }
}

CI 작업 규칙

  • 안정 컴파일·실행·빌드 산출물 검사는 필수 배포 게이트입니다.
  • 프리뷰 컴파일·실행은 별도 배포 불가 실험 빌드 산출물을 만듭니다.
  • 프리뷰 경고와 명령·JDK 버전을 보고서로 보존합니다.
  • 정식 소스가 프리뷰 패키지를 import하지 않는지 확인하는 의존성 검사를 둡니다.
  • 프리뷰 빌드 산출물은 만료일과 소유자를 갖고 다음 JDK 릴리스 때 재평가합니다.
프리뷰 CI 작업이 실패해도 병합해도 되나요?

실험 목적에 따라 다릅니다. 학습 브랜치의 프리뷰 동작 회귀를 추적한다면 필수 검사로 둘 수 있지만 운영 배포 빌드 산출물과는 독립이어야 합니다. 기능이 새 JDK에서 제거된 경우 실험을 삭제할 수 있게 소유자와 종료 기준을 둡니다.

연습 문제

배포 가능 파이프라인에는 프리뷰 플래그가 없어야 하고 산출물 경로도 프리뷰 구간을 포함하지 않아야 합니다.

해설 보기
exercise/PublishablePipelineGateSolution.java
import java.nio.file.Path;
import java.util.List;

public final class PublishablePipelineGateSolution {
    private record Pipeline(Path output, List<String> flags, boolean publishable) {}

    static boolean valid(Pipeline pipeline) {
        if (!pipeline.publishable()) {
            return true;
        }
        boolean previewFlag = pipeline.flags().contains("--enable-preview");
        boolean previewPath = pipeline.output().toString().toLowerCase().contains("preview");
        return !previewFlag && !previewPath;
    }

    public static void main(String[] args) {
        Pipeline stable =
                new Pipeline(Path.of("build/classes/stable"), List.of("--release", "25"), true);
        Pipeline leaked =
                new Pipeline(Path.of("build/classes/preview"), List.of("--enable-preview"), true);
        System.out.println("stable-valid=" + valid(stable));
        System.out.println("leaked-valid=" + valid(leaked));
    }
}

종료 기준은 truefalse이며 실제 게시 작업은 클래스 파일 마이너 버전 검사까지 통과해야 합니다.