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

안동민 개발노트

본문 시작
36장 : Java 25 안정 기능

Java 25 마이그레이션

실행 환경·SourceVersion·안정 기능 기초 실행 검사로 실제 JDK를 확인하고 컴파일러 경고, 의존성, 실행 시점 이미지, 롤백 기준을 점검합니다.

Java 25는 LTS 릴리스이지만 버전 번호만 바꾼다고 마이그레이션이 끝나지는 않습니다. 빌드 JDK, --release 대상, 런타임 이미지, 라이브러리·에이전트·프레임워크 호환성, GC·컨테이너 설정을 구분합니다. 소스가 컴파일되는지, 경고가 새로 생겼는지, 대표 작업 부하가 같은 결과와 운영 특성을 보이는지 단계적으로 확인합니다.

버전 문자열 첫 글자 해석 오류

과거 1.8 형식에 맞춘 파서나 문자열 substring()은 두 자리 기능 버전을 잘못 해석합니다. Java 25에서 아래 출력은 wrong-feature=2입니다.

lab/LegacyVersionStringParserBug.java
public final class LegacyVersionStringParserBug {
    public static void main(String[] args) {
        String version = System.getProperty("java.version");
        int feature = Integer.parseInt(version.substring(0, 1));
        System.out.println("runtime-version=" + version);
        System.out.println("wrong-feature=" + feature);
    }
}

원칙은 Runtime.version().feature()Runtime.Version 구성 요소 API를 사용하는 것입니다. 버전 문자열 형식을 업무 로직이나 기능 전환에 직접 넣지 않습니다.

컴파일러·실행 환경 버전

빌드 로그에는 java -version, javac -version, 도구 체인 경로를 모두 남깁니다. 애플리케이션에서는 런타임 기능과 클래스 파일 지원 수준을 진단 엔드포인트에 표시할 수 있습니다.

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);
    }
}

JDK 25 클래스 파일의 메이저 버전은 69입니다. 컴파일 JDK가 25여도 --release 21을 쓰면 Java 21 API와 언어 수준을 대상으로 산출물을 만들 수 있습니다. 대상 런타임보다 높은 API를 실수로 참조하지 않도록 빌드 도구 체인과 릴리스 옵션을 함께 고정합니다.

안정 기능의 기초 실행 검사

record, sealed 타입, 패턴 switch, 가상 스레드, ScopedValue가 이 장에서 다룬 Java 25 정식 기능입니다. 작은 프로그램 하나를 기본 javac로 컴파일해 배포 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());
        }
    }
}

이 소스는 javac --release 25 또는 JDK 25의 기본 컴파일에서 프리뷰 옵션 없이 성공해야 합니다. 다음 장의 프리뷰 실험 소스와 같은 소스 세트에 섞지 않습니다.

마이그레이션 경고 목록

-Xlint:all과 사용 중단 경고를 켜고 경고를 범주별로 분류합니다. 처음부터 모든 경고를 오류로 바꾸기보다 기준선을 기록하고 새 경고가 늘지 않게 한 뒤 점진적으로 제거합니다. 다음 예제는 JavaCompiler 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);
        }
    }
}

isSupportedOption의 -1은 미지원, 0 이상은 옵션이 받는 인수 수입니다. 운영 빌드에서는 실제 소스 전체를 컴파일하고 경고 로그를 산출물로 보존합니다.

의존성·빌드 도구 호환 조합표

애플리케이션 라이브러리뿐 아니라 애너테이션 프로세서, 바이트코드 계측 에이전트, 테스트 엔진, 네이티브 라이브러리, 컨테이너 기본 이미지가 클래스 파일 69와 JDK 내부 구조 변화에 대응해야 합니다. 컴파일에 성공한 뒤에는 시작 확인, 주요 엔드포인트, 직렬화, 데이터베이스 드라이버, TLS, 모니터링 에이전트를 실제 조합으로 실행합니다.

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));
    }
}

boolean만 적지 말고 실행 명령, 버전, 로그·지표 링크를 근거로 남깁니다. 마이그레이션 확인이 오래되면 같은 이름의 라이브러리도 다른 버전이 배포될 수 있습니다.

단계적 전환과 복구 기준

  1. 개발자와 CI 도구 체인을 JDK 25로 맞추되 기존 릴리스 대상을 유지합니다.
  2. 의존성과 플러그인을 호환 버전으로 올리고 경고 기준선을 정리합니다.
  3. 릴리스 대상을 25로 바꾸고 안정 기능의 기본 동작을 별도 프리뷰 플래그 없이 확인합니다.
  4. 스테이징에서 시작, 주요 사용 사례, 적재·메모리·GC 프로필을 비교합니다.
  5. 카나리 비율을 늘리며 오류 비율, p95·p99 지연 시간, CPU, 힙, 스레드·연결을 관찰합니다.
  6. 롤백 이미지와 데이터베이스 호환성을 유지하고 중단 임계값을 사전에 정합니다.

프리뷰 기능은 마이그레이션 종료 기준에 넣지 않습니다. 실험 소스 세트와 CI 작업에서만 사용하고 운영 산출물에는 기본적으로 포함하지 않습니다.

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

아닙니다. 런타임 바이트코드 생성, 리플렉션, 에이전트 계측, JNI는 컴파일 단계에 드러나지 않을 수 있습니다. 시작 과정과 대표 연산을 실제 런타임·컨테이너·에이전트 조합으로 실행하고 공급자 지원 조합표를 확인합니다.

연습 문제

필수 확인 하나라도 실패하면 readyfalse인 결과와 실패 영역 목록을 반환하세요. 입력 목록을 변경하지 않는 불변 보고서를 만듭니다.

해설 보기
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);
    }
}

종료 기준은 ready=false와 실패 목록 [driver]입니다. 게이트가 통과해도 카나리 관찰과 롤백 준비는 별도 조건으로 유지합니다.