Java 25 LTS 글을 여러 개 읽다 보니 Scoped Values와 Structured Concurrency가 늘 한 묶음으로 나옵니다. 둘 다 가상 스레드를 뒷받침하는 기능이니 그럴듯하죠. 그런데 JEP 목록을 직접 확인해 보니 두 기능의 처지가 정반대였습니다.
Scoped Values(JEP 506)는 정식입니다. 오늘 프로덕션에 넣어도 됩니다. Structured Concurrency(JEP 505)는 아직 프리뷰고, 그것도 다섯 번째 프리뷰입니다.
이 구분을 모르고 둘 다 쓸 수 있다고 생각하면 곤란해집니다. LTS라고 해서 안에 든 게 전부 안정된 건 아니더군요.
JEP 18개를 세 칸으로 나누면
Java 25는 2025년 9월 16일에 GA 됐고 JEP 18개가 들어갔습니다. 이름만 훑으면 다 써도 되는 것처럼 보여서, 성숙도로 나눠 봤습니다.
지금 써도 되는 것 (정식)
| JEP | 기능 | 실무 가치 |
|---|---|---|
| 506 | Scoped Values | 높음 — 가상 스레드 환경의 컨텍스트 전달 |
| 519 | Compact Object Headers | 높음 — 플래그 하나로 힙 절감 |
| 511 | Module Import Declarations | 중간 — import 정리 |
| 512 | Compact Source Files and Instance Main Methods | 중간 — 학습·스크립트 |
| 513 | Flexible Constructor Bodies | 중간 — 생성자 검증 |
| 514·515 | Ahead-of-Time Command-Line Ergonomics / Method Profiling | 중간 — 기동 시간 |
| 521 | Generational Shenandoah | 상황에 따라 |
| 510 | Key Derivation Function API | 암호 관련 작업 시 |
| 518·520 | JFR Cooperative Sampling / Method Timing & Tracing | 프로파일링 |
아직 쓰면 안 되는 것 (프리뷰·인큐베이터·실험)
| JEP | 기능 | 상태 |
|---|---|---|
| 505 | Structured Concurrency | 5차 프리뷰 |
| 507 | Primitive Types in Patterns, instanceof, switch | 3차 프리뷰 |
| 502 | Stable Values | 1차 프리뷰 |
| 470 | PEM Encodings of Cryptographic Objects | 1차 프리뷰 |
| 508 | Vector API | 10차 인큐베이터 |
| 509 | JFR CPU-Time Profiling | 실험 |
없어진 것
JEP 503 으로 32비트 x86 포트가 빠졌습니다.
정식 10개, 프리뷰·인큐베이터·실험 6개, 제거 1개입니다. 새 문법을 기대했다면 좀 심심할 수 있습니다. 반대로 런타임 쪽을 노린다면 얻을 게 꽤 있는 릴리스입니다.
지금 쓸 것 ① Scoped Values — 가상 스레드가 만든 문제의 답
왜 ThreadLocal이 문제가 되었나
ThreadLocal은 플랫폼 스레드 시절에 잘 동작했습니다. 스레드 풀의 스레드 수가 수십~수백 개로 제한돼 있었기 때문입니다.
가상 스레드는 전제를 뒤집습니다. 요청마다 하나씩 만들고 버리는 게 정상이라 동시에 수십만~수백만 개가 존재할 수 있습니다. JEP 506은 이렇게 적고 있습니다.
백만 개의 가상 스레드가 각자 스레드 로컬 변수의 복사본을 가진다면 메모리 사용량이 상당해질 수 있다.
문제는 메모리만이 아닙니다. ThreadLocal은 가변(mutable)이고 수명이 불명확합니다.
static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
void handle(Request req) {
CURRENT_USER.set(authenticate(req));
try {
process();
} finally {
CURRENT_USER.remove(); // 이걸 빼먹으면 값이 남아 다음 작업에 새어 나간다
}
}
remove()를 빼먹으면 스레드 풀에서 재사용되는 스레드에 값이 남습니다. 다른 사용자의 요청이 앞선 사용자의 컨텍스트를 보게 되는 사고가 여기서 나옵니다.
Scoped Values는 어떻게 다른가
값을 불변으로, 명확한 블록 안에서만 유효하게 만듭니다.
import java.lang.ScopedValue;
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handle(Request req) {
ScopedValue.where(CURRENT_USER, authenticate(req))
.run(this::process); // 블록을 벗어나면 자동으로 해제된다
}
void audit() { // 호출 깊이와 무관하게 읽을 수 있다
User u = CURRENT_USER.get();
log.info("actor={}", u.id());
}
remove()가 없습니다. run()이 끝나면 바인딩이 사라지므로 누수가 구조적으로 불가능합니다. 값도 바꿀 수 없어서 “중간에 누가 덮어썼나”를 추적할 일이 없습니다.
API 전체
| 용도 | 메서드 |
|---|---|
| 선언 | ScopedValue.newInstance() |
| 바인딩 | ScopedValue.where(key, value) |
| 실행 (반환값 없음) | .run(Runnable) |
| 실행 (반환값 있음) | .call(Callable) |
| 읽기 | get() |
| 바인딩 여부 확인 | isBound() |
| 없으면 기본값 | orElse(기본값) |
| 없으면 예외 | orElseThrow() |
여러 값을 함께 바인딩하려면 where를 이어 붙입니다.
ScopedValue.where(CURRENT_USER, user)
.where(TENANT, tenant)
.run(() -> handleRequest());
반환값이 필요하면 call()을 씁니다. Callable을 받으므로 검사 예외를 던질 수 있습니다.
Order order = ScopedValue.where(CURRENT_USER, user)
.call(() -> orderService.place(request));
바인딩 없이 get()을 호출하면 예외가 납니다. 선택적으로 쓰는 값이라면 orElse()나 isBound()로 감싸야 합니다.
String actor = CURRENT_USER.isBound() ? CURRENT_USER.get().id() : "anonymous";
// 또는
User u = CURRENT_USER.orElse(GUEST);
프리뷰에서 바뀐 것 하나
정식화 과정에서 API 변경은 사실상 없었습니다. 단 하나, orElse가 더 이상 null을 인자로 받지 않습니다. 프리뷰 시절 orElse(null)을 쓰던 코드는 컴파일 또는 실행 단계에서 걸립니다.
언제 쓰고 언제 쓰지 말아야 하나
- 적합 — 인증 주체, 테넌트 ID, 요청 추적 ID, 로케일처럼 요청 단위로 고정되고 호출 스택 깊은 곳에서 읽어야 하는 값
- 부적합 — 중간에 값을 바꿔야 하는 경우. Scoped Values는 불변입니다. 이럴 때는 명시적으로 인자를 넘기거나 가변 홀더를 설계해야 합니다.
기존 ThreadLocal 코드를 전부 갈아엎을 필요는 없습니다. 가상 스레드로 전환하는 경로에 있는 컨텍스트 전달부터 옮기는 것이 순서입니다.
지금 쓸 것 ② Compact Object Headers — 플래그 하나로 힙 절감
코드를 한 줄도 안 고치고 메모리를 줄일 수 있는 항목입니다. 객체 헤더 크기를 줄여 힙 사용량을 낮춥니다.
기본값이 아닙니다. 명시적으로 켜야 합니다.
-XX:+UseCompactObjectHeaders
Java 24까지는 -XX:+UnlockExperimentalVMOptions를 함께 붙여야 했지만, 25에서 정식 제품 기능이 되어 플래그 하나로 충분합니다.
JEP 519가 제시하는 측정치입니다.
| 지표 | 효과 |
|---|---|
| 힙 사용량 (SPECjbb2015) | 22% 감소 |
| CPU 사용량 (SPECjbb2015) | 8% 감소 |
| GC 빈도 (G1·Parallel) | 약 15% 감소 |
| JSON 파서 벤치마크 | 10% 빠름 |
작은 객체를 대량으로 만드는 애플리케이션일수록 효과가 큽니다. 반대로 큰 배열 몇 개가 힙을 차지하는 구조라면 별 차이가 없습니다. 본인 워크로드에서 재보고 판단하는 것이 맞습니다.
한 가지 알아둘 것이 있습니다. JEP 519는 비목표(non-goal)에 “이 레이아웃을 기본값으로 만드는 것은 목표가 아니다” 라고 명시했습니다. 앞으로도 옵트인으로 남을 가능성이 있으니, 켰다면 JVM 옵션 관리 대상에 넣어 두세요.
지금 쓸 것 ③ 문법 정리 3종
큰 변화는 아니지만 손에 익으면 편한 것들입니다.
Module Import Declarations (JEP 511)
모듈이 내보내는 패키지 전체를 한 줄로 가져옵니다.
import module java.base; // java.base 가 export 하는 모든 패키지
List<String> names = new ArrayList<>(); // java.util.* 별도 import 없이
편하지만 애플리케이션 코드에는 권하지 않습니다. 어떤 타입이 어디서 왔는지 보이지 않게 되고 이름 충돌 위험도 커집니다. 학습용 코드, 스크립트, 테스트 픽스처 정도가 적당합니다.
Compact Source Files and Instance Main Methods (JEP 512)
클래스 선언과 String[] args 없이 실행 가능한 파일을 쓸 수 있습니다.
void main() {
IO.println("Hello");
}
java Hello.java로 바로 돌아갑니다. 교육과 짧은 스크립트를 위한 기능입니다.
Flexible Constructor Bodies (JEP 513)
super(...) 호출 앞에 문장을 쓸 수 있게 됐습니다. 이전에는 super 호출이 반드시 첫 문장이어야 해서, 인자 검증을 하려면 정적 메서드로 우회해야 했습니다.
public Order(List<Item> items, Money total) {
if (items == null || items.isEmpty()) { // 이제 여기서 바로 검증 가능
throw new IllegalArgumentException("items required");
}
super(total);
this.items = List.copyOf(items);
}
super 호출 전에는 인스턴스 필드에 접근할 수 없다는 제약은 그대로입니다.
아직 쓰지 말 것 — Structured Concurrency (5차 프리뷰)
가장 기대되는 기능이면서, 가장 미룰 이유가 뚜렷한 기능입니다.
다섯 번 프리뷰를 거치는 동안 API가 계속 바뀌었다
5차 프리뷰에서 바뀐 것만 봐도 이렇습니다.
공개 생성자로 만들던 것이 open() 정적 팩토리로 바뀌었고, fork 가 돌려주던 Future 는 Subtask 가 됐습니다. 완료 정책을 서브클래싱으로 표현하던 방식도 Joiner 인터페이스로 갈렸습니다.
Joiner는 onFork, onComplete, result 세 메서드로 하위 작업의 완료를 처리하고, anySuccessfulResultOrThrow(), allSuccessfulOrThrow(), awaitAll(), allUntil(Predicate) 같은 팩토리가 제공됩니다. 설계는 확실히 좋아졌습니다. 문제는 이번이 마지막이라는 보장이 없다는 것입니다.
프리뷰 기능을 프로덕션에 쓰면 안 되는 이유
프리뷰는 --enable-preview를 붙여야 컴파일과 실행이 됩니다.
javac --release 25 --enable-preview Main.java
java --enable-preview Main
여기서 실무 문제가 생깁니다.
- 프리뷰 API는 마이너 버전 사이에서도 호환을 보장하지 않습니다. Java 26에서 시그니처가 바뀌면 코드를 고쳐야 합니다.
--enable-preview로 컴파일한 클래스 파일은 정확히 같은 버전의 JVM에서만 실행됩니다. 25로 빌드한 산출물은 26에서 안 돌아갑니다.- 라이브러리라면 더 심각합니다. 사용자 전원에게
--enable-preview를 강제하게 됩니다.
LTS를 고르는 이유가 안정성이라면, 그 위에서 프리뷰를 쓰는 건 목적과 어긋납니다. 지금은 ExecutorService와 CompletableFuture로 버티면서, 정식화되면 옮길 지점을 미리 좁혀 두는 편이 낫습니다.
나머지 프리뷰도 마찬가지
Vector API는 열 번째 인큐베이터입니다. JDK 16(2021)에서 JEP 338로 처음 들어온 뒤 17부터 24까지 아홉 번을 거쳤습니다. JEP 508은 이유를 명시하고 있습니다 — Project Valhalla의 필요한 기능이 프리뷰로 제공될 때까지 인큐베이터에 머문다는 것입니다. 현재는 value-based 클래스를 쓰고 있고, Valhalla의 값 클래스가 준비되면 그것으로 전환한 뒤 프리뷰로 승격할 계획입니다. 즉 이 기능의 일정은 Valhalla에 묶여 있어 예측이 불가능합니다. Primitive Types in Patterns는 3차, Stable Values와 PEM Encodings는 1차 프리뷰입니다.
없어진 것 — 32비트 x86 포트
JEP 503으로 32비트 x86 포트가 제거됐습니다. 실무에서 걸릴 가능성은 낮지만, 오래된 임베디드 장비나 레거시 빌드 서버가 32비트 리눅스라면 Java 25로 올라갈 수 없습니다. 업그레이드 계획을 세우기 전에 대상 장비의 아키텍처를 먼저 확인하세요.
그래서 무엇을 해야 하나
-XX:+UseCompactObjectHeaders를 스테이징에 켜고 힙과 GC 지표를 비교한다. 코드 변경이 없으니 가장 먼저 할 일입니다. 효과가 있으면 그대로 얻는 이득입니다.- 가상 스레드를 쓰고 있거나 쓸 계획이면
ThreadLocal사용처를 찾아 목록을 만든다. 요청 단위로 고정되는 값부터ScopedValue로 옮깁니다. orElse(null)을 쓰는 코드가 있는지 확인한다. 프리뷰 시절 코드가 있다면 여기서 걸립니다.- 32비트 x86 장비가 있는지 확인한다.
- Structured Concurrency는 목록에만 올려 둔다. 지금 도입하지 않습니다.
Spring Boot를 쓰고 있다면 Spring Boot 4.0 마이그레이션과 함께 계획하는 편이 낫습니다. Spring Boot 4.0의 최소 요구는 Java 17이지만 Spring 팀은 Java 25를 권장 타깃으로 안내하고 있어, 두 작업을 묶으면 검증을 한 번만 하면 됩니다.
자주 묻는 질문
Q. Java 21에서 25로 올려야 할 이유가 있나요? A. 문법이 크게 바뀌지 않아 코드 변경 부담은 작습니다. 실질적인 이유는 런타임 쪽입니다. Compact Object Headers로 힙을 줄일 수 있고, Scoped Values가 정식이 되어 가상 스레드 환경의 컨텍스트 전달 문제를 제대로 풀 수 있습니다. 둘 다 안 쓸 거라면 급하지 않습니다.
Q. Scoped Values가 ThreadLocal을 완전히 대체하나요?
A. 아니요. Scoped Values는 불변이라 값을 바꿔야 하는 용도에는 쓸 수 없습니다. ThreadLocal은 그대로 남아 있고 없어질 계획도 없습니다. 요청 단위로 고정되는 컨텍스트에만 적용하세요.
Q. Compact Object Headers를 켜면 위험하지 않나요? A. 25에서 실험 옵션에서 정식 제품 기능으로 승격됐습니다. 다만 JEP 문서가 “향후 기능이 헤더 비트를 더 요구할 수 있다”는 위험을 언급하고 있으므로, 스테이징에서 충분히 돌려 보고 적용하세요. 코드 변경이 없어 되돌리기도 플래그를 빼는 것으로 끝납니다.
이 글은 JDK 25 프로젝트 페이지와 각 JEP 문서를 2026년 9월 기준으로 정리한 것입니다. 벤치마크 수치는 JEP 519가 제시한 값이며, 실제 효과는 워크로드에 따라 달라집니다. 적용 전 본인 환경에서 측정하시기 바랍니다.
참고 자료
- JDK 25 (OpenJDK 프로젝트 페이지)
- JEP 506: Scoped Values
- JEP 505: Structured Concurrency (Fifth Preview)
- JEP 519: Compact Object Headers
- JEP 511: Module Import Declarations
- JEP 512: Compact Source Files and Instance Main Methods
- JEP 513: Flexible Constructor Bodies
- JEP 503: Remove the 32-bit x86 Port
“Java 25 LTS 실무 정리: 지금 쓸 것과 아직 아닌 것”에 대한 1개의 생각