이번 포스팅에서는 Java 25의 주요 변경 사항에 대해서 정리하고자 한다.
Java 25는 2025년 9월에 공식 출시된 Long-Term Support(LTS) 버전이다. 직전 LTS인 Java 21 이후 약 2년에 걸쳐 비LTS로 출시된 Java 22, 23, 24에서 Preview 상태로 실험되던 기능들이 Java 25에서 정식 API로 승격되었다. 엔터프라이즈 환경에서는 LTS 버전을 기준으로 업그레이드 계획을 수립하는 경우가 많기 때문에, Java 25는 Java 21과 함께 향후 몇 년간 현장에서 가장 많이 사용될 버전이 될 것이다.
이 글에서는 Java 25에서 정식화된 핵심 기능들을 하나씩 살펴보고, 실무 프로젝트에서 어떻게 적용할 수 있는지 코드와 함께 정리한다.
Java 25 LTS 개요
Java 25는 Java Community Process(JCP)를 통해 공식 채택된 다수의 JEP(JDK Enhancement Proposal)를 포함한다. Java 21에서 Preview로 도입되어 Java 22~24에서 점진적으로 개선된 기능들이 Java 25에서 최종 확정되었으며, Valhalla 프로젝트의 첫 번째 결과물도 Preview 형태로 포함되었다.
업그레이드를 고려할 때 중요한 점은 Java 25가 최소 8년의 상업적 지원을 받는다는 사실이다. OpenJDK 기준으로는 OpenJDK 25 릴리스 페이지에서 세부 JEP 목록과 빌드를 확인할 수 있다.
Java 25의 주요 변경 사항은 크게 세 가지 범주로 나뉜다.
- 스트림 처리 확장 (Stream Gatherers)
- 동시성 모델의 고도화 (Scoped Values, Structured Concurrency)
- JVM 성능 최적화 (AOT Class Loading & Linking, ZGC 개선)
개별 항목에 대해 순서대로 살펴본다.
Java 25에서 정식 도입된 Stream Gatherers
Stream Gatherers는 JEP 485로 Java 24에서 처음 정식 API로 채택되었으며, Java 25에도 그대로 포함된다. 기존 java.util.stream.Stream은 filter, map, flatMap, reduce 등의 고정된 중간 연산만 제공했는데, Gatherers API는 사용자가 직접 중간 연산의 동작을 정의할 수 있게 해준다.
Gatherers는 네 가지 컴포넌트로 구성된다.
initializer: 상태를 초기화하는 공급자integrator: 각 요소를 처리하는 로직combiner: 병렬 스트림에서 부분 결과를 병합하는 로직finisher: 스트림 종료 시 마지막 처리를 수행하는 로직
Maven을 사용하는 프로젝트에서 Java 25를 대상으로 설정하는 방법은 다음과 같다.
<!-- pom.xml -->
<properties>
<java.version>25</java.version>
<maven.compiler.source>25</maven.compiler.source>
<maven.compiler.target>25</maven.compiler.target>
</properties>XMLGradle을 사용하는 경우는 아래와 같다.
// build.gradle
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}Groovy아래는 연속된 중복 요소를 제거하는 커스텀 Gatherer 구현 예제이다.
import java.util.stream.Gatherer;
import java.util.stream.Stream;
public class GathererExample {
// 연속된 중복 요소를 제거하는 커스텀 Gatherer
// 예: Stream.of(1, 1, 2, 2, 3, 1) → [1, 2, 3, 1]
static <T> Gatherer<T, ?, T> deduplicateConsecutive() {
// 상태를 명확하게 관리하기 위한 내부 클래스 정의
class State {
boolean hasValue = false; // 첫 번째 요소인지 확인하는 플래그
T lastValue = null; // 이전 요소를 저장
}
return Gatherer.ofSequential(
State::new, // initializer: 상태 객체 생성
// integrator: 각 요소를 처리
Gatherer.Integrator.ofGreedy((state, element, downstream) -> {
// 첫 번째 요소이거나, 이전 요소와 현재 요소가 다를 때만 다운스트림으로 전달
if (!state.hasValue || !Objects.equals(state.lastValue, element)) {
state.lastValue = element;
state.hasValue = true;
downstream.push(element);
}
return true;
})
);
}
public static void main(String[] args) {
var result = Stream.of(1, 1, 2, 2, 3, 1)
.gather(deduplicateConsecutive())
.toList();
// 출력: [1, 2, 3, 1]
System.out.println(result);
}
}Java표준 라이브러리도 자주 쓰이는 Gatherer를 java.util.stream.Gatherers 유틸리티 클래스로 제공한다. 대표적인 예로 windowSliding(슬라이딩 윈도우), windowFixed(고정 크기 윈도우), fold(상태 기반 축약) 등이 있다.
import java.util.stream.Gatherers;
import java.util.stream.Stream;
public class BuiltinGathererExample {
public static void main(String[] args) {
// windowSliding: 크기 3인 슬라이딩 윈도우로 분할
// 결과: [[1,2,3], [2,3,4], [3,4,5]]
var windows = Stream.of(1, 2, 3, 4, 5)
.gather(Gatherers.windowSliding(3))
.toList();
System.out.println(windows);
// fold: 초기값 0에서 시작하여 모든 요소를 하나의 최종값으로 축약
// reduce와 달리 스트림 중간 연산으로 동작하며, 결과는 단일 원소 리스트
// 결과: [15]
var total = Stream.of(1, 2, 3, 4, 5)
.gather(Gatherers.fold(() -> 0, Integer::sum))
.toList();
System.out.println(total);
}
}JavaStream Gatherers는 기존에 외부 라이브러리에 의존하거나 복잡한 reduce/collect 조합으로 해결하던 로직을 표준 스트림 파이프라인 안에서 깔끔하게 표현하게 해준다. 자세한 API 명세는 JEP 485 공식 문서에서 확인할 수 있다.
Java 25 동시성 모델의 완성 — Scoped Values와 Structured Concurrency
Java 25에서 정식화된 두 가지 동시성 관련 API인 Scoped Values와 Structured Concurrency는 Virtual Threads(Java 21 정식화)와 함께 Java의 현대적 동시성 프로그래밍 모델을 완성한다.
Scoped Values (JEP 481)
Scoped Values는 기존 ThreadLocal의 한계를 극복하기 위해 도입되었다. ThreadLocal은 스레드가 많아질수록 메모리 누수 위험이 있고, Virtual Threads와 함께 사용할 때 성능 저하가 발생할 수 있다. Scoped Values는 불변(immutable) 값을 특정 실행 범위(scope) 내에서만 유효하게 공유하는 방식으로 이 문제를 해결한다.
ScopedValue의 핵심은 where().run() 패턴이다. 값을 바인딩하면 run() 블록이 끝날 때 자동으로 해제되므로, try-finally로 정리 로직을 작성할 필요가 없다.
import java.lang.ScopedValue;
public class ScopedValueExample {
// ScopedValue는 불변 바인딩이라 thread-safe하게 사용할 수 있음
// static final로 선언하는 것이 관용적 패턴
private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public static void handleRequest(String userId, String requestId) {
// where()로 값을 바인딩하고, run() 블록 내부에서만 유효하게 함
// run() 종료 시 바인딩이 자동으로 해제됨
ScopedValue.where(CURRENT_USER, userId)
.where(REQUEST_ID, requestId)
.run(() -> {
// 이 블록 내에서 호출되는 모든 메서드에서 값을 읽을 수 있음
processRequest();
});
}
private static void processRequest() {
// 파라미터로 전달받지 않아도 현재 컨텍스트의 값을 안전하게 읽을 수 있음
String user = CURRENT_USER.get();
String reqId = REQUEST_ID.get();
System.out.println("Processing " + reqId + " for user " + user);
auditLog();
}
private static void auditLog() {
// 깊은 호출 스택 어디서든 동일한 방법으로 접근 가능
// ThreadLocal처럼 set/remove 쌍을 맞출 필요가 없음
System.out.println("Audit: user=" + CURRENT_USER.get()
+ ", reqId=" + REQUEST_ID.get());
}
}Java더 자세한 내용은 ThreadLocal의 개선: Java Scoped Value 가이드 포스팅을 참고하기 바란다.
Structured Concurrency (JEP 480)
Structured Concurrency는 여러 비동기 작업을 하나의 논리적 단위로 묶어 관리하는 방식이다. StructuredTaskScope를 사용하면 여러 하위 작업을 병렬로 실행하고, 모든 작업이 완료되거나 하나라도 실패했을 때 일관된 방식으로 처리할 수 있다.
import java.util.concurrent.StructuredTaskScope;
public class StructuredConcurrencyExample {
record UserProfile(String name, String email) {}
record UserOrders(java.util.List<String> orders) {}
record UserDashboard(UserProfile profile, UserOrders orders) {}
// 사용자 정보와 주문 내역을 병렬로 조회하는 예제
public static UserDashboard fetchDashboard(long userId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// fork()로 두 작업을 병렬로 실행
StructuredTaskScope.Subtask<UserProfile> profileTask =
scope.fork(() -> fetchUserProfile(userId));
StructuredTaskScope.Subtask<UserOrders> ordersTask =
scope.fork(() -> fetchUserOrders(userId));
// 모든 작업이 완료될 때까지 대기
// 하나라도 실패하면 나머지도 취소되고 예외가 전파됨
scope.join().throwIfFailed();
// 두 결과를 조합하여 반환
return new UserDashboard(profileTask.get(), ordersTask.get());
}
// try-with-resources 블록을 벗어나면 scope가 자동으로 닫히고
// 완료되지 않은 하위 스레드도 모두 정리됨
}
private static UserProfile fetchUserProfile(long userId) throws InterruptedException {
Thread.sleep(50); // 외부 서비스 호출 시뮬레이션
return new UserProfile("홍길동", "hong@example.com");
}
private static UserOrders fetchUserOrders(long userId) throws InterruptedException {
Thread.sleep(80); // 데이터베이스 조회 시뮬레이션
return new UserOrders(java.util.List.of("ORDER-001", "ORDER-002"));
}
}JavaShutdownOnFailure 외에도 하나의 작업이 성공하면 나머지를 취소하는 ShutdownOnSuccess를 사용할 수 있다. 이는 여러 데이터 소스 중 가장 빠른 응답을 사용하는 경쟁(race) 패턴에 유용하다.
더 자세한 내용은 스레드 누수는 이제 그만! Structured Concurrency 소개 (ft. JDK 25) 포스팅을 참고하기 바란다.
Java 25의 AOT Class Loading & Linking으로 기동 시간 단축
AOT(Ahead-of-Time) Class Loading & Linking은 JEP 483으로 Java 24에서 도입된 기능이다. JVM이 애플리케이션 실행 중 수행하는 클래스 로딩과 링킹 작업의 결과를 캐시로 저장해두고, 다음 실행 시 재사용하는 방식이다.
기존 JVM은 실행할 때마다 클래스파일을 로드하고, 검증하고, 링크하는 과정을 반복했다. AOT Class Loading은 이 과정을 한 번만 수행하고 결과를 캐시에 저장함으로써 기동 시간을 크게 단축한다. GraalVM Native Image처럼 별도의 AOT 컴파일러를 사용하지 않기 때문에 JVM의 JIT 최적화를 그대로 유지하면서 기동 시간만 개선한다는 장점이 있다.
# 1단계: 훈련 실행 — 클래스 로딩 정보를 구성 파일로 기록
java -XX:AOTMode=record \
-XX:AOTConfiguration=app.aotconf \
-jar app.jar
# 2단계: AOT 캐시 생성 — 훈련 데이터를 바탕으로 캐시 파일 빌드
java -XX:AOTMode=create \
-XX:AOTConfiguration=app.aotconf \
-XX:AOTCache=app.aot \
-jar app.jar
# 3단계: 캐시를 활용한 실행 — 기동 시간 단축 효과 확인
java -XX:AOTCache=app.aot -jar app.jarShellScriptSpring Boot 애플리케이션에서는 기동 시간이 30~50% 단축되는 효과를 볼 수 있다. GraalVM Native Image가 필요하지 않으면서도 기동 시간을 줄이고 싶을 때 유용한 대안이 된다. 단, 캐시는 동일한 JVM 버전과 클래스패스 구성에서만 유효하므로 배포 파이프라인에서 캐시 재생성 단계를 포함해야 한다.
Flexible Constructor Bodies (JEP 492)
Flexible Constructor Bodies는 Java 25에서 정식화된 기능으로, 기존의 생성자 작성 제약을 완화한다. 기존 Java에서는 this() 또는 super() 호출이 생성자의 첫 번째 문장이어야 했다. 이 제약으로 인해 상위 클래스 생성자를 호출하기 전에 파라미터 유효성 검사 등의 로직을 넣기 어려웠다.
// 기존 Java: super() 이전에 코드를 넣을 수 없어서 불편했던 패턴
// 유효성 검사를 위해 억지로 static 메서드를 만들어야 했음
public class OldStyle extends AbstractBase {
public OldStyle(String value) {
super(validate(value)); // static 메서드 우회 호출
}
private static String validate(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value는 비어있을 수 없음");
}
return value;
}
}
// Java 25: super() 이전에 일반 구문을 자연스럽게 작성 가능
public class NewStyle extends AbstractBase {
public NewStyle(String value) {
// super() 호출 이전에 유효성 검사 로직을 직접 작성
// this 참조는 아직 사용할 수 없지만, 지역 변수와 파라미터는 사용 가능
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("value는 비어있을 수 없음");
}
// 검증 통과 후 상위 클래스 생성자 호출
super(value);
}
}Java이 기능은 특히 DTO, Value Object, 도메인 모델 클래스를 작성할 때 코드의 가독성을 높인다. record와 함께 사용하면 compact constructor에서도 동일한 방식으로 유효성 검사 로직을 간결하게 표현할 수 있다. 자세한 내용은 JEP 492 공식 문서에서 확인할 수 있다.
Valhalla Preview — Value Classes와 Null-Restricted Types
Java 25에는 Valhalla 프로젝트의 첫 번째 사용자-가시적 결과물이 Preview로 포함된다. JEP 401(Value Classes and Objects)과 JEP 402(Null-Restricted and Nullable Types)가 그것이다.
Value Class는 객체 동일성(identity)이 없는 순수한 데이터 컨테이너다. 일반 클래스와 달리 동일성 비교 의미론이 제거되며, JVM이 스택 또는 레지스터에 인라인으로 저장할 수 있어 힙 할당 오버헤드가 없다. 소규모 데이터 타입(좌표, 금액, 날짜 범위 등)을 표현할 때 record보다 더 효율적인 선택이 될 수 있다.
// Preview 기능 사용 시 컴파일 플래그 필요
// javac --enable-preview --release 25 MoneyExample.java
// java --enable-preview MoneyExample
// value record: 동일성(identity)이 없는 순수 값 타입 + equals/hashCode 자동 생성
// 일반 value class와 달리 record가 equals/hashCode/toString을 자동으로 필드 기반으로 구현해 줌
public value record Money(long amount, String currency) {
// compact constructor: 유효성 검사를 super() 이전에 자연스럽게 배치 가능 (Flexible Constructor Bodies)
public Money {
if (amount < 0) {
throw new IllegalArgumentException("음수 금액 불가: " + amount);
}
}
}
class MoneyUsage {
public static void main(String[] args) {
var a = new Money(10_000L, "KRW");
var b = new Money(10_000L, "KRW");
// value record는 equals()가 필드 기반으로 자동 생성됨
System.out.println(a.equals(b)); // true
// JVM은 Money 인스턴스를 힙이 아닌 스택에 직접 저장할 수 있음
// → GC 압력 감소, 캐시 지역성 향상
}
}JavaValue Class는 record와 비슷해 보이지만 핵심 차이가 있다. record는 단순 데이터 운반자(carrier)이고, Value Class는 JVM 수준에서 메모리 레이아웃까지 최적화가 가능한 값 타입이라는 점이 다르다. Valhalla의 전체 개요는 Valhalla 프로젝트 페이지에서 확인할 수 있다.
Java 25 실무 마이그레이션 고려사항
Java 21에서 Java 25로 업그레이드할 때 주의해야 할 점들을 정리한다.
Security Manager 비활성화 현황
JEP 486으로 Java 24부터 Security Manager가 영구적으로 비활성화되었다. Java 25에서도 동일하게 비활성화 상태가 유지된다. SecurityManager를 직접 구현하거나 System.setSecurityManager()를 호출하는 코드가 있다면 사전에 제거해야 한다. 관련 기능 대체는 Java 플랫폼 모듈 시스템(JPMS)의 exports/opens 지시자나 컨테이너 수준의 보안 정책으로 이관하는 방향이 권장된다.
JNI 사용 제약 강화
--enable-native-access 플래그 없이 네이티브 메서드를 호출하면 경고가 발생하며, 향후 버전에서는 에러로 전환될 예정이다. 네이티브 라이브러리를 사용하는 경우 실행 옵션에 아래와 같이 추가한다.
# 특정 모듈에 네이티브 접근을 허용
java --enable-native-access=com.example.mymodule -jar app.jar
# 모든 모듈에 허용 (임시 방편, 프로덕션 권장하지 않음)
java --enable-native-access=ALL-UNNAMED -jar app.jarShellScript주요 프레임워크 호환성
Java 25는 바이트코드 수준에서 이전 버전과 호환되지만, 리플렉션을 과도하게 사용하는 일부 라이브러리는 업데이트가 필요할 수 있다. 업그레이드 전 다음 라이브러리들의 최신 버전 호환성을 확인하는 것이 좋다.
- Spring Framework 7.x 이상 (Java 25 LTS 공식 지원)
- Hibernate ORM 7.x 이상
- Lombok 최신 버전 (Annotation Processor 호환성)
- Mockito 5.x 이상
Spring Boot 3.x 프로젝트는 Spring Framework 6.x를 사용하므로, Java 25에서 완전한 지원을 받으려면 Spring Boot 4.x로의 업그레이드도 함께 검토할 필요가 있다.
단계적 마이그레이션 전략
대규모 프로젝트에서는 Java 25로의 전환을 한 번에 진행하기보다 단계적으로 접근하는 것이 안전하다. 첫 단계로 Java 25 바이너리 호환성 모드(--source 21 --target 21)로 컴파일하여 기존 코드가 이상 없이 동작하는지 확인한다. 이후 Scoped Values, Structured Concurrency 등 새 API를 점진적으로 도입하고, AOT Class Loading은 CI/CD 파이프라인을 갖춘 이후에 적용하는 순서로 진행하면 리스크를 낮출 수 있다.
마치며
지금까지 Java 25의 주요 변경 사항에 대해서 정리해 보았다. Stream Gatherers는 커스텀 중간 연산을 표준 스트림 파이프라인에서 표현할 수 있게 해주고, Scoped Values와 Structured Concurrency는 Virtual Threads 위에서 안전하고 구조화된 동시성 코드를 작성하는 기반을 마련한다. AOT Class Loading은 네이티브 컴파일 없이 기동 시간을 단축하는 현실적인 방법을 제공하며, Valhalla의 Value Classes는 Java 메모리 모델의 근본적 변화를 예고한다. Java 25 LTS는 현업에서 오랫동안 사용될 버전이므로, 신규 기능들을 파악해두고 팀 내 적용 계획을 점진적으로 수립해 두는 것이 이후 마이그레이션 비용을 줄이는 데 도움이 된다.
