"Virtual Thread 쓰면 그냥 빨라지는 거 아닌가요?"
맞기도 하고 틀리기도 합니다.
Virtual Thread는 올바르게 이해하고 적용하면 강력하지만,
잘못 사용하면 오히려 성능이 나빠지거나 디버깅하기 어려운 문제가 생깁니다.
이 글에서는 Virtual Thread가 메모리를 어떻게 관리하는지,
어떤 상황에서 막히는지(Pinning), Spring Boot 3.2에서 어떻게 적용하는지를 정리합니다.
👉 JDK Flight Recorder + JDK Mission Control 실전 — 운영 중 JVM 프로파일링
👉 JVM GC 로그 분석 실전 — GCViewer와 GCEasy로 튜닝 시작하기
Table of Contents
1. 기존 스레드의 문제 — 왜 Virtual Thread가 나왔나요?
Virtual Thread를 이해하려면 먼저 기존 플랫폼 스레드(Platform Thread)의 한계를 알아야 합니다.
플랫폼 스레드의 비용
// 전통적인 방식: 요청 1개 = 스레드 1개
@RestController
public class TraditionalController {
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
// 이 스레드는 DB 응답을 기다리는 동안 아무것도 못 하고 블로킹됨
return orderRepository.findById(id); // 예: DB 응답 50ms 대기
}
}Code language: PHP (php)
위 코드에서 스레드가 findById() 결과를 기다리는 50ms 동안 무슨 일이 일어날까요?
플랫폼 스레드 라이프사이클 (DB I/O 50ms 대기 시):
[0ms] 스레드 A: 요청 처리 시작
[1ms] 스레드 A: DB 쿼리 전송 → 대기 시작 (BLOCKED)
↓↓↓ 49ms 동안 이 스레드는 아무 일도 하지 않음 ↓↓↓
OS 커널: "스레드 A는 블로킹 중, 다른 스레드에 CPU 할당"
(컨텍스트 스위칭 발생, 비용 발생)
[50ms] 스레드 A: DB 응답 수신 → 처리 재개
[52ms] 스레드 A: 응답 반환Code language: JavaScript (javascript)
플랫폼 스레드 1개가 차지하는 메모리는 기본 1MB (스택 메모리) 입니다.
동시 요청 1,000개를 처리하려면 최소 1GB의 스택 메모리가 필요합니다.
2. Virtual Thread의 동작 원리
Virtual Thread는 JVM이 관리하는 경량 스레드입니다.
OS 스레드와 1:1로 매핑되지 않고, 소수의 OS 스레드(캐리어 스레드) 위에서 수백만 개의 Virtual Thread가 스케줄링됩니다.
핵심은 mount / unmount 개념입니다.
- mount: Virtual Thread가 캐리어 스레드에 올라가 실제로 CPU에서 실행되는 상태
- unmount: I/O 블로킹이 발생하면 캐리어 스레드에서 분리되어 힙에 저장. 캐리어 스레드는 다른 Virtual Thread를 실행할 수 있게 됨
3. 메모리 모델 — 플랫폼 스레드와 무엇이 다른가요?
이 부분이 이 글의 핵심입니다.
스택 메모리 비교
Virtual Thread의 스택은 JVM Heap에 저장됩니다.
이것이 플랫폼 스레드와 가장 큰 차이입니다.
// 실제로 확인해보기
public class MemoryComparison {
public static void main(String[] args) throws Exception {
int threadCount = 100_000; // 10만 개
// 플랫폼 스레드 10만 개 생성 시도
// → 대부분의 서버에서 OutOfMemoryError 또는 수십 GB 메모리 필요
// (실제로는 수천 개로 제한됨)
// Virtual Thread 10만 개 생성
// → 가능! 각 VT 초기 스택이 수백 바이트이므로 수십 MB 수준
long start = System.currentTimeMillis();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < threadCount; i++) {
Thread vt = Thread.ofVirtual().start(() -> {
try {
Thread.sleep(1000); // 1초 대기 (I/O 시뮬레이션)
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
threads.add(vt);
}
// 모든 Virtual Thread 완료 대기
for (Thread t : threads) {
t.join();
}
System.out.println("소요 시간: " + (System.currentTimeMillis() - start) + "ms");
// 플랫폼 스레드: 불가능 (OOM) 또는 매우 오래 걸림
// Virtual Thread: ~1000ms (모두 병렬로 sleep)
}
}Code language: PHP (php)
Heap에 스택이 있다는 것의 의미
Virtual Thread 스택이 Heap에 있기 때문에 생기는 변화가 있습니다.
변화 1: GC가 Virtual Thread 스택을 수거할 수 있음
→ Virtual Thread 종료 시 스택 메모리가 GC 대상이 됨
→ 메모리 누수 가능성 감소
변화 2: Heap 사용량 증가
→ Virtual Thread를 대량 사용하면 Heap 사용량이 늘어남
→ -Xmx 설정 시 VT 스택 공간도 고려 필요
변화 3: 스택 공간이 유연
→ 재귀 호출이 깊어지면 스택이 동적으로 증가
→ StackOverflowError 발생 임계값이 플랫폼 스레드와 다를 수 있음
4. Pinning — Virtual Thread의 가장 큰 주의사항
Pinning은 Virtual Thread가 블로킹 상태임에도 캐리어 스레드에서 분리(unmount)되지 못하고 고정되는 현상입니다.
Pinning이 발생하면 캐리어 스레드가 그 Virtual Thread에 묶여 다른 Virtual Thread를 처리하지 못합니다.
Virtual Thread를 쓰는 의미가 없어지는 상황입니다.
Pinning이 발생하는 두 가지 상황
Pinning 발생 코드 vs 안전한 코드
// ❌ Pinning 발생: synchronized + 블로킹 I/O 조합
@Service
public class PinningProblemService {
private final Object lock = new Object();
public Order processOrder(Long orderId) {
synchronized (lock) { // 여기서 캐리어 스레드에 고정
Order order = orderRepository.findById(orderId); // DB I/O — 블로킹!
// → VThread가 unmount 되지 못함
// → 캐리어 스레드가 DB 응답 올 때까지 점유됨
// → 다른 VThread들이 실행 대기
return processLogic(order);
}
}
}
// ✅ Pinning 없음: ReentrantLock 사용
@Service
public class PinningFixedService {
private final ReentrantLock lock = new ReentrantLock();
public Order processOrder(Long orderId) {
lock.lock();
try {
Order order = orderRepository.findById(orderId); // DB I/O — 블로킹 발생
// → VThread unmount 가능!
// → 캐리어 스레드는 다른 VThread 실행 가능
// → DB 응답 오면 다시 mount되어 처리 재개
return processLogic(order);
} finally {
lock.unlock();
}
}
}Code language: PHP (php)
⚠️ 주의: JDK의 일부 내부 API와 서드파티 라이브러리가 내부적으로
synchronized를 사용합니다. 예를 들어 Java 21 이전의java.io.InputStream, JDBC 드라이버 일부 구현 등이 해당합니다. 이런 경우 JFR로 Pinning 발생 여부를 모니터링하는 것이 중요합니다.
JFR로 Pinning 감지하기
# Pinning 이벤트 포함해서 녹화
jcmd 12345 JFR.start \
duration=60s \
filename=/tmp/pinning_check.jfr \
settings=profile
# jfr CLI로 Pinning 이벤트만 추출
jfr print --events jdk.VirtualThreadPinned /tmp/pinning_check.jfrCode language: PHP (php)
# 출력 예시 (Pinning 발생 시)
jdk.VirtualThreadPinned {
startTime = 13:42:07.123
duration = 52.3 ms ← Pinning 지속 시간
carrierThread = "ForkJoinPool-1-worker-1" ← 묶인 캐리어 스레드
eventThread = "virtual-34"
stackTrace = [
com.example.service.PinningProblemService.processOrder(PinningProblemService.java:15)
... ← Pinning 발생 위치 확인 가능
]
}Code language: PHP (php)
JVM 시작 시 아래 옵션을 추가하면 Pinning 발생 시 로그도 출력됩니다.
# Pinning 감지 로그 활성화
java -Djdk.tracePinnedThreads=full -jar app.jar
# 출력 예시:
# Thread[#34,ForkJoinPool-1-worker-1,5,CarrierThreads]
# com.example.service.PinningProblemService.processOrder(PinningProblemService.java:15) <== monitors:1Code language: PHP (php)
5. Virtual Thread를 쓰면 안 되는 경우
Virtual Thread는 I/O 바운드 작업에 최적화되어 있습니다.
모든 상황에서 더 빠르지 않으며, 오히려 역효과가 날 수 있는 경우가 있습니다.
CPU 바운드 작업에서의 잘못된 예
// ❌ CPU 바운드 작업에 Virtual Thread — 의미 없거나 오히려 느림
@Service
public class ImageProcessingService {
public void processImages(List<byte[]> images) throws Exception {
List<Thread> threads = new ArrayList<>();
for (byte[] image : images) {
Thread vt = Thread.ofVirtual().start(() -> {
// 이미지 압축: 순수 CPU 작업, I/O 없음
// → VThread가 unmount될 기회가 없음
// → 캐리어 스레드 수만큼만 병렬 실행 (플랫폼 스레드와 동일)
compressImage(image);
});
threads.add(vt);
}
for (Thread t : threads) t.join();
}
}
// ✅ CPU 바운드 작업은 ForkJoinPool 또는 고정 스레드풀 사용
@Service
public class ImageProcessingService {
// CPU 코어 수에 맞춘 고정 스레드풀
private final ExecutorService cpuPool =
Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
public void processImages(List<byte[]> images) throws Exception {
List<Future<?>> futures = images.stream()
.map(image -> cpuPool.submit(() -> compressImage(image)))
.toList();
for (Future<?> f : futures) f.get();
}
}Code language: PHP (php)
6. ThreadLocal과 Virtual Thread — 주의사항
플랫폼 스레드 환경에서 ThreadLocal은 스레드 수가 제한되어 있어 메모리 영향이 작았습니다.
Virtual Thread는 수백만 개가 생성될 수 있으므로 ThreadLocal 사용이 메모리 문제로 이어질 수 있습니다.
// ❌ Virtual Thread 환경에서 ThreadLocal 대량 데이터 저장 — 메모리 위험
public class RequestContextHolder {
// 각 VThread마다 이 데이터가 복사되어 저장됨
// VThread 100만 개 × 1KB = 1GB 이상 소비 가능
private static final ThreadLocal<Map<String, Object>> context =
ThreadLocal.withInitial(HashMap::new);
public static void set(String key, Object value) {
context.get().put(key, value);
}
}
// ✅ Java 21 Scoped Values 사용 (Virtual Thread 시대의 ThreadLocal 대체)
// 불변(immutable), 상속 가능, 메모리 효율적
import java.lang.ScopedValue;
public class RequestContext {
// ScopedValue: 특정 스코프 내에서만 유효, 스코프 종료 시 자동 해제
public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
// 사용 예시
public void handleRequest(String userId, String requestId) {
ScopedValue.where(USER_ID, userId)
.where(REQUEST_ID, requestId)
.run(() -> {
// 이 람다 내부에서만 USER_ID, REQUEST_ID 접근 가능
processRequest();
});
// 람다 종료 후 자동으로 스코프 해제 — 메모리 누수 없음
}
private void processRequest() {
String userId = USER_ID.get(); // 현재 스코프의 값 조회
String requestId = REQUEST_ID.get();
// ...
}
}Code language: JavaScript (javascript)
7. Spring Boot 3.2에서 Virtual Thread 적용하기
Spring Boot 3.2부터 설정 한 줄로 전체 서버를 Virtual Thread 기반으로 전환할 수 있습니다.
기본 활성화
# application.yml
spring:
threads:
virtual:
enabled: true # 이 한 줄로 Tomcat/Jetty 스레드 → Virtual Thread 전환Code language: PHP (php)
내부적으로 아래와 같이 동작합니다.
// Spring Boot가 내부적으로 설정하는 내용 (직접 작성할 필요 없음)
@Bean
public TomcatProtocolHandlerCustomizer<?> virtualThreadCustomizer() {
return protocolHandler ->
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}Code language: PHP (php)
세밀한 제어가 필요한 경우 직접 설정
@Configuration
public class ThreadConfig {
/**
* Virtual Thread 기반 ExecutorService
* 요청 처리, 비동기 작업에 사용
*/
@Bean("virtualThreadExecutor")
public ExecutorService virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
/**
* CPU 바운드 작업 전용 ExecutorService (기존 방식 유지)
* 이미지 처리, 암호화 등에 사용
*/
@Bean("cpuBoundExecutor")
public ExecutorService cpuBoundExecutor() {
return Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
}
/**
* @Async 기본 실행기를 Virtual Thread로 설정
*/
@Bean
public AsyncTaskExecutor applicationTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
}
// 서비스에서 용도에 맞게 선택
@Service
public class OrderService {
@Qualifier("virtualThreadExecutor")
@Autowired
private ExecutorService virtualExecutor; // I/O 작업용
@Qualifier("cpuBoundExecutor")
@Autowired
private ExecutorService cpuExecutor; // CPU 작업용
public CompletableFuture<Order> getOrderAsync(Long id) {
// I/O 작업 → Virtual Thread
return CompletableFuture.supplyAsync(
() -> orderRepository.findById(id), virtualExecutor
);
}
public CompletableFuture<byte[]> generatePdfAsync(Order order) {
// CPU 작업 → 고정 스레드풀
return CompletableFuture.supplyAsync(
() -> pdfGenerator.generate(order), cpuExecutor
);
}
}Code language: PHP (php)
JDBC / JPA와 Virtual Thread — 커넥션 풀 설정 주의
Virtual Thread 환경에서 DB 커넥션 풀 설정을 잘못하면 성능이 오히려 나빠질 수 있습니다.
# application.yml — Virtual Thread 환경에서의 HikariCP 설정
spring:
threads:
virtual:
enabled: true
datasource:
hikari:
# ❌ 잘못된 설정: 커넥션 풀 크기를 무작정 크게
# maximum-pool-size: 500 ← DB 서버 부하 급증
# ✅ 올바른 설정
# VThread가 많다고 커넥션도 많이 필요한 건 아님
# DB 서버의 최대 동시 처리 능력에 맞게 설정 (보통 10~50)
maximum-pool-size: 20
minimum-idle: 5
# Virtual Thread 환경에서는 타임아웃 설정이 더 중요해짐
# 커넥션 대기 최대 시간 (VThread가 많을수록 경합 증가)
connection-timeout: 3000Code language: PHP (php)
💡 VThread는 커넥션을 기다리는 동안 unmount되어 다른 작업을 처리할 수 있습니다. 따라서 커넥션 풀이 작더라도 VThread 환경에서는 덜 문제가 됩니다. 오히려 풀 크기를 너무 크게 늘리면 DB 서버에 부하를 줄 수 있습니다.
8. 전환 전 점검 체크리스트
Virtual Thread로 전환하기 전에 아래를 확인하세요.
9. 결과 및 검증 — 적용 후 확인 방법
# 1. Virtual Thread 동작 확인 (JFR 활용)
jcmd 12345 JFR.start duration=30s filename=/tmp/vt_check.jfr settings=profile
jfr print --events jdk.VirtualThreadStart,jdk.VirtualThreadEnd /tmp/vt_check.jfr | head -30
# 2. Pinning 이벤트 없는지 확인
jfr print --events jdk.VirtualThreadPinned /tmp/vt_check.jfr
# 출력 없으면 Pinning 없음 ✅
# 3. 스레드 수 변화 확인 (JMX)
jcmd 12345 Thread.print | grep "virtual" | wc -l
# 4. Prometheus 메트릭으로 응답 시간 변화 비교
# 전환 전후 p99 레이턴시 비교
histogram_quantile(0.99,
rate(http_server_requests_seconds_bucket[5m])
)Code language: PHP (php)
10. 추가 고려사항 💡
Reactive(WebFlux)와 Virtual Thread — 같이 쓸 필요 없음
Spring WebFlux + Project Reactor는 이미 Non-blocking I/O로 구현되어 있어
Virtual Thread의 혜택이 크지 않습니다. 오히려 Reactor의 스케줄러와 VT 스케줄러가 충돌할 수 있습니다.
Virtual Thread는 기존 Blocking MVC 코드를 Non-blocking처럼 동작하게 만드는 것이 주 목적입니다.
JDK 21 이후의 변화
Java 21부터 JDK 내부의 synchronized를 점진적으로 ReentrantLock으로 교체하고 있어
Pinning 발생 가능성이 줄어들고 있습니다. Java 24에서는 synchronized 블록 내 Pinning 문제도
상당 부분 해결될 예정입니다.
Virtual Thread 이름 설정 — 디버깅 편의성
// 디버깅 시 스레드를 구분하기 쉽게 이름 설정
Thread.ofVirtual()
.name("order-processor-", 0) // "order-processor-0", "order-processor-1" ...
.start(() -> processOrder(orderId));Code language: JavaScript (javascript)
👉 JDK Flight Recorder + JDK Mission Control 실전 — 운영 중 JVM 프로파일링
👉 Spring Boot + Micrometer로 JVM 메모리 대시보드 구축하기
11. 마무리
이번 글을 세 줄로 정리합니다.
- Virtual Thread는 스택을 JVM Heap에 저장하는 경량 스레드로, I/O 바운드 작업에서 플랫폼 스레드 대비 훨씬 적은 메모리로 높은 동시성을 달성합니다.
synchronized블록 내 I/O 블로킹은 Pinning을 유발해 Virtual Thread의 장점을 무력화하므로,ReentrantLock으로 교체하고 JFRVirtualThreadPinned이벤트로 모니터링하는 것이 필수입니다.- Spring Boot 3.2에서는
spring.threads.virtual.enabled=true한 줄로 전환 가능하지만, CPU 바운드 작업 분리, 커넥션 풀 크기 조정, ThreadLocal 사용 점검을 반드시 함께 진행해야 합니다.
다음 글에서는 이 시리즈의 마지막으로 Spring Boot + Micrometer로 JVM 메모리 대시보드를 구축하는 방법을 다룹니다.
지금까지 다룬 GC 로그, Heap Dump, JFR, Virtual Thread 지표를 Grafana 하나에서 한눈에 보는 파이프라인을 완성합니다.
조회수: 0