Skip to content
메모장
Go back

Java 21 Virtual Thread의 Pinning 문제와 점진적 도입 전략

개요

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)
│ ...       ...       ...       ...        │
└──────────────────────────────────────────┘

스케줄러의 동작

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로 처리

문제:

패턴 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 적재되지만 실행 불가

결과:

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는 강력하지만, synchronized 블록에서의 pinning으로 인해 성능 이득이 순식간에 사라질 수 있다.

실무 원칙:

  1. 측정 먼저: 프로파일링으로 병목 확인
  2. 선택적 도입: 전역 활성화 말고 I/O 바운드 작업만
  3. 라이브러리 검증: synchronized 사용 여부 확인
  4. 점진적 전환: 신규 코드/기능부터 적용, 기존 코드는 리팩토링 후 적용
  5. 모니터링 설정: Carrier Thread 고갈 감지 (백프레셔 정책 수립)

Virtual Thread의 약속(C10K 해결)을 실현하려면, synchronized 없는 코드 아키텍처가 필수 전제조건이다.


Share this post:

Previous Post
Redis 캐싱 전략: String Blob vs Hash 자료구조 선택
Next Post
네이버 API - JSON 데이터의 Content-Type: text/plain 응답