← all posts
DEV 2026.05.02 · 13 min read Intermediate

Virtual Thread는 왜 OS 스레드의 한계를 넘는가

Thread-per-Request 모델의 처리량 상한선부터 Pinning·ThreadLocal 함정까지, Java 21 Virtual Thread의 설계 원리와 실전 함의를 추적한다.


Java 21의 Virtual Thread는 “스레드 풀 크기를 얼마로 설정해야 하는가”라는 수십 년간의 질문에 “스레드를 수백만 개 만들면 된다”는 새로운 답을 제시한다. OS 스레드 1개당 ~1MB 메모리를 소비하던 모델에서, JVM 힙 위의 수 KB짜리 Continuation으로 전환하는 것이 어떻게 가능한가? 그리고 이 전환에는 어떤 새로운 함정이 따라오는가?

OS 스레드가 I/O 바운드에 취약한 이유

전형적인 웹 서버 요청을 분해하면 구조가 드러난다.

요청 수신
  ├── DB 쿼리    10ms  ← 스레드 WAITING
  ├── 외부 API   50ms  ← 스레드 WAITING
  ├── 파일 읽기   5ms  ← 스레드 WAITING
  └── 응답 생성   1ms  ← 실제 CPU 사용

총 66ms, CPU 사용 1ms (1.5%)

OS 스레드는 I/O 대기 중에도 스택 메모리(기본 ~1MB)를 점유하고 스케줄러 부담을 유지한다. 10,000 req/s를 달성하려면 100ms/요청 × 1000개 스레드가 필요하고, 그건 ~1GB RAM이다. 이것이 C10K 문제의 본질이다.

기존 해결책은 두 방향이었다. Reactive(WebFlux)는 처리량 문제를 해결하지만 코드를 Mono/Flux API로 전면 재작성해야 한다. 스레드 풀 확장은 처리량 상한선을 풀 크기에 고정시킨다. Virtual Thread는 세 번째 방향을 제안한다 — 기존 동기 코드 그대로, I/O 대기 비용만 제거.

Continuation이 문제를 해결하는 방식

Virtual Thread의 핵심 구조는 M:N 매핑이다.

수백만 개 Virtual Thread
        ↕  JVM 스케줄러 (ForkJoinPool)
수십 개 캐리어 스레드 (OS 스레드, CPU 코어 수)

VT가 Socket.read() 같은 블로킹 I/O를 만나면 다음 흐름이 작동한다.

  1. JDK 내부에서 NIO non-blocking read를 시도한다.
  2. 데이터가 없으면(EAGAIN) Poller에 fd를 등록하고 LockSupport.park()를 호출한다.
  3. park → VT Unmount: 현재 스택 프레임이 힙의 StackChunk로 복사된다.
  4. 캐리어 스레드는 즉시 다른 VT를 실행한다.
  5. 데이터 도착 → Poller가 unpark → VT Mount → 힙에서 스택 복원 → 실행 재개.

Thread.sleep()도 동일하다. 내부적으로 LockSupport.parkNanos()를 호출하므로 캐리어를 블로킹하지 않는다. VT 100만 개가 sleep 중이어도 캐리어 스레드는 자유롭다.

// 코드는 동기 스타일 그대로
ExecutorService vExecutor = Executors.newVirtualThreadPerTaskExecutor();
vExecutor.submit(() -> {
    String data = httpClient.get("https://api.example.com");  // Unmount → Mount
    return processData(data);
});

Pinning — synchronized가 숨긴 함정

Unmount가 발생하지 않는 조건이 있다. synchronized 블록 내 블로킹이다.

JVM 모니터(synchronized)는 내부적으로 ObjectMonitor._owner에 OS 스레드 포인터를 저장한다. VT가 Unmount되면 캐리어(OS 스레드)가 바뀔 수 있는데, 모니터 소유자가 이전 캐리어를 가리키므로 해제가 불가능해진다. 결국 VT는 캐리어에 고착(Pinned)된다.

⚠ Pinning = 캐리어 고착

synchronized 블록 내에서 Thread.sleep(), I/O, ReentrantLock.lock() 대기 등 어떤 블로킹도 Pinning을 유발한다. Pinning된 VT는 캐리어를 독점하며, VT로 전환해도 OS 스레드 방식과 처리량이 같아진다.

해결책은 명확하다. synchronized → ReentrantLock으로 교체한다. AQS(AbstractQueuedSynchronizer)는 VT 자체(Thread 객체)를 소유자로 추적하므로 Unmount 후 캐리어가 바뀌어도 정상 동작한다.

// Before (Pinning)
synchronized (lock) {
    result = db.query("SELECT ...");  // 캐리어 고착
}

// After (Unmount 가능)
lock.lock();
try {
    result = db.query("SELECT ...");  // VT Unmount → 캐리어 해방
} finally { lock.unlock(); }

진단은 JVM 플래그로 시작한다.

# 개발 환경: Pinning 발생 즉시 스택 출력
java -Djdk.tracePinnedThreads=full MyApp

# 운영 환경: JFR 이벤트
jcmd <pid> JFR.start duration=30s filename=pinning.jfr
jfr print --events jdk.VirtualThreadPinned pinning.jfr

ThreadLocal과 ScopedValue

synchronized 외에 또 다른 함정이 있다. Spring Security의 SecurityContextHolder, SLF4J MDC, DB 트랜잭션 컨텍스트는 모두 ThreadLocal을 사용한다. OS 스레드 환경에서는 스레드 수가 수백 개 수준이지만, VT 환경에서는 각 요청마다 새 VT가 생성되고 각각 독립적인 ThreadLocalMap을 갖는다. VT 100만 개 = ThreadLocalMap 100만 개 = 메모리 압박.

VT를 풀링하는 것은 안티패턴이다. ThreadLocal 값이 다음 요청으로 누출되기 때문이다. 항상 Executors.newVirtualThreadPerTaskExecutor()로 태스크마다 새 VT를 생성해야 한다.

Java 21의 ScopedValue는 이 문제의 근본 해결책이다.

static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

ScopedValue.where(USER_ID, "user-123")
           .run(() -> {
               // 이 스코프 내에서 어디서든 읽기 가능
               processRequest();  // USER_ID.get() == "user-123"
           });
// 스코프 종료 시 자동 정리 — remove() 불필요

ScopedValue는 불변이고, run() 블록 종료 시 자동으로 정리되며, StructuredTaskScope의 자식 VT에 복사 없이 참조로 상속된다. 요청 컨텍스트(userId, traceId) 전달은 ThreadLocal에서 ScopedValue로 이전하는 것이 VT 환경에서의 올바른 방향이다.

Spring Boot 통합과 트레이드오프

Spring Boot 3.2부터는 설정 한 줄로 Tomcat 요청 처리 스레드 전체를 VT로 전환할 수 있다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은 Tomcat 요청 처리 스레드와 @Async 기본 executor를 VT로 전환한다. HikariCP 커넥션 풀은 흥미로운 변화를 보인다. VT가 커넥션 대기 중 Unmount되므로, 커넥션 풀 크기를 줄여도 처리량을 유지할 수 있다.

✎ 트레이드오프

이점: I/O 바운드 API 처리량 향상, 기존 동기 코드 유지, 메모리 효율.
제약: synchronized 내 I/O는 Pinning 유발(JEP 491에서 개선 예정), 대량 VT 환경에서 ThreadLocal 남용은 메모리 압박, CPU 바운드 작업은 이점 없음.
마이그레이션 순서: VT 활성화 → -Djdk.tracePinnedThreads=full로 Pinning 스캔 → 수정 → 부하 테스트 → 운영 배포.

StructuredTaskScope는 여러 VT 작업을 구조화된 방식으로 조율한다. ShutdownOnFailure는 하나가 실패하면 나머지를 취소하고, ShutdownOnSuccess는 가장 빠른 하나가 성공하면 나머지를 취소한다. 순차 실행이 80ms인 API가 병렬 VT로 50ms가 되는 것은 이 패턴으로 구현한다.

정리

  • OS 스레드 모델의 처리량 상한은 I/O 대기 중 스레드를 낭비하는 구조에서 온다.
  • Virtual Thread는 Continuation을 힙에 저장해 I/O 대기 비용을 제거한다. 캐리어는 항상 실제 작업에만 쓰인다.
  • synchronized 내 블로킹은 Pinning을 유발한다. ReentrantLock으로 교체하거나, I/O를 synchronized 밖으로 이동해 해결한다.
  • 대량 VT 환경에서 ThreadLocal은 메모리 압박의 원인이 된다. 요청 컨텍스트는 ScopedValue로 전환하는 것이 올바른 방향이다.

다음 글에서는 VT 환경에서 데드락이 어떻게 발생하고 진단하는지, ReentrantLock과 synchronized의 차이가 실제 데드락 패턴에 어떤 영향을 주는지 추적한다.