Java 25 LTS 가이드 — 정식 출시된 핵심 기능과 마이그레이션 체크리스트
Java 25는 Java 21 이후 4년 만의 LTS입니다. Scoped Values가 정식 출시됐고 Structured Concurrency는 아직 5차 프리뷰 단계입니다. 한 버전 앞선 Java 24에서 해결된 Virtual Threads 핀닝 문제까지 포함해 22~25 버전대의 핵심 변경사항과 마이그레이션 체크리스트를 정리합니다.
개요
Java 25는 Java 21 이후 4년 만에 나온 LTS(Long-Term Support) 릴리스입니다. Scoped Values가 드디어 정식 기능으로 자리 잡았고, Virtual Threads의 오랜 골칫거리였던 핀닝 문제는 한 버전 앞선 Java 24에서 이미 해결됐습니다. 다만 Structured Concurrency는 Java 25에서도 아직 5차 프리뷰 단계라 --enable-preview 없이는 쓸 수 없습니다. 그 사이 Java 22·23·24는 단순 버전 업이 아니라 이 기능들의 프리뷰 기간 역할을 했습니다.
이 글은 Java 25 LTS로 업그레이드하려는 백엔드 개발자를 위해 지금 바로 쓸 수 있는 기능과 아직 프리뷰인 기능을 구분해서 정리하고, 그 기능들이 22~24를 거치며 어떻게 다져졌는지를 뒤에서 보여줍니다.
버전 여정 한눈에 보기
| 기능 | Java 22 | Java 23 | Java 24 | Java 25 |
|---|---|---|---|---|
| Structured Concurrency | Preview 2 | Preview 3 | Preview 4 | Preview 5 |
| Scoped Values | Preview 2 | Preview 3 | Preview 4 | Final |
| Primitive Types in Patterns | — | Preview 1 | Preview 2 | Preview 3 |
| Flexible Constructor Bodies | Preview 1 | Preview 2 | Preview 3 | Final |
| Module Import Declarations | — | Preview 1 | Preview 2 | Final |
| Unnamed Variables & Patterns | Final | — | — | — |
| Stream Gatherers | Preview 1 | Preview 2 | Final | — |
| VT 핀닝 해결 (JEP 491) | — | — | Final | — |
| Generational Shenandoah | — | — | Experimental | Final |
| Compact Object Headers | — | — | Experimental | Final |
Preview로 표시된 기능은
--enable-preview플래그가 필요하고, 다음 버전에서 API가 바뀔 수 있습니다.
Java 25 — 핵심 기능
아래 기능 중 Final로 표시된 것만 별도 플래그 없이 바로 쓸 수 있습니다. Preview 기능은 방향성은 안정적이지만 프로덕션에 바로 올리기 전에 한 번 더 점검하는 게 안전합니다.
Structured Concurrency — 병렬 작업을 하나의 단위로 관리하기 (5차 Preview)
Java 19에서 인큐베이터로 시작해 21부터 프리뷰 단계를 거쳤고, Java 25(JEP 505)에서도 다섯 번째 프리뷰입니다. --enable-preview 없이는 컴파일조차 되지 않고, 프리뷰를 거칠 때마다 API가 계속 바뀌고 있습니다 — 5차 프리뷰에서는 ShutdownOnFailure/ShutdownOnSuccess 같은 서브클래스 방식이 사라지고, Joiner를 받는 정적 팩토리 메서드(StructuredTaskScope.open(Joiner))로 전부 교체됐습니다.
여러 다운스트림 서비스를 병렬로 호출하는 팬아웃 패턴은 ExecutorService와 Future로 구현해왔습니다. 문제는 한 태스크에서 예외가 발생해도 나머지 태스크가 계속 실행된다는 점입니다.
public OrderSummary getOrderSummary(Long orderId) throws Exception {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<Order> orderFuture = executor.submit(() -> orderService.findById(orderId));
Future<Payment> paymentFuture = executor.submit(() -> paymentService.findByOrderId(orderId));
try {
Order order = orderFuture.get(); // 예외 발생 시
Payment payment = paymentFuture.get(); // paymentFuture는 여전히 실행 중
return new OrderSummary(order, payment);
} finally {
executor.shutdown(); // 수동 정리 필요
}
}StructuredTaskScope는 자식 태스크가 스코프보다 오래 살아남을 수 없음을 보장합니다. 서로 다른 타입(Order, Payment)을 fork한다면 Joiner.awaitAllSuccessfulOrThrow()를 사용합니다 — 하나라도 실패하면 나머지가 자동 취소되고 예외가 전파됩니다.
public OrderSummary getOrderSummary(Long orderId) throws Exception {
try (var scope = StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow())) {
StructuredTaskScope.Subtask<Order> orderTask =
scope.fork(() -> orderService.findById(orderId));
StructuredTaskScope.Subtask<Payment> paymentTask =
scope.fork(() -> paymentService.findByOrderId(orderId));
scope.join(); // 하나라도 실패하면 여기서 예외 발생, 나머지 자동 취소
return new OrderSummary(orderTask.get(), paymentTask.get());
} // AutoCloseable: 스코프 종료 시 모든 자식 태스크 정리
}Primary/Fallback 레이싱 패턴처럼 같은 타입의 결과 중 하나만 필요하다면 Joiner.anySuccessfulResultOrThrow()를 사용합니다. 먼저 성공한 태스크의 결과를 반환하고 나머지는 취소됩니다.
public String getProductInfo(String productId) throws Exception {
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> primaryCatalogService.fetch(productId));
scope.fork(() -> fallbackCatalogService.fetch(productId));
return scope.join(); // 최초 성공 결과 반환
}
}Scoped Values — ThreadLocal의 진화 (Final)
ThreadLocal은 요청 컨텍스트(userId, traceId 등)를 메서드 파라미터 없이 전달하는 표준 수단이었습니다. 그러나 Virtual Threads 환경에서는 자식 스레드에 값이 상속될 때마다 복사가 발생해 오버헤드가 커집니다. Scoped Values는 불변이고 스코프 내에서만 유효해 이 문제를 해결합니다.
// 기존 ThreadLocal 방식: mutable, 수동 remove() 필요
public class RequestContext {
private static final ThreadLocal<String> USER_ID = new ThreadLocal<>();
public static void set(String userId) { USER_ID.set(userId); }
public static String get() { return USER_ID.get(); }
public static void clear() { USER_ID.remove(); } // 누락 시 메모리 누수
}// Java 25 ScopedValue 방식: immutable, 스코프 종료 시 자동 해제
public class RequestContext {
public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
}
// 사용 측: where().run()으로 바인딩 범위 명시
ScopedValue.where(RequestContext.USER_ID, userId).run(() -> {
orderService.createOrder(orderRequest); // 하위 호출 어디서든 USER_ID 접근 가능
});스코프 밖에서 USER_ID.get()을 호출하면 NoSuchElementException이 발생해 컨텍스트 누수를 런타임에 즉시 감지할 수 있습니다.
Primitive Types in Patterns — switch와 instanceof 확장 (3차 Preview)
패턴 매칭이 참조 타입을 넘어 **기본형(primitive type)**으로 확장됩니다. Java 23(JEP 455)에서 시작해 Java 25(JEP 507)까지 세 번째 프리뷰를 거치고 있고, 아직 --enable-preview가 필요합니다.
// switch에서 int 패턴 가드 사용
public static String classifyStatus(int statusCode) {
return switch (statusCode) {
case int n when n >= 500 -> "서버 오류";
case int n when n >= 400 -> "클라이언트 오류";
case int n when n >= 300 -> "리다이렉션";
case int n when n >= 200 -> "성공";
default -> "정보";
};
}
// instanceof로 역직렬화 Number 타입 체크
public static long toLong(Number value) {
if (value instanceof int n) return n;
if (value instanceof long n) return n;
if (value instanceof double n) return (long) n;
throw new IllegalArgumentException("지원하지 않는 타입: " + value.getClass());
}그 외 (모두 Final)
Flexible Constructor Bodies — super() 호출 이전에 로컬 변수 선언과 유효성 검사를 작성할 수 있습니다. 기존에는 스태틱 팩토리 메서드로 우회해야 했습니다.
public class PositiveAmount {
private final int value;
public PositiveAmount(int value) {
if (value <= 0) throw new IllegalArgumentException("양수여야 합니다: " + value);
super();
this.value = value;
}
}Module Import Declarations — import module java.sql; 한 줄로 모듈 내 공개 패키지를 모두 임포트합니다.
// 기존
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
// Java 25
import module java.sql;Compact Source Files & Instance Main Methods — class 선언 없이 void main() 메서드만으로 Java 파일을 실행할 수 있습니다. 빠른 프로토타이핑이나 스크립트성 작업에 유용합니다.
void main() {
System.out.println("Hello, Java 25!");
}메모리·보안 — Compact Object Headers, Quantum-Resistant 암호화 (모두 Final)
Compact Object Headers(JEP 519) — 모든 객체의 헤더가 12바이트에서 8바이트로 줄어듭니다. 코드 변경 없이 -XX:+UseCompactObjectHeaders 플래그만 켜면 적용되며, SPECjbb2015 기준 힙 사용량이 22% 줄어든 사례가 있습니다(워크로드에 따라 절감폭은 다릅니다). 객체를 대량으로 들고 있는 캐시·세션 서버라면 시도해볼 가치가 있습니다.
Quantum-Resistant 암호화(JEP 496 ML-KEM, JEP 497 ML-DSA) — NIST FIPS 203/204 표준 양자 내성 알고리즘이 KeyPairGenerator, KEM, Signature 같은 표준 java.security API에 그대로 추가됐습니다. 보안 규제를 받는 서비스라면 알고리즘 이름만 바꿔서 마이그레이션을 검토할 수 있습니다.
Shenandoah GC를 쓰고 있다면 Generational 모드도 이번에 -XX:+UnlockExperimentalVMOptions 없이 켤 수 있는 정식 기능(JEP 521)이 됐습니다.
Java 25로 가는 길 — Java 22~24
위 기능들이 어떻게 다져졌는지, 그리고 Virtual Threads 핀닝처럼 Java 25보다 먼저 해결된 것들은 무엇인지 궁금하다면 참고할 배경입니다. 지금 바로 업그레이드만 할 거라면 이 섹션은 건너뛰어도 됩니다.
Java 22 — Foreign Function API 확정, Unnamed Variables
Unnamed Variables & Patterns
예외를 잡아야 하지만 변수가 필요 없는 경우, 반복문에서 인덱스 변수를 사용하지 않는 경우에 _로 명시적으로 표현할 수 있습니다.
// 기존: 쓰지 않는 변수에 이름을 붙여야 했음
try {
return parseJson(input);
} catch (JsonParseException e) { // e를 전혀 안 씀
return defaultValue;
}
// Java 22: _ 로 "사용하지 않음" 명시
try {
return parseJson(input);
} catch (JsonParseException _) {
return defaultValue;
}
// 향상된 for문에서도 동일
for (var _ : events) {
count++;
}Foreign Function & Memory API
JNI를 대체하는 네이티브 코드 호출 API가 Java 22에서 정식 출시됐습니다. 암호화 라이브러리나 OS 레벨 API를 호출할 때 JNI보다 안전하고 빠릅니다. 대부분의 업무용 백엔드에서 직접 쓸 일은 적지만, 의존 라이브러리들이 내부적으로 활용합니다.
G1 Region Pinning (JEP 423)
G1 GC는 JNI critical region에 진입하면 해당 영역이 GC로 옮겨지지 않도록 보호해야 합니다. Java 21까지는 이를 위해 GC 전체를 일시 중단했기 때문에, JNI 호출이 길어지면 다른 스레드까지 GC 대기에 걸렸습니다. Java 22부터는 해당 리전만 핀닝하고 나머지 힙은 정상적으로 수거합니다. 네이티브 압축·암호화 라이브러리나 JNI 기반 DB 드라이버를 쓰는 서비스라면 GC 레이턴시가 줄어드는 효과를 볼 수 있습니다. 뒤에서 다루는 Virtual Threads 핀닝(JEP 491)과 문제 구조가 비슷합니다 — 둘 다 "특정 자원이 고정되어 전체 처리량이 떨어지는 현상"을 더 정밀한 핸들링으로 해결한 것입니다.
String Templates — 프리뷰 후 사라진 기능
Java 21에서 1차 프리뷰, Java 22에서 2차 프리뷰(JEP 459)까지 진행됐던 문자열 템플릿(STR."Hello \{name}" 형태)은 Java 23에서 3차 프리뷰 없이 그대로 빠졌습니다. 처리기(processor) 중심 설계가 사용성과 합성 가능성 문제로 컨센서스를 얻지 못해 재설계가 필요하다는 게 OpenJDK 쪽 공식 이유입니다. 혹시 어딘가에서 이 문법을 보고 따라 써보려 했다면, Java 22 이후 버전에서는 컴파일되지 않는 게 정상입니다.
Java 23 — ZGC 기본값 변경, Markdown JavaDoc
이 버전에서는 import module 선언(JEP 476)도 처음 프리뷰로 등장했습니다. 정식 기능 설명은 위쪽 ## Java 25 — 핵심 기능의 "그 외" 항목을 참고하세요.
ZGC Generational Mode 기본값
GC 튜닝 없이도 처리량과 레이턴시가 개선됩니다. Java 23부터 ZGC 사용 시 Generational Mode가 기본입니다.
# Java 22까지: Generational ZGC 명시 필요
-XX:+UseZGC -XX:+ZGenerational
# Java 23부터: -XX:+UseZGC 만으로 Generational Mode 적용
-XX:+UseZGCZGC를 이미 쓰고 있다면 별도 작업 없이 Generational 모드의 이점을 가져옵니다. -XX:-ZGenerational으로 기존 동작으로 되돌릴 수 있지만 권장하지 않습니다.
Markdown JavaDoc
JavaDoc 주석에서 마크다운 문법을 사용할 수 있습니다. /// 주석 형식으로 작성합니다.
/// 주문을 생성하고 결제를 요청합니다.
///
/// ## 주의사항
/// - 재고가 없으면 `OutOfStockException` 발생
/// - 결제 실패 시 주문은 자동 롤백
///
/// @param request 주문 생성 요청
/// @return 생성된 주문
public Order createOrder(CreateOrderRequest request) { ... }Java 24 — Virtual Threads 핀닝 해결, Stream Gatherers 확정
Virtual Threads 핀닝 문제 해결 (JEP 491)
Java 21에서 Virtual Threads를 도입했을 때, synchronized 블록 내부에서 블로킹 I/O를 호출하면 캐리어 스레드 전체가 함께 블로킹되는 핀닝(pinning) 문제가 있었습니다. **Java 24(JEP 491)**에서 이 제약이 프리뷰 단계 없이 곧바로 해제됐습니다.
Virtual Threads가 I/O로 블로킹될 때 정상적으로는 Carrier Thread에서 unmount되어 다른 Virtual Thread가 그 자리를 채웁니다. 핀닝이 발생하면 이 unmount가 일어나지 않습니다.
Carrier Thread는 CPU 코어 수만큼만 존재합니다. 핀닝이 쌓이면:
| 핀닝 수 | Carrier Thread (예: 8개) | 결과 |
|---|---|---|
| 0개 | 자유롭게 순환 | 정상 처리 |
| 8개 | 전부 블로킹 | 나머지 VT 모두 대기 → 사실상 hang |
// Java 21~23: synchronized 내부 블로킹 → 캐리어 스레드 핀닝 발생
public class UserRepository {
public synchronized User findById(Long id) {
// JDBC 블로킹 호출 동안 캐리어 스레드 점유 → 처리량 저하
return jdbcTemplate.queryForObject(SQL, User.class, id);
}
}// Java 24+: 동일 코드에서 블로킹 시 캐리어 스레드 해제 → Virtual Threads 본래 동작
public class UserRepository {
public synchronized User findById(Long id) {
// 캐리어 스레드가 블로킹 중 다른 Virtual Thread를 실행할 수 있음
return jdbcTemplate.queryForObject(SQL, User.class, id);
}
}Java 21~23에서 ReentrantLock으로 우회했던 코드는 Java 24부터 synchronized를 그대로 유지해도 됩니다.
Stream Gatherers
Java 22 첫 프리뷰(JEP 461)로 시작해 Java 23 재프리뷰(JEP 473)를 거쳐 Java 24에서 정식 출시(JEP 485)됐습니다. Stream.gather(Gatherer)로 표준 API에 없는 커스텀 중간 연산을 만들 수 있습니다.
// 1,000건 스트림을 100건씩 배치로 나눠 저장
productStream
.gather(Gatherers.windowFixed(100))
.forEach(batch -> productRepository.saveAll(batch));
// 슬라이딩 윈도우: 최근 5개 이벤트를 순서대로 처리
eventStream
.gather(Gatherers.windowSliding(5))
.forEach(window -> anomalyDetector.check(window));Java 24 이상을 쓴다면 바로 활용할 수 있습니다.
Security Manager 완전 비활성화 (JEP 486)
Java 17(JEP 411)에서 제거 대상으로 deprecate된 Security Manager가 Java 24부터 완전히 비활성화됩니다. System.setSecurityManager()를 호출하면 더 이상 조용히 무시되지 않고 예외가 발생합니다. 레거시 라이브러리나 오래된 애플리케이션 서버 연동 코드가 이 API를 쓰고 있다면 업그레이드 전에 점검이 필요합니다.
sun.misc.Unsafe 경고 시작 (JEP 471 → JEP 498)
Java 23(JEP 471)에서 sun.misc.Unsafe의 메모리 접근 메서드가 제거 대상으로 deprecate됐지만 기본값은 allow라 조용히 동작했습니다. Java 24(JEP 498)부터는 기본값이 warn으로 바뀌어, Unsafe를 직접 또는 의존 라이브러리를 통해 간접적으로 쓰면 빌드·런타임 로그에 경고가 출력됩니다. 당장 급하지 않다면 --sun-misc-unsafe-memory-access=allow 플래그로 경고를 임시로 끌 수 있습니다.
Ahead-of-Time Class Loading & Linking
JVM 시작 시 클래스 로딩과 링킹 비용을 줄입니다. 서버리스나 짧은 수명 컨테이너 환경에서 콜드 스타트 시간이 단축됩니다. 별도 코드 변경 없이 JVM 옵션으로 활성화합니다. Java 25에서는 AOT 캐시 생성이 단일 명령으로 단순화(JEP 514)되고, 메서드 실행 프로파일까지 캐시에 포함(JEP 515)되어 웜업 속도도 함께 개선됩니다.
Java 21 → 25 마이그레이션 체크리스트
synchronized내 블로킹 I/O: Java 24(JEP 491)부터ReentrantLock으로 우회한 코드를 원복할 수 있음ThreadLocal요청 컨텍스트: Virtual Threads 사용 시 Scoped Values(Java 25, Final) 전환 검토ExecutorService팬아웃 패턴: Structured Concurrency 전환을 검토할 수 있지만, Java 25 기준 5차 프리뷰라--enable-preview가 필요하고 API가 다음 버전에서 또 바뀔 수 있음- catch/for 미사용 변수:
_로 교체해 의도를 명시 (Java 22+) - ZGC 사용 중이라면: Java 23부터 Generational Mode가 기본 — 별도 옵션 추가 불필요
- Stream 배치 처리: Java 24+ 환경에서
Gatherers.windowFixed()활용 검토 System.setSecurityManager()사용 코드: Java 24(JEP 486)부터 호출 시 예외 발생 — 레거시 의존성 점검 필요sun.misc.Unsafe직접/간접 사용 의존성: Java 24(JEP 498)부터 기본적으로 경고 출력 — 빌드 로그 확인, 필요 시--sun-misc-unsafe-memory-access=allow로 임시 완화- 메모리 사용량이 큰 서비스: Compact Object Headers(JEP 519, Java 25)를
-XX:+UseCompactObjectHeaders로 켜서 힙 절감 검토 - Java 21 LTS 지원 기간: Oracle 기준 2031년까지 지원되므로 업그레이드 타이밍은 팀 상황에 맞게 결정
마무리
Java 22~24를 건너뛰더라도 Java 25 LTS만 올라타면 핵심 변경 사항을 대부분 가져갈 수 있습니다. Scoped Values가 정식 기능으로 자리 잡으면서 컨텍스트 전파가 안전한 모델로 통합됐고, Virtual Threads 핀닝 문제는 한 버전 앞선 Java 24에서 이미 해결됐습니다. Structured Concurrency는 아직 5차 프리뷰라 API가 더 바뀔 수 있지만 방향성은 충분히 안정적이라 미리 익혀둘 가치가 있습니다. 여기에 Compact Object Headers로 메모리를 더 아끼고 Quantum-Resistant 암호화까지 표준 API에 들어오면서, ZGC 개선과 Stream Gatherers까지 더해진 Java 25는 고처리량 I/O 서비스를 구축하기에 지금까지 나온 Java LTS 중 가장 완성도 높은 선택지입니다.
주간 기술 뉴스레터
Backend · AI · Java 핵심 내용을 매주 이메일로 보내드립니다.