MMax's Develop Story

Java 21에서 Java 25 LTS로: Virtual Thread 성숙기와 우리가 챙겨야 할 적용 전략

Max··33분 읽기
Java 25Java 21Virtual ThreadJVMMigration회고

Java 21을 깔고 Virtual Thread를 도입한 게 엊그제 같은데, 벌써 Java 25 LTS가 나왔다. 버전 점프 4개의 가치는 새 문법이 아니라, 21이 던져준 함정들이 25에서 거의 다 메워졌다는 사실에 있다. 새 도구를 배우는 글이 아니라, 21에서 어렵게 다듬어 둔 것들을 25가 얼마나 가볍게 만들어 주는지에 대한 글이다.

Java 25는 2025년 9월에 LTS로 GA됐다. 21 LTS 이후 두 번째 LTS이고, 21에서 25 사이엔 정식화된 JEP가 18개, 그중 21 사용자가 곧장 체감할 수 있는 변화가 절반이 넘는다. 단순한 문법 추가가 아니라, JVM 런타임의 핵심(스레드 모델·오브젝트 헤더·AOT 캐시·GC)이 한 단계 정리된 릴리스다.

그래서 이 글은 "Java 25의 새 문법 소개"가 아니다. 이미 Java 21로 운영 중인 팀이, 25로 올라가면서 코드 한 줄을 어떻게 다르게 쓸 수 있고, 운영 비용이 어떻게 달라질지를 시니어 백엔드의 시선에서 정리한 전략 노트다. 특정 서비스를 지목하지 않고, 사내에서 흔히 마주치는 세 가지 유형의 시스템을 기준으로 적용 순서를 짠다.

Java 22~25 JEP 타임라인

1. 상황: Java 21이 남긴 함정과, 그것이 새겨진 코드 패턴들

먼저 출발선을 정확히 짚어 두자. 사내에 있는 Java 21 서비스를 기술 특성으로 나누면 대체로 세 부류로 떨어진다.

  • 유형 A: I/O-bound + 외부 API fan-out 위에 Virtual Thread를 얹은 서비스. 한 요청에서 수십 개 외부 endpoint를 동시에 호출하는 합산 지연 도메인.
  • 유형 B: WebFlux/Reactor 기반 비동기 서비스. Virtual Thread는 도입하지 않고, boundedElastic 스케줄러로 Blocking 작업을 격리해 운영.
  • 유형 C: 일반 동기 MVC 서비스. Java 21로는 갔지만 Virtual Thread는 도입하지 않은, 가장 무난한 형태.

Java 21이 우리에게 준 가장 큰 자산은 두 가지다: Virtual Thread(JEP 444)와 Pattern Matching for switch(JEP 441). 두 기능 모두 운영 코드에 박혀 있고, 되돌릴 생각은 없다.

다만 21을 1년 넘게 굴려본 입장에서 솔직히 적자면, 유형별로 남아 있는 함정이 분명했다.

  • 유형 A: synchronized 블록 안에서 Blocking I/O를 만나면 VThread가 캐리어에 핀(pin) 되어 처리량이 떨어졌다. JDBC 드라이버 내부, 라이브러리 깊숙한 곳까지 검사가 필요했고, BlockHound로 PR 단계에서 막는 컨벤션이 필수였다.
  • 유형 A·C: ThreadLocal로 들고 있던 RequestContext, TraceId 같은 컨텍스트가 VThread × 동시 접속 수만큼 슬롯을 잡아먹었다.
  • 모든 유형: K8s에서 새 Pod이 뜰 때 워밍업 트래픽을 흘려야 P99가 정상화됐다. JIT가 hot path를 학습하는 시간이 운영 SLA에 직결됐다.
  • 모든 유형: Heap 사용량이 평소 GC 빈도를 결정하는 가장 큰 변수였다. 특히 작은 DTO를 대량으로 만드는 도메인에서 "오브젝트 헤더 12바이트가 곱하기 수억"이 되는 부담이 컸다.

이 네 가지 함정이 정확히 Java 22~25에서 하나씩 메워진다. 그래서 25를 단순한 LTS 갱신이 아니라 "21의 빚을 청산하는 릴리스"로 본다.

2. 4년의 압축: 25가 가져오는 변화의 큰 그림

Java 22(2024.03), 23(2024.09), 24(2025.03), 25(2025.09 LTS). 4번의 릴리스 동안 정식화된 주요 JEP만 따로 묶으면 이렇게 된다.

  • JEP 491: Synchronize Virtual Threads without Pinning (JDK 24 정식)
  • JEP 506: Scoped Values (JDK 25 정식)
  • JEP 519: Compact Object Headers (JDK 25 정식)
  • JEP 483 + 515: AOT Class Loading & Linking + AOT Method Profiling (JDK 24, 25)
  • JEP 521: Generational Shenandoah (JDK 25 정식). ZGC Generational은 JDK 23부터 default
  • JEP 485: Stream Gatherers (JDK 24 정식)
  • JEP 511: Module Import Declarations (JDK 25 정식)
  • JEP 513: Flexible Constructor Bodies (JDK 25 정식)
  • JEP 484: Class-File API (JDK 24 정식)

여전히 preview인 것도 정리해 두면: Structured Concurrency(JEP 505, 5차 preview), Primitive Patterns(JEP 507, 3차 preview), Stable Values(JEP 502), Pattern Matching의 잔여 확장. 이 preview 그룹은 25 LTS 위에서도 production에 쓰면 안 된다. preview는 다음 LTS(28)까지 한 번 더 흔들릴 수 있다.

시니어 입장에서 한 가지 강조하고 싶다. "new in 25"라는 라벨이 붙은 것 중 절반은 실제로 23이나 24에 들어왔다. 25 LTS는 그 누적분을 한 번에 안정적으로 쓸 수 있게 모은 릴리스에 가깝다. 그래서 21에서 25로 가는 것은 "한 번에 4년 치를 받는다"는 의미고, 동시에 "4년 치 변경 위험을 한 번에 떠안는다"는 뜻이기도 하다.

3. 첫 번째 큰 변화: synchronized와 Virtual Thread의 화해 (JEP 491)

가장 먼저 짚고 싶은 건 JEP 491이다. 이 한 가지만으로 25로 가야 할 이유가 충분하다고 본다.

Java 21의 Virtual Thread는 강력했지만, synchronized 블록 안에서 Blocking I/O를 만나면 VThread가 캐리어 스레드를 그대로 핀(pin) 했다. 즉, VThread가 mount된 platform 캐리어를 unmount 못 하고 점유한 채 잠들었다. 캐리어 풀(CPU 코어 × 1)이 작은 환경에서 이게 풀로 차면, 새 VThread는 실행 자체를 못 하고 큐에서 굶주린다.

synchronized 핀닝 before/after

JEP 491이 한 일은 monitor 구현 자체를 VThread-aware로 다시 짠 것이다. synchronized를 만나도 VThread는 자유롭게 unmount되고, 캐리어는 다른 VThread를 받는다. 코드 변경 없이, 그저 JDK 24+로 올리는 것만으로 적용된다.

// Before: Java 21에서 우리가 했던 회피책
public Result fetchProfile(String userId) {
    return profileLock.lock(userId, () -> {  // synchronized 대신 ReentrantLock 강제
        return externalApi.call(userId);     // Blocking I/O
    });
}
 
// After: Java 25에서는 다시 synchronized를 자연스럽게 쓸 수 있다
public synchronized Result fetchProfile(String userId) {
    return externalApi.call(userId);  // VThread는 unmount, 캐리어는 자유로워짐
}

작은 차이로 보이지만, 사내 코드 베이스에서 의미가 크다. 이유는 우리가 쓴 코드만이 아니라 우리가 의존하는 라이브러리들의 내부에 박혀 있는 synchronized 가 이제 안전해진다는 데 있다. 일부 JDBC 드라이버, 오래된 connection pool 구현, 로깅 라이브러리의 lock들이 VThread를 핀 시키던 케이스가 사라진다.

단, native frame이 잡고 있는 lock은 여전히 핀 시킨다. JNI를 거치는 경로(예: 특정 압축 라이브러리, 일부 native 보안 모듈)는 그대로다. 그래서 BlockHound나 jcmd JFR 기반 핀닝 모니터링을 완전히 끌 수 있는 건 아니다. 다만 수면 위 함정의 90%가 사라진다고 보면 된다.

유형별 적용 포인트는 이렇게 갈린다.

  • 유형 A (VThread 사용 중): 21 도입 당시 synchronizedReentrantLock 일괄 치환을 했던 부분이 가장 큰 수혜자다. 25 전환 후엔 롤백 PR 한 번으로 가독성을 회복할 수 있다. 굳이 급할 건 없다. 다만 새로 들어오는 코드에선 synchronized를 다시 default로 허용한다.
  • 유형 B (WebFlux): 직접 수혜는 적다. boundedElastic이 이미 Blocking을 격리하고 있어서, 이벤트 루프 핀닝과는 별 상관이 없다. 다만 이 시점이 boundedElastic의 일부를 VThread executor로 점진 치환하는 옵션을 검토할 타이밍이다(8번 섹션 참고).
  • 유형 C (동기 MVC): 25 전환과 동시에 Tomcat의 Virtual Thread executor(spring.threads.virtual.enabled=true 또는 Tomcat 자체 옵션)를 켤지 결정한다. JEP 491 덕에 21 때보다 훨씬 안전하게 켤 수 있다.

4. 두 번째 큰 변화: Compact Object Headers, 코드 한 줄 안 고치고 받는 메모리 절감 (JEP 519)

JEP 519는 24에서 experimental로 들어왔고, 25에서 product로 승격됐다. 64-bit JVM에서 모든 오브젝트가 갖고 있는 헤더 크기를 96~128비트 → 64비트(8바이트) 로 줄인다.

이게 왜 큰 효과를 내냐면, Java의 모든 오브젝트가 헤더를 갖고 있기 때문이다. 4바이트짜리 Integer wrapper 하나도 헤더가 1216바이트, 즉 실제 데이터의 34배다. 이런 작은 오브젝트가 heap에 쌓이는 도메인에서 헤더 4~8바이트 절감은 그대로 heap 총량과 GC 빈도에 반영된다.

OpenJDK가 공개한 SPECjbb2015 벤치 기준:

  • Heap 사용량 22% 감소
  • CPU 시간 8% 감소
  • G1 / Parallel GC 빈도 15% 감소
  • JSON 파서 벤치는 10% 빠르게

켜는 방법은 단순하다.

java -XX:+UseCompactObjectHeaders -jar app.jar

product 플래그이지만, 25에서도 default는 아니라는 점에 유의. 켜는 건 운영자가 명시적으로 결정한다. Amazon이 17, 21로 백포트해서 수백 개 서비스에 적용한 결과 회귀 없이 일관된 효율 개선이 나왔다는 보고도 있다.

유형별 수혜 차이는 분명하다.

  • 유형 A·C (작은 DTO 다량 생성): 외부 응답을 받아 변환하거나, 요청당 수천 개의 작은 record/엔티티를 생성하는 경로가 있는 서비스가 가장 큰 수혜 도메인이다. heap·GC pause·throughput 모두 측정 지표가 움직인다.
  • 유형 B (외부 호출 latency가 응답 시간을 지배): 효과는 분명히 있지만, 메모리보다 외부 호출 latency·cost가 훨씬 큰 도메인이라 우선순위는 낮다. 다만 큰 배열을 요청당 다량 보유하는 경로(임베딩, 이미지 버퍼, 큰 페이로드 파싱 등)가 있다면 측정 가치는 충분하다.

한 가지 트레이드오프: Compact Headers는 클래스 포인터 인코딩을 32비트 → 22비트로 줄여서 공간을 만든다. 즉 한 JVM에 적재할 수 있는 클래스 수의 상한이 줄어든다(약 400만 개). 일반 모놀리스에서 부딪힐 일은 거의 없지만, 수많은 동적 클래스를 만드는 프레임워크(예: 어떤 OR 매퍼, 일부 런타임 코드 생성기)와 부딪힐 수 있다는 보고가 있다. 일반 스택에서는 문제 없을 가능성이 99%지만, production 적용 전 24/7 부하 테스트 한 번은 반드시.

5. 세 번째 큰 변화: Scoped Values 정식화 (JEP 506)

ThreadLocal을 안 써본 백엔드 개발자는 없을 거다. 그런데 ThreadLocal스레드가 비싸던 시대의 패턴이다. Virtual Thread 시대엔 이 모델의 비용이 그대로 폭증한다.

ThreadLocal vs ScopedValue

ScopedValue는 21~24에서 preview였다가 25에서 정식 API가 됐다. 차이의 본질은 세 가지다.

  • 불변 vs 가변: ScopedValue는 한 번 바인딩되면 그 범위 안에서 절대 바뀌지 않는다. ThreadLocal.set()처럼 어디서든 덮어쓸 수 없다.
  • 동적 범위 vs 스레드 수명: ScopedValuewhere(...).run(...) 블록을 벗어나면 자동 해제된다. try-finallyremove()를 부르지 않아도 누수 걱정이 없다.
  • 복사 없는 상속: StructuredTaskScope 안에서 자식 task로 전파될 때 값을 복사하지 않고 그대로 공유한다. InheritableThreadLocal의 복사 비용이 사라진다.
// Before: SecurityFilter가 들고 있는 ThreadLocal
public class CurrentUserHolder {
    private static final ThreadLocal<User> CURRENT = new ThreadLocal<>();
 
    public static void set(User u) { CURRENT.set(u); }
    public static User get() { return CURRENT.get(); }
    public static void clear() { CURRENT.remove(); }
}
 
// After: ScopedValue로 다시 쓰기
public class CurrentUser {
    public static final ScopedValue<User> CURRENT = ScopedValue.newInstance();
}
 
// Filter에서
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) {
    User user = authService.resolve(req);
    ScopedValue.where(CurrentUser.CURRENT, user)
        .run(() -> chain.doFilter(req, res));  // 범위 종료 시 자동 해제
}
 
// 서비스 코드에서
String username = CurrentUser.CURRENT.get();  // 범위 밖이면 예외

유형별 적용 포인트:

  • 유형 A·C (VThread 또는 동기 MVC + ThreadLocal 컨텍스트): RequestContext, TraceId, AuthPrincipal을 모두 ThreadLocal로 들고 있는 경우, VThread × 동시 사용자 수만큼 슬롯 비용이 발생한다. ScopedValue 전환으로 메모리 footprint를 줄이고 누수 위험을 제거한다. 가장 큰 단일 효과는 ThreadLocal 누수의 클래스를 통째로 제거하는 것.
  • 유형 B (WebFlux): ContextView를 이미 쓰고 있어 직접 영향은 적다. 다만 Reactor 컨텍스트와 ScopedValue를 어떻게 양립시킬지는 별도 ADR이 필요. 둘 다 동적 범위라 개념적으로는 잘 맞지만, bridging 레이어가 한 번 필요하다.

주의: ScopedValueThreadLocal의 완전한 대체가 아니다. "값이 자주 바뀌어야 하는 경우"(예: per-step 카운터, 미들웨어 내부의 임시 상태)는 여전히 ThreadLocal이 맞다. ScopedValue는 "요청 단위 read-only 컨텍스트" 같은 곳에 정확히 들어맞는다. 모든 ThreadLocal을 일괄 치환하지 말 것.

6. 네 번째 큰 변화: AOT Class Loading + Method Profiling, K8s 콜드 스타트의 새 무기 (JEP 483, 515)

Project Leyden의 두 결과물이 24~25에 나란히 들어왔다.

  • JEP 483 (JDK 24): AOT Class Loading & Linking. 클래스 파싱·로딩·연결을 사전 캐시.
  • JEP 515 (JDK 25): AOT Method Profiling. 메서드 실행 프로파일을 사전 수집해서 JIT가 즉시 hot path를 컴파일.

두 가지를 같이 쓰면 K8s에서 새 Pod이 뜰 때 JVM 시작 시간 + 워밍업 시간이 한 번에 줄어든다. OpenJDK 측 측정으로는 한 워크로드에서 워밍업 시간이 ~19% 감소.

사용 흐름은 두 단계다.

# Step 1: training run (스테이징에서 한 번, 또는 CI에서)
java -XX:AOTMode=record \
     -XX:AOTConfiguration=app.aotconf \
     -jar app.jar
 
# Step 2: production run (이 캐시를 들고 떠난다)
java -XX:AOTCache=app.aot \
     -jar app.jar

이건 CRaC이나 GraalVM Native Image와는 다른 위치의 도구다.

  • GraalVM Native Image: AOT 컴파일까지 모두 함. 가장 빠른 startup, 가장 제약 많은 호환성.
  • CRaC: 프로세스 메모리 스냅샷 자체를 저장/복원. 가장 빠른 콜드 스타트.
  • JEP 483 + 515: HotSpot JVM의 표준 JIT 경로를 유지하면서, 학습 결과만 미리 갖고 시작.

유형별 적용 포인트:

  • 트래픽이 시간대에 따라 10배 이상 출렁이는 서비스 / HPA 스케일아웃이 잦은 서비스: 새 Pod이 뜰 때마다 워밍업 트래픽을 흘려야 P99가 정상화된다면, AOT 캐시 도입의 최우선 대상이다. 유형 A·C 중 트래픽 곡선이 이 모양이면 가장 큰 수혜.
  • 외부 호출 latency가 응답 시간을 지배하는 서비스: 효과는 분명히 있지만 비중이 작다. 유형 B는 우선순위 낮음.

트레이드오프는 빌드 파이프라인 복잡도다. training run을 어디서 어떻게 돌릴지가 ADR 한 번이 필요한 문제다. staging의 실제 트래픽을 일정 시간 흘린 후 캐시를 떠야 가장 효과가 좋다. CI에서 합성 트래픽으로 떠도 효과는 나오지만, 도메인 분포가 어긋나면 hit rate가 떨어진다.

7. Structured Concurrency: fan-out 코드가 가야 할 다음 단계

JEP 505는 5차 preview다. 25에서 정식화될 거라 기대했지만, API가 한 번 더 다듬어지면서(StructuredTaskScope.open() static factory + Joiner 도입) preview로 남았다. 26, 27에서 6차, 7차 preview가 예정되어 있다.

production에는 아직 쓰지 않는다. 단, 유형 A의 fan-out 코드는 이 방향이 도착지점이 맞다.

// Today (Java 21): CompletableFuture + Virtual Thread executor
public List<ExternalResponse> fanOut(String userId) {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<CompletableFuture<ExternalResponse>> futures = endpoints.stream()
            .map(e -> CompletableFuture.supplyAsync(() -> e.fetch(userId), executor))
            .toList();
        return futures.stream().map(CompletableFuture::join).toList();
        // 한 endpoint 실패 시 다른 호출 취소가 까다롭다
        // 타임아웃·예외 전파가 수동
    }
}
 
// Tomorrow (Java 25 preview): StructuredTaskScope
public List<ExternalResponse> fanOut(String userId) throws InterruptedException {
    try (var scope = StructuredTaskScope.<ExternalResponse>open(
            Joiner.allSuccessfulOrThrow())) {
        for (var endpoint : endpoints) {
            scope.fork(() -> endpoint.fetch(userId));
        }
        return scope.join();
        // 하나 실패하면 나머지 자동 cancel
        // 타임아웃은 scope.joinUntil(...)
    }
}

차이는 두 가지다. 첫째, 실패의 전파가 명시적이다(Joiner.allSuccessfulOrThrow(), anySuccessful() 같은 정책이 명문화). 둘째, 상위 task가 끝나면 자식이 자동 cancel된다(Structured Concurrency의 본질).

유형 A의 적용 방향은 "준비만" 이다. preview 동안은 PoC만 돌려보고, 정식화될 때까지 CompletableFuture + VThread executor를 유지한다. 단, 새로 들어오는 fan-out 코드는 Joiner API의 시그니처를 흉내 낸 사내 추상화로 감싸 두면, 정식화 후 일괄 치환이 쉽다.

8. 그 외 가시화할 것들: Stream Gatherers, Pattern Matching, Module Import

이 절은 짧게 정리한다.

  • Stream Gatherers (JEP 485, JDK 24 정식): 중간 연산을 사용자 정의 가능. windowing, batching, scan 같은 패턴을 라이브러리 없이 표준 Stream으로 표현. 데이터 가공 파이프라인이 있는 어떤 팀이든 즉시 도움된다.
  • Module Import (JEP 511, JDK 25 정식): import module java.base; 한 줄로 모듈 전체를 들여온다. 일반 운영 코드보다는 스크립트/PoC/교육용에서 효과적. 본 코드에 남기진 않는다.
  • Flexible Constructor Bodies (JEP 513, JDK 25 정식): super(...) 호출 전에 검증·계산 로직을 둘 수 있다. 그동안 정적 메서드 우회로 풀던 패턴이 자연스러워진다.
  • Pattern Matching 확장 (JEP 507, preview): primitive 타입을 패턴에 직접 사용. 아직 preview.
  • JFR CPU-Time Profiling (JEP 509, experimental): wall-clock이 아닌 CPU time 기반 프로파일링. 운영 환경에서 더 정확한 hot path 분석.
  • KDF API (JEP 510, JDK 25 정식): HKDF 등 키 파생 함수의 표준 API. 자체 구현하던 팀은 표준으로 교체.

9. 적용 시나리오: 유형별 4단계 롤아웃

여기까지의 정리를 사내 적용 계획으로 압축한다. 빅뱅으로 21을 25로 갈아치우지 않는다. 유형별로 우선순위가 다르고, 단계가 있다.

Phase 1: 비운영 환경부터 (1~2주)

목표는 빌드·테스트가 25에서 통과하는지 확인. 운영에는 손대지 않는다.

  • 모든 신규 PR의 CI 매트릭스에 JDK 25 추가. 21과 25 양쪽에서 빌드/테스트 통과를 강제.
  • 의존성 호환성 매트릭스 작성. Spring Boot, Spring AI, Reactor, JPA 드라이버, Hibernate, BlockHound가 25에서 동작하는 버전 확인.
  • 사내 라이브러리들의 --enable-preview 사용 여부 점검. preview를 production에 안 쓰는 게 원칙이지만, 일부 dev tool은 손볼 게 있을 수 있다.

이 단계의 산출물은 호환성 ADR 하나다. "이 시점에 25로 올린다면 어떤 라이브러리가 어떤 버전에서 멈춰 있는지"를 명시.

Phase 2: 트래픽이 낮은 서비스부터 (2~4주)

  • 사내 admin 서버, 알람 서비스, 배치 등 트래픽이 낮은 서비스부터 Java 25 컨테이너로 교체.
  • 이 단계에서 JEP 491의 효과(synchronized + VThread)를 BlockHound off / on 양쪽으로 검증.
  • Compact Headers -XX:+UseCompactObjectHeaders 를 staging 부하 테스트로 측정. 회귀가 없는지, heap 사용이 실제로 줄어드는지 그래프로 본다.

Phase 3: 핵심 운영 서비스, 유형 기준 우선순위 (4~12주)

유형 A부터 시작한다. 이유는 단순하다: 25의 수혜가 유형 A에서 가장 크기 때문이다(VThread 사용 중 + 작은 DTO 다량 생성 + 트래픽 출렁임). 유형 B(WebFlux)는 가장 늦게 간다. 효과 측정이 가장 복잡하고, 수혜는 가장 작다.

유형 A 적용 순서 (가장 큰 수혜):

  1. JDK 25로 컨테이너만 교체. 코드 변경 없음. JEP 491의 무료 수혜(synchronized 핀닝 해소)를 그대로 받는다.
  2. -XX:+UseCompactObjectHeaders 켜기. heap·GC 지표 일주일 관찰.
  3. AOT 캐시 도입. training run을 staging에서 1시간 흘리고 캐시 생성, production에 배포.
  4. RequestContext 같은 read-only context를 ScopedValue로 전환.
  5. ReentrantLock으로 강제 치환했던 부분 중, synchronized로 돌릴 수 있는 곳을 가독성 회복 목적으로 일부 PR.

유형 C 적용 순서 (중간 수혜):

  1. 동일하게 컨테이너 교체. JEP 491 무료 수혜.
  2. VThread 도입 결정 시점. 21 때 미루던 결정을 25에서 다시 본다. Tomcat의 VThread executor를 켜고 부하 테스트.
  3. Compact Headers + AOT 캐시 도입.
  4. ThreadLocal → ScopedValue는 VThread 도입과 함께 묶어서.

유형 B 적용 순서 (작은 수혜):

  1. 컨테이너만 교체. 효과 측정에 시간을 들이지 않고 전환.
  2. Reactor 컨텍스트와 ScopedValue의 양립 ADR 작성. 즉시 도입하지 않는다.
  3. boundedElastic 일부를 VThread executor로 옮기는 PoC는 별도 트랙으로 운영. 25 전환과 분리.

Phase 4: 새 코드는 25 기본값 (지속)

  • 신규 마이크로서비스는 처음부터 25 위에서 출발.
  • ScopedValue를 컨텍스트 전파 표준으로 채택. ThreadLocal은 "가변이 필요한 경우만"으로 제한.
  • Structured Concurrency는 사내 추상화로 한 겹 감싸 두고, 정식화되면 일괄 치환.
  • AOT 캐시 생성 파이프라인을 CI에 표준 단계로 추가.

한 가지 강조하고 싶은 건, "21에서 22, 23, 24, 25를 거치지 않고 한 번에 점프" 한다는 점이다. 25 LTS의 가장 큰 가치는 22~24에서 흔들리던 API들이 모두 안정된 상태로 모여 있다는 점이다. 중간 릴리스를 거칠 이유가 없다.

10. 시니어 시선 인사이트

① "새 LTS = 새 기능"이 아니라 "새 LTS = 안정된 누적분"이다

엔지니어들은 "Java 25에 뭐가 새로 들어왔는지"를 묻는다. 이 질문은 절반만 맞다. 25의 진짜 의미는, 22~24에 등장한 기능들 중 정식화된 것들이 한 자리에 모여 있다는 점이다.

운영 서비스에 적용할 때 봐야 할 건 "JDK 25에 새로 들어온 한두 JEP"가 아니라, "21 이후 4년 동안 누적된 정식 API 전체"다. JEP 519, JEP 491, JEP 485가 모두 25 이전에 정식화됐지만, LTS 라벨이 붙은 안정 릴리스에서 한꺼번에 받을 수 있다는 것이 적용 시점을 결정한다.

② Preview는 production에 들어오지 않는다, 그러나 코드 모양에는 영향을 준다

Structured Concurrency, Stable Values, Primitive Patterns는 모두 25에서 preview다. production에는 쓰지 않는다. 다음 LTS(예상 28)까지 한 번 더 흔들릴 가능성이 있고, 이미 5차 preview 동안 API가 두세 번 바뀐 전례가 있다.

다만 새로 작성하는 코드의 모양이 정식화될 API와 흡사하게 가는 것은 의미가 있다. 새 fan-out 추상화를 Joiner 시그니처를 흉내 내서 만들어 두면, 정식화 후 일괄 치환이 mechanical change로 끝난다. preview를 production에 쓰지 않는 것과, preview의 방향을 무시하는 것은 다르다.

③ JVM 플래그 하나로 받는 수혜를 과소평가하지 않는다

JEP 519는 -XX:+UseCompactObjectHeaders 한 줄로 켜진다. JEP 491은 켤 필요조차 없다. 코드 변경 없이 JVM만 25로 바꾸면 synchronized 핀닝 해소 + heap 22% 절감 + GC 빈도 15% 감소를 받을 수 있다.

이건 사실 굉장히 드문 종류의 수혜다. 보통 새 LTS는 "쓰려면 코드를 고쳐야" 하는 경우가 많다. 25는 그 반대다. 가만히만 있어도 JVM이 더 잘 일한다. 그래서 적용 우선순위가 단순한 새 문법 도입보다 훨씬 높다.

④ 도구의 진화는 멘탈 모델까지 같이 끌고 와야 의미가 있다

JEP 491이 synchronized를 다시 안전하게 만들었다고 해서, BlockHound를 끄거나 코드 리뷰에서 핀닝 점검을 빼지 않는다. 이유는 두 가지다. 첫째, native frame 기반 핀닝은 여전히 존재한다. 둘째, "이제 안전해졌으니 신경 안 써도 된다"는 멘탈은 5년 뒤에 다시 같은 함정에 빠지게 만든다.

도구가 좋아진 만큼, 그 안에서 무엇을 신뢰하고 무엇을 검증하는지를 더 명확히 적어 두는 게 시니어의 몫이다. "JEP 491로 해결됐다"는 한 줄을 ADR에 적고, 그 ADR이 매년 갱신되는 게 정상 상태다.

⑤ 유형별로 다른 답을 가지는 것이 시니어의 진짜 역할이다

같은 LTS 전환이라도 유형 A에서는 "최우선·다섯 단계 롤아웃"이지만 유형 B에서는 "그냥 컨테이너 교체, 나머지는 별도 트랙"이다. 수혜의 크기가 다르고, 위험의 크기가 다르고, 검증 비용이 다르다.

"전사 표준이니까 모두 25로 같은 방식으로 간다"는 정책은 듣기엔 깔끔하지만, 운영 비용을 합치면 가장 비싸다. 각 유형의 특성을 보고 적용 깊이를 다르게 주는 것이 LTS 전환의 ROI를 결정한다.

마무리: 한 문장으로 줄이면

Java 25 LTS는 새 문법을 배우러 가는 릴리스가 아니다. 21에서 만들어 둔 함정 대응 코드(ReentrantLock 우회, ThreadLocal 정리, BlockHound 컨벤션, JIT 워밍업 전략)의 비용을 절반 이상 청산해 주는 릴리스다.

다음 액션 아이템은 셋이다.

  1. JDK 25 호환성 매트릭스 ADR: 사내 의존성이 25에서 어떻게 동작하는지 한 페이지로 정리.
  2. 유형 A 대표 서비스 PoC: JEP 491 + Compact Headers + AOT 캐시의 staging 측정. 한 달 안에 숫자를 본다.
  3. ScopedValue / Structured Concurrency 사내 추상화 가이드: 본격 도입이 아니라, 방향을 미리 합의해 두는 것. 정식화 시점에 일괄 치환할 수 있는 표면을 만들어 둔다.

Java 21이 Virtual Thread의 가능성을 보여 줬다면, Java 25는 그 가능성을 운영 비용 없이 누리는 방법을 같이 가져왔다. LTS 라벨이 붙은 만큼, 서두를 필요는 없되 미루지도 않는다. 이 릴리스는 그럴 가치가 있다.