본문 바로가기

Java 25로 마이그레이션하기

JDK 25로 올릴 때 실행 중인 JDK와 컴파일 대상을 확인하고, 컴파일러 경고와 의존성을 점검하며, 단계적으로 전환하고 되돌릴 기준을 세우는 방법을 배웁니다.

이 장에서 다룬 기능을 실제 프로젝트에서 쓰려면 프로젝트를 JDK 25로 올려야 합니다. Java 25는 장기 지원(LTS) 릴리스라서, 이전 LTS인 Java 21이나 17을 쓰던 프로젝트가 옮겨 갈 대상으로 삼기 좋습니다. 그런데 빌드 파일의 버전 숫자만 바꾼다고 마이그레이션이 끝나지는 않습니다. 코드를 컴파일하는 JDK, 컴파일 결과물이 대상으로 삼는 Java 버전, 운영 환경에서 실제로 실행되는 JDK는 서로 다를 수 있고, 라이브러리나 에이전트, 프레임워크가 새 JDK를 지원하는지는 따로 확인해야 하기 때문입니다.

그래서 마이그레이션은 확인을 여러 단계로 나눠 진행합니다. 먼저 어떤 JDK로 빌드하고 실행하는지 정확히 확인하고, 소스가 컴파일되는지와 새 경고가 생겼는지 봅니다. 그다음 대표적인 작업을 실제 조합으로 실행해 결과와 성능, 메모리 사용이 예전과 같은지 비교합니다. 이 글은 이 순서를 따라갑니다.

버전 문자열을 잘라 읽으면 생기는 버그

JDK 버전을 확인하는 코드부터 점검해 봅시다. 오래된 코드 중에는 java.version 시스템 속성의 문자열을 잘라 버전을 알아내는 것이 있습니다. Java 9 시절처럼 버전이 한 자리일 때는 맨 앞 한 글자만 읽어도 맞았지만, 버전이 두 자리가 된 뒤로는 틀린 값이 나옵니다. 다음 예제는 여러 버전 문자열을 그런 방식으로 읽은 결과와, 표준 API인 Runtime.Version으로 읽은 결과를 나란히 보여 줍니다.

lab/LegacyVersionStringParserBug.java
import java.util.List;

public final class LegacyVersionStringParserBug {
    // 한 자리 버전만 있던 시절에 맞춘 코드: 맨 앞 한 글자를 기능 버전으로 읽는다
    static int legacyFeature(String version) {
        return Integer.parseInt(version.substring(0, 1));
    }

    public static void main(String[] args) {
        for (String version : List.of("9.0.4", "17.0.10", "21.0.5", "25.0.1")) {
            int wrong = legacyFeature(version);
            int right = Runtime.Version.parse(version).feature();
            System.out.println(version + " -> substring=" + wrong + ", feature()=" + right);
        }
    }
}

각 줄은 같은 버전 문자열을 두 방식으로 읽은 결과를 나란히 찍습니다. Runtime.Version.parse는 맨 앞 한 글자가 아니라 첫 점 앞의 숫자 전체를 기능 버전으로 읽습니다.

9.0.4 -> substring=9, feature()=9
17.0.10 -> substring=1, feature()=17
21.0.5 -> substring=2, feature()=21
25.0.1 -> substring=2, feature()=25

첫 줄만 맞고 나머지는 모두 틀렸습니다. JDK 25에서 실행하면 java.version이 25로 시작하므로, 이런 코드는 실행 중인 JDK를 버전 2로 판단합니다. 그러면 '버전 9 이상이면 새 API를 쓴다' 같은 분기가 엉뚱한 쪽으로 가게 됩니다.

Java 9부터 버전 문자열은 1.로 시작하지 않는 새 형식을 따릅니다. Java 10부터는 각 자리를 기능.중간.업데이트.패치 번호라고 부르고, Runtime.Version이 그 구성 요소를 숫자로 꺼내 줍니다. 실행 중인 JDK의 기능 버전이 필요하면 Runtime.version().feature()를, 문자열로 받은 버전을 해석해야 하면 Runtime.Version.parse(문자열)을 씁니다. 업무 로직이나 기능 전환 조건에 버전 문자열의 형식을 직접 넣지 않는 것이 핵심입니다.

실행 중인 JDK와 컴파일 대상 확인하기

마이그레이션 중에는 '지금 어느 JDK로 빌드했고 어느 JDK에서 실행 중인가'를 자주 묻게 됩니다. 그러니 빌드 로그에는 java -version, javac -version의 출력과 빌드 도구가 쓰는 JDK의 경로를 모두 남겨 둡니다. 애플리케이션 안에서도 다음과 같은 정보를 모아 진단용 화면이나 엔드포인트에 보여 줄 수 있습니다.

src/Java25RuntimeInventory.java
import javax.lang.model.SourceVersion;

public final class Java25RuntimeInventory {
    public static void main(String[] args) {
        Runtime.Version runtime = Runtime.version();
        int classFileFeature = (int) Float.parseFloat(System.getProperty("java.class.version"));

        System.out.println("feature=" + runtime.feature());
        System.out.println("interim=" + runtime.interim());
        System.out.println("update=" + runtime.update());
        System.out.println("vendor=" + System.getProperty("java.vendor"));
        System.out.println("source=" + SourceVersion.latestSupported());
        System.out.println("class-file-major=" + classFileFeature);
    }
}

java.class.version 속성은 65.0처럼 소수점이 붙은 문자열이므로, Float.parseFloat로 읽은 뒤 int로 바꿔 메이저 버전만 남깁니다. JDK 21.0.12(Ubuntu 배포판)에서 실행한 결과입니다.

feature=21
interim=0
update=12
vendor=Ubuntu
source=RELEASE_21
class-file-major=65

feature, interim, update는 이 JDK의 버전 21.0.12.1에서 앞의 세 숫자이고, vendor는 JDK를 배포한 곳입니다. source는 이 JDK의 컴파일러 API가 지원하는 가장 높은 언어 버전이고, class-file-major는 이 JVM이 실행할 수 있는 가장 높은 클래스 파일 메이저 버전입니다. JDK 25에서 실행하면 feature=25, source=RELEASE_25가 되고, JDK 25의 클래스 파일 메이저 버전은 69이므로 마지막 줄은 class-file-major=69가 됩니다. update와 vendor는 설치한 배포판과 업데이트에 따라 달라집니다.

여기서 헷갈리기 쉬운 점이 있습니다. java.class.version은 실행 중인 JVM이 지원하는 수준이지, 우리가 만든 클래스 파일의 버전이 아닙니다. SourceVersion.latestSupported()도 이 JDK가 지원하는 최고 수준일 뿐, 빌드에서 고른 대상 버전은 아닙니다. 컴파일러는 --release 옵션으로 대상 버전을 정할 수 있어서, JDK 25로 컴파일하더라도 --release 21을 주면 Java 21의 언어 기능과 API만 허용하고 메이저 버전 65의 클래스 파일을 만듭니다. 그래서 산출물의 버전은 클래스 파일을 직접 보고 확인해야 합니다. 예를 들어 앞의 예제를 --release 17로 컴파일하고 javap로 확인하면 Java 17에 해당하는 61이 나옵니다.

javac --release 17 -d out lab/LegacyVersionStringParserBug.java
javap -verbose out/LegacyVersionStringParserBug.class | grep "major version"
  major version: 61

--release는 대상 버전의 API 목록까지 함께 적용하므로, 대상 런타임에 없는 메서드를 실수로 호출하면 컴파일 단계에서 걸러 줍니다. 빌드 도구에서 JDK 버전(툴체인)과 --release 값을 함께 고정해 두면, 개발자마다 다른 JDK를 써도 같은 산출물이 나옵니다.

정식 기능이 켜져 있는지 확인하는 작은 프로그램

이 장에서 다룬 record, sealed 타입, 패턴 switch, 가상 스레드, ScopedValue는 모두 JDK 25에서 프리뷰 옵션 없이 쓸 수 있는 정식 기능입니다. 이 가운데 record부터 가상 스레드까지는 Java 21까지 이미 정식 기능이 되었고, ScopedValue는 JDK 25에서 정식 기능이 되었습니다. 이 기능들을 모두 쓰는 작은 프로그램을 하나 두고 기본 옵션으로 컴파일해 보면, 빌드와 배포에 쓰는 JDK가 기대한 기능을 갖췄는지 빠르게 확인할 수 있습니다.

src/Java25StableFeatureSmoke.java
import java.util.concurrent.Executors;

public final class Java25StableFeatureSmoke {
    private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();

    private sealed interface Result permits Success, Failure {}

    private record Success(String value) implements Result {}

    private record Failure(String reason) implements Result {}

    static String render(Result result) {
        return switch (result) {
            case Success(String value) -> "success:" + value;
            case Failure(String reason) -> "failure:" + reason;
        };
    }

    public static void main(String[] args) throws Exception {
        ScopedValue.where(CONTEXT, "stable")
                .run(() -> System.out.println(render(new Success(CONTEXT.get()))));
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            System.out.println(
                    "virtual=" + executor.submit(() -> Thread.currentThread().isVirtual()).get());
        }
    }
}

이 소스는 JDK 25에서 javac를 기본 옵션으로 실행하든 --release 25를 주든 프리뷰 옵션 없이 컴파일되어야 합니다. 실행하면 ScopedValue로 바인딩한 값을 record 패턴으로 꺼낸 success:stable과, 작업이 가상 스레드에서 실행되었다는 virtual=true 두 줄이 나옵니다. 이 두 줄은 JDK 21에서 프리뷰 옵션을 붙여 실행해 확인한 결과이며, 여기서 쓴 API는 JDK 25의 정식 API에도 똑같이 있습니다.

success:stable
virtual=true

반대로 이 프로그램이 컴파일되지 않는다면 JDK가 기대와 다르다는 신호입니다. 예를 들어 JDK 21의 javac로 컴파일하면 ScopedValue가 아직 프리뷰 API이므로 다음 오류가 나고, 같은 오류가 두 번 더 나온 뒤 3 errors로 끝납니다.

src/Java25StableFeatureSmoke.java:4: error: ScopedValue is a preview API and is disabled by default.
    private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();
                         ^
  (use --enable-preview to enable preview APIs)

이런 확인용 소스는 정식 기능만으로 작성하고, 다음 장에서 다루는 프리뷰 기능 실험 소스와 같은 소스 세트에 섞지 않습니다. 프리뷰 옵션이 함께 켜져 있으면 정식 기능의 확인이라는 의미가 흐려지기 때문입니다. 프리뷰 소스를 따로 관리하는 방법은 선택 심화: 프리뷰 기능 사용법과 소스 분리에서 다룹니다.

컴파일러 경고를 기준선으로 관리하기

JDK를 올리면 컴파일러 경고가 늘어나는 일이 흔합니다. 새로 사용 중단(deprecated)된 API가 생기기도 하고, 제거 예정(removal)으로 표시된 API를 쓰는 곳이 드러나기도 합니다. -Xlint:all로 경고를 모두 켜고 범주별로 분류해 보세요. 처음부터 모든 경고를 오류로 바꾸면(-Werror) 빌드가 한꺼번에 멈추므로, 먼저 현재 경고 목록을 기준선으로 기록하고 새 경고가 늘지 않게 막은 뒤 하나씩 줄여 나가는 편이 현실적입니다.

빌드 스크립트나 진단 도구에서 이런 옵션을 쓰기 전에, 현재 컴파일러가 그 옵션을 지원하는지 프로그램으로 확인할 수도 있습니다. 다음 예제는 javax.tools API로 시스템 컴파일러를 얻어 옵션 네 개의 지원 여부를 묻습니다.

src/CompilerCapabilityInventory.java
import java.util.List;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;

public final class CompilerCapabilityInventory {
    public static void main(String[] args) {
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        if (compiler == null) {
            throw new IllegalStateException("JDK compiler unavailable");
        }

        List<String> options = List.of("--release", "--enable-preview", "-Xlint", "-Werror");
        for (String option : options) {
            int arguments = compiler.isSupportedOption(option);
            System.out.println(option + " arguments=" + arguments);
        }
    }
}

getSystemJavaCompiler()는 컴파일러가 들어 있지 않은 런타임에서는 null을 돌려주므로, 예제는 먼저 그 경우를 걸러 냅니다. JDK 21에서 실행한 결과입니다.

--release arguments=1
--enable-preview arguments=0
-Xlint arguments=0
-Werror arguments=0

isSupportedOption은 옵션이 받는 인수의 개수를 돌려주고, 지원하지 않는 옵션이면 -1을 돌려줍니다. --release는 --release 25처럼 값을 하나 받으므로 1이고, 나머지 세 옵션은 값 없이 쓰므로 0입니다. 여기서 이름이 같은 ToolProvider가 모듈 시스템과 ServiceLoader에서 본 java.util.spi.ToolProvider가 아니라 javax.tools.ToolProvider라는 점에 주의하세요. 이 검사는 옵션이 있는지만 알려 줄 뿐, 옵션 조합이 올바른지나 소스가 컴파일되는지까지 판단하지는 않습니다. 실제 빌드에서는 소스 전체를 컴파일하고 경고 로그를 빌드 결과와 함께 보관합니다.

의존성과 도구가 새 JDK를 지원하는지 확인하기

애플리케이션 코드가 컴파일된다고 해서 끝이 아닙니다. 라이브러리뿐 아니라 애노테이션 프로세서, 바이트코드를 조작하는 계측 에이전트, 테스트 엔진, 네이티브 라이브러리, 컨테이너 기본 이미지도 새 JDK를 지원해야 합니다. 특히 클래스 파일을 직접 읽고 고치는 도구는 메이저 버전 69를 이해하는 버전이어야 하고, JDK 내부 구현에 기대던 도구는 내부 구조가 바뀌면 동작하지 않을 수 있습니다. 그래서 컴파일에 성공한 뒤에도 애플리케이션 시작, 주요 엔드포인트, 직렬화, 데이터베이스 드라이버, TLS 연결, 모니터링 에이전트를 실제 조합으로 실행해 봐야 합니다.

이런 확인 결과는 목록으로 관리하면 빠진 곳이 잘 보입니다. 다음 예제는 확인 항목을 record로 표현하고, 모든 항목이 통과했는지 집계합니다.

app/MigrationCompatibilityReport.java
import java.util.List;

public final class MigrationCompatibilityReport {
    private record Check(String area, boolean passed, String evidence) {}

    static boolean ready(List<Check> checks) {
        return checks.stream().allMatch(Check::passed);
    }

    public static void main(String[] args) {
        List<Check> checks =
                List.of(
                        new Check("compile", true, "javac 25 --release 25"),
                        new Check("stable-features", true, "no preview flag"),
                        new Check("database-driver", true, "integration query"),
                        new Check("observability-agent", true, "startup and trace"),
                        new Check("rollback-image", true, "previous digest retained"));
        checks.forEach(System.out::println);
        System.out.println("ready=" + ready(checks));
    }
}

ready는 allMatch로 모든 항목의 passed를 확인합니다. 그래서 하나라도 false였다면 마지막 줄은 ready=false가 됩니다.

Check[area=compile, passed=true, evidence=javac 25 --release 25]
Check[area=stable-features, passed=true, evidence=no preview flag]
Check[area=database-driver, passed=true, evidence=integration query]
Check[area=observability-agent, passed=true, evidence=startup and trace]
Check[area=rollback-image, passed=true, evidence=previous digest retained]
ready=true

앞의 다섯 줄은 record가 자동으로 만든 toString()의 출력입니다. 이 목록의 passed=true와 설명 문자열은 예시로 직접 적어 넣은 값이고, 이 코드가 컴파일이나 드라이버 검사를 실행하는 것은 아닙니다. 실제로 쓸 때는 각 항목에 수행한 명령과 버전, 로그나 지표의 링크를 연결해야 판정을 믿을 수 있습니다.

ready의 판정 방식에도 함정이 있습니다. allMatch는 '통과하지 않은 원소가 하나도 없는가'를 묻기 때문에, 빈 목록을 넘기면 true를 돌려줍니다. 확인을 하나도 하지 않았는데 준비가 끝났다고 답하는 셈입니다. 같은 이름의 항목을 여러 번 넣어도, 빠진 다른 항목을 대신하지는 못합니다. 그래서 '필요한 항목이 모두 들어 있는가'를 확인하는 일과 '들어 있는 항목이 모두 통과했는가'를 집계하는 일은 따로 해야 하는데, 연습 문제 2에서 이 둘을 함께 하는 판정을 만듭니다. 또 확인한 지 오래되었다면 같은 이름의 라이브러리라도 그사이 다른 버전이 배포되었을 수 있으니, 확인 결과에는 언제 어떤 버전으로 확인했는지도 남겨 둡니다.

더 깊이: JDK 21에서 25로 올릴 때 미리 확인할 변경

JDK 21과 25 사이에는 기존 코드나 운영 설정에 영향을 줄 수 있는 변경이 여럿 있습니다. Oracle의 JDK 25 마이그레이션 가이드에는 릴리스별 주요 변경이 정리되어 있는데, 그 가운데 JDK 24에 들어간 다음 항목은 특히 먼저 확인할 만합니다.

  • 보안 관리자(Security Manager)가 영구적으로 비활성화되었습니다(JEP 486). 이 기능에 기대던 코드나 실행 옵션이 있다면 대안을 찾아야 합니다.
  • JNI를 사용하면 경고가 나오기 시작했습니다(JEP 472). 네이티브 라이브러리를 쓰는 의존성이 있다면 실행 로그에 이 경고가 찍히는지 확인합니다.
  • sun.misc.Unsafe의 메모리 접근 메서드를 처음 호출하면 실행 중에 경고가 나옵니다(JEP 498). 성능을 위해 Unsafe를 쓰던 라이브러리가 원인일 수 있으니, 경고가 나오면 그 라이브러리의 새 버전을 확인합니다.
  • 가상 스레드가 synchronized 안에서 기다려도 운반 스레드에 고정되지 않게 되었습니다(JEP 491). JDK 21에서 고정을 피하려고 바꿔 둔 코드를 다시 검토할 수 있습니다.
  • ZGC의 비세대(non-generational) 모드가 제거되어, ZGC는 세대별 모드로만 동작합니다(JEP 490).

이 목록이 전부는 아니므로, 실제 마이그레이션에서는 가이드의 JDK 22부터 25까지 각 절을 모두 읽고 우리 시스템과 관련된 항목을 골라내야 합니다.

단계적으로 전환하고 되돌릴 기준 정하기

마이그레이션은 한 번에 모든 것을 바꾸기보다, 한 단계에 한 가지만 바꾸고 확인하는 식으로 진행해야 문제가 생겼을 때 원인을 좁힐 수 있습니다. 대개 다음 순서가 자연스럽습니다.

  1. 개발자와 CI의 빌드 JDK를 25로 맞추되, --release는 기존 값을 유지합니다. 컴파일러만 바뀐 상태라서, 여기서 생기는 문제는 빌드 도구와 플러그인 쪽입니다.
  2. 의존성과 빌드 플러그인을 JDK 25를 지원하는 버전으로 올리고, 경고 기준선을 정리합니다.
  3. --release를 25로 바꾸고, 정식 기능이 프리뷰 옵션 없이 동작하는지 확인합니다.
  4. 스테이징 환경에서 애플리케이션 시작, 주요 사용 사례, 부하를 걸었을 때의 메모리와 GC 동작을 이전 버전과 비교합니다.
  5. 운영에서는 일부 트래픽에만 새 버전을 적용하는 카나리 배포로 시작해 비율을 늘려 가며, 오류율, p95와 p99 지연 시간, CPU, 힙, 스레드와 연결 수를 관찰합니다.
  6. 이전 버전의 이미지와 데이터베이스 호환성을 유지해 언제든 되돌릴 수 있게 하고, 어떤 지표가 어느 값을 넘으면 중단하고 되돌릴지 미리 정해 둡니다.

되돌릴 기준을 미리 정하는 이유는 문제가 생긴 순간에는 판단이 흔들리기 쉽기 때문입니다. '오류율이 기존보다 눈에 띄게 높아지면 되돌린다'처럼 막연하게 두지 말고, 지표와 값으로 정해 두어야 망설이지 않고 되돌릴 수 있습니다.

프리뷰 기능은 마이그레이션의 완료 조건에 넣지 않습니다. 프리뷰 기능은 다음 릴리스에서 바뀌거나 사라질 수 있으므로, 실험용 소스 세트와 별도의 CI 작업에서만 쓰고 운영 산출물에는 기본적으로 넣지 않습니다.

JDK 25에서 컴파일되면 의존성도 호환된다고 볼 수 있나요?

아닙니다. 실행 중에 바이트코드를 만드는 라이브러리, 리플렉션으로 내부 구현에 접근하는 코드, 클래스를 계측하는 에이전트, JNI로 부르는 네이티브 코드의 문제는 컴파일 단계에서 드러나지 않을 수 있습니다. 그래서 애플리케이션 시작과 대표 작업을 실제 런타임, 컨테이너, 에이전트 조합으로 실행해 보고, 각 공급자가 공개한 지원 버전 표도 함께 확인해야 합니다.

정리

  • 버전 문자열을 잘라 읽지 말고 Runtime.version().feature()와 Runtime.Version을 씁니다. JDK 25에서 첫 글자만 읽으면 버전 2가 됩니다.
  • 빌드 JDK, --release로 정하는 대상 버전, 실행 JDK는 서로 다를 수 있습니다. 산출물의 클래스 파일 버전은 javap로 확인하고, 빌드 도구에서 툴체인과 --release를 함께 고정합니다.
  • 정식 기능만 쓰는 작은 확인용 프로그램으로 JDK가 기대한 기능을 갖췄는지 빠르게 확인할 수 있습니다. 프리뷰 실험 소스와는 섞지 않습니다.
  • 경고는 기준선을 기록한 뒤 늘지 않게 막고 점차 줄입니다. 컴파일 성공은 의존성과 에이전트의 호환까지 알려 주지 않으므로 실제 조합으로 실행해 봅니다.
  • 전환은 한 단계씩 진행하고, 되돌릴 기준은 지표와 값으로 미리 정합니다.

연습 문제

  1. 확인 항목 목록을 받아 ready와 실패한 영역 목록을 담은 불변 결과를 돌려주는 evaluate를 만드세요. 입력은 compile=true, driver=false, agent=true입니다. 실행하기 전에 결과를 먼저 예상하고, 모든 값을 true로 바꾼 경우와 빈 목록을 넘긴 경우의 ready도 예상해 보세요. 두 경우의 ready가 같다면, 실제로 확인한 범위도 같은지 설명하세요.

  2. 1번의 evaluate가 필수 영역의 집합도 받게 고쳐서, 필수 영역 가운데 목록에 없는 것을 missing으로 보고하세요. 실패한 항목도 빠진 항목도 없을 때만 ready가 true여야 합니다. 1번의 입력, 빈 목록, compile을 두 번 넣고 driver는 빠뜨린 목록, 세 항목이 모두 통과한 목록으로 확인하세요.

  3. Runtime.Version.parse로 21.0.5+11-LTS를 해석해 기능 버전, 업데이트 번호, 빌드 번호, 선택 정보를 출력하세요. 그리고 버전 문자열을 받아 기능 버전이 25 이상인지 판단하는 메서드를 만들어 21.0.5+11-LTS, 25, 25.0.1, 1.8.0_402를 넣어 보세요. 마지막 문자열에서는 무슨 일이 생기고, 왜 그런가요?

해설 보기

1번. 실패한 항목만 골라 영역 이름의 목록으로 만들고, 그 목록이 비어 있는지로 ready를 정합니다. record 심화와 불변 스냅샷에서 본 것처럼 결과 record의 컴팩트 생성자에서 List.copyOf로 복사해 두면, 나중에 누가 넘겨준 목록을 바꿔도 결과는 바뀌지 않습니다.

exercise/MigrationGateSolution.java
import java.util.List;

public final class MigrationGateSolution {
    private record Check(String area, boolean passed) {}

    private record Gate(boolean ready, List<String> failures) {
        private Gate {
            failures = List.copyOf(failures);
        }
    }

    static Gate evaluate(List<Check> checks) {
        List<String> failures =
                checks.stream().filter(check -> !check.passed()).map(Check::area).toList();
        return new Gate(failures.isEmpty(), failures);
    }

    public static void main(String[] args) {
        Gate gate =
                evaluate(
                        List.of(
                                new Check("compile", true),
                                new Check("driver", false),
                                new Check("agent", true)));
        System.out.println(gate);
    }
}
Gate[ready=false, failures=[driver]]

실패로 골라지는 것은 driver 하나이므로 실패 목록은 [driver]이고 ready는 false입니다. 모든 값을 true로 바꾼 목록과 빈 목록은 이 구현에서 둘 다 실패 목록이 비어 ready=true가 됩니다. 아래 그림은 세 경우를 정리한 것입니다.

통과 판정은 전달된 확인 목록에만 적용된다

통과 판정은 전달된 확인 목록에만 적용된다

실패 목록이 비어 있다는 제한된 판정

통과 판정은 전달된 확인 목록에만 적용된다

evaluate는 전달된 목록에서 실패 항목만 모읍니다. 빈 입력도 ready=true여서 필수 확인 항목의 존재를 보장하지 않습니다.

통과 판정은 전달된 확인 목록에만 적용된다
전달한 확인 목록ready실패 목록·해석
main의 compile·driver·agentfalse[driver]
모든 passed 값이 truetrue빈 실패 목록; 실제 검사 여부는 검증하지 않음
확인 목록 자체가 비어 있음true빈 실패 목록; 필수 항목의 누락을 잡지 못함
main의 compile·driver·agent
ready
false
실패 목록·해석
[driver]
모든 passed 값이 true
ready
true
실패 목록·해석
빈 실패 목록; 실제 검사 여부는 검증하지 않음
확인 목록 자체가 비어 있음
ready
true
실패 목록·해석
빈 실패 목록; 필수 항목의 누락을 잡지 못함

첫 행은 MigrationGateSolution의 main 입력입니다. 나머지는 evaluate의 소스 분기이며, 필수 영역의 존재·중복·근거 유효성을 검증하는 코드는 별도로 없습니다.

두 경우의 ready는 같지만 확인한 범위는 전혀 다릅니다. 앞의 경우는 세 영역을 확인해 모두 통과한 것이고, 빈 목록은 아무것도 확인하지 않은 것입니다. 이 구현은 '받은 목록 안에 실패가 없다'는 것만 판정하므로, 필수 확인을 빠뜨린 경우를 걸러 내지 못합니다. 그러니 2번처럼 빠진 영역을 확인하기 전까지는 이 판정을 마이그레이션 승인으로 쓰면 안 됩니다. 판정을 통과하더라도 카나리 배포 중의 관찰과 되돌릴 준비는 별도의 조건으로 남겨 둡니다.

2번. 목록에 들어 있는 영역 이름을 집합으로 모은 뒤, 필수 영역 가운데 그 집합에 없는 것을 missing으로 만듭니다. Set.of는 원소의 순서를 정해 두지 않으므로, 출력이 실행마다 같도록 missing은 정렬합니다.

exercise/MigrationGateWithRequired.java
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public final class MigrationGateWithRequired {
    private record Check(String area, boolean passed) {}

    private record Gate(boolean ready, List<String> failures, List<String> missing) {
        private Gate {
            failures = List.copyOf(failures);
            missing = List.copyOf(missing);
        }
    }

    static Gate evaluate(List<Check> checks, Set<String> required) {
        List<String> failures =
                checks.stream().filter(check -> !check.passed()).map(Check::area).toList();
        Set<String> present = checks.stream().map(Check::area).collect(Collectors.toSet());
        List<String> missing =
                required.stream().filter(area -> !present.contains(area)).sorted().toList();
        return new Gate(failures.isEmpty() && missing.isEmpty(), failures, missing);
    }

    public static void main(String[] args) {
        Set<String> required = Set.of("compile", "driver", "agent");
        System.out.println(evaluate(
                List.of(new Check("compile", true), new Check("driver", false), new Check("agent", true)),
                required));
        System.out.println(evaluate(List.of(), required));
        System.out.println(evaluate(
                List.of(new Check("compile", true), new Check("compile", true), new Check("agent", true)),
                required));
        System.out.println(evaluate(
                List.of(new Check("compile", true), new Check("driver", true), new Check("agent", true)),
                required));
    }
}
Gate[ready=false, failures=[driver], missing=[]]
Gate[ready=false, failures=[], missing=[agent, compile, driver]]
Gate[ready=false, failures=[], missing=[driver]]
Gate[ready=true, failures=[], missing=[]]

첫 줄은 1번과 같은 입력이라 driver가 실패로 잡힙니다. 둘째 줄의 빈 목록은 이제 세 영역이 모두 빠졌다고 보고되어 ready=false입니다. 셋째 줄은 compile을 두 번 넣었지만 driver가 없으므로 역시 통과하지 못합니다. 같은 항목을 여러 번 넣어도 빠진 영역을 대신할 수 없다는 점이 결과에 그대로 드러납니다. 세 영역이 모두 들어 있고 모두 통과한 마지막 줄만 ready=true입니다.

3번. Runtime.Version은 + 뒤의 빌드 번호와 - 뒤의 선택 정보를 Optional로 돌려주므로, 값이 없을 때 쓸 대체값을 정해 출력합니다.

exercise/VersionGateSolution.java
import java.util.List;

public final class VersionGateSolution {
    static boolean atLeast25(String version) {
        return Runtime.Version.parse(version).feature() >= 25;
    }

    public static void main(String[] args) {
        Runtime.Version v = Runtime.Version.parse("21.0.5+11-LTS");
        System.out.println("feature=" + v.feature() + ", update=" + v.update()
                + ", build=" + v.build().orElse(-1) + ", optional=" + v.optional().orElse("-"));
        for (String version : List.of("21.0.5+11-LTS", "25", "25.0.1", "1.8.0_402")) {
            try {
                System.out.println(version + " -> " + atLeast25(version));
            } catch (IllegalArgumentException e) {
                System.out.println(version + " -> " + e);
            }
        }
    }
}
feature=21, update=5, build=11, optional=LTS
21.0.5+11-LTS -> false
25 -> true
25.0.1 -> true
1.8.0_402 -> java.lang.IllegalArgumentException: Invalid version string: '1.8.0_402'

21.0.5+11-LTS에서 21.0.5는 기능, 중간, 업데이트 번호이고, +11은 빌드 번호, -LTS는 선택 정보입니다. 25처럼 뒤의 숫자가 생략된 문자열도 올바른 버전이라서 기능 버전 25로 읽힙니다. 하지만 1.8.0_402는 Java 8까지 쓰던 옛 형식이라 Java 9부터의 형식 규칙에 맞지 않고, parse가 IllegalArgumentException을 던집니다. 실행 중인 JVM의 버전은 Runtime.version()으로 얻으면 되므로 보통은 문제가 없습니다. 다만 로그나 설정 파일에서 옛 JVM의 버전 문자열을 읽어야 한다면 1.로 시작하는 형식을 따로 처리해야 합니다.

다음 글

이 글로 이 교재의 기본 학습 과정이 끝납니다. 이어지는 두 장은 필요할 때 골라 읽는 선택 심화 과정입니다. 프리뷰 기능에 관심이 있다면 선택 심화: 프리뷰 기능 사용법과 소스 분리에서 JDK 25의 프리뷰 기능을 정식 소스와 분리해 실험하는 방법부터, JVM 내부 동작이 궁금하다면 선택 심화: 클래스 로딩과 초기화부터 시작하세요.