
SOFTMOA TECHNOLOGY
Java 21 Virtual Threads 완벽 가이드: 기존 스레드 대비 100배 성능 향상 달성하기
소프트모아가 정리한 기술 기록입니다.
Contents
Java 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로 전환할 수 있다.
