한 줄 정의

JVM이 관리하는 경량 유저 레벨 스레드. 소수의 OS 스레드(캐리어 스레드) 위에서 M:N 비선점 스케줄링으로 동작하며, 블로킹 API 호출 시 자동으로 캐리어 스레드를 반환한다.

쉽게 말하면

플랫폼 스레드가 “전용 택시”라면, Virtual Thread는 “공유 택시”입니다. 승객(작업)이 목적지에서 볼일을 보는 동안(I/O 대기) 택시(캐리어 스레드)는 다른 승객을 태우러 가고, 볼일이 끝나면 아무 택시나 잡아서 다시 이동합니다. 택시 수는 적어도 동시에 수백만 명의 승객을 처리할 수 있습니다.

왜 이걸 알아야 하는가?

  • Spring Boot 3.2+ 마이그레이션: spring.threads.virtual.enabled=true 한 줄로 활성화할 수 있지만, pinning이 뭔지 모르고 켜면 오히려 성능이 떨어질 수 있으므로 내부 라이브러리의 synchronized 사용 여부를 반드시 확인해야 합니다.
  • 스레드 풀 고갈 문제 해결: 전통적인 thread-per-request 모델에서 I/O-bound 워크로드의 동시성 한계를 돌파하는 가장 실용적인 방법이며, WebFlux처럼 코드를 통째로 바꾸지 않아도 됩니다.
  • JVM 내부 동작 이해의 심화: Continuation, ForkJoinPool work-stealing, JDK 블로킹 API 내부 변경 등을 이해하면 JVM 레벨에서 일어나는 일에 대한 시야가 넓어지고, 장애 분석이나 성능 최적화에서 “왜 이런 동작이 발생하는가”를 설명할 수 있게 됩니다.

왜 이렇게 설계했는가?

  • 해결하는 문제: One-to-One 모델에서 스레드는 I/O 대기 중에도 OS 커널 스레드를 점유합니다. 전형적인 웹 애플리케이션은 작업 시간의 대부분이 DB 쿼리, HTTP 호출, 파일 I/O 같은 대기 시간인데, 스레드 200개가 전부 DB 응답을 기다리고 있으면 CPU는 놀고 있는데 새 요청은 처리할 수 없는 상황이 발생합니다.
  • 기존 해결책과 한계:
    • 스레드 풀 늘리기: 메모리 한계가 있습니다 (스레드당 1MB). 10,000개 = 10GB 스택만으로.
    • 리액티브 (WebFlux, RxJava): 논블로킹으로 스레드를 효율적으로 쓰지만, 코드가 콜백/모나드 체인으로 바뀌어서 가독성이 급격히 떨어집니다. 스택 트레이스가 끊겨서 디버깅이 힘들고, 기존 블로킹 라이브러리(JDBC 등)를 쓸 수 없습니다.
    • Kotlin Coroutine: suspend 함수 기반으로 리액티브보다 가독성이 낫지만, suspend/non-suspend 경계를 개발자가 관리해야 합니다. 기존 Java 블로킹 라이브러리를 withContext(Dispatchers.IO)로 감싸야 합니다.
  • Virtual Thread의 접근: 기존 블로킹 코드를 그대로 쓰면서, JVM이 내부적으로 논블로킹으로 변환합니다. “간단한 코드 + 높은 동시성”이라는 두 마리 토끼를 잡는 방식입니다.

어떻게 동작하는가?

핵심 구조: Continuation

Virtual Thread의 핵심은 Continuation 객체로, 실행 상태(스택 프레임, 로컬 변수, PC)를 Heap 객체로 캡처하고 나중에 복원할 수 있는 메커니즘입니다.

Mount/Unmount 사이클:

  1. Virtual Thread가 실행을 시작하면 **캐리어 스레드(플랫폼 스레드)**에 mount됩니다. 이 시점에 Continuation의 스택 프레임이 캐리어 스레드의 실제 스택에 복사됩니다.
  2. Socket.read() 같은 블로킹 API를 호출하면, JDK 내부에서 이를 감지하고 unmount를 트리거합니다.
  3. Unmount 시 현재 실행 스택 프레임을 Heap에 저장하고 캐리어 스레드를 반환합니다. 캐리어 스레드는 즉시 다른 Virtual Thread를 실행할 수 있습니다.
  4. I/O가 완료되면 (epoll/kqueue 이벤트 발생) ForkJoinPool의 스케줄러가 이 Virtual Thread를 아무 캐리어 스레드에 다시 mount해서 이어서 실행합니다.

개발자에게는 블로킹 코드, JVM 내부에서는 논블로킹 실행. 이것이 Virtual Thread의 핵심 가치입니다.

비선점 스케줄링 (Cooperative Scheduling)

OS 커널 스레드는 **선점형(preemptive)**으로 타이머 인터럽트를 통해 강제 전환하는 반면, Virtual Thread는 **비선점형(cooperative)**으로 블로킹 포인트에서만 자발적으로 양보합니다.

비선점이 가능한 이유: JDK의 블로킹 API들을 JVM이 직접 제어하기 때문입니다. Thread.sleep(), Socket.read(), Lock.lock(), BlockingQueue.take() 등 모든 블로킹 지점이 yield 포인트로 동작합니다. 과거 Many-to-Many 모델이 실패한 이유는 커널이 제공하는 블로킹 API를 투명하게 가로챌 수 없었기 때문인데, Virtual Thread는 JDK 자체를 수정해서 이 문제를 해결했습니다.

비선점의 함정: CPU-bound 작업을 오래 돌리면 해당 캐리어 스레드를 독점하게 됩니다. while(true) { compute(); } 같은 코드에서는 양보 지점이 없으므로 다른 Virtual Thread가 실행되지 못하며, 이런 경우에는 플랫폼 스레드가 여전히 적합합니다.

스케줄러: ForkJoinPool

Virtual Thread의 기본 스케줄러는 ForkJoinPool입니다 (Executors.newVirtualThreadPerTaskExecutor 내부).

  • 캐리어 스레드 수: 기본값 = Runtime.getRuntime().availableProcessors(). CPU 코어 수만큼의 플랫폼 스레드가 캐리어 역할을 합니다.
  • Work-stealing: 유휴 캐리어 스레드가 다른 캐리어의 큐에서 작업을 훔쳐옵니다. 코어 활용률을 극대화합니다.
  • FIFO 모드: Virtual Thread용 ForkJoinPool은 기존 ForkJoinPool과 달리 FIFO 모드로 동작합니다. 먼저 들어온 작업을 먼저 처리합니다.

Pinning — Virtual Thread의 아킬레스건

Pinning이란: Virtual Thread가 unmount할 수 없어서 캐리어 스레드를 점유한 채 블록되는 현상입니다.

발생 조건:

  1. synchronized 블록/메서드 안에서 블로킹 I/O: Java의 synchronized는 OS 레벨 모니터 lock에 의존합니다. 이 lock은 특정 OS 스레드에 바인딩되므로, lock을 잡은 상태에서 스택을 Heap으로 옮길 수 없습니다.
  2. JNI (native 코드) 실행 중: native 프레임은 JVM이 제어할 수 없으므로 unmount가 불가능합니다.

왜 위험한가: 캐리어 스레드가 코어 수만큼만 있으므로, pinning이 캐리어 스레드 수만큼 발생하면 모든 Virtual Thread가 멈추게 되어 스레드 풀 고갈과 같은 효과가 나타납니다.

해결책:

  • synchronizedReentrantLock으로 교체합니다. ReentrantLockjava.util.concurrentLockSupport.park()를 사용하며, 이는 unmount를 지원합니다.
  • -Djdk.tracePinnedThreads=full JVM 옵션으로 pinning 발생 지점을 로깅할 수 있습니다.
  • Java 24에서는 synchronized에서도 pinning이 발생하지 않도록 Object Monitor를 재구현하고 있습니다 (JEP 491).

JDK 내부 변경 사항

Virtual Thread를 위해 JDK 자체가 대규모로 수정되었습니다:

  • java.net.Socket, ServerSocket: 내부 구현을 NIO 기반으로 교체했습니다. 블로킹 호출 시 자동 yield됩니다.
  • Thread.sleep(): 실제 OS 스레드를 재우는 대신, 스케줄러 타이머에 등록하고 unmount합니다.
  • java.io.InputStream/OutputStream: 파일 I/O도 가능한 범위에서 논블로킹으로 처리됩니다.
  • java.util.concurrent.locks: LockSupport.park()가 Virtual Thread를 인식해서 unmount를 트리거합니다.

시각화

Mount/Unmount 사이클

sequenceDiagram
    participant VT as Virtual Thread
    participant S as JVM 스케줄러
    participant CT as 캐리어 스레드
    participant IO as I/O (epoll)

    S->>CT: VT를 캐리어에 mount
    CT->>CT: 사용자 코드 실행
    CT->>CT: Socket.read() 호출
    CT->>S: unmount 요청 (스택 → Heap)
    S->>CT: 캐리어 반환 → 다른 VT mount
    IO-->>S: I/O 완료 이벤트
    S->>CT: VT를 아무 캐리어에 재mount
    CT->>CT: read() 이후 코드 이어서 실행

Pinning 발생 시나리오

sequenceDiagram
    participant VT as Virtual Thread
    participant CT as 캐리어 스레드
    participant DB as Database

    VT->>CT: synchronized 블록 진입 (모니터 lock 획득)
    CT->>DB: JDBC query (블로킹 I/O)
    Note over CT: Pinned 상태<br/>unmount 불가<br/>캐리어 스레드 점유
    DB-->>CT: 응답
    CT->>VT: synchronized 블록 탈출 (lock 해제)
    Note over CT: 이제야 다른 VT 실행 가능

정리

기준플랫폼 스레드Virtual ThreadKotlin Coroutine
스케줄링OS 선점형JVM 비선점형Dispatcher 비선점형
스택 저장OS 스택 (고정 1MB)Heap (가변, 수백 B~)Heap (Continuation 객체)
블로킹 처리OS 스레드 점유자동 unmountsuspend 함수에서만 양보
코드 스타일블로킹 (직관적)블로킹 (직관적)suspend/resume (명시적)
기존 라이브러리 호환완전 호환대부분 호환 (pinning 주의)블로킹 코드는 withContext 필요
디버깅스택 트레이스 완전스택 트레이스 완전스택 트레이스 일부 손실 가능
Structured Concurrency없음StructuredTaskScope (JEP 462)CoroutineScope (성숙)

실무에서 만난 사례

  • Spring Boot 3.2+: spring.threads.virtual.enabled=true 한 줄로 톰캣이 Virtual Thread를 사용하게 되며, 기존 thread-per-request 코드를 한 글자도 바꾸지 않고 동시성이 대폭 향상됩니다. 다만 내부에서 synchronized를 쓰는 라이브러리(일부 JDBC 드라이버, 레거시 코드)가 있으면 pinning이 발생하여 오히려 성능이 떨어질 수 있습니다.
  • JDBC와 Virtual Thread: HikariCP의 커넥션 풀 내부에 synchronized가 있어서 초기에 pinning 이슈가 보고되었고, HikariCP 5.1.0+에서 ReentrantLock으로 교체하여 해결했습니다. Virtual Thread 도입 시 의존 라이브러리의 lock 방식을 확인하는 것이 필수입니다.
  • CPU-bound 작업 주의: 이미지 처리, 암호화 같은 CPU-bound 작업을 Virtual Thread에서 돌리면 캐리어 스레드를 장시간 점유하므로, 이런 작업은 기존 플랫폼 스레드 풀에서 처리하는 것이 적합합니다.

관련 개념

출처

  • JEP 444: Virtual Threads
  • JEP 462: Structured Concurrency
  • JEP 491: Synchronize Virtual Threads without Pinning
  • Ron Pressler, “Project Loom: Modern Scalable Concurrency for the Java Platform” (QCon 2023)
  • Inside Java Podcast: Episodes on Project Loom