한 줄 정의

가상 스레드의 정체는 ForkJoinPool이 스케줄링하고 컨티뉴에이션으로 멈췄다 이어 실행하는 코드 조각이며, 블로킹을 만나면 yield로 캐리어를 비우고 I/O 폴러가 완료를 감지해 다시 깨웁니다.

쉽게 말하면

가상 스레드를 ‘마법’이 아니라 도서관의 일 처리 방식으로 풀어 봅시다. 사서(ForkJoinPool 스케줄러)가 열람석(캐리어 스레드)에 읽을 책(태스크)을 배정합니다. 책을 읽다 “창고에서 자료를 꺼내 와야 한다”(블로킹 I/O)는 순간이 오면, 지금까지 읽은 위치와 머릿속 메모를 통째로 책갈피(컨티뉴에이션)에 적어 두고 열람석을 비웁니다. 그 자리엔 곧장 다른 사람이 앉습니다. 창고 담당(I/O 폴러)이 “자료 준비됐다”고 알려 주면, 사서는 비어 있는 아무 열람석에나 그 사람을 다시 앉히고, 책갈피를 펴 멈춘 자리부터 읽게 합니다.

가상 스레드가 블로킹을 만나면 열람석을 비웠다가 자리가 나면 다시 앉아 이어 읽는다는 동작은 익숙합니다. 이 장은 그 동작을 실제로 가능하게 하는 세 부품 — 누가 자리를 배정하나(스케줄러), 어떻게 멈춘 자리를 기억하나(책갈피), 누가 다시 부르나(알림) — 을 뜯어봅니다.

왜 중요한가?

가상 스레드가 “무엇을 하는가”(마운트·언마운트·피닝)는 어느 정도 알려져 있지만, “그게 어떻게 가능한가”는 가려져 있습니다.

마운트·언마운트는 마법이 아니라 컨티뉴에이션의 스택 프레임 저장·복원이고, 스케줄링은 ForkJoinPool이며, 블로킹된 스레드를 다시 깨우는 것은 I/O 폴러입니다. 이 세 부품을 알면 피닝이나 풀 데드락 같은 문제가 왜·어디서 생기는지 추론할 수 있고, 가상 스레드를 감이 아니라 구조로 다룰 수 있습니다.

핵심 내용

스레드 풀: 직접 만들어 이해하기

스레드 풀은 애플리케이션 시작 시 미리 만들어 계속 살려 두는 스레드들의 집합입니다. 태스크를 담는 큐가 있고, 사용 가능한 스레드가 생기는 즉시 큐에서 꺼내 실행합니다.

필요할 때마다 스레드를 새로 만드는 방식은 결국 OS의 최대 스레드 한도에 부딪혀 애플리케이션을 다운시킵니다. 스레드 풀은 (1) 생성할 스레드 개수를 통제해 자원 고갈을 막고, (2) 스레드를 재사용해 생성 비용을 없애며, (3) 개발자가 “스레드”가 아닌 “작업 단위”에 집중하게 해 줍니다.

SimpleThreadPool을 직접 만들어 보면 핵심은 단순합니다 — 블로킹 큐 + 큐를 돌며 꺼내 실행하는 워커 스레드들입니다.

// 워커: 큐가 비면 take()에서 블로킹하며 새 태스크를 기다린다
public void run() {
    while (running) {
        Runnable task = queue.take();
        task.run();
    }
}

직접 만들며 드러나는 설계 포인트는 다음과 같습니다.

요소역할
BlockingQueue (LinkedBlockingDeque)여러 워커가 동시에 꺼내도 안전(스레드 안전). 큐가 비면 워커를 대기시키고, 가득 차면 put()이 블로킹해 배압(backpressure) 역할
volatile boolean running모든 워커가 셧다운 신호를 즉시 인지하도록 보장
ThreadGroup그룹에 인터럽트 한 번으로 모든 워커를 동시에 종료
안전한 셧다운(close)큐가 빌 때까지 기다린 뒤 종료해 처리 중이던 작업이 유실되지 않게 함. AutoCloseable이라 try-with-resources 가능

ExecutorService와 풀 종류

실무에선 직접 만든 풀 대신 이그제큐터 프레임워크를 씁니다. 공통 인터페이스 ExecutorService를 팩토리 클래스 Executors로 골라 생성하며, 대부분 내부적으로 ThreadPoolExecutor 인스턴스를 만듭니다.

ThreadPoolExecutor 생성자 파라미터가 모든 팩토리 메서드의 설정 원본입니다.

파라미터의미
corePoolSize유휴여도 유지되는 최소 스레드 수. 생성 비용이 크므로 미리 확보해 두면 새 태스크를 곧장 할당
maximumPoolSize활성 스레드 상한. 태스크 급증 시 여기까지 늘려 부하를 흡수
keepAliveTime / unitcore를 초과한 유휴 스레드가 살아 있는 최대 시간
workQueue실행 전 태스크를 보관하는 큐
threadFactory새 스레드 생성에 쓰는 팩토리
handler스레드·큐 용량이 모두 한계에 도달해 실행이 막혔을 때의 거부 처리기

newFixedThreadPool()corePoolSize == maximumPoolSize로 고정 풀을 만드는 것처럼, 팩토리 메서드는 이 파라미터 조합으로 성격이 갈립니다.

팩토리특징적합 상황
FixedThreadPool고정 개수, 초과 태스크는 큐에서 대기예측 가능한 동시성, 자원·DB 연결 수가 제한된 환경
CachedThreadPool유휴 스레드 재사용, 필요 시 동적 생성·축소짧은 태스크 다량·불규칙 몰림 (긴 태스크엔 부적합)
SingleThreadExecutor단일 스레드 순차 실행순서 보장·공유 자원 경쟁 방지
ScheduledThreadPoolExecutor지연 후 또는 주기적 반복 실행알림, 헬스체크, 주기적 유지보수
WorkStealingPoolWork-Stealing(= async 모드 ForkJoinPool)작고 독립적인 태스크 다량, CPU 코어 사용률 극대화

스레드 수에 정답은 없지만 기준은 분명합니다. CPU 집중 작업은 코어 수에 맞춥니다(더 늘리면 컨텍스트 전환·동기화 오버헤드만 증가). I/O 집중 작업은 코어 수보다 많이 둡니다(대기 중 다른 태스크를 처리할 수 있으므로). 가상 스레드가 I/O 작업의 풀 관리 복잡성을 거의 없애 주지만, 레거시·호환성·특정 성능 요구 때문에 전통 풀이 사라지지는 않습니다.

Callable과 Future로 결과 받기

Runnable은 반환값이 없습니다. 실행 결과가 필요하면 Callable(값을 반환하고 예외를 던질 수 있는 함수형 인터페이스)을 제출하고, 결과는 Future 로 받습니다.

submit()은 태스크가 끝나기 전에 Future를 즉시 반환합니다. isDone()으로 완료를 확인하고 get()으로 결과를 가져옵니다. get()은 블로킹 연산이라 결과가 나올 때까지 호출 스레드를 멈춥니다 — 단, 그 스레드가 멈춰 있는 동안에도 다른 스레드들은 자기 태스크를 계속 실행합니다.

ForkJoinPool: 가상 스레드의 스케줄러

전통 풀에는 재귀 분할 작업에서 치명적인 함정이 있습니다. 100개 스레드를 가진 고정 풀로 피보나치를 재귀 계산하면 데드락이 납니다.

Future<Long> future1 = pool.submit(() -> getFibonacci(i - 1, pool));
Future<Long> future2 = pool.submit(() -> getFibonacci(i - 2, pool));
long l1 = future1.get();  // 서브태스크가 끝날 때까지 스레드를 점유한 채 블로킹

각 호출이 서브태스크 2개를 제출하고 자신은 그 결과를 get()으로 기다립니다. 곧 모든 스레드가 “서브태스크 완료 대기” 상태로 블로킹되고, 정작 큐에 쌓인 서브태스크를 꺼내 실행할 스레드가 하나도 남지 않습니다. 전통 풀은 태스크 사이의 의존성을 인식하지 못하기 때문입니다.

ForkJoinPool은 같은 작업을 데드락 없이 해냅니다. 핵심은 부모가 서브태스크를 기다리는 동안 그 스레드를 놀리지 않는다는 것입니다.

FibonacciTask f1 = new FibonacciTask(n - 1);
f1.fork();                                  // f1을 현재 스레드의 deque에 비동기 제출
FibonacciTask f2 = new FibonacciTask(n - 2);
long result = f2.compute() + f1.join();     // f2는 현재 스레드에서 직접 실행,
                                            // f1.join()은 블로킹 대신 다른 태스크를 훔쳐 실행

compute()로 f2를 현재 스레드에서 직접 처리해 불필요한 스레드 소모를 피하고, join()은 f1이 안 끝났으면 블로킹하는 대신 다른 태스크를 가져와 일합니다.

Work-Stealing: 워커마다 자기 큐

ForkJoinPool은 공유 큐 하나를 두고 경쟁시키는 대신, **워커마다 자신의 작업 큐(deque)**를 줍니다. 일거리가 떨어진 워커는 ‘도둑’이 되어 다른 워커의 큐에서 태스크를 훔쳐 옵니다. 경합을 최소화하기 위해 소유자와 도둑은 큐의 서로 다른 끝에서 가져갑니다.

접근 주체가져가는 끝순서이유
소유 워커앞쪽(head)LIFO(pop)최신 태스크 우선 → 필요한 데이터가 CPU 캐시에 남아 있을 확률↑ (캐시 미스↓)
도둑 스레드뒤쪽(tail)FIFO가장 오래된 태스크를 가져가 뒤로 밀리는 일을 막음(공정성)
flowchart LR
    subgraph deque["워커의 작업 큐 (deque)"]
        direction LR
        H["맨 앞 (최신)"] --- M["..."] --- T["맨 뒤 (가장 오래됨)"]
    end
    OWNER["소유 워커<br/>push/pop · LIFO"] -->|앞쪽| H
    THIEF["도둑 스레드<br/>steal · FIFO"] -->|뒤쪽| T

여기에 더해 큐를 관리할 때 락 대신 비교 후 교체(CAS) 같은 원자적 연산을 씁니다. CAS는 “현재 값이 예상 값과 같을 때만 새 값으로 바꾸고, 다르면 실패 후 재시도”하는 락프리 방식입니다(아래 비교 절). 경합이 심하지 않을 때 락 획득·해제 오버헤드가 없어 워커가 불필요하게 대기하지 않습니다.

가상 스레드 스케줄러로 선택된 이유

ForkJoinPool은 경합을 최소화하도록 설계돼 일반 풀보다 훨씬 효율적이고, 바로 이 점 때문에 가상 스레드 스케줄링에 선택됐습니다. 단 가상 스레드는 분할 정복이 아니라 서로 독립적인 태스크를 다루므로, 생성자 인자로 비동기(async) 모드를 켜 LIFO 대신 FIFO(제출된 순서대로 처리)로 동작시킵니다. Executors.newWorkStealingPool()이 곧 이 async 모드 ForkJoinPool입니다.

ForkJoinPool은 분할을 대신해 주지 않는다

ForkJoinPool은 스레드 풀 관리자일 뿐, 작업을 작은 단위로 쪼개는 일은 하지 않습니다. 어떻게 나눌지는 개발자의 책임입니다. RecursiveTask(반환값 있음)·RecursiveAction(없음)을 상속해 분할·처리 로직을 직접 구현합니다.

컨티뉴에이션: 멈췄다 이어 실행

컨티뉴에이션(continuation)은 프로그램의 현재 실행 상태를 저장해 중단하고, 나중에 중단 지점부터 이어서 실행하는 기능입니다. 메서드가 특정 지점에서 ‘일시 정지’됐다가, 다시 호출되면 변숫값까지 그대로 유지한 채 멈춘 자리에서 재개됩니다.

자바 21부터 내부 패키지(jdk.internal.vm.Continuation)로 쓸 수 있습니다. run()yield()가 핵심이며, 둘이 핑퐁하듯 제어권을 주고받습니다.

Continuation c = new Continuation(scope, () -> {
    System.out.println("Hello from continuation");
    Continuation.yield(scope);                 // 여기서 멈추고 제어권을 호출자에게
    System.out.println("Hello again from continuation");
    Continuation.yield(scope);
    System.out.println("Done from continuation");
});
c.run();  // Hello from continuation 출력 후 yield에서 멈춤
c.run();  // 멈춘 지점부터 → Hello again ... 출력 후 다음 yield에서 멈춤
c.run();  // 멈춘 지점부터 → Done 출력, 남은 코드 없으니 종료

가상 스레드는 이 메커니즘 위에서 동작합니다. 블로킹 I/O를 만나면 컨티뉴에이션이 yield를 호출해 가상 스레드를 일시 정지·언마운트하고, I/O가 끝나면 다시 스케줄링되어 멈춘 지점부터 재개됩니다.

컨티뉴에이션은 내부 API

학습용 코드입니다. 실행하려면 --add-exports java.base/jdk.internal.vm=ALL-UNNAMED JVM 옵션이 필요하며, 내부 API는 공지 없이 변경·제거될 수 있어 운영 환경에 쓰면 안 됩니다.

어떻게 멈춤·재개가 되나: 스택 프레임의 이동

각 스레드는 스택과 스택 프레임을 가집니다. 메서드를 호출할 때마다 프레임이 쌓입니다. 컨티뉴에이션의 멈춤·재개는 결국 이 프레임을 어디에 두느냐의 문제입니다.

  • 언마운트(yield): 현재 스레드 스택의 프레임들(a → b → c → yield)을 통째로 컨티뉴에이션 객체 안에 저장합니다. 스레드는 c.run()이 반환된 것처럼 보이지만, 실제로는 일시 정지된 것입니다.
  • 마운트(재개): 컨티뉴에이션에 저장해 둔 프레임을 다시 스레드 스택으로 복원하고, yield가 반환되며 멈춘 지점부터 이어집니다.

문제는 복사 비용입니다. 멈출 때마다 스택 전체를 복사하면 호출 스택이 깊을수록 비싸집니다. JVM은 두 가지로 최적화합니다.

기법내용
지연 복사(lazy copy)처음 중단 시엔 전체 프레임을 컨티뉴에이션으로 옮기되, 재개 시엔 전체가 아니라 한두 프레임만 스레드 스택으로 복원
반환 배리어(return barrier)함수 반환 지점에 주입된 작은 코드. 반환에 필요한 프레임이 스레드 스택에 없으면 그때 컨티뉴에이션에서 끌어와 복사

즉 필요한 프레임만 그때그때 가져와 깊은 스택의 복사 비용을 피합니다.

언마운트yielda부터 c.yield()까지 프레임 전체를 컨티뉴에이션 객체로 옮깁니다. 스레드 스택엔 c.run()만 남아 마치 반환된 것처럼 보입니다.

flowchart LR
    subgraph T1["스레드 스택"]
        direction TB
        a1["......"] --> b1["c.run()"]
    end
    subgraph K1["컨티뉴에이션 객체"]
        direction TB
        f1["a"] --> f2["b"] --> f3["c"] --> f4["c.yield()"]
    end
    b1 ==>|"yield(): 전체 프레임 저장"| K1

재개 + 반환 배리어 — 지연 복사로 우선 c·c.yield()만 스레드 스택에 복원하고 a·b는 컨티뉴에이션에 남겨 둡니다. 나중에 c가 반환돼 b가 필요해지는 순간, 반환 지점에 심긴 반환 배리어가 그때 b를 컨티뉴에이션에서 끌어와 복사합니다.

flowchart LR
    subgraph K2["컨티뉴에이션 객체"]
        direction TB
        g1["a"] --> g2["b"]
    end
    subgraph T2["스레드 스택"]
        direction TB
        h1["......"] --> h2["c.run()"] --> h3["반환 배리어"] --> h4["c"] --> h5["c.yield()"]
    end
    K2 -.->|"필요해지면 한두 프레임만 복원"| h4

NanoThread: 가상 스레드 직접 시뮬레이션

Continuation API로 가상 스레드를 흉내 내는 학습용 구조 NanoThread로 세 부품이 맞물리는 모습을 확인할 수 있습니다.

구성역할
NanoThreadRunnableContinuation으로 감싼 커스텀 가상 스레드. run()은 컨티뉴에이션을 실행/재개
NanoThreadSchedulerWorkStealingPool(워커 2개)로 NanoThread를 스케줄링. ThreadLocal로 어느 워커가 어떤 NanoThread를 실행 중인지 추적
IO_EVENT_SCHEDULER임의 지연 후 NanoThread를 다시 제출해 I/O 완료를 시뮬레이션

파일 전송을 흉내 내는 transfer()는 전송 시작 → IO_EVENT_SCHEDULER에 “지연 후 재개”를 예약 → Continuation.yield()로 멈춤(캐리어 해방) → 지연이 끝나면 스케줄러가 다시 제출해 멈춘 지점부터 재개합니다.

핵심 관찰은 출력에 있습니다. 같은 NanoThread-2의 전송이 시작은 worker-2, 완료는 worker-1 에서 일어납니다. 가상 스레드도 똑같이 — I/O로 멈췄다 재개될 때 처음과 다른 캐리어 스레드에서 이어질 수 있습니다.

세 주체(나노 스레드·컨티뉴에이션·OS 입출력) 사이에서 제어권이 오가는 흐름은 다음과 같습니다.

sequenceDiagram
    participant N as 나노 스레드
    participant C as 컨티뉴에이션
    participant IO as 운영체제 입출력
    Note over N: 실행 시작
    N->>C: Continuation.run()
    Note over N: I/O 연산 호출
    N->>C: Continuation.yield()
    C-->>N: 호출자에게 제어권 반환
    C->>IO: 블로킹 I/O 호출
    IO-->>C: I/O 완료 → 스레드 실행 재개
    Note over N: 실행 재개
    N->>C: Continuation.run()
    C-->>N: 실행 완료

yield로 제어권이 호출자에게 돌아가 캐리어가 다른 일을 하다가, I/O가 끝나면 run으로 멈춘 지점부터 다시 이어지는 한 사이클이 한눈에 보입니다.

I/O 폴링: 누가 다시 깨우나

가상 스레드가 블로킹 I/O를 시작하면 내부적으로 LockSupport.park()가 호출됩니다.

public static void park() {
    if (Thread.currentThread().isVirtual()) {
        VirtualThreads.park();   // 가상 스레드는 여기로
    } else {
        U.park(false, 0L);
    }
}

VirtualThreads.park()는 몇 단계 뒤 yieldContinuation()을 호출해 가상 스레드를 캐리어 스레드로부터 분리합니다. 그런데 소켓에 데이터가 도착하면 누가 이 가상 스레드를 다시 깨우나요?

답은 JVM 수준에서 도는 읽기 폴러(read poller)입니다. 이벤트 루프로서 read()·connect()·accept() 같은 동기 네트워크 연산이 즉시 끝나지 않을 때 이를 모니터링합니다. I/O가 완료되면 폴러가 알림을 받아, 그 완료를 기다리던 가상 스레드를 언파킹합니다. 쓰기 연산엔 쓰기 폴러(write poller)가 같은 역할을 합니다.

폴러는 파일 디스크립터 ↔ 가상 스레드 매핑을 유지합니다. 소켓이 폴러에 등록되면 그 FD와 대기 중인 가상 스레드가 연결되고, 이벤트(데이터 도착)가 오면 폴러가 매핑에서 해당 가상 스레드를 찾아 깨웁니다. OS별로 **리눅스 epoll / 맥OS kqueue / 윈도우 wepoll**을 활용해, I/O를 기다리는 동안 자원을 낭비하지 않습니다.

비교 / 트레이드오프

전통 ThreadPoolExecutor vs ForkJoinPool

구분전통 ThreadPoolExecutorForkJoinPool
큐 구조공유 큐 1개 (스레드들이 경쟁)워커마다 deque (경합 최소)
동기화락·조건 변수에 의존Work-Stealing + CAS, 명시적 락 거의 없음
태스크 의존성인식 못함 → 재귀 분할 시 데드락 위험서브태스크 대기 중 다른 태스크 처리 → 데드락 회피
스레드 수보통 고정, 덜 유동적작업량·코어 가용성에 따라 능동 조정

CAS(락프리) vs 락

CAS는 락을 잡는 대신 “현재 값이 예상과 같을 때만 교체하고, 다르면 재시도”합니다. 블로킹이 없어 락 획득·해제 오버헤드를 없애지만, 경합이 심하면 재시도가 잦아집니다. 경합이 낮을 때 특히 효율적이고, ForkJoinPool이 큐 관리에 CAS를 쓰는 이유도 여기 있습니다.

내 생각

  • 가상 스레드는 발명이 아니라 조립입니다. ForkJoinPool(스케줄러) + 컨티뉴에이션(스택 프레임 저장·복원) + 폴러(epoll/kqueue) + CAS — 이미 있던 부품의 영리한 조합입니다. “마법”이라는 인상이 사라지면 한계도 자연스럽게 보입니다.
  • 전통 풀에 재귀 작업을 제출하는 건 실무 함정입니다. 고정 풀에 서브태스크를 제출하고 부모가 get()으로 기다리면 풀이 고갈돼 데드락이 납니다. 분할 정복류는 ForkJoinPool/RecursiveTask로, 아니면 풀을 분리해야 합니다.
  • 가상 스레드 스케줄러가 왜 FIFO async 모드인지 납득됩니다. 분할 정복이 아닌 독립 태스크는 제출 순서대로 고르게 처리하는 게 응답 지연에 유리하므로, LIFO 대신 FIFO를 씁니다.
  • 마운트·언마운트의 실체가 “스택 복사”임을 알면 깊은 호출 스택의 비용이 와닿습니다. 지연 복사·반환 배리어가 있어도 결국 프레임을 옮기는 작업이라, 호출이 깊은 코드일수록 컨텍스트 전환 비용이 큽니다.

관련 개념