Java 25의 동시성 혁명

Java 25(JDK 25)는 동시성 프로그래밍을 근본적으로 변화시키는 두 가지 핵심 기능을 완성했습니다. Virtual Threads는 수십만 개의 경량 스레드를 지원하고, Structured Concurrency는 복잡한 비동기 코드를 안전하고 읽기 쉽게 만들어줍니다.

Virtual Threads 이해하기

Virtual Thread는 JVM이 관리하는 경량 스레드로, OS 스레드와 1:1로 매핑되지 않습니다. 수십만 개를 동시에 실행해도 메모리 부담이 없습니다.

// 플랫폼 스레드 vs 버추얼 스레드 비교

// 기존 플랫폼 스레드 (OS 스레드 1:1)
Thread platformThread = new Thread(() -> {
    System.out.println("플랫폼 스레드: " + Thread.currentThread());
});
platformThread.start();

// Virtual Thread 생성 방법 1: Thread.ofVirtual()
Thread vThread = Thread.ofVirtual().start(() -> {
    System.out.println("버추얼 스레드: " + Thread.currentThread().isVirtual()); // true
});

// Virtual Thread 생성 방법 2: Thread.startVirtualThread()
Thread.startVirtualThread(() -> {
    System.out.println("간편 생성!");
});

// Virtual Thread 생성 방법 3: ExecutorService 사용 (권장)
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // 블로킹 I/O도 가상 스레드에서 안전
            String data = fetchFromDatabase(); // 블로킹 OK!
            return processData(data);
        });
    }
} // try-with-resources로 자동 종료

JDK 24: 동기화 핀닝 문제 해결 (JEP 491)

JDK 24 이전에는 synchronized 블록 안에서 가상 스레드가 블로킹되면 캐리어 스레드(OS 스레드)가 점유되는 핀닝(Pinning) 문제가 있었습니다. JDK 24의 JEP 491로 이 문제가 해결되었습니다.

// JDK 24 이후 - synchronized 안에서도 안전
public class SafeCounter {
    private int count = 0;

    public synchronized void increment() {
        // 이 안에서 블로킹 I/O를 해도 이제 캐리어 스레드를 점유하지 않음
        String log = writeLog(count); // 블로킹 I/O - 안전!
        count++;
    }
}

// 웹 서버 예시 - 10만 동시 요청 처리
public class HighConcurrencyServer {
    public static void main(String[] args) throws Exception {
        try (ServerSocket serverSocket = new ServerSocket(8080);
             ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            while (true) {
                Socket socket = serverSocket.accept();
                executor.submit(() -> handleRequest(socket)); // 각 요청을 가상 스레드로 처리
            }
        }
    }

    static void handleRequest(Socket socket) {
        try (socket) {
            // 블로킹 I/O가 포함된 복잡한 로직도 OK
            String request = readRequest(socket.getInputStream());
            String dbResult = queryDatabase(request); // DB 블로킹 OK
            String apiResult = callExternalAPI(dbResult); // HTTP 블로킹 OK
            writeResponse(socket.getOutputStream(), apiResult);
        }
    }
}

Structured Concurrency (JEP 505)

Java 25에서 구조적 동시성 API가 5번째 프리뷰를 거쳐 StructuredTaskScope가 완전히 재설계되었습니다. 여러 작업을 하나의 단위로 묶어 안전하게 관리합니다.

import java.util.concurrent.StructuredTaskScope;

// 기본 사용 예시
public class UserService {
    record UserProfile(String name, List orders, String balance) {}

    public UserProfile getUserProfile(String userId) throws Exception {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            // 세 가지 작업을 병렬로 실행
            var nameFuture    = scope.fork(() -> fetchName(userId));
            var ordersFuture  = scope.fork(() -> fetchOrders(userId));
            var balanceFuture = scope.fork(() -> fetchBalance(userId));

            // 모든 작업 완료 대기 (하나라도 실패하면 나머지 취소)
            scope.join().throwIfFailed();

            // 모두 성공한 경우 결과 수집
            return new UserProfile(
                nameFuture.get(),
                ordersFuture.get(),
                balanceFuture.get()
            );
        }
    }
}

// ShutdownOnSuccess - 하나만 성공하면 나머지 취소
public String fetchFromFastestSource(String query) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnSuccess()) {
        scope.fork(() -> fetchFromPrimaryDB(query));
        scope.fork(() -> fetchFromReplicaDB(query));
        scope.fork(() -> fetchFromCache(query));

        scope.join(); // 가장 빨리 성공한 것 반환
        return scope.result();
    }
}

Scoped Values (JEP 506 - Java 25에서 finalized)

불변 데이터를 스레드 계층 구조 전체에 안전하게 전달하는 ScopedValue가 Java 25에서 정식 API로 확정되었습니다.

// ThreadLocal 대신 ScopedValue 사용
public class RequestContext {
    // ScopedValue - 불변, 안전, 성능 좋음
    static final ScopedValue CURRENT_USER = ScopedValue.newInstance();
    static final ScopedValue REQUEST_ID = ScopedValue.newInstance();

    public void handleRequest(HttpRequest req) {
        User user = authenticate(req);
        String reqId = UUID.randomUUID().toString();

        ScopedValue.runWhere(CURRENT_USER, user,
            () -> ScopedValue.runWhere(REQUEST_ID, reqId,
                () -> processRequest(req)
            )
        );
    }

    void processRequest(HttpRequest req) {
        // 어디서든 현재 사용자 정보 접근 가능
        User user = CURRENT_USER.get();
        String reqId = REQUEST_ID.get();
        logAction(reqId, user, "processing request");

        // 가상 스레드로 분기해도 ScopedValue 자동 상속
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            scope.fork(() -> {
                // 자식 스레드에서도 CURRENT_USER 접근 가능!
                checkPermission(CURRENT_USER.get(), req);
                return null;
            });
            scope.join().throwIfFailed();
        }
    }
}

// ThreadLocal과 비교
// ThreadLocal: 변경 가능, 상속 불안정, 메모리 누수 가능
// ScopedValue: 불변, 자동 상속, 스코프 종료 시 자동 정리

성능 비교: 플랫폼 스레드 vs 가상 스레드

// 벤치마크 예시: 1만 개 I/O 작업
import java.time.Instant;

public class ThreadBenchmark {
    static final int TASK_COUNT = 10_000;

    // 플랫폼 스레드 풀 (최대 200개)
    static void platformThreadTest() throws Exception {
        long start = Instant.now().toEpochMilli();
        try (ExecutorService exec = Executors.newFixedThreadPool(200)) {
            for (int i = 0; i < TASK_COUNT; i++) {
                exec.submit(() -> { Thread.sleep(100); return null; });
            }
        }
        System.out.println("플랫폼 스레드: " + (Instant.now().toEpochMilli() - start) + "ms");
        // 약 5000ms (10000/200 * 100)
    }

    // 가상 스레드 (무제한)
    static void virtualThreadTest() throws Exception {
        long start = Instant.now().toEpochMilli();
        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < TASK_COUNT; i++) {
                exec.submit(() -> { Thread.sleep(100); return null; });
            }
        }
        System.out.println("가상 스레드: " + (Instant.now().toEpochMilli() - start) + "ms");
        // 약 110ms (모두 병렬 실행!)
    }
}

언제 가상 스레드를 사용해야 하나?

  • I/O 바운드 작업: 데이터베이스 쿼리, HTTP 요청, 파일 읽기
  • 고동시성 서버: 수천~수만 개의 동시 연결 처리
  • 마이크로서비스: 여러 서비스를 병렬로 호출하는 패턴
  • CPU 바운드 작업(계산 집약적)에는 기존 스레드 풀이 더 적합

마치며

Java 25의 Virtual Threads와 Structured Concurrency는 Java 동시성 프로그래밍의 패러다임을 바꿉니다. 복잡한 비동기 코드 없이 간단한 블로킹 스타일로 높은 처리량을 달성할 수 있습니다.