가상 스레드란?
Java 21에서 정식 출시된 Virtual Thread(JEP 444)는 JVM이 관리하는 경량 스레드입니다. 기존 Platform Thread가 OS 스레드와 1:1로 매핑되는 것과 달리, 수백만 개의 Virtual Thread가 소수의 OS 스레드(Carrier Thread) 위에서 실행됩니다.
가상 메모리를 떠올리면 이해하기 쉽습니다. OS가 제한된 물리 RAM을 큰 가상 주소 공간으로 매핑하듯, JVM이 소수의 OS 스레드를 대량의 Virtual Thread로 매핑합니다.
| 항목 | Platform Thread | Virtual Thread |
|---|---|---|
| 매핑 | OS 스레드 1:1 | M:N (다수 → 소수 OS 스레드) |
| 메모리 | 스레드당 ~1MB | 스레드당 ~1-2KB |
| 최대 동시 수 | 수천 개 | 수백만 개 |
| 스케줄링 | OS 스케줄러 | JVM ForkJoinPool |
| 풀링 | 스레드 풀 필수 | 풀링 불필요 |
| 적합한 작업 | CPU 집약 | I/O 바운드 |
핵심을 짚으면: Virtual Thread는 코드를 더 “빠르게” 실행하지 않습니다. 목적은 처리량(throughput) 확장입니다. I/O 대기 시간에 Carrier Thread를 다른 작업에 양보하여 동시 처리 수를 극적으로 늘립니다.
기본 사용법
Thread.ofVirtual()
Virtual Thread를 생성하는 가장 기본적인 방법입니다.
public class VirtualThreadBasic {
public static void main(String[] args) throws InterruptedException {
// Virtual Thread 생성 및 실행
Thread vThread = Thread.ofVirtual()
.name("my-vthread")
.start(() -> {
System.out.println("스레드: " + Thread.currentThread());
System.out.println("가상 스레드인가? " + Thread.currentThread().isVirtual());
});
vThread.join();
// 출력:
// 스레드: VirtualThread[#21,my-vthread]/runnable@ForkJoinPool-1-worker-1
// 가상 스레드인가? true
}
}
ExecutorService (권장)
실무에서는 Executors.newVirtualThreadPerTaskExecutor()를 더 자주 사용합니다. 태스크마다 새 Virtual Thread를 생성하며, try-with-resources와 함께 쓰면 모든 태스크 완료를 자동으로 기다립니다.
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class VirtualThreadExecutor {
public static void main(String[] args) throws Exception {
// 10,000개 동시 작업 처리
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
final int taskId = i;
futures.add(executor.submit(() -> {
// I/O 작업 시뮬레이션 (실제로는 HTTP 호출, DB 쿼리 등)
Thread.sleep(100);
return "결과-" + taskId;
}));
}
// 처음 5개 결과만 출력
for (int i = 0; i < 5; i++) {
System.out.println(futures.get(i).get());
}
System.out.println("... 총 " + futures.size() + "개 태스크 완료");
}
// try 블록 종료 시 모든 Virtual Thread 완료 대기
// 출력:
// 결과-0
// 결과-1
// 결과-2
// 결과-3
// 결과-4
// ... 총 10000개 태스크 완료
}
}
Platform Thread 풀에서는 200개씩 처리하던 것을 10,000개 동시 처리로 확장할 수 있습니다. 코드 변경은 newFixedThreadPool(200)을 newVirtualThreadPerTaskExecutor()로 바꾸는 것뿐입니다.
성능 비교
Spring Boot 환경(Tomcat, 블로킹 I/O)에서의 실측 데이터입니다.
| 지표 | Platform Thread (200 풀) | Virtual Thread | 개선 |
|---|---|---|---|
| 동시 1,000 요청 | 큐잉 시작 | 전부 처리 | ~2x |
| 동시 10,000+ 요청 | 타임아웃 발생 | 원활 처리 | ~3x |
| I/O 응답 지연 | 448ms | 319ms | 28.8% 감소 |
| 메모리 사용량 | 기준선 | -43% | 43% 절감 |
CPU 바운드 작업에서는 성능 차이가 거의 없습니다. Virtual Thread는 I/O 바운드 서비스에서 빛납니다.
주의사항 3가지
1. Pinning — synchronized 블록의 함정
Virtual Thread가 synchronized 블록 내에서 블로킹 I/O를 수행하면, Carrier Thread에서 언마운트되지 못하고 고정(Pinned) 됩니다. 경량 스레드의 이점이 사라집니다.
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
public class PinningExample {
// 문제: synchronized 안에서 I/O → Pinning 발생
private static final Object syncLock = new Object();
static void badExample() throws Exception {
synchronized (syncLock) {
Thread.sleep(1000); // 블로킹 I/O 시뮬레이션 → Carrier Thread 고정!
}
}
// 해결: ReentrantLock으로 교체
private static final ReentrantLock reentrantLock = new ReentrantLock();
static void goodExample() throws Exception {
reentrantLock.lock();
try {
Thread.sleep(1000); // 블로킹 I/O 시뮬레이션 → Pinning 없음
} finally {
reentrantLock.unlock();
}
}
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// ReentrantLock 방식으로 10개 동시 실행
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
goodExample();
System.out.println(Thread.currentThread() + " 완료");
return null;
});
}
}
System.out.println("모든 작업 완료 (Pinning 없이)");
}
}
실제로 Netflix에서 synchronized Pinning으로 인한 전체 애플리케이션 데드락 사고가 발생한 적이 있습니다. JFR(Java Flight Recorder)로 Pinning을 감지할 수 있습니다.
# Pinning 이벤트 감지
java -Djdk.tracePinnedThreads=full -jar your-app.jar
참고로 Java 24(JEP 491)부터는 이 문제가 근본적으로 해결되어 synchronized에서도 Pinning이 발생하지 않습니다.
2. ThreadLocal 메모리 폭발
Platform Thread 풀(200개)에서 ThreadLocal 캐시는 최대 200개 인스턴스입니다. 하지만 Virtual Thread는 풀링되지 않으므로 50,000개 동시 스레드 = 50,000개 인스턴스가 생성됩니다.
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.Executors;
public class ThreadLocalDanger {
// 위험: Virtual Thread에서 ThreadLocal 캐싱
// 50,000개 Virtual Thread → 50,000개 인스턴스 생성!
static final ThreadLocal<SimpleDateFormat> BAD_FORMATTER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
// 해결: 스레드 안전한 불변 객체 공유 → 하나만 생성
static final DateTimeFormatter GOOD_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd");
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 5; i++) {
executor.submit(() -> {
// 불변 DateTimeFormatter 사용 (권장)
String date = LocalDate.now().format(GOOD_FORMATTER);
System.out.println(Thread.currentThread().threadId() + " → " + date);
return null;
});
}
}
// 출력:
// 21 → 2026-04-07
// 22 → 2026-04-07
// ...
}
}
3. 하위 시스템 보호
Virtual Thread는 저렴하지만, DB 커넥션 풀이나 외부 API는 그렇지 않습니다. Semaphore로 동시성을 제한하세요.
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
// DB 커넥션 풀 크기에 맞춰 동시 접근 제한
static final Semaphore dbSemaphore = new Semaphore(3); // 예시: 최대 3개 동시
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
dbSemaphore.acquire();
try {
// DB 쿼리 시뮬레이션
System.out.println("작업 " + taskId + " 실행 중 (동시 허용: "
+ (3 - dbSemaphore.availablePermits()) + "/3)");
Thread.sleep(500);
} finally {
dbSemaphore.release();
}
return null;
});
}
}
System.out.println("모든 작업 완료");
}
}
Spring Boot에서 활성화
Spring Boot 3.2 이상 + Java 21 환경이라면 한 줄로 활성화됩니다.
# application.properties
spring.threads.virtual.enabled=true
이 설정으로 Tomcat HTTP 요청 처리, @Async 메서드, 메시지 리스너(Kafka, RabbitMQ)가 모두 Virtual Thread에서 실행됩니다.
단, WebFlux를 이미 사용 중이라면 Virtual Thread가 불필요합니다. WebFlux는 이미 논블로킹이므로 혼용하면 오히려 문제가 생깁니다. Spring MVC(블로킹 I/O) 기반 서비스에서 Virtual Thread가 가장 큰 효과를 발휘합니다.
정리
| 상황 | 권장 |
|---|---|
| I/O 바운드 서비스 (DB, API 호출) | Virtual Thread 적극 활용 |
| CPU 집약 작업 (이미지 처리, 암호화) | Platform Thread 풀 유지 |
| Java 21 사용 시 | synchronized Pinning 주의 |
| Java 24+ 사용 시 | Pinning 해결됨, 안심하고 사용 |
| ThreadLocal 캐싱 | 불변 객체 공유로 교체 |
| 동시성 제한 필요 시 | Semaphore 사용 |
Virtual Thread를 풀링하지 마세요. 태스크당 하나씩 만들고 버리는 것이 설계 의도입니다. “스레드는 비싸다”는 기존 상식을 버리고, “Virtual Thread는 거의 무료”라는 새로운 모델로 전환하면 됩니다.