이 글은 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% 감소로 메모리 절약 및 성능 향상