콘텐츠로 이동

Virtual Thread

가상 스레드는 Java 21에서 정식 도입된 경량 스레드 모델로, 기존의 플랫폼 스레드가 가진 리소스 종속성 문제를 해결하고, 단순한 동기식 코드로 높은 처리량을 달성하기 위해 설계되었다.

  • OS 스레드에 1:1로 매핑되지 않고 JVM 스케줄러를 통해 관리되는 논리적 실행 단위
  • 수백만 개의 스레드를 동시에 생성하더라도 메모리와 CPU 오버헤드가 극히 적음
  • 비동기 프로그래밍의 복잡성 없이 기존 서블릿 기반의 Thread-per-request 모델 유지 가능
  • 블로킹 I/O 작업이 발생해도 OS 스레드 점유 없이 효율적으로 대기할 수 있도록 설계

전통적인 자바 동기화 모델은 운영체제의 커널 스레드를 래핑한 플랫폼 스레드를 사용했는데, 이 방식은 확장성 측면에서 명확한 한계를 지닌다.

  • 플랫폼 스레드의 비용 문제
    • 메모리 점유: 플랫폼 스레드는 생성 시 약 1MB의 고정된 스택 메모리를 할당
    • 생성 비용: OS 커널이 직접 개입하여 스레드를 생성하므로 시간 소모가 큼
    • 컨텍스트 스위칭: 스레드 간 전환 시 커널 모드 권한 변경이 필요하여 CPU 사이클 낭비 발생
  • 데이터 처리 모델의 변화
    • 현대의 웹 애플리케이션은 CPU 연산보다 I/O(DB 호출, API 연동) 대기 시간이 더 큼
    • 플랫폼 스레드는 I/O 블로킹 발생 시 해당 스레드가 아무 일도 하지 못하고 자원을 점유하는 낭비 발생
    • 가상 스레드는 이러한 대기 시간에 자원을 반납하도록 설계되어 자원 효율성 극대화

가상 스레드는 JDK 내부의 ForkJoinPool을 스케줄러로 사용하며, 실제 연산을 수행하는 플랫폼 스레드인 캐리어 스레드(Carrier Thread) 위에서 실행된다.

graph LR
subgraph "JVM (Virtual Thread Scheduler)"
VT1[Virtual Thread 1]
VT2[Virtual Thread 2]
VT3[Virtual Thread 3]
VT4[Virtual Thread 4]
end
subgraph "Platform Threads (Carrier)"
CT1[Carrier Thread A]
CT2[Carrier Thread B]
end
VT1 -->|Mount| CT1
VT3 -->|Mount| CT2
VT2 -.->|Wait in Heap| VT2
VT4 -.->|Wait in Heap| VT4
CT1 -->|Mapping| OS[OS Kernel Thread]
CT2 -->|Mapping| OS2[OS Kernel Thread]

가상 스레드가 실행될 때 스케줄러에 의해 캐리어 스레드에 할당되는 과정을 마운트라고 하며, 그 과정은 다음과 같다.

  1. Mount: 스케줄러가 가상 스레드를 캐리어 스레드에 할당하여 실행 상태로 전환
  2. Execution: 코드 실행
  3. Unmount (Yield): I/O 작업이나 sleep 등 블로킹이 발생하면, 가상 스레드는 캐리어 스레드에서 내려와 언마운트 상태로 전환
    • 현재까지의 스택 상태를 Heap 영역에 저장
  4. Wait: 가상 스레드가 쉬는 동안 캐리어 스레드는 다른 가상 스레드를 처리
  5. Remount: I/O가 완료되면 다시 캐리어 스레드에 마운트되어 Heap에 저장했던 상태를 복구하고 실행 재개
  • 가상 스레드의 핵심 메커니즘으로, 실행 중인 지점의 상태를 보존하고 나중에 해당 지점부터 재개할 수 있게 하는 기능
  • JVM은 언마운트 시점에 CPU 레지스터와 스택의 데이터를 힙으로 복사하고, 재개 시점에 다시 복구
  • 스택에 네이티브 프레임(JNI로 호출된 C/C++ 함수의 스택)이 포함된 경우에는 힙 복사 불가로 언마운트 실패 - 피닝(Pinning) 발생의 직접적 원인 중 하나

외부 연동이 끝났을 때 멈춰 있던 가상 스레드를 다시 깨우는 주체는 애플리케이션 코드나 캐리어 스레드가 아니라, 운영체제의 완료 신호를 받아 처리하는 JDK 내부의 전담 스레드인 Poller가 담당한다.

  1. 관심 이벤트 등록 (readiness)
    • 외부 연동(네트워크 read 등) 시작 시 해당 소켓을 논블로킹 모드로 전환
    • 완료 통지를 받도록 OS 이벤트 알림 메커니즘에 등록
  2. 언마운트
    • 가상 스레드는 캐리어 스레드에서 내려와 언마운트 상태로 전환
    • 그동안 캐리어는 다른 가상 스레드를 처리
  3. 준비 완료 이벤트 발생
    • 외부 작업이 끝나면 운영체제가 등록된 디스크립터에 readiness 이벤트 발생
  4. 이벤트 수신 (Poller)
    • JDK 내부 전담 스레드 Poller가 신호를 받아 끝난 가상 스레드를 식별
  5. unpark
    • Poller가 해당 가상 스레드를 깨워 실행 대기열(스케줄러 큐)에 재등록
  6. 재마운트
    • 비어 있는 캐리어에 다시 올려 중단 지점(컨티뉴에이션)을 복구하고 멈췄던 곳부터 재개
    • 처음 실행되던 캐리어와 다를 수 있음

가상 스레드는 스스로 끝났는지 계속 확인하지 않고, 멈춰서 기다리다가 운영체제가 보낸 완료 신호를 Poller가 대신 받아 깨워주는 구조다.

sequenceDiagram
participant VT as 가상 스레드
participant CT as 캐리어 스레드
participant P as Poller (전담 스레드)
participant OS as 운영체제
VT ->> CT: 캐리어에 올라가 외부 연동(read) 시작
CT ->> P: 미완료 시 Poller에 등록 (OS 감시 시작)
Note over VT: 멈춤 (언마운트, 상태를 Heap에 저장)
CT ->> CT: 그동안 다른 가상 스레드 처리
OS -->> P: 완료 신호
P ->> VT: 깨움 (unpark, 대기열에 재등록)
VT ->> CT: 빈 캐리어에 다시 올라가 멈췄던 곳부터 재개

피닝(Pinning)과 캐리어 스레드 풀

섹션 제목: “피닝(Pinning)과 캐리어 스레드 풀”

가상 스레드에서 블로킹 시 언마운트되어야 할 가상 스레드가 캐리어에 고정되는 현상을 피닝(Pinning)이라고 한다.

  • 피닝이 발생하면 해당 가상 스레드는 캐리어 스레드에 묶여 언마운트되지 못함
  • 캐리어가 OS 스레드를 점유한 채로 대기
sequenceDiagram
participant VT as 가상 스레드
participant CT as 캐리어 스레드
participant OS as OS 커널 스레드
Note over VT,CT: 정상 - 블로킹 시 언마운트
VT ->> CT: 블로킹 I/O
Note over VT: 언마운트 (캐리어 반납)
CT ->> CT: 다른 가상 스레드 처리
Note over VT,CT: 피닝 - 언마운트 불가
VT ->> CT: synchronized 안에서 블로킹
Note over VT,CT: 고정(pinned), 캐리어 반납 못 함
CT ->> OS: 캐리어가 OS 스레드를 점유한 채 대기

캐리어를 놓지 못한 채 블로킹되는 가상 스레드가 생기면, 스케줄러가 임시 캐리어를 추가해 ForkJoinPool 크기를 parallelism 값 너머로 일시 확장하는 메커니즘이다.

프로퍼티역할기본값
jdk.virtualThreadScheduler.parallelism평상시 캐리어 스레드 수CPU 코어 수
jdk.virtualThreadScheduler.maxPoolSize일시 확장 가능한 최대 크기256
  • 상한 도달: 모든 캐리어가 블로킹되어 maxPoolSize(256)에 닿으면 더 이상 확장 불가, 처리량은 플랫폼 스레드 수준으로 하락
  • 도달 가속: synchronized 구간이 잦거나 길수록 상한에 빠르게 도달
  • 진단: -Djdk.tracePinnedThreads=full 또는 JFR jdk.VirtualThreadPinned 이벤트로 피닝 위치 추적
  • 근본 해결: synchronizedReentrantLock 전환, JDK 24(JEP 491) 이상 사용

플랫폼 스레드와 가상 스레드 비교

섹션 제목: “플랫폼 스레드와 가상 스레드 비교”
구분플랫폼 스레드 (Platform Thread)가상 스레드 (Virtual Thread)
관리 주체OS 커널JVM
기본 스택 크기약 1MB (고정적)수백 바이트 ~ 수 KB (가변적)
생성 비용매우 높음 (풀링 필수)매우 낮음 (필요 시 생성)
컨텍스트 스위칭OS 커널 스위칭 (무거움)JVM 내 스위칭 (가벼움)
확장성수천 개 수준수백만 개 수준

가상 스레드 vs 리액티브 프로그래밍(WebFlux) 상세 비교

섹션 제목: “가상 스레드 vs 리액티브 프로그래밍(WebFlux) 상세 비교”

가상 스레드와 리액티브 모델은 모두 높은 처리량을 목적으로 하지만, 근본적으로 다른 철학과 프로그래밍 모델을 가지고 있다.

구분가상 스레드 (Virtual Thread)리액티브 (Spring WebFlux)
동작 메커니즘컨티뉴에이션 및 마운트 전략 활용이벤트 루프 및 콜백 기반 처리
I/O 처리 방식기존 블로킹 코드 방식논블로킹 메커니즘 필요
배압 (Backpressure)별도의 제어 메커니즘이 존재하지 않음리액티브 스트림즈 표준을 통한 강력한 흐름 제어
메모리 관리 및 위험수백만 개 생성 시 힙 메모리 사용량이 급증하거나 ThreadLocal 남용 시 OOM 위험 존재고정된 수의 스레드만 사용하므로 메모리 사용량이 일정하고 예측 가능
자원 효율성수백만 개의 스레드를 생성할 수 있으나 개별 스레드의 상태 저장 비용 발생적은 수의 스레드로 대규모 동시 연결을 처리하는 고밀도 효율성
에러 핸들링표준적인 try-catch 및 명령형 예외 처리 활용연산자 체인 내 전용 핸들러를 통한 비동기 예외 처리
주요 사용 사례일반적인 CRUD API, JDBC 기반 레거시 시스템 전환, 단순 병렬 작업실시간 스트리밍, 게이트웨이, 정교한 흐름 제어 / 배압 필요한 시스템

가상 스레드는 기존 Thread API를 계승하지만, 내부 동작 방식의 차이로 인해 기존의 스레드 사용 패턴이 그대로 적용되지 않는 경우가 많다.

  • 스레드 풀 미사용
    • 가상 스레드는 재사용을 목적으로 설계되지 않음
    • 생성 비용이 매우 저렴하므로 ExecutorService를 통해 풀링하기보다 작업마다 새 스레드를 생성하는 방식 권장
  • 피닝(Pinning) 현상
    • 발생 원인: synchronized 블록은 JVM 모니터 락이 캐리어 스레드 단위로 관리
      • JNI·네이티브 프레임이 스택에 포함된 구간에서는 컨티뉴에이션 저장이 불가능하여 언마운트 실패
    • 영향: I/O 대기 중에도 캐리어 스레드가 점유되어, 오버커밋먼트로 풀이 일시 확장되더라도 maxPoolSize(기본 256) 상한에 도달하면 처리량이 플랫폼 스레드 수준으로 하락
    • 해결: synchronized 대신 ReentrantLock으로 전환하여 언마운트 경로 확보
    • JDK 24 개선: JEP 491에 의해 synchronized 블록 내부의 블로킹 호출도 피닝 없이 언마운트 가능하도록 수정되어, 대부분의 레거시 코드가 수정 없이 가상 스레드와 호환
  • ThreadLocal 메모리 관리
    • 가상 스레드도 ThreadLocal을 지원하지만, 값을 담는 ThreadLocalMap은 각 스레드 객체에 매달려 힙에 상주
    • 대용량 데이터를 ThreadLocal에 저장할 경우, 수백만 개의 가상 스레드가 동시에 존재하면 각자의 사본이 힙에 누적되어 메모리 부족 현상 발생 가능
    • 가상 스레드 환경에서는 ThreadLocal 사용을 최소화하거나 가벼운 데이터 위주로 구성하고, 불변 데이터 공유에는 JDK 25에서 정식화된 ScopedValue 활용 권장
  • 다운스트림 리소스 고갈
    • 발생 원인: 가상 스레드는 거의 무제한 생성 가능하지만, DB 커넥션 풀, 외부 API 쿼터, 파일 디스크립터 같은 하위 자원은 한정적
    • 영향: 동시 수만 개의 작업이 한정된 풀(예: HikariCP 기본 maximumPoolSize=10)에 동시에 접근하여 즉시 고갈, 대기열 폭증과 connection timeout 유발
    • WebFlux 대비 한계: 리액티브 스트림즈와 달리 가상 스레드에는 표준 백프레셔(Backpressure) 메커니즘이 없어 호출 측에서 동시성 제어 책임 부담
    • 해결: Semaphore로 동시 작업 수 제한, 커넥션 풀 크기를 트래픽에 맞게 재산정, 구조적 동시성(StructuredTaskScope)으로 작업 lifecycle 관리
  • 처리량(Throughput) vs 응답 속도(Latency): 가상 스레드는 개별 작업의 속도를 빠르게 만드는 것이 아니라, 단위 시간당 처리할 수 있는 작업의 양을 늘려주는 도구
  • CPU Bound 작업: 복잡한 연산이 주를 이루는 작업에서는 가상 스레드의 이점이 없으며, 오히려 스케줄링 오버헤드로 인해 성능이 하락 가능성 존재
  • I/O Bound 작업: 대규모 트래픽을 처리하는 API 서버나 마이크로서비스 환경에서 리소스 효율성을 극대화하는 데 최적

Java 21부터 추가된 Executors.newVirtualThreadPerTaskExecutor()를 사용하면, 제출되는 모든 태스크에 대해 새로운 가상 스레드를 생성하여 실행한다.

Spring Boot 3.2 버전부터는 복잡한 설정 없이 프로퍼티 하나로 모든 서블릿 처리 스레드를 가상 스레드로 전환할 수 있다.

  • Tomcat이나 Jetty 같은 내장 웹 서버가 가상 스레드를 사용하여 요청 처리
  • 기존의 @Async 처리나 스케줄러 등도 가상 스레드 기반으로 동작
spring:
threads:
virtual:
enabled: true
public class ThreadComparison {
public static void main(String[] args) {
int taskCount = 10_000;
// 1. 플랫폼 스레드 (Fixed Thread Pool) 방식
System.out.println("=== 플랫폼 스레드 시작 ===");
long startPlatform = System.currentTimeMillis();
// 실제 운영 환경에서 10,000개를 동시에 띄우는 것은 불가능하므로 풀링 사용
try (ExecutorService platformExecutor = Executors.newFixedThreadPool(100)) {
runTasks(platformExecutor, taskCount);
}
System.out.println("플랫폼 스레드 소요 시간: " + (System.currentTimeMillis() - startPlatform) + "ms");
// 2. 가상 스레드 (Virtual Thread) 방식
System.out.println("\n=== 가상 스레드 시작 ===");
long startVirtual = System.currentTimeMillis();
try (ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
runTasks(virtualExecutor, taskCount);
}
System.out.println("가상 스레드 소요 시간: " + (System.currentTimeMillis() - startVirtual) + "ms");
}
private static void runTasks(ExecutorService executor, int count) {
IntStream.range(0, count).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1)); // I/O 블로킹 시뮬레이션
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return i;
});
});
}
}
/*
=== 플랫폼 스레드 시작 ===
플랫폼 스레드 소요 시간: 100387ms
=== 가상 스레드 시작 ===
가상 스레드 소요 시간: 1098ms
*/

가상 스레드 방식 사용 시, 메모리 부족(OOM)이나 성능 저하 없이 작업 수행하는 것을 확인할 수 있다.

가상 스레드 직접 생성 (Thread Builder)

섹션 제목: “가상 스레드 직접 생성 (Thread Builder)”

특정 작업 하나를 가상 스레드로 즉시 실행하고 싶을 때는 Thread.ofVirtual() 빌더를 사용하여 간단히 생성할 수 있다.

public static void main(String[] args) {
// 가상 스레드 빌더를 통한 생성 및 실행
Thread vThread = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
System.out.println("가상 스레드 실행 중: " + Thread.currentThread().getName());
});
// 가상 스레드가 종료될 때까지 대기
vThread.join();
}

마지막 업데이트:

Java