Contents
see ListJava 21에서 정식 도입된 Virtual Threads(가상 스레드)는 Project Loom의 핵심 결과물로, 동시성 프로그래밍의 패러다임을 바꾸고 있다. 기존 플랫폼 스레드 대비 메모리 사용량을 1/100 수준으로 줄이면서도, 기존 코드와의 호환성을 유지한다. 이 글에서는 Virtual Threads의 원리, 사용법, 성능 최적화를 다룬다.
Virtual Threads란?
기존 Java 스레드(Platform Thread)는 OS 스레드와 1:1로 매핑되어 각각 약 1MB의 스택 메모리를 차지한다. 서버에서 1만 개의 동시 요청을 처리하려면 1만 개의 OS 스레드가 필요하고, 이는 약 10GB의 메모리를 소비한다. Virtual Thread는 JVM이 관리하는 경량 스레드로, 수십 바이트~수 KB의 메모리만 사용하며 수백만 개를 생성할 수 있다.
기본 사용법
// 1. Thread.ofVirtual()로 생성
Thread vThread = Thread.ofVirtual().name("worker-1").start(() -> {
System.out.println(Thread.currentThread());
// VirtualThread[#21,worker-1]/runnable@ForkJoinPool-1-worker-1
});
vThread.join();
// 2. Thread.startVirtualThread() - 가장 간단한 방법
Thread.startVirtualThread(() -> {
System.out.println("Hello from virtual thread!");
});
// 3. Executors.newVirtualThreadPerTaskExecutor()
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 10만 개의 작업을 동시 실행 - 각각 가상 스레드에서 실행
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 100_000; i++) {
final int taskId = i;
futures.add(executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return "Task " + taskId + " completed";
}));
}
// 모든 결과 수집
for (Future<String> future : futures) {
future.get(); // 블로킹해도 OK - 가상 스레드이므로
}
}
// executor가 자동으로 닫히며 모든 작업 완료 대기
Structured Concurrency (구조화된 동시성)
Java 21의 StructuredTaskScope는 여러 하위 작업의 생명주기를 부모 작업에 묶어 관리한다.
import java.util.concurrent.StructuredTaskScope;
public record UserProfile(User user, List<Order> orders, int points) {}
public UserProfile fetchUserProfile(long userId) throws Exception {
// ShutdownOnFailure: 하나라도 실패하면 나머지 자동 취소
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 3개의 API를 동시에 호출
var userTask = scope.fork(() -> userService.findById(userId));
var ordersTask = scope.fork(() -> orderService.findByUserId(userId));
var pointsTask = scope.fork(() -> pointService.getPoints(userId));
// 모든 작업 완료 대기 (또는 하나라도 실패 시 즉시 반환)
scope.join();
scope.throwIfFailed();
// 결과 조합
return new UserProfile(
userTask.get(),
ordersTask.get(),
pointsTask.get()
);
}
}
// ShutdownOnSuccess: 하나라도 성공하면 나머지 취소
public String fetchFromFastestMirror(String path) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> downloadFrom("mirror1.example.com", path));
scope.fork(() -> downloadFrom("mirror2.example.com", path));
scope.fork(() -> downloadFrom("mirror3.example.com", path));
scope.join();
return scope.result(); // 가장 먼저 성공한 결과 반환
}
}
Scoped Values (스코프 값)
ThreadLocal의 가상 스레드 친화적 대안인 ScopedValue를 사용하면 불변 값을 스레드 계층에 효율적으로 전파할 수 있다.
import java.lang.ScopedValue;
public class RequestContext {
// 불변, 상속 가능한 스코프 값
static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
public void handleRequest(HttpRequest request) {
String userId = extractUserId(request);
String requestId = UUID.randomUUID().toString();
// ScopedValue.where()로 바인딩
ScopedValue.where(CURRENT_USER, userId)
.where(REQUEST_ID, requestId)
.run(() -> {
// 이 블록과 하위 호출에서 값 접근 가능
processRequest(request);
});
}
private void processRequest(HttpRequest request) {
// 별도 파라미터 없이 컨텍스트 접근
String user = CURRENT_USER.get();
String reqId = REQUEST_ID.get();
logger.info("[{}] User {} processing request", reqId, user);
// 하위 가상 스레드에도 자동 전파
Thread.startVirtualThread(() -> {
// 부모의 ScopedValue를 그대로 사용
auditService.log(CURRENT_USER.get(), "action");
});
}
}
성능 비교 벤치마크
// Platform Thread vs Virtual Thread 성능 비교
public class ThreadBenchmark {
static final int TASK_COUNT = 100_000;
// Platform Thread - OOM 위험
static void platformThreadTest() throws Exception {
long start = System.currentTimeMillis();
try (var executor = Executors.newFixedThreadPool(200)) {
for (int i = 0; i < TASK_COUNT; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100));
return "done";
});
}
}
System.out.printf("Platform: %dms%n", System.currentTimeMillis() - start);
// 약 50,000ms (스레드 풀 200개로 10만 작업 순차 처리)
}
// Virtual Thread - 동시에 모두 실행
static void virtualThreadTest() throws Exception {
long start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < TASK_COUNT; i++) {
futures.add(executor.submit(() -> {
Thread.sleep(Duration.ofMillis(100));
return "done";
}));
}
futures.forEach(f -> { try { f.get(); } catch (Exception e) {} });
}
System.out.printf("Virtual: %dms%n", System.currentTimeMillis() - start);
// 약 500ms (10만 개 동시 실행, 100ms 대기)
}
}
주의사항
- synchronized 블록 주의: Virtual Thread가 synchronized 블록에서 블로킹되면 캐리어 스레드도 함께 블로킹됨 (pinning). ReentrantLock으로 대체 권장
- CPU 바운드 작업 비적합: Virtual Thread는 I/O 바운드 작업에 최적화됨. CPU 집약적 작업은 기존 스레드 풀 사용
- ThreadLocal 주의: 수백만 개의 Virtual Thread에서 ThreadLocal을 사용하면 메모리 낭비. ScopedValue로 대체
- 풀링 금지: Virtual Thread는 풀링하지 말 것. 매번 새로 생성하는 것이 올바른 사용법
Virtual Thread는 특히 Spring Boot, Quarkus 등의 웹 프레임워크에서 큰 효과를 발휘한다. Spring Boot 3.2+에서는 spring.threads.virtual.enabled=true 한 줄로 모든 요청 처리를 Virtual Thread로 전환할 수 있다.