안정 빌드와 프리뷰 빌드를 CI에서 나누기
프리뷰 클래스가 배포용 산출물에 섞이는 사고를 재현해 보고, 컴파일 옵션·빌드 캐시 키·실행 확인·JAR 검사로 두 빌드를 끝까지 따로 유지하는 방법을 배웁니다.
프리뷰 소스를 격리하는 글에서는 정식 기능만 쓰는 소스를 src/main/java에, 프리뷰 실험을 src/preview/java에 두고 출력 디렉터리도 build/classes/main과 build/classes/preview로 따로 나눴습니다. 이 글에서는 정식 기능만 쓰는 쪽을 안정(stable) 빌드, 프리뷰 실험 쪽을 프리뷰 빌드라고 부르고, 그에 맞춰 안정 빌드의 출력 디렉터리를 build/classes/stable이라고 쓰겠습니다. 디렉터리를 나누는 것은 출발점일 뿐입니다. 빌드는 컴파일 뒤에도 테스트, 캐시, 패키징 같은 여러 단계를 거치는데, 그중 한 단계에서라도 두 출력이 다시 합쳐지면 프리뷰 클래스가 배포용 JAR에 들어갈 수 있습니다.
앞 글에서 본 것처럼 그런 JAR은 빌드 서버에서는 멀쩡해 보이다가, 배포 서버가 플래그 없이 실행하는 순간 UnsupportedClassVersionError로 멈춥니다. 이 글에서는 먼저 이런 사고를 직접 만들어 보고, CI의 각 단계에서 두 빌드를 어떻게 떨어뜨려 둘지 차례로 살펴봅니다. 마지막에는 어느 단계에서 실수가 있었더라도 배포 직전에 걸러 낼 수 있도록 산출물 자체를 검사합니다. 예제는 JDK 25 기준입니다.
프리뷰 클래스가 배포 산출물에 섞이는 과정
아래 프로그램은 사고 하나를 재현합니다. 프리뷰 소스를 --enable-preview --release 25로 컴파일해 preview 디렉터리에 출력한 뒤, 패키징 설정이 잘못 연결된 상황을 흉내 내어 그 클래스 파일을 stable 디렉터리로 복사합니다. 그런 다음 복사된 파일의 헤더를 읽어 프리뷰 표식(마이너 버전 65535)이 있는지 확인합니다.
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)));
}
}preview 메서드는 클래스 파일 맨 앞의 매직 넘버 0xCAFEBABE를 확인한 다음, 바로 뒤의 2바이트인 마이너 버전이 0xFFFF(65535)인지 봅니다. javac의 프리뷰 안내(Note) 두 줄이 표준 오류로 나온 뒤, 표준 출력에는 다음 한 줄이 나옵니다.
wrong-preview-in-stable=true사고에 필요한 것은 파일 복사 한 번뿐이었습니다. 실제 빌드에서는 패키징 작업의 입력 목록에 프리뷰 출력 디렉터리가 잘못 들어가거나, 두 출력을 한 디렉터리로 합치는 설정이 이 복사와 같은 일을 합니다. 복사된 PreviewOnly.class는 이름도 확장자도 다른 클래스 파일과 똑같아서, 디렉터리 목록만 봐서는 문제를 알 수 없습니다. 차이는 파일 안의 헤더에만 있습니다.
그래서 배포 직전의 마지막 검사는 파일 이름이나 패키지 이름이 아니라 클래스 파일 헤더를 보고 판단해야 합니다. preview라는 단어가 경로에 들어 있는지로 판단하면, 이 예제처럼 이름에 아무 흔적이 없는 유출을 놓칩니다. 마이너 버전이 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);
}
}Pipeline[name=stable, sourceRoot=src/main/java, output=build/classes/stable, compileFlags=[--release, 25, -Xlint:all], runtimeFlags=[], publishable=true]
Pipeline[name=preview, sourceRoot=src/preview/java, output=build/classes/preview, compileFlags=[--enable-preview, --release, 25, -Xlint:preview], runtimeFlags=[--enable-preview], publishable=false]출력된 두 줄을 비교하면 규칙이 드러납니다. 안정 빌드는 컴파일 옵션에도 실행 옵션에도 --enable-preview가 없고, 배포할 수 있습니다(publishable=true). 프리뷰 빌드는 컴파일과 실행 양쪽에 --enable-preview가 있고, 배포할 수 없습니다. 안정 빌드에 프리뷰 플래그가 없으면 프리뷰 기능을 쓴 소스가 안정 쪽에 잘못 들어왔을 때 컴파일 단계에서 바로 오류가 납니다. 이 오류가 첫 번째 안전장치이므로, 안정 빌드에는 어떤 이유로도 프리뷰 플래그를 넣지 않습니다.
두 빌드 사이에서 산출물이 흐르는 방향도 정해 둬야 합니다. 아래 그림의 화살표는 한 작업의 산출물이 다음 작업의 입력이 되는 방향입니다.
정식 클래스는 프리뷰의 입력으로 참조할 수 있지만 프리뷰 산출물을 정식 패키지로 되돌리지 않는 CI 설계입니다.
설계 원칙 · 정식 산출물에 프리뷰를 섞지 않기
정식·프리뷰 산출물의 입력 방향
정식 클래스는 프리뷰의 입력으로 참조할 수 있습니다. 프리뷰 산출물을 정식 패키지로 되돌리지 않는 설계이며, 본문 코드는 구성값을 만들 뿐 CI 작업을 실행하지 않습니다.
프리뷰 실험이 안정 클래스를 불러 쓰는 것은 괜찮습니다. 안정 클래스는 플래그 없이도, 플래그가 있어도 실행되기 때문입니다. 반대 방향은 막아야 합니다. 프리뷰 클래스가 정식 패키지로 들어가면 앞의 사고가 되고, 안정 빌드의 테스트 클래스 경로에 들어가도 문제가 됩니다. 안정 테스트가 프리뷰 클래스를 건드리는 순간 테스트 JVM이 --enable-preview를 요구하는데, 이때 누군가 테스트를 통과시키려고 안정 테스트에 플래그를 붙이면 유출이 조용히 묻혀 버립니다.
빌드 캐시 키에 프리뷰 여부 넣기
빌드 도구의 캐시는 입력이 같으면 예전에 만든 출력을 다시 씁니다. 입력이 같은지는 입력들로 계산한 캐시 키로 판단하는데, 이 키가 소스 해시만으로 만들어지면 문제가 생깁니다. 프리뷰 빌드가 --enable-preview로 컴파일해 캐시에 넣은 출력을, 같은 소스 해시를 가진 안정 빌드가 그대로 받아 갈 수 있기 때문입니다. 캐시가 적중하면 컴파일 단계를 건너뛰므로, 플래그 없는 컴파일이 냈을 오류도 나지 않습니다. 첫 번째 안전장치가 캐시 때문에 통째로 사라지는 셈입니다.
그래서 캐시 키에는 출력을 바꿀 수 있는 것을 모두 넣어야 합니다. JDK 공급자와 전체 버전, --release 대상, 프리뷰 사용 여부, 컴파일러 옵션, 의존성 잠금 파일의 해시가 그것입니다. 아래 예제는 이 가운데 의존성 해시를 뺀 나머지를 이어 붙여 SHA-256으로 키를 만듭니다.
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));
}
}실행하면 첫 줄은 언제나 different=true이고, 둘째와 셋째 줄에는 두 키의 앞 12자리가 나옵니다. 이 12자리는 실행하는 JDK의 공급자와 버전이 키에 들어가므로 환경마다 다릅니다. 소스 해시가 abc123으로 같아도 프리뷰 여부와 옵션이 다르니 키가 달라지고, 그래서 안정 빌드는 프리뷰 빌드의 캐시를 받아 갈 수 없습니다.
JDK 버전을 25처럼 기능 릴리스 번호만 넣지 않고 Runtime.version() 전체를 넣은 데도 이유가 있습니다. 25.0.1과 25.0.2 같은 업데이트 릴리스에도 컴파일러나 프리뷰 기능 구현의 버그 수정이 들어갈 수 있어서, 같은 소스라도 다른 출력이 나올 수 있기 때문입니다.
이 예제의 직렬화는 단순화한 것입니다. 필드를 |로 이어 붙이면 필드 값 안에 |가 들어 있을 때 서로 다른 입력이 같은 문자열이 될 수 있습니다. 예를 들어 소스 해시 a|b와 공급자 c를 이은 결과는 소스 해시 a와 공급자 b|c를 이은 결과와 똑같습니다. 실제 키를 만들 때는 필드마다 길이를 앞에 붙이는 식으로, 어디까지가 한 필드인지 모호해지지 않게 인코딩해야 합니다. 그리고 캐시가 적중했더라도 마지막의 산출물 헤더 검사는 건너뛰지 않습니다. 캐시는 빌드를 빠르게 하는 장치이지 산출물이 올바르다는 확인이 아니기 때문입니다.
프리뷰 빌드는 실행까지 해 봐야 한다
프리뷰 빌드가 컴파일에 성공했다고 해서 실행도 된다는 뜻은 아닙니다. 실행하는 JVM에 --enable-preview가 빠지면 클래스를 불러오는 단계에서 바로 실패하기 때문입니다. 그래서 프리뷰 빌드의 CI 작업은 컴파일 뒤에 main을 실제로 한 번 실행해 보는 간단한 확인(smoke test)을 두고, 그때 쓴 JVM 명령을 기록으로 남깁니다. 아래 프로그램은 그 명령의 인수 목록을 만들어 출력하기만 하고, JVM을 실제로 띄우지는 않습니다.
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public final class PreviewRuntimeCommand {
static List<String> command(Path java, Path classes, String mainClass, boolean preview) {
List<String> values = new 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));
}
}[.../bin/java, -cp, build/classes/stable, App]
[.../bin/java, --enable-preview, -cp, build/classes/preview, PreviewLab]첫 항목은 지금 실행 중인 JDK의 java 실행 파일 경로라서 설치 위치마다 다르므로, 위에서는 앞부분을 ...으로 줄였습니다. 프리뷰 명령에만 --enable-preview가 들어가고, 그 위치가 클래스 이름보다 앞이라는 점을 확인하세요. 클래스 이름 뒤에 오는 인수는 JVM 옵션이 아니라 프로그램의 main에 전달되는 인수가 됩니다.
명령을 이렇게 인수 목록으로 다루는 데는 이유가 있습니다. 하나의 셸 문자열로 이어 붙이면 경로에 공백이나 특수 문자가 있을 때 인용 처리가 꼬이고, 바깥에서 들어온 값이 명령으로 해석될 위험도 생깁니다. 목록을 ProcessBuilder에 그대로 넘기면 각 항목이 하나의 인수로 전달됩니다. Windows 실행기에서는 java.exe를 가리키도록 경로를 만들거나, 빌드 도구가 제공하는 도구 체인 설정에서 실행 파일 경로를 받아 쓰는 편이 안전합니다.
빌드 도구를 쓸 때 특히 주의할 점이 있습니다. 빌드 도구 자체를 실행하는 JVM에 준 옵션은 테스트를 위해 따로 띄우는 JVM에 자동으로 전달되지 않는 경우가 많습니다. 프리뷰 테스트 작업에서는 테스트 JVM의 인수에 --enable-preview를 명시하고, 실제로 그 인수로 실행됐는지 기록에서 확인합니다.
JAR 안의 모든 클래스 검사하기
마지막 검사는 디렉터리가 아니라 실제로 배포할 산출물을 대상으로 해야 합니다. 배포 산출물은 보통 JAR이고, 그 안에는 생각보다 많은 곳에 클래스 파일이 있습니다. 의존성을 모두 풀어 넣은 팻 JAR(fat JAR)에는 라이브러리 클래스가 함께 들어 있고, JAR 안에 다른 JAR을 통째로 넣는 형식이라면 안쪽 JAR까지 열어 봐야 합니다. 다중 릴리스 JAR(multi-release JAR)은 META-INF/versions/25/ 같은 디렉터리에 특정 릴리스에서만 쓰는 클래스를 따로 두므로, 이 항목들도 빠뜨리면 안 됩니다.
아래 예제는 그 검사의 핵심인 판별 함수와 집계 함수만 보여 줍니다. JAR을 순회하는 부분은 없고, 클래스 파일 대신 헤더 8바이트만 담은 배열 세 개를 넘깁니다.
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 = {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, 0x00, 0x00, 0x00, 0x45};
byte[] preview = {(byte) 0xCA, (byte) 0xFE, (byte) 0xBA, (byte) 0xBE, (byte) 0xFF, (byte) 0xFF, 0x00, 0x45};
System.out.println("preview-count=" + count(List.of(stable, preview, preview)));
}
}preview-count=2넘긴 세 항목은 안정 헤더 하나와 프리뷰 헤더 둘입니다. 두 프리뷰 항목은 내용이 같지만 목록의 서로 다른 원소이므로 따로 세어 2가 됩니다. JAR에서도 같은 클래스가 여러 경로(예를 들어 기본 위치와 META-INF/versions/25/)에 들어 있을 수 있으니, 실제 검사에서는 개수만이 아니라 어느 항목이 프리뷰인지 이름까지 보고해야 고치기 쉽습니다.
원문 코드와 Java 25 명세에 따른 조건과 결과를 비교합니다.
프리뷰 집계 조건과 클래스 검증의 범위
원문 클래스 바이트 판별기의 범위
판별기는 전달된 바이트의 길이·magic·minor만 봅니다. false는 유효한 정식 클래스라는 인증이 아니며, major와 클래스의 나머지 구조는 검사하지 않습니다.
| 넘긴 바이트 | 원문 판정 | 해석의 한계 |
|---|---|---|
| 정상 magic · minor 0 | 프리뷰 아님 | 전체 클래스 유효성 검사는 아님 |
| 정상 magic · minor 65535 | 프리뷰로 집계 | major와 나머지 클래스 구조 미검사 |
| 8바이트 미만 · 다른 magic | 프리뷰 아님 | 잘못된 입력 거부가 별도로 필요 |
- 정상 magic · minor 0
- 원문 판정
- 프리뷰 아님
- 해석의 한계
- 전체 클래스 유효성 검사는 아님
- 정상 magic · minor 65535
- 원문 판정
- 프리뷰로 집계
- 해석의 한계
- major와 나머지 클래스 구조 미검사
- 8바이트 미만 · 다른 magic
- 원문 판정
- 프리뷰 아님
- 해석의 한계
- 잘못된 입력 거부가 별도로 필요
main의 세 항목 중 프리뷰 헤더 두 항목을 집계해 2가 됩니다. 8바이트 예제는 완전한 클래스가 아니며, 코드에 JAR 순회는 없습니다.
그림의 마지막 행처럼 이 판별 함수는 8바이트보다 짧거나 매직 넘버가 다른 입력에 false를 돌려줍니다. "프리뷰가 아니다"라는 답이지 "올바른 클래스 파일이다"라는 답은 아닙니다. 앞 글의 연습 문제에서 만든 함수는 같은 입력에 예외를 던졌습니다. 두 함수가 망가진 입력을 다르게 다루므로, 배포 검사에 쓸 때는 정책을 정해야 합니다. .class로 끝나는데 클래스 파일이 아닌 항목은 그 자체로 이상한 산출물이므로, 프리뷰 여부와 별개로 배포를 멈추게 하는 편이 안전합니다.
실제 JAR을 검사하려면 java.util.jar.JarFile로 JAR을 열고 이름이 .class로 끝나는 항목을 모두 읽어 이 함수에 넘기면 됩니다. 이름이 .jar로 끝나는 항목은 안쪽 JAR이므로, 그 항목의 입력 스트림을 JarInputStream으로 다시 열어 같은 검사를 되풀이합니다.
CI 작업에 적용할 규칙
지금까지의 내용을 CI 작업 규칙으로 정리하면 다음과 같습니다.
- 안정 빌드의 컴파일, 테스트 실행, 산출물 헤더 검사는 배포 전에 반드시 통과해야 하는 검사로 둡니다.
- 프리뷰 빌드는 별도 작업으로 컴파일하고 실행하며, 그 산출물은 배포할 수 없는 실험 결과로만 보관합니다.
- 프리뷰 빌드의
-Xlint:preview경고, 실행한 명령, JDK 버전을 기록으로 남깁니다. - 안정 소스가 프리뷰 소스 세트의 패키지를
import하지 않는지 의존성 검사로 확인합니다. - 프리뷰 실험마다 담당자와 종료 예정일을 정하고, 다음 JDK 릴리스가 나오면 다시 평가합니다.
프리뷰 CI 작업이 실패해도 병합해도 되나요?
실험의 목적에 따라 다릅니다. 학습용 브랜치에서 프리뷰 기능의 동작 변화를 추적하는 것이 목적이라면 프리뷰 작업을 필수 검사로 두어도 됩니다. 반대로 운영 코드를 다루는 주 브랜치라면 프리뷰 작업은 참고용 검사로 두고, 실패해도 병합을 막지 않게 하는 편이 낫습니다. 다만 어느 경우든 프리뷰 작업의 성공 여부가 운영 배포 산출물의 배포 여부를 좌우해서는 안 됩니다. 프리뷰 기능은 다음 JDK에서 바뀌거나 사라질 수 있으므로, 그런 일이 생겼을 때 실험을 지울 수 있도록 담당자와 종료 기준을 미리 정해 둡니다.
정리
- 소스 디렉터리를 나눠도 패키징, 테스트 클래스 경로, 캐시 중 한 곳에서 합쳐지면 프리뷰 클래스가 배포 산출물에 들어갑니다.
- 안정 빌드에는 프리뷰 플래그를 넣지 않습니다. 그래야 잘못 들어온 프리뷰 소스가 컴파일 오류로 드러납니다.
- 캐시 키에는 JDK 공급자와 전체 버전, 대상 릴리스, 프리뷰 여부, 컴파일 옵션, 의존성 해시를 넣습니다.
- 배포 직전에는 JAR 안의 모든 클래스 파일(안쪽 JAR과
META-INF/versions포함)의 헤더를 검사해 마이너 버전 65535를 거부합니다.
연습 문제
배포 가능한 빌드 설정이 규칙을 지키는지 확인하는 valid 함수를 작성하세요. 배포 가능한 설정이라면 컴파일 옵션에 --enable-preview가 없어야 하고, 출력 경로에 대소문자와 상관없이 preview라는 글자가 없어야 합니다. 배포할 수 없는 실험 설정은 프리뷰를 써도 되므로 언제나 true입니다. 다음 다섯 설정의 결과를 먼저 예상해 보세요.
| 이름 | 출력 경로 | 컴파일 옵션 | 배포 가능 |
|---|---|---|---|
| stable | build/classes/stable | --release 25 | 예 |
| leaked | build/classes/preview | --enable-preview | 예 |
| flag-only | build/classes/stable | --enable-preview --release 25 | 예 |
| path-only | build/PREVIEW/classes | --release 25 | 예 |
| experiment | build/classes/preview | --enable-preview --release 25 | 아니요 |
해설 보기
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
public final class PublishablePipelineGateSolution {
private record Pipeline(String name, 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(Locale.ROOT).contains("preview");
return !previewFlag && !previewPath;
}
public static void main(String[] args) {
List<Pipeline> pipelines = List.of(
new Pipeline("stable", Path.of("build/classes/stable"),
List.of("--release", "25"), true),
new Pipeline("leaked", Path.of("build/classes/preview"),
List.of("--enable-preview"), true),
new Pipeline("flag-only", Path.of("build/classes/stable"),
List.of("--enable-preview", "--release", "25"), true),
new Pipeline("path-only", Path.of("build/PREVIEW/classes"),
List.of("--release", "25"), true),
new Pipeline("experiment", Path.of("build/classes/preview"),
List.of("--enable-preview", "--release", "25"), false));
for (Pipeline pipeline : pipelines) {
System.out.println(pipeline.name() + "-valid=" + valid(pipeline));
}
}
}stable-valid=true
leaked-valid=false
flag-only-valid=false
path-only-valid=false
experiment-valid=true배포 가능한 설정은 프리뷰 플래그와 프리뷰 경로 가운데 하나만 있어도 false입니다. experiment는 첫 번째 if에서 바로 true를 돌려주는데, 이것은 실험에서 프리뷰를 써도 된다는 뜻이지 배포해도 된다는 뜻이 아닙니다.
toLowerCase에 Locale.ROOT를 넘긴 이유는 기본 로캘에 따라 결과가 달라질 수 있기 때문입니다. 예를 들어 터키어 로캘에서는 대문자 I가 점 없는 소문자 ı로 바뀌어, PREVIEW를 소문자로 바꾼 결과가 preview가 되지 않습니다. 그러면 path-only가 true로 잘못 판정됩니다. 로캘에 상관없이 같은 규칙을 적용해야 하는 비교에는 Locale.ROOT를 씁니다.
이 함수는 설정만 검사할 뿐 클래스 파일은 읽지 않는다는 점도 기억해 두세요. 경로 검사도 부분 문자열 비교라서 previewer처럼 다른 뜻의 이름까지 걸러 내고, 반대로 이 글 처음의 사고처럼 정상 경로에 프리뷰 클래스가 복사된 경우는 찾지 못합니다. 설정 검사는 실수를 일찍 잡는 장치이고, 배포 여부는 산출물 헤더 검사로 최종 판단합니다.