Java: JEP 444, 485, 506, 519 설명
이 글은 Claude Opus 4.5 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.
JEP 444: Virtual Threads (Java 21 - 정식 릴리스)#
개요: 경량 스레드를 제공하여 수십만~수백만 개의 동시 작업을 효율적으로 처리할 수 있게 합니다. 플랫폼 스레드와 달리 OS 스레드를 1:1로 매핑하지 않습니다.
예제 1: 대량의 HTTP 요청 처리#
Before (Platform Threads):
// 스레드 풀 사용 (제한된 스레드 수)
ExecutorService executor = Executors.newFixedThreadPool(100);
List<String> urls = IntStream.range(0, 10_000)
.mapToObj(i -> "https://api.example.com/data/" + i)
.toList();
List<Future<String>> futures = new ArrayList<>();
for (String url : urls) {
futures.add(executor.submit(() -> {
// HTTP 요청 시뮬레이션
Thread.sleep(100);
return fetchData(url);
}));
}
// 결과 수집
List<String> results = new ArrayList<>();
for (Future<String> future : futures) {
results.add(future.get());
}
executor.shutdown();
// 100개 스레드로 10,000개 요청 처리 → 느림
After (Virtual Threads):
// Virtual Thread 사용
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<String> urls = IntStream.range(0, 10_000)
.mapToObj(i -> "https://api.example.com/data/" + i)
.toList();
List<Future<String>> futures = urls.stream()
.map(url -> executor.submit(() -> {
Thread.sleep(100);
return fetchData(url);
}))
.toList();
List<String> results = futures.stream()
.map(f -> {
try { return f.get(); }
catch (Exception e) { throw new RuntimeException(e); }
})
.toList();
}
// 10,000개 virtual thread 동시 실행 → 매우 빠름
예제 2: 간단한 Virtual Thread 생성#
Before:
// Platform Thread
Thread thread = new Thread(() -> {
System.out.println("Running on: " + Thread.currentThread());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
thread.start();
thread.join();
After:
// Virtual Thread
Thread vThread = Thread.startVirtualThread(() -> {
System.out.println("Running on: " + Thread.currentThread());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
vThread.join();
// 또는 Thread.ofVirtual() 사용
Thread vThread2 = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
// task
});
예제 3: 실제 웹 서버 시나리오#
Before (Platform Threads):
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// DB 조회 (100ms)
User user = userRepository.findById(id);
// 외부 API 호출 (200ms)
Profile profile = externalApi.getProfile(user.getEmail());
// 결과 병합
return mergeUserWithProfile(user, profile);
}
}
// Tomcat 기본 설정: 200 threads
// 200개 요청 동시 처리 가능
// 201번째 요청부터 대기
After (Virtual Threads - Spring Boot 3.2+):
// application.properties
// spring.threads.virtual.enabled=true
@RestController
public class UserController {
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
// 동일한 코드
User user = userRepository.findById(id);
Profile profile = externalApi.getProfile(user.getEmail());
return mergeUserWithProfile(user, profile);
}
}
// Virtual threads 사용
// 수만 개의 요청을 동시 처리 가능
// 블로킹 I/O에서도 다른 작업 처리
예제 4: Structured Concurrency와 함께 사용#
Before:
// 여러 API를 병렬로 호출
ExecutorService executor = Executors.newFixedThreadPool(3);
Future<String> userData = executor.submit(() -> fetchUserData(userId));
Future<String> orderData = executor.submit(() -> fetchOrderData(userId));
Future<String> paymentData = executor.submit(() -> fetchPaymentData(userId));
try {
String user = userData.get();
String orders = orderData.get();
String payments = paymentData.get();
return combine(user, orders, payments);
} finally {
executor.shutdown();
}
After (Virtual Threads + Structured Concurrency):
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> userData = scope.fork(() -> fetchUserData(userId));
Future<String> orderData = scope.fork(() -> fetchOrderData(userId));
Future<String> paymentData = scope.fork(() -> fetchPaymentData(userId));
scope.join(); // 모든 태스크 완료 대기
scope.throwIfFailed(); // 하나라도 실패하면 예외
return combine(
userData.resultNow(),
orderData.resultNow(),
paymentData.resultNow()
);
}
// scope 종료 시 모든 virtual thread 자동 정리
성능 비교#
// Platform Threads
long start = System.currentTimeMillis();
try (var executor = Executors.newFixedThreadPool(1000)) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(100);
return i;
});
});
}
long platform = System.currentTimeMillis() - start;
// 약 10초 이상 소요
// Virtual Threads
start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(100);
return i;
});
});
}
long virtual = System.currentTimeMillis() - start;
// 약 100ms 소요
JEP 485: Stream Gatherers (Java 24 Preview)#
개요:
Stream API에 중간 연산을 커스터마이징할 수 있는 새로운 메커니즘을 추가합니다. 기존 map, filter 등으로는 구현하기 어려운 복잡한 스트림 변환을 가능하게 합니다.
예제 1: 고정 크기 윈도우 (Fixed-size Window)#
Before (JEP 485 없이):
// 리스트를 N개씩 묶어서 처리
public static <T> List<List<T>> windowed(List<T> list, int size) {
List<List<T>> result = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
result.add(list.subList(i, Math.min(i + size, list.size())));
}
return result;
}
// 사용
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
List<List<Integer>> windows = windowed(numbers, 3);
// [[1,2,3], [4,5,6], [7,8,9]]
After (JEP 485 사용):
import java.util.stream.Gatherers;
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
List<List<Integer>> windows = numbers.stream()
.gather(Gatherers.windowFixed(3))
.toList();
// [[1,2,3], [4,5,6], [7,8,9]]
예제 2: 슬라이딩 윈도우 (Sliding Window)#
Before:
public static <T> List<List<T>> slidingWindow(List<T> list, int size) {
List<List<T>> result = new ArrayList<>();
for (int i = 0; i <= list.size() - size; i++) {
result.add(list.subList(i, i + size));
}
return result;
}
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
List<List<Integer>> windows = slidingWindow(numbers, 3);
// [[1,2,3], [2,3,4], [3,4,5]]
After:
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
List<List<Integer>> windows = numbers.stream()
.gather(Gatherers.windowSliding(3))
.toList();
// [[1,2,3], [2,3,4], [3,4,5]]
예제 3: 커스텀 Gatherer - 중복 제거하면서 누적#
Before:
// 중복을 제거하면서 각 요소가 처음 나타날 때의 누적 합계를 추적
public static List<Integer> distinctWithRunningSum(List<Integer> numbers) {
Set<Integer> seen = new HashSet<>();
List<Integer> result = new ArrayList<>();
int sum = 0;
for (Integer num : numbers) {
if (seen.add(num)) {
sum += num;
result.add(sum);
}
}
return result;
}
List<Integer> numbers = List.of(1, 2, 2, 3, 1, 4);
List<Integer> result = distinctWithRunningSum(numbers);
// [1, 3, 6, 10] (1, 1+2, 1+2+3, 1+2+3+4)
After:
record State(Set<Integer> seen, int sum) {
State() { this(new HashSet<>(), 0); }
}
Gatherer<Integer, State, Integer> distinctSum = Gatherer.ofSequential(
State::new,
(state, element, downstream) -> {
if (state.seen.add(element)) {
int newSum = state.sum + element;
downstream.push(newSum);
return new State(state.seen, newSum);
}
return state;
}
);
List<Integer> numbers = List.of(1, 2, 2, 3, 1, 4);
List<Integer> result = numbers.stream()
.gather(distinctSum)
.toList();
// [1, 3, 6, 10]
JEP 506: Scoped Values (Java 25 - 정식 릴리스)#
개요: Thread-local variables의 문제점을 해결하는 새로운 데이터 공유 메커니즘입니다. 메서드가 불변 데이터를 자신의 호출 스택 내 모든 메서드 및 자식 스레드와 공유할 수 있게 합니다. Thread-local variables보다 이해하기 쉽고, 특히 Virtual Threads와 함께 사용할 때 메모리 및 성능 면에서 우수합니다.
예제 1: 웹 프레임워크 컨텍스트 공유#
Before (ThreadLocal 사용):
public class Framework {
// ThreadLocal 선언
private static final ThreadLocal<FrameworkContext> CONTEXT
= new ThreadLocal<>();
void serve(Request request, Response response) {
var context = createContext(request);
CONTEXT.set(context); // 값 설정
try {
Application.handle(request, response);
} finally {
CONTEXT.remove(); // 명시적 정리 필요 (안 하면 메모리 누수!)
}
}
public PersistedObject readKey(String key) {
var context = CONTEXT.get(); // 어디서든 읽기 가능
if (context == null) {
throw new IllegalStateException("No context");
}
var db = getDBConnection(context);
return db.readKey(key);
}
}
// 문제점:
// 1. CONTEXT.remove()를 깜빡하면 메모리 누수
// 2. 어디서든 CONTEXT.set()으로 값을 변경 가능 (예측 불가능)
// 3. 값의 lifetime이 불명확
After (ScopedValue 사용):
import static java.lang.ScopedValue.where;
public class Framework {
// ScopedValue 선언
private static final ScopedValue<FrameworkContext> CONTEXT
= ScopedValue.newInstance();
void serve(Request request, Response response) {
var context = createContext(request);
// 값을 바인딩하고 run 블록 내에서만 사용 가능
where(CONTEXT, context)
.run(() -> Application.handle(request, response));
// run 종료 후 자동으로 바인딩 해제 - 메모리 누수 없음!
}
public PersistedObject readKey(String key) {
var context = CONTEXT.get(); // run 블록 내에서만 읽기 가능
var db = getDBConnection(context);
return db.readKey(key);
}
}
// 장점:
// 1. 자동 정리 - remove() 불필요
// 2. 불변성 - set() 메서드 없음
// 3. 명확한 lifetime - run 블록 내에서만
예제 2: 중첩 바인딩 (Rebinding)#
Before (ThreadLocal):
private static final ThreadLocal<String> PRINCIPAL = new ThreadLocal<>();
void processRequest() {
PRINCIPAL.set("user1");
try {
System.out.println(PRINCIPAL.get()); // user1
// 내부 처리
PRINCIPAL.set("admin"); // 값 변경
try {
doAdminWork();
} finally {
PRINCIPAL.set("user1"); // 수동으로 복원
}
System.out.println(PRINCIPAL.get()); // user1
} finally {
PRINCIPAL.remove();
}
}
After (ScopedValue):
private static final ScopedValue<String> PRINCIPAL = ScopedValue.newInstance();
void processRequest() {
where(PRINCIPAL, "user1").run(() -> {
System.out.println(PRINCIPAL.get()); // user1
// 중첩 바인딩
where(PRINCIPAL, "admin").run(() -> {
doAdminWork();
System.out.println(PRINCIPAL.get()); // admin
});
// 중첩 블록 종료 후 자동 복원
System.out.println(PRINCIPAL.get()); // user1
});
// 여기서는 PRINCIPAL.get() 호출 시 예외 발생
}
예제 3: Virtual Threads와 자식 스레드 상속#
Before (InheritableThreadLocal):
private static final InheritableThreadLocal<User> CURRENT_USER
= new InheritableThreadLocal<>();
void handleRequest(Request request) {
var user = authenticateUser(request);
CURRENT_USER.set(user);
try {
// 자식 스레드 생성
Thread childThread = new Thread(() -> {
// 부모의 값이 복사됨 (메모리 오버헤드!)
var inheritedUser = CURRENT_USER.get();
processInBackground(inheritedUser);
});
childThread.start();
childThread.join();
} finally {
CURRENT_USER.remove();
}
}
// 문제점:
// 1. 자식 스레드마다 부모의 모든 ThreadLocal 값 복사 (메모리 증가)
// 2. 자식 스레드가 부모보다 오래 살 수 있음 (lifetime 불명확)
After (ScopedValue + Structured Concurrency):
private static final ScopedValue<User> CURRENT_USER
= ScopedValue.newInstance();
void handleRequest(Request request) throws Exception {
var user = authenticateUser(request);
where(CURRENT_USER, user).run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 자식 스레드 생성 - 부모의 ScopedValue 자동 상속
var task1 = scope.fork(() -> {
// 복사 없이 부모의 값 공유!
var sharedUser = CURRENT_USER.get();
return processInBackground(sharedUser);
});
var task2 = scope.fork(() -> {
var sharedUser = CURRENT_USER.get();
return fetchData(sharedUser);
});
scope.join().throwIfFailed();
// scope 종료 시 모든 자식 스레드 보장되게 종료
}
});
// 모든 자식 스레드가 종료된 후에만 바인딩 해제
}
// 장점:
// 1. 메모리 공유 (복사 없음)
// 2. 자식 스레드가 부모 scope을 벗어날 수 없음 (안전)
// 3. Virtual Threads와 완벽한 호환
예제 4: 여러 값 동시 바인딩#
Before:
private static final ThreadLocal<String> USER = new ThreadLocal<>();
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
private static final ThreadLocal<String> REQUEST_ID = new ThreadLocal<>();
void process() {
USER.set("pj");
TENANT.set("kakao");
REQUEST_ID.set("req-123");
try {
doWork();
} finally {
USER.remove();
TENANT.remove();
REQUEST_ID.remove();
}
}
After:
private static final ScopedValue<String> USER = ScopedValue.newInstance();
private static final ScopedValue<String> TENANT = ScopedValue.newInstance();
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
void process() {
where(USER, "pj")
.where(TENANT, "kakao")
.where(REQUEST_ID, "req-123")
.run(() -> doWork());
// 자동 정리 - 깔끔!
}
JEP 519: Compact Object Headers (Java 25 - 정식 릴리스)#
개요: Java 객체의 헤더 크기를 줄여 메모리 사용량과 성능을 개선하는 기능입니다. JDK 24에서 실험적 기능으로 도입되었고(JEP 450), JDK 25에서 정식 기능이 되었습니다.
배경: Java 객체 헤더란?#
Java의 모든 객체는 실제 데이터 외에 메타데이터를 저장하는 헤더를 가집니다:
기존 Object Header (64-bit JVM, Compressed OOPs 사용 시):
Mark Word: 8 bytes (lock state, hash code, GC age 등)
Class Pointer: 4 bytes (compressed)
─────────────────────────
Total: 12 bytes (배열의 경우 +4 bytes for length)
Compact Object Header:
Combined Header: 8 bytes (mark word + class pointer 통합)
─────────────────────────
Total: 8 bytes (배열의 경우 +4 bytes for length)
예제 1: 메모리 절감 효과#
Before (기존 헤더):
class Point {
int x; // 4 bytes
int y; // 4 bytes
}
// 메모리 레이아웃:
// Header: 12 bytes
// x: 4 bytes
// y: 4 bytes
// Padding: 4 bytes (alignment)
// ─────────────────────
// Total: 24 bytes per object
Point[] points = new Point[1_000_000];
// 배열 자체: 16 bytes (header + length)
// 객체들: 24 MB
// 포인터들: 4 MB (compressed)
// Total: ~28 MB
After (Compact Headers):
class Point {
int x; // 4 bytes
int y; // 4 bytes
}
// 메모리 레이아웃:
// Header: 8 bytes ← 4 bytes 절감!
// x: 4 bytes
// y: 4 bytes
// Padding: 0 bytes (더 나은 alignment)
// ─────────────────────
// Total: 16 bytes per object
Point[] points = new Point[1_000_000];
// 배열 자체: 12 bytes (compact header + length)
// 객체들: 16 MB ← 33% 절감!
// 포인터들: 4 MB (compressed)
// Total: ~20 MB ← 약 28% 절감
예제 2: 작은 객체에서의 효과#
Before:
class Flag {
boolean value; // 1 byte
}
// 메모리 레이아웃:
// Header: 12 bytes
// value: 1 byte
// Padding: 11 bytes
// ─────────────────────
// Total: 24 bytes
// 1백만 개 객체 = 24 MB
After:
class Flag {
boolean value; // 1 byte
}
// 메모리 레이아웃:
// Header: 8 bytes
// value: 1 byte
// Padding: 7 bytes
// ─────────────────────
// Total: 16 bytes
// 1백만 개 객체 = 16 MB ← 33% 절감!
예제 3: 실제 애플리케이션 시나리오 - 캐시#
Before:
// Redis 스타일 캐시 엔트리
class CacheEntry {
String key; // 8 bytes (reference)
Object value; // 8 bytes (reference)
long timestamp; // 8 bytes
}
// 메모리 레이아웃:
// Header: 12 bytes
// key: 8 bytes
// value: 8 bytes
// timestamp: 8 bytes
// Padding: 0 bytes
// ──────────────────────────
// Total: 36 bytes per entry
// 10,000,000 entries = 360 MB (just for CacheEntry objects)
After:
class CacheEntry {
String key; // 8 bytes (reference)
Object value; // 8 bytes (reference)
long timestamp; // 8 bytes
}
// 메모리 레이아웃:
// Header: 8 bytes ← 4 bytes 절감
// key: 8 bytes
// value: 8 bytes
// timestamp: 8 bytes
// ──────────────────────────
// Total: 32 bytes per entry
// 10,000,000 entries = 320 MB ← 40 MB 절감 (11% 개선)
예제 4: 컬렉션에서의 효과#
Before:
// ArrayList의 내부 배열
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
list.add(i);
}
// Integer 객체 하나:
// Header: 12 bytes
// value: 4 bytes
// Padding: 8 bytes
// Total: 24 bytes
// 1000개 Integer = 24 KB
// ArrayList 자체 + 배열 = ~4 KB
// Total: ~28 KB
After:
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
list.add(i);
}
// Integer 객체 하나:
// Header: 8 bytes ← compact!
// value: 4 bytes
// Padding: 4 bytes
// Total: 16 bytes
// 1000개 Integer = 16 KB ← 33% 절감
// ArrayList 자체 + 배열 = ~4 KB
// Total: ~20 KB
예제 5: 성능 벤치마크 결과#
SPECjbb2015 벤치마크:
// Before (기존 헤더)
// Heap usage: 4.5 GB
// CPU time: 100% (baseline)
// GC count: 200 collections
// After (Compact Headers)
// Heap usage: 3.5 GB ← 22% 감소
// CPU time: 92% ← 8% 감소
// GC count: 170 ← 15% 감소
// JSON Parser 벤치마크
// Before: 10.0 seconds
// After: 9.0 seconds ← 10% 빠름
사용 방법#
JDK 24 (실험적):
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders MyApp
JDK 25 (정식):
java -XX:+UseCompactObjectHeaders MyApp
# UnlockExperimentalVMOptions 불필요!
예제 6: 실제 코드에서 차이 없음#
중요: 코드 변경 불필요!
// 애플리케이션 코드는 전혀 변경할 필요 없음
class User {
String name;
int age;
List<String> roles;
}
// 기존 코드 그대로 작동
User user = new User();
user.name = "PJ";
user.age = 30;
// JVM이 내부적으로 메모리 레이아웃만 최적화
// 동작은 완전히 동일
요약#
- JEP 444 (Virtual Threads): 경량 스레드로 대규모 동시성 처리. I/O 집약적 작업에 매우 효과적
- JEP 485 (Stream Gatherers): Stream API의 중간 연산을 커스터마이징. 윈도잉, 복잡한 변환 작업을 간결하게 표현
- JEP 506 (Scoped Values): ThreadLocal의 현대적 대안. 불변 데이터를 안전하고 효율적으로 공유
- JEP 519 (Compact Object Headers): 객체 헤더 크기 33% 감소로 메모리 절약 및 성능 향상