한 줄 정의

가상 스레드는 JVM이 직접 스케줄링하는 경량 스레드로, 블로킹을 만나면 캐리어 스레드에서 비켜나 그 자리를 다른 가상 스레드에 내주어, 익숙한 블로킹 코드 그대로 수백만 동시성을 얻게 합니다.

쉽게 말하면

직원(가상 스레드)은 수백만 명인데 책상(캐리어 스레드)은 CPU 코어 수만큼밖에 없는 핫데스킹 오피스를 떠올려 봅시다. 직원이 전화 응답(I/O)을 기다리느라 멍하니 있어야 한다면, 그 순간 책상을 비워 주고 일감이 있는 다른 직원이 그 책상에 앉습니다. 전화가 오면 비어 있는 아무 책상에나 다시 앉아 하던 일을 이어 갑니다.

플랫폼 스레드는 직원 한 명이 책상 하나를 독점하는 방식이라, 전화를 기다리는 동안에도 비싼 책상이 놀게 됩니다. 가상 스레드는 “기다릴 땐 책상을 비켜라”는 규칙 하나로 적은 책상으로 무수히 많은 직원을 굴립니다. 정작 직원이 적는 코드는 그냥 “전화를 기다린다”로 똑같습니다.

왜 중요한가?

플랫폼 스레드는 OS 커널 스레드와 1:1로 묶입니다. 그래서 두 가지 한계가 따라옵니다.

  1. 개수가 OS 한도에 막힙니다. 스레드를 무한정 만들 수 없습니다.
  2. I/O 대기 중에도 그 비싼 스레드를 점유한 채 놀립니다. 일하지 않는데 자원만 붙잡습니다.

클라우드 환경에서 수천 동시 요청을 받는 서버라면 이 두 가지가 곧 확장성의 천장이 됩니다.

가상 스레드는 이 둘을 동시에 풉니다. 스레드를 OS가 아니라 JVM이 관리하므로 생성 비용이 거의 0이고, 블로킹을 만나면 실제 일꾼(캐리어 스레드)을 반납하므로 “노는 스레드”가 사라집니다. 프로그래밍 모델을 바꾸지 않고 확장성만 끌어올린다는 것이 핵심입니다.

핵심 내용

플랫폼 스레드 vs 가상 스레드

구분플랫폼 스레드가상 스레드
관리 주체OS (커널 스케줄러)JVM (JDK 스케줄러)
OS 스레드와 관계1:1 매핑M:N (다수가 소수 캐리어 공유)
무게무거움 (스레드당 ~MiB)가벼움 (수백 바이트~)
스택 저장 위치OS가 할당한 고정 메모리 블록힙 (GC 대상, 가변)
블로킹 시OS 스레드 점유한 채 대기언마운트하여 캐리어 반납
개수 한계OS 자원에 묶임사실상 무제한

JDK 21부터 두 종류가 공존합니다. API는 거의 같아서(둘 다 Thread 인스턴스, interrupt() 처리도 동일) 외형상 차이가 없고, 결정적 차이는 블로킹을 다루는 방식에 있습니다.

flowchart TB
    subgraph JVM
        VT["가상 스레드 (수백만)"]
        SCHED["JDK 스케줄러<br/>FIFO ForkJoinPool"]
        CT["캐리어 스레드 (소수)"]
        VT --> SCHED --> CT
    end
    subgraph OS["운영체제"]
        OST["OS 스레드"]
        OSSCHED["OS 스케줄러"]
        OST --> OSSCHED
    end
    CT --> OST

JDK 스케줄러: FIFO ForkJoinPool

가상 스레드를 실제로 실행할 플랫폼 스레드를 캐리어 스레드 (carrier thread)라 부르며, 이들은 전용 ForkJoinPool의 워커입니다. Work-Stealing으로 노는 캐리어를 없앱니다. 단, 병렬 스트림용 공용 풀이 LIFO인 것과 달리 이 풀은 FIFO 모드(asyncMode=true)로 동작합니다 — 포크된 태스크도 먼저 들어온 것부터 처리해 응답 지연을 고르게 합니다.

캐리어 스레드 수(= 스케줄러의 병렬성)는 기본값이 CPU 코어 수이고 jdk.virtualThreadScheduler.parallelism으로 조정합니다. 이 값이 곧 동시에 “실제로 CPU를 점유할 수 있는” 상한입니다.

생성 방법

// 1. 즉시 시작
Thread.startVirtualThread(() -> { ... });
 
// 2. 빌더 — 생성과 시작을 분리할 수 있음
Thread.ofVirtual().start(() -> { ... });
 
// 3. 이그제큐터 — 기존 코드 전환에 최적
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(task);
}

가상 스레드는 모두 데몬 스레드

메인 스레드가 끝나면 JVM이 데몬 스레드를 함께 종료시키므로, 가상 스레드 작업을 끝까지 보려면 join()으로 기다려야 합니다. setDaemon()·setPriority()는 가상 스레드에서 무시되며(우선순위는 NORM_PRIORITY 고정), 모든 가상 스레드는 단일 ThreadGroup에 속합니다. 가상 스레드 여부는 Thread::isVirtual로 판별합니다.

내부 동작: 마운트와 언마운트

가상 스레드의 스택 프레임은 평소 에 보관됩니다. 고정 크기를 미리 예측할 필요가 없고, 호출 깊이에 따라 수백 바이트에서 시작해 자동으로 늘고 줍니다. 실행할 때 자바 런타임이 이 스택을 캐리어 스레드로 복사해 올리는데, 이를 마운트 (mount)라 합니다.

가상 스레드가 I/O 대기 같은 블로킹 연산을 만나면 언마운트 (unmount)됩니다. 바뀐 스택을 힙으로 되돌려 보내고 캐리어 스레드를 풀어 주면, 캐리어는 곧장 다른 가상 스레드를 실행합니다. 블로킹이 끝나면 가상 스레드는 비어 있는 아무 캐리어에나 다시 마운트되어 멈췄던 지점부터 재개합니다. 이 처리가 JDK 내 거의 모든 블로킹 지점에 소급 적용되어 있어, 개발자가 따로 할 일은 없습니다.

sequenceDiagram
    participant A as 가상 스레드 A
    participant CT as 캐리어 스레드
    participant B as 가상 스레드 B
    A->>CT: 마운트 (스택을 캐리어로 복사)
    Note over CT: A 실행
    A-->>CT: 블로킹(I/O) 만남 → 언마운트
    Note over A: 스택을 힙으로 되돌리고 대기
    B->>CT: 마운트 (캐리어 재사용)
    Note over CT: B 실행
    A->>CT: I/O 완료 → 재마운트, 멈춘 지점부터 재개

이 과정은 완전히 투명해서 코드에서 보이지 않습니다. 어떤 캐리어가 실행 중인지 알 수 없고, 캐리어의 ThreadLocal 값도 가상 스레드에 보이지 않습니다. 가상 메모리가 “무한한 주소 공간”이라는 환상을 주듯, 가상 스레드는 소수의 비싼 플랫폼 스레드를 공유해 “거의 무한한 멀티스레딩”의 환상을 만듭니다.

확장성의 원리: 리틀의 법칙

가상 스레드는 더 빠른 것이 아니라 더 확장성 있는 것입니다. 큐잉 시스템의 근본 공식인 리틀의 법칙이 그 이유를 설명합니다.

  • λ = 처리량 (단위 시간당 완료 항목)
  • N = 동시성 (동시에 처리 중인 항목 수)
  • d = 응답 시간 (한 항목이 시작부터 완료까지 걸리는 시간)

I/O 집중 작업에서는 응답 시간 d를 줄이기 어렵습니다(네트워크·디스크 지연은 통제 밖). 전통적 모델은 N(동시 스레드 수)마저 OS 한도에 막혀 처리량을 더 못 올립니다. 가상 스레드는 d를 건드리지 않고 N을 폭발적으로 늘려 λ를 끌어올립니다.

벤치마크 — 1만 개의 500ms I/O 태스크:

방식처리량
가상 스레드18,115 tasks/s
고정 풀 (1,000)1,968 tasks/s
고정 풀 (500)989 tasks/s
고정 풀 (100)198 tasks/s

스레드 수로는 꽤 많은 1,000개짜리 플랫폼 스레드 풀로도 가상 스레드 처리량의 약 1/9에 그칩니다.

동시성 태스크 조율

블로킹이 더 이상 비싸지 않으므로, Future.get()으로 결과를 기다리는 단순한 코드를 마음 놓고 쓸 수 있습니다. 여러 태스크 결과를 모을 때(invokeAll 등)도 콜백 구조나 정교한 동기화 없이 직관적으로 집계됩니다.

구조적 동시성 (JEP 505)은 여기에 안전성을 더합니다. StructuredTaskScope 영역 안에서 여러 태스크를 fork하면, 하나가 실패하거나 타임아웃될 때 영역 내 모든 태스크가 자동 취소되고 예외가 전파됩니다(fail-fast). 스레드 생성·취소 같은 저수준 처리를 API에 맡기고, 의도를 선언적으로 드러내게 해 줍니다.

세마포어로 요청 제한

가상 스레드가 무제한 동시성을 열어 주면서 새로운 문제가 생깁니다. 웹 앱이 백만 요청을 받아 백만 가상 스레드를 띄워도, 그 뒤의 DB나 외부 API는 그만큼을 못 받습니다. 과거엔 스레드 풀 크기 자체가 자연스러운 상한이었지만, 가상 스레드엔 그 상한이 없습니다.

해법은 자원별 요청 제한 (rate-limiting)이고, 가장 단순한 도구가 세마포어 (semaphore)입니다. 정해진 개수의 출입증(permit)을 관리해, 출입증을 얻은 스레드만 임계 영역에 들어가고 없으면 대기시킵니다.

semaphore.acquire();          // 출입증 확보 (없으면 대기)
try {
    return queryDatabase(q);  // 임계 영역
} finally {
    semaphore.release();      // 항상 반납
}

운영에서 신경 쓸 점:

  • 타임아웃: acquire() 대신 tryAcquire(5, SECONDS)로 무기한 블로킹을 막고, 실패 시 Optional.empty()처럼 자연스럽게 처리합니다.
  • 공정성: new Semaphore(n, true)는 출입증을 FIFO로 줘 스레드 고갈(starvation)을 막습니다(순서 유지 오버헤드는 감수).
  • 반납 누락 방지: 출입증은 반드시 finally에서 반납합니다.

가상 스레드는 블로킹돼도 자원 효율이 떨어지지 않으므로, 요청 제한을 위해 의도적으로 병목을 두더라도 전체 성능에 미치는 악영향이 작다는 점이 핵심입니다.

세마포어는 소유권을 추적하지 않는다

세마포어는 “누가 출입증을 가져갔는지”를 기록하지 않습니다. 그래서 획득하지 않은 스레드가 release()를 호출해도 막지 못하고, 그만큼 유효 출입증이 늘어나 제한이 무력화됩니다(DB 연결 초과·과부하·메모리 고갈로 이어짐). acquire()tryfinally release() 패턴을 한 곳에 캡슐화해 획득·반납의 짝을 강제하는 것이 안전합니다.

가상 스레드 고정 (pinning)

가상 스레드의 가치는 블로킹을 만나면 캐리어 스레드를 양보하는 데서 나옵니다. 고정 (pinning)은 이 양보가 막혀, 가상 스레드가 블로킹돼도 캐리어에서 언마운트하지 못하고 묶이는 상황입니다. 고정된 가상 스레드는 그 시간 동안 캐리어를 독점하므로, 캐리어는 다른 가상 스레드를 실행하지 못합니다.

발생 시나리오는 두 가지입니다.

시나리오고정되는 이유
synchronized 블록/메서드객체 모니터가 언마운트를 막음
네이티브 메서드 / 외부 함수(FFM)JVM이 네이티브 코드 실행을 검사·제어할 수 없음

많은 가상 스레드가 동시에 고정되면 소수의 캐리어가 모두 점유되어, 나머지는 줄 서서 기다립니다. 처리량 감소·자원 비효율·확장성 저하로 이어지고, 결국 “수백만 동시성”이라는 목적이 무너집니다.

핵심은 고정 자체가 아니라 고정된 동안 무슨 일을 하느냐입니다. synchronized 블록 내부의 작업 성격에 따라 위험도가 갈립니다.

synchronized 블록 내부고정 위험
return this.a + this.b (논블로킹·즉시 완료)거의 없음
httpClient.sendBlockingRequest() (네트워크 블로킹)높음
this.wait() (notify까지 대기)높음

synchronized 대신 ReentrantLock

synchronized가 피닝을 부르는 이유는 자바 객체 모니터 (object monitor)를 쓰기 때문이고, 객체 모니터로는 가상 스레드를 언마운트할 수 없습니다. 반면 ReentrantLock파킹/언파킹 (park/unpark) 메커니즘을 씁니다. JVM은 가상 스레드가 파킹되는 순간을 감지해 캐리어에서 언마운트할 수 있습니다 — 바로 이 점이 둘을 가릅니다.

ReentrantLock 영역에서 블로킹되면 런타임은 다음 순서로 처리합니다.

  1. 가상 스레드 상태 저장
  2. 캐리어에서 언마운트
  3. 다른 가상 스레드를 마운트
  4. 락 반납 후 임의의 캐리어에 재마운트
lock.lock();              // synchronized와 달리 고정하지 않음
try {
    Thread.sleep(25);     // 이 동안 캐리어에서 언마운트됨
} finally {
    lock.unlock();        // 반드시 finally에서 (데드락 예방)
}

코드로 확인하면 결정적 차이가 드러납니다. synchronized는 블록 진입 전후 캐리어가 동일하지만(worker-1worker-1), ReentrantLocksleep 동안 언마운트되어 다른 캐리어로 바뀝니다(worker-1worker-3).

JEP 491 — JDK 24부터 synchronized 피닝 해소

JDK 24에서 synchronized가 가상 스레드 친화적으로 재작업되어, 블록 안에서도 가상 스레드가 스스로 락을 획득·유지·해제하며 언마운트할 수 있게 됐습니다. synchronized로 인한 고정은 사라집니다. 단 클래스 초기화 블로킹, 네이티브 호출 중 심볼릭 참조(symbolic reference) 해석 같은 일부 예외 상황에서는 여전히 고정이 발생합니다. 또한 현재 대부분의 LTS는 JDK 21이므로, 마이그레이션 전까지는 고정을 계속 의식하고 ReentrantLock을 우선하는 것이 합리적입니다.

네이티브 메서드 고정

가상 스레드가 네이티브 메서드나 외부 함수(FFM API)를 호출하면 호출 전체 구간 동안 캐리어에 고정됩니다. JVM이 네이티브 코드를 검사·제어할 수 없기 때문입니다 — 네이티브 코드는 스레드 간 이전이 불가능한 스레드 로컬 상태를 가질 수 있고, 네이티브 호출 스택은 자바 스택처럼 저장·복원할 수 없습니다.

synchronized와 달리 JDK 24에서도 해소되지 않으므로, 네이티브 호출이 잦은 애플리케이션은 완화책을 직접 적용해야 합니다.

  • 호출을 일괄(batch) 처리해 빈도 줄이기
  • 비동기 네이티브 API 사용
  • 중요한 기능을 순수 자바로 재구현
  • 네이티브 호출이 실제 병목인지 항상 측정

ThreadLocal의 함정

ThreadLocal은 스레드별 전용 변수를 만들어, 여러 스레드가 같은 데이터에 접근할 때 동기화 없이 격리(자원 격리)하거나 컨텍스트(트랜잭션 ID·세션)를 암묵적으로 나르는 데 쓰여 왔습니다. 스레드 풀처럼 스레드 수가 적을 때는 문제가 없었습니다.

가상 스레드에선 전제가 바뀝니다. 수백만 개가 저마다 ThreadLocal 복사본을 가지면 다음 문제가 불거집니다.

문제내용
메모리 소비저장 데이터가 크면 복사본 수만큼 메모리 폭증
오버헤드초기화·정리 비용이 스레드 수만큼 누적
상속부모 스레드의 값을 자동 상속 → 추적·디버깅이 어려운 버그 유발

500KB 객체를 1,000개 가상 스레드의 ThreadLocal에 담은 예제는 약 538MB를 썼고, ThreadLocal을 빼자 약 34MB로 떨어졌습니다.

대안은 스코프드 밸류 (scoped value)입니다. 불변이고 수명이 제한적이라 가상 스레드에 맞게 설계됐습니다. 또는 데이터 공유 방식 자체를 재검토해 ThreadLocal 의존을 줄입니다.

한계를 드러내는 모니터링

피닝과 ThreadLocal은 코드에 숨어 있어 눈으로 찾기 어렵습니다. JDK는 이를 표면화하는 도구를 제공합니다.

도구용도
-Djdk.traceVirtualThreadLocalsThreadLocal 사용 지점마다 스택 트레이스 출력 → 과도한 사용 위치 식별
-Djdk.tracePinnedThreads=full|short고정 상태로 블로킹 시 스택 트레이스 출력(reason:MONITOR, <== monitors:1로 위치 표시)
JFR (jdk.VirtualThreadPinned)피닝 이벤트를 기본 20ms 이상부터 기록, duration으로 고정 시간 확인
jcmd Thread.dump_to_file스레드 덤프(text/json) — 블로킹된 가상 스레드 나열(단 락·객체 주소·힙 통계는 빠짐)
HotSpotDiagnosticMXBean.dumpThreads프로그램·원격에서 JSON/텍스트 스레드 덤프 생성

특히 jdk.VirtualThreadPinned는 기본 활성화된 핵심 JFR 이벤트로, 가상 스레드가 캐리어를 놓아주지 못하고 점유할 때 발생합니다. duration 값(예: 504ms)이 곧 잠재적 성능 손실의 크기입니다.

마이그레이션 요령

상황권장
고정 회피의 정석가상 스레드용으로 업데이트된 라이브러리 사용(최신 동기화 도구라 캐리어를 붙잡지 않음)
업데이트된 라이브러리가 없을 때블로킹 I/O·구형 코드는 Executors.newFixedThreadPool() 같은 전통 풀로 격리
고정 구간 동시 진입 제한세마포어로 제한하되, 너무 낮게 잡으면 동시성 이점이 사라지니 주의

도입 후엔 모니터링 수준을 높여 CPU 사용 패턴(예상 못한 고정 여부), 메모리·GC(힙·GC 튜닝), 지연 시간과 처리량(부하 상황에서 실제 개선 여부)을 함께 관찰해야 합니다. 이 지표들이야말로 가상 스레드 전환의 실제 효과를 보여 주는 근거입니다.

비교 / 트레이드오프

가상 스레드는 만능이 아닙니다. 이득은 병목이 무엇이냐에 달려 있습니다.

작업 성격가상 스레드 효과
I/O 바운드 (DB·API·네트워크 대기 많음)매우 큼 — 대기 중 캐리어를 반납해 동시성 N을 폭증
높은 동시 요청 수 (수천+)큼 — thread-per-request를 그대로 두고 확장
CPU 바운드 (연산이 병목)거의 없음 — 결국 코어 수가 한계, 블로킹이 없어 비켜날 일도 없음

“블로킹이 비싼 스레드를 낭비한다”는 전제가 성립하는 곳에서만 빛납니다. CPU가 병목이면 가상 스레드는 답이 아닙니다.

내 생각

  • thread-per-request의 부활입니다. WebFlux로 넘어가며 잃었던 “한 요청 = 한 스레드, 위에서 아래로 읽히는 블로킹 코드”를 확장성 손해 없이 되찾습니다. 리액티브의 디버깅·학습 비용을 치러 본 입장에선 이게 가장 큰 실무 가치입니다.
  • 무제한 동시성은 공짜가 아닙니다. 가상 스레드를 켜는 순간 병목이 앱에서 DB·외부 API로 옮겨 갑니다. 커넥션 풀·세마포어 같은 백프레셔를 의식적으로 설계하지 않으면 오히려 더 빨리 무너집니다.
  • synchronized 피닝을 점검해야 합니다. JDK 21 기준 블로킹 구간이 synchronized 안이면 캐리어 스레드가 고정(pinning)되어 언마운트 이점이 사라집니다. JDK 24(JEP 491)에서 해소되지만 LTS가 21에 머물러 있어, 핫스팟을 ReentrantLock으로 바꾸는 점검이 여전히 도입 체크리스트에 들어가야 합니다.
  • 풀 시절의 ThreadLocal을 그대로 들고 오면 안 됩니다. 스레드 수가 적던 풀에선 복사본도 적었지만, 수백만 가상 스레드에선 복사본이 곧 메모리 폭증입니다(예제 538MB → 34MB). 컨텍스트 전파는 스코프드 밸류로 옮기는 것이 정공법입니다.
  • 피닝은 이제 정량 측정할 수 있습니다. jdk.VirtualThreadPinned JFR 이벤트의 duration이 곧 손실 크기이므로, “피닝이 의심된다”는 감이 아니라 부하 테스트에서 누적 고정 시간으로 회귀를 감시하는 게 맞습니다.

관련 개념