안정·프리뷰 CI 분리
프리뷰 클래스가 안정 산출물에 섞이는 문제를 재현하고 소스 세트별 플래그, 캐시 키, 기초 실행 검사, JAR 게이트를 설계합니다.
프리뷰 소스 디렉터리만 나눠도 산출물, 의존성, 테스트 실행 환경, 캐시, 패키징이 다시 합쳐지면 격리가 깨집니다.
프리뷰 실패는 실험 결과로 보고하되 운영 배포 게이트와 의미를 구분합니다.
프리뷰 클래스의 안정 산출물 유입
아래 확인 프로그램은 실제 프리뷰 소스를 컴파일한 뒤 클래스 파일을 정식 산출물에 복사합니다.
헤더 검사는 wrong-preview-in-stable=true를 출력합니다.
소스 폴더가 분리돼도 패키징 입력을 잘못 연결하면 프리뷰 코드가 배포 산출물에 들어갑니다.
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인 클래스를 거부합니다.
파일 이름이나 패키지 이름만으로 프리뷰 여부를 판단하지 않습니다.
두 컴파일 파이프라인
아래 Pipeline 레코드는 구성값을 만들고 출력할 뿐, 실제 CI 작업이나 의존성 검사를 실행하지 않습니다.
안정 작업에는 프리뷰 플래그가 절대 없고 프리뷰 작업은 컴파일·실행 양쪽에 플래그를 둡니다.
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);
}
}프리뷰 산출물을 정식 기능 테스트의 클래스 경로에 추가하면 해당 테스트도 프리뷰 실행 플래그를 요구할 수 있습니다.
화살표는 산출물이 다음 작업의 입력이 되는 방향입니다. 프리뷰 산출물을 정식 경로로 되돌리지 않는 CI 설계를 나타냅니다.
정식 클래스는 프리뷰의 입력으로 참조할 수 있지만 프리뷰 산출물을 정식 패키지로 되돌리지 않는 CI 설계입니다.
캐시 키와 프리뷰 상태
같은 소스 해시라도 JDK 25 프리뷰 클래스와 정식 기능만 사용한 클래스는 바이너리 규칙이 다릅니다.
캐시 키에는 JDK 공급자·전체 버전, 릴리스 대상, 프리뷰 활성화 여부, 컴파일러 플래그, 의존성 잠금 해시가 필요합니다. 아래 예제는 소스 해시와 JDK·플래그 정보를 사용하며 의존성 잠금 해시는 아직 포함하지 않습니다.
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을 실제로 띄우지는 않습니다.
빌드 도구 데몬의 플래그가 테스트 작업의 JVM에 자동으로 전파된다고 가정하지 않습니다.
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 항목을 검사해야 합니다.
다음 함수는 전달된 클래스 바이트 목록만 검사합니다. 예제의 8바이트 배열 세 개는 완전한 클래스 파일이 아니며, JAR 순회도 구현하지 않습니다.
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)));
}
}원문 코드와 Java 25 명세에 따른 조건과 결과를 비교합니다.
| 넘긴 바이트 | 원문 판정 | 해석의 한계 |
|---|---|---|
| 정상 magic · minor 0 | 프리뷰 아님 | 전체 클래스 유효성 검사는 아님 |
| 정상 magic · minor 65535 | 프리뷰로 집계 | major와 나머지 클래스 구조 미검사 |
| 8바이트 미만 · 다른 magic | 프리뷰 아님 | 잘못된 입력 거부가 별도로 필요 |
- 정상 magic · minor 0
- 원문 판정: 프리뷰 아님해석의 한계: 전체 클래스 유효성 검사는 아님
- 정상 magic · minor 65535
- 원문 판정: 프리뷰로 집계해석의 한계: major와 나머지 클래스 구조 미검사
- 8바이트 미만 · 다른 magic
- 원문 판정: 프리뷰 아님해석의 한계: 잘못된 입력 거부가 별도로 필요
CI 작업 규칙
- 안정 컴파일·실행·빌드 산출물 검사는 필수 배포 게이트입니다.
- 프리뷰 컴파일·실행은 별도 배포 불가 실험 빌드 산출물을 만듭니다.
- 프리뷰 경고와 명령·JDK 버전을 보고서로 보존합니다.
- 정식 소스가 프리뷰 패키지를
import하지 않는지 확인하는 의존성 검사를 둡니다. - 프리뷰 빌드 산출물은 만료일과 소유자를 갖고 다음 JDK 릴리스 때 재평가합니다.
프리뷰 CI 작업이 실패해도 병합해도 되나요?
실험 목적에 따라 다릅니다.
학습 브랜치의 프리뷰 동작 회귀를 추적한다면 필수 검사로 둘 수 있지만 운영 배포 빌드 산출물과는 독립이어야 합니다.
기능이 새 JDK에서 제거된 경우 실험을 삭제할 수 있게 소유자와 종료 기준을 둡니다.
연습 문제
배포 가능 파이프라인에는 프리뷰 플래그가 없어야 하고 산출물 경로 문자열에도 대소문자 구분 없이 preview가 없어야 합니다.
해설 보기
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));
}
}원문 두 구성의 판정은 true와 false입니다. 경로는 구간별 판정이 아니라 부분 문자열 검사이며, 이 함수는 클래스 바이트를 읽지 않습니다. 실제 게시에는 산출물 검사도 필요합니다.