
개요
Java 21에서 정식 기능(GA)으로 채택된 Java Virtual Threads는 Project Loom이 수년간 개발해 온 경량 스레드 모델입니다. 전통적인 Java 서버 애플리케이션은 요청마다 OS 스레드를 생성하거나 스레드 풀(Thread Pool)을 사용해 동시성을 처리했지만, 수천~수만 건의 I/O 바운드 요청이 집중되는 운영 환경에서는 컨텍스트 스위칭 비용과 메모리 한계가 병목으로 작용합니다.
Virtual Threads를 활용하면 JVM이 소수의 OS 스레드 위에서 수백만 개의 경량 스레드를 스케줄링하므로, 블로킹 I/O가 많은 서비스에서 처리량(throughput)을 크게 높일 수 있습니다. 이 글에서는 Virtual Threads의 동작 원리부터 실제 프로젝트에 적용하는 방법, 성능 측정 시 주의할 점까지 단계별로 살펴봅니다.
Virtual Threads의 동작 원리
Virtual Threads는 java.lang.Thread를 상속하지만, OS 스레드에 1:1로 매핑되지 않습니다. JVM은 내부적으로 캐리어 스레드(Carrier Thread)라 불리는 소수의 OS 스레드 풀을 유지하고, Virtual Thread가 블로킹 작업(예: Socket, InputStream, JDBC 호출)을 만나면 캐리어 스레드를 즉시 반환(unmount)합니다. 블로킹이 해제되면 스케줄러가 다시 캐리어 스레드에 마운트(mount)해 실행을 이어갑니다.
이 방식 덕분에 스레드가 I/O를 기다리는 동안에도 OS 스레드가 낭비되지 않습니다. 반면 기존 플랫폼 스레드는 블로킹 I/O 중에도 OS 스레드를 점유하기 때문에, 동시 접속자 수가 늘어날수록 메모리와 컨텍스트 스위칭 비용이 선형적으로 증가합니다.
핵심 차이: Platform Thread = OS 스레드 1개 점유 / Virtual Thread = 블로킹 시 OS 스레드 즉시 반환
Java Virtual Threads 적용 방법
기본 생성 방법
JDK 21 이상이라면 별도 의존성 없이 바로 사용할 수 있습니다. 아래 예제는 Virtual Thread를 생성하는 대표적인 두 가지 방법을 보여줍니다.
// 방법 1: Thread.ofVirtual() 팩토리 사용
Thread vThread = Thread.ofVirtual()
.name("virtual-thread-1")
.start(() -> System.out.println("Hello from " + Thread.currentThread()));
// 방법 2: Executors.newVirtualThreadPerTaskExecutor() 사용 (권장)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
executor.submit(() -> {
// 블로킹 I/O 시뮬레이션
Thread.sleep(Duration.ofMillis(100));
System.out.printf("Task %d done on %s%n",
taskId, Thread.currentThread());
return taskId;
});
}
} // try-with-resources로 shutdown 자동 처리실행 결과 (일부)
Task 0 done on VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
Task 1 done on VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
Task 2 done on VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1
...takeaway:
Executors.newVirtualThreadPerTaskExecutor()를 사용하면 태스크마다 Virtual Thread가 생성되므로, 기존 스레드 풀 크기 튜닝 없이도 높은 동시성을 달성할 수 있습니다.
Spring Boot 3.2+에서 Virtual Threads 활성화
Spring Boot 3.2 이상에서는 application.properties 한 줄 설정으로 Virtual Threads를 활성화할 수 있습니다. Tomcat의 기본 스레드 풀이 Virtual Threads로 교체됩니다.
# application.properties
spring.threads.virtual.enabled=trueJava Config 방식으로 직접 설정하려면 아래와 같이 TomcatProtocolHandlerCustomizer를 빈으로 등록합니다.
@Configuration
public class VirtualThreadConfig {
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
// Tomcat 요청 처리 스레드를 Virtual Thread로 교체
return protocolHandler ->
protocolHandler.setExecutor(
Executors.newVirtualThreadPerTaskExecutor()
);
}
}이 설정만으로 I/O 바운드 API 서버에서 처리량이 2~4배 향상되는 사례를 현업 프로젝트에서 확인할 수 있습니다. 단, CPU 바운드 작업이 주를 이루는 서비스라면 개선 효과가 제한적입니다.
성능 비교와 주의사항
벤치마크: Platform Thread vs Virtual Thread
10,000개의 I/O 바운드 태스크(각 100ms 슬립)를 처리하는 시간을 직접 비교해 봅니다. 변경전은 고정 크기 스레드 풀, 변경후는 Virtual Thread 방식입니다.
public class ThreadBenchmark {
public static void main(String[] args) throws Exception {
int taskCount = 10_000;
// 변경전: 고정 크기 스레드 풀 (200개)
long start = System.currentTimeMillis();
try (var executor = Executors.newFixedThreadPool(200)) {
runTasks(executor, taskCount);
}
System.out.printf("[Platform] %d tasks → %dms%n",
taskCount, System.currentTimeMillis() - start);
// 변경후: Virtual Thread
start = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
runTasks(executor, taskCount);
}
System.out.printf("[Virtual ] %d tasks → %dms%n",
taskCount, System.currentTimeMillis() - start);
}
static void runTasks(ExecutorService executor, int count)
throws InterruptedException {
CountDownLatch latch = new CountDownLatch(count);
for (int i = 0; i < count; i++) {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofMillis(100)); // I/O 대기 시뮬레이션
} finally {
latch.countDown();
}
});
}
latch.await();
}
}실행 결과
[Platform] 10000 tasks → 5124ms // 스레드 200개 → 큐잉 발생
[Virtual ] 10000 tasks → 108ms // Virtual Thread → 즉시 처리takeaway: I/O 대기가 많은 태스크일수록 Virtual Thread의 처리량 이점이 두드러집니다. 운영 환경에서 스레드 풀 크기 설정에 드는 복잡도도 함께 줄어듭니다.
주의사항: Pinning과 synchronized 블록
Virtual Thread가 synchronized 블록 또는 synchronized 메서드 안에서 블로킹 작업을 수행하면, 캐리어 스레드에 고정(pinned)되어 Virtual Thread의 이점을 잃습니다. 특히 일부 JDBC 드라이버나 레거시 라이브러리에서 빈번하게 발생할 수 있습니다.
// ❌ 주의: synchronized + 블로킹 I/O → 캐리어 스레드 pinning 발생
synchronized (lock) {
result = jdbcTemplate.query(SQL, rowMapper); // 블로킹 I/O
}
// ✅ 권장: ReentrantLock 사용으로 pinning 회피
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
result = jdbcTemplate.query(SQL, rowMapper);
} finally {
lock.unlock();
}Pinning 발생 여부는 JVM 플래그 -Djdk.tracePinnedThreads=full로 확인할 수 있습니다. 실제 프로젝트에 Virtual Threads를 도입할 때 가장 먼저 점검해야 할 항목입니다.
맺음말
Java Virtual Threads는 I/O 바운드 워크로드가 많은 웹 서버, API 게이트웨이, 배치 처리 시스템에서 성능 최적화 효과가 큽니다. 기존 코드 구조를 거의 변경하지 않고도 적용할 수 있어 도입 장벽이 낮은 것도 장점입니다.
단, 아래 상황에서는 도입 전 충분한 검토가 필요합니다.
- CPU 바운드 연산 중심의 서비스 — 개선 효과가 미미하거나 오히려 오버헤드가 생길 수 있습니다
synchronized+ 블로킹 I/O가 혼재하는 레거시 코드 — pinning 위험이 높습니다- JDK 21 미만 환경 — 공식 지원이 없어 Preview 기능을 사용해야 합니다
Java 21 GA 이후 Virtual Threads는 새로운 동시성의 기본 모델로 자리잡고 있습니다. 더 나아가 Structured Concurrency와 함께 사용하면 계층적 태스크 관리와 오류 전파를 더욱 체계적으로 다룰 수 있습니다. 관련하여 Java 21 주요 신기능 정리, Spring Boot 3 성능 최적화 가이드, Project Loom 심화 분석 글도 함께 참고하길 권장합니다.
참고 자료
'프로그래밍 PROGRAMMING > 자바 JAVA AND FRAMEWORKS' 카테고리의 다른 글
| Java HashMap 성능 최적화 초기 용량과 로드 팩터를 제대로 설정하는 법 (0) | 2026.03.07 |
|---|---|
| Java Virtual Threads로 고성능 서버 구현하기 스레드 병목을 해소하는 방법 (0) | 2026.03.06 |
| Spring AI로 Java 애플리케이션에 LLM 연동하기 (0) | 2026.03.05 |
| [JAVA/SPRING] Java 캐시 전략 총정리: Caffeine부터 Redis 이중 캐시까지 (0) | 2026.03.04 |
| [JAVA/SPRING] Spring Boot Virtual Threads로 처리량 높이기 어떻게 설정하고 언제 써야 할까 (0) | 2026.03.04 |