개요
Java 21에서 정식 출시된 Virtual Thread(Project Loom)는 “몇십만 개의 가상 스레드를 수십 개의 OS 스레드로 멀티플렉싱해 C10K 문제를 해결한다”는 약속을 했다.
실제로는 그렇게 단순하지 않다.
이상적인 시나리오: Virtual Thread가 I/O 대기 중이면 Carrier Thread 전환
→ 매우 가볍고 빠름
현실: synchronized 블록에서 Virtual Thread가 "고정(pin)"되면
→ Carrier Thread가 블로킹되고 다른 Virtual Thread들은 대기
→ 성능 이득 상실
1. Virtual Thread의 기본 구조
Carrier Thread vs Virtual Thread
OS 레벨
┌──────────────────────────────────────────┐
│ [Carrier Thread 1] [Carrier Thread 2] │ (OS가 관리)
│ (Platform Thread) │ (1:1 매핑)
└──────────────────────────────────────────┘
↑ ↑
스케줄러 멀티플렉싱 스케줄러 멀티플렉싱
↓ ↓
JVM 레벨
┌──────────────────────────────────────────┐
│ Virtual Virtual Virtual Virtual │ (수천~수만 개)
│ Thread 1 Thread 2 Thread 3 Thread 4 │ (경량, 수 KB)
│ ... ... ... ... │
└──────────────────────────────────────────┘
- Carrier Thread: 실제 OS 스레드, CPU 코어에 1:1 매핑
- Virtual Thread: 경량 사용자 레벨 스레드, 스택 크기 ~1 KB (Platform Thread 대비 1/1000)
스케줄러의 동작
Virtual Thread는 I/O 대기 등의 이유로 block되면 Carrier Thread를 해제하고, 다른 Virtual Thread가 그 Carrier Thread를 점유한다.
시간 →
[Carrier Thread A]
├─ Virtual Thread 1 실행
│ ├─ Socket.read() ← I/O 대기
│ └─ [블로킹, Carrier 해제]
│
├─ Virtual Thread 2 실행 ← Carrier Thread A 재사용
│ ├─ 계산
│ └─ I/O 완료 시 다시 블로킹
│
└─ Virtual Thread 3 실행 ← 또 다시 Carrier Thread A 재사용
이론상 100,000개 Virtual Thread도 수십 개 Carrier Thread (= CPU 코어 수)로 처리 가능하다.
2. Pinning: Virtual Thread가 Carrier Thread를 “고정”하다
Pinning이란?
Virtual Thread가 Carrier Thread를 해제하지 않고 계속 점유하는 현상을 pinning이라 부른다.
[Carrier Thread A] ← Virtual Thread 1이 "핀닝"됨
└─ Virtual Thread 1
├─ synchronized block 진입
│ (다른 Virtual Thread들이 대기 중인데도)
│ └─ Lock 경합 발생
│ └─ park() 호출 → Carrier Thread 해제 불가!
│
└─ Carrier Thread A는 Virtual Thread 1의 lock 대기로 블로킹
결과: Carrier Thread A가 아무 일도 안 하면서 점유 중 → 다른 Virtual Thread들이 대기
Pinning이 발생하는 경우
1. synchronized 메서드/블록
// ❌ 핀닝 발생
synchronized void blockingMethod() {
Thread.sleep(1000); // I/O 대기처럼 보이지만...
// Virtual Thread가 Carrier를 해제할 수 없음
}
// Virtual Thread가 park() 호출 시
// Carrier Thread도 함께 블로킹됨
이유: synchronized는 JVM 레벨에서 구현되는데, Carrier Thread 해제 로직이 synchronized 진입 시점에는 실행되지 않기 때문이다.
2. Native 메서드 호출
// ❌ 핀닝 발생
System.out.println("Hello"); // 내부적으로 native code 호출
native_file_write(); // JNI
// Carrier Thread가 JVM을 떠나서 OS 커널로 진입하면
// Virtual Thread 스케줄러가 개입할 수 없음
3. ReentrantLock이 아닌 Lock 구현체 (거의 드물지만)
// ✅ 핀닝 안 함
Lock lock = new ReentrantLock(); // Virtual Thread 인식
lock.lock();
try {
// 안전함
} finally {
lock.unlock();
}
// ❌ 핀닝 발생 가능
Object monitor = new Object();
synchronized(monitor) { // 모니터는 Virtual Thread 인식 안 함
// 핀닝됨
}
3. Pinning 검출하기
jdk.tracePinnedThreads 옵션
JVM 시작 시 다음 옵션으로 pinning을 추적할 수 있다:
java -Djdk.tracePinnedThreads=short \
-Djdk.virtualThreadScheduler.parallelism=4 \
MyApp
출력 예:
[pinned] VirtualThread "VirtualThread-1" (runnable)
at java.lang.Object.wait(Object.java:335) @bci 0
at my.service.SocialUnlinkService.unlinkAsync(SocialUnlinkService.java:42)
- locked <0x00007f8c4b2a0100> (a java.lang.Object)
수동 검출 (코드 분석)
// 이 코드는 pinning을 유발한다
public class SocialUnlinkService {
private Object lock = new Object();
public void unlinkAsync() {
synchronized(lock) { // ← pinning 발생
callExternalApi(); // I/O
}
}
private void callExternalApi() {
// Redis, MySQL, HTTP 호출 등
// I/O 대기 중 Carrier Thread 해제 불가
}
}
4. Virtual Thread와 스레드풀의 백프레셔 차이
Platform Thread (Tomcat 기본)
[ThreadPool: size = 100]
├─ [Thread 1] busy
├─ [Thread 2] busy
├─ ...
├─ [Thread 100] busy
└─ [요청 101] 큐에서 대기 ← 백프레셔 발생
스레드 풀이 가득 차면 새 요청이 큐에 들어가서 대기한다. 이를 **백프레셔(backpressure)**라 부르며, 시스템이 과부하 상황을 인지할 수 있다.
[ALB]
└─ max_connections 초과 → 요청 거부
(또는 큐 크기 초과 → 에러)
Virtual Thread (무제한 생성)
[Virtual Thread Scheduler]
├─ [Virtual Thread 1] waiting
├─ [Virtual Thread 2] waiting
├─ ...
├─ [Virtual Thread 10,000] waiting
└─ [Carrier Thread 1] blocking
[Carrier Thread 2] blocking
... (모든 Carrier Thread 고갈)
Virtual Thread는 제한 없이 생성되므로 백프레셔가 없다. 모든 Carrier Thread가 고갈될 때까지 계속 Virtual Thread가 쌓인다.
결과: 메모리 부족, GC 스톱, 응답 불가 상태 돌입
이상적 시나리오 (백프레셔 있음):
부하 증가 → 스레드풀 가득 → 요청 거부 → 클라이언트 재시도
Virtual Thread 시나리오 (백프레셔 없음):
부하 증가 → Virtual Thread 무제한 생성 → 메모리 폭발 → JVM 크래시
5. 성능 테스트: Pinning의 영향
벤치마크 설정
// 시나리오: Redis 조회 (원격) + MySQL 쿼리 (로컬)
@Benchmark
public void redisQueryWithPlatformThread() {
jedis.get("key"); // 블로킹
mysql.query("SELECT"); // 블로킹
}
@Benchmark
public void redisQueryWithVirtualThread() throws Exception {
Thread.ofVirtual().start(() -> {
jedis.get("key"); // 블로킹 (pinning 발생 가능)
mysql.query("SELECT"); // 블로킹
}).join();
}
@Benchmark
public void redisQueryWithVirtualThreadAndReentrantLock() throws Exception {
ReentrantLock lock = new ReentrantLock();
Thread.ofVirtual().start(() -> {
lock.lock();
try {
jedis.get("key");
mysql.query("SELECT");
} finally {
lock.unlock();
}
}).join();
}
예상 결과
처리량 (높을수록 좋음):
Platform Thread (Tomcat): ~5,000 ops/sec
Virtual Thread (no sync): ~50,000 ops/sec (10배 향상)
Virtual Thread (synchronized): ~3,000 ops/sec (오히려 악화)
Virtual Thread (ReentrantLock): ~45,000 ops/sec (거의 10배)
synchronized 사용 시 Carrier Thread 고갈로 인한 성능 저하가 심각하다.
6. 실무 적용 전략
패턴 1: 전역 Virtual Thread 활성화 (비추천)
# application.yml
spring:
threads:
virtual:
enabled: true # ❌ 모든 요청을 Virtual Thread로 처리
문제:
- 기존 코드에
synchronized가 있는지 전수 검사 필수 - synchronized 라이브러리(Jedis, 구형 MySQL 드라이버) 사용 시 pinning 폭주
패턴 2: 선택적 도입 (권장)
@Configuration
public class VirtualThreadConfig {
@Bean(name = "virtualTaskExecutor")
public Executor virtualThreadExecutor() {
// I/O 바운드 작업 전용 Executor
return Executors.newVirtualThreadPerTaskExecutor();
}
@Bean
public Executor platformThreadExecutor() {
// CPU 바운드 작업, synchronized 필요한 작업용
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
executor.setQueueCapacity(100);
return executor;
}
}
패턴 3: 라이브러리 검증
Virtual Thread 사용 전 확인사항:
// ✅ Virtual Thread 안전
// - Lettuce (Redis 드라이버)
// - Spring WebClient (비동기 HTTP)
// - Project Reactor (Reactive Streams)
// - Spring Data R2DBC (Reactive DB)
// - Jakarta EE WebClient
// ❌ Virtual Thread 위험 (synchronized 사용)
// - Jedis (구형 Redis 드라이버, synchronized 사용)
// - MySQL JDBC (synchronize된 Connection)
// - HttpURLConnection (synchronized)
검증 방법:
# 의존성 분석
mvn dependency:tree | grep jedis
# 소스 코드에서 synchronized 검색
grep -r "synchronized" ./src
# 또는 라이브러리 문서 확인
패턴 4: 부분 마이그레이션 (점진적)
@Service
public class UserService {
@Autowired
@Qualifier("virtualTaskExecutor")
private Executor virtualExecutor;
// ✅ I/O 바운드: Virtual Thread 사용
public CompletableFuture<User> findUserAsync(Long id) {
return CompletableFuture.supplyAsync(
() -> userRepository.findById(id), // Redis 조회
virtualExecutor
);
}
// ⚠️ Mixed: 신중하게 선택
public CompletableFuture<Void> withdrawUserAsync(Long id) {
return CompletableFuture.runAsync(() -> {
// 트랜잭션, Redis, MySQL 모두 필요
// synchronized 사용 가능성 검증 필수
withdrawalService.execute(id);
}, virtualExecutor); // 또는 platformExecutor
}
}
7. Pinning 영향도 분석
메모리 측면
Pinning 발생 시 메모리 구조:
Carrier Thread 1 (pinned)
├─ Virtual Thread A (synchronized 블록)
│ ├─ Carrier Thread 해제 불가
│ └─ 메모리: ~1 KB (Virtual Thread) + 8 MB (Carrier)
│
Carrier Thread 2 (pinned)
├─ Virtual Thread B
│ └─ 메모리: ~1 KB + 8 MB
Carrier Thread 3 (idling)
└─ Virtual Thread C, D, E, ... (모두 waiting)
└─ 메모리: 많은 Virtual Thread 적재되지만 실행 불가
결과:
- Virtual Thread 생성은 저비용이지만, Carrier Thread 고갈로 인한 컨텍스트 스위칭 폭주
- 메모리 폭발 (Virtual Thread는 쌓이는데 처리 불가)
CPU 측면
Carrier Thread 중단 상황에서의 컨텍스트 스위칭:
1. Virtual Thread A가 synchronized 진입
2. Carrier Thread가 Virtual Thread A를 실행 중
3. 다른 Virtual Thread B도 같은 lock 필요
4. Carrier Thread가 switching overhead 없이 대기
(Virtual Thread 스케줄러가 개입할 수 없음)
5. CPU 사이클 낭비 (spin-waiting, 또는 OS 커널 lock wait)
8. 권장 체크리스트
Virtual Thread 도입 전:
- 애플리케이션의 병목이 정말 스레드 수인가? (프로파일링 필수)
- 의존성 라이브러리가 Virtual Thread 안전한가?
-Djdk.tracePinnedThreads=short 로 테스트 실행 - 기존 코드에
synchronized사용이 많지 않은가? - 네트워크 I/O (DB, HTTP)에 Lettuce/WebClient 같은 비동기 드라이버 사용 가능한가?
- 메모리 설정이 충분한가? (Virtual Thread 대량 생성 시)
- 모니터링: JMX 메트릭에서 Carrier Thread 활용도, Virtual Thread 수 추적
결론
Virtual Thread는 강력하지만, synchronized 블록에서의 pinning으로 인해 성능 이득이 순식간에 사라질 수 있다.
실무 원칙:
- 측정 먼저: 프로파일링으로 병목 확인
- 선택적 도입: 전역 활성화 말고 I/O 바운드 작업만
- 라이브러리 검증: synchronized 사용 여부 확인
- 점진적 전환: 신규 코드/기능부터 적용, 기존 코드는 리팩토링 후 적용
- 모니터링 설정: Carrier Thread 고갈 감지 (백프레셔 정책 수립)
Virtual Thread의 약속(C10K 해결)을 실현하려면, synchronized 없는 코드 아키텍처가 필수 전제조건이다.