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]마운트(Mount)와 언마운트(Unmount)
섹션 제목: “마운트(Mount)와 언마운트(Unmount)”가상 스레드가 실행될 때 스케줄러에 의해 캐리어 스레드에 할당되는 과정을 마운트라고 하며, 그 과정은 다음과 같다.
- Mount: 스케줄러가 가상 스레드를 캐리어 스레드에 할당하여 실행 상태로 전환
- Execution: 코드 실행
- Unmount (Yield): I/O 작업이나
sleep등 블로킹이 발생하면, 가상 스레드는 캐리어 스레드에서 내려와 언마운트 상태로 전환- 현재까지의 스택 상태를 Heap 영역에 저장
- Wait: 가상 스레드가 쉬는 동안 캐리어 스레드는 다른 가상 스레드를 처리
- Remount: I/O가 완료되면 다시 캐리어 스레드에 마운트되어 Heap에 저장했던 상태를 복구하고 실행 재개
컨티뉴에이션(Continuation)
섹션 제목: “컨티뉴에이션(Continuation)”- 가상 스레드의 핵심 메커니즘으로, 실행 중인 지점의 상태를 보존하고 나중에 해당 지점부터 재개할 수 있게 하는 기능
- JVM은 언마운트 시점에 CPU 레지스터와 스택의 데이터를 힙으로 복사하고, 재개 시점에 다시 복구
- 스택에 네이티브 프레임(JNI로 호출된 C/C++ 함수의 스택)이 포함된 경우에는 힙 복사 불가로 언마운트 실패 - 피닝(Pinning) 발생의 직접적 원인 중 하나
언마운트 후 재개 트리거
섹션 제목: “언마운트 후 재개 트리거”외부 연동이 끝났을 때 멈춰 있던 가상 스레드를 다시 깨우는 주체는 애플리케이션 코드나 캐리어 스레드가 아니라, 운영체제의 완료 신호를 받아 처리하는 JDK 내부의 전담 스레드인 Poller가 담당한다.
- 관심 이벤트 등록 (readiness)
- 외부 연동(네트워크 read 등) 시작 시 해당 소켓을 논블로킹 모드로 전환
- 완료 통지를 받도록 OS 이벤트 알림 메커니즘에 등록
- 언마운트
- 가상 스레드는 캐리어 스레드에서 내려와 언마운트 상태로 전환
- 그동안 캐리어는 다른 가상 스레드를 처리
- 준비 완료 이벤트 발생
- 외부 작업이 끝나면 운영체제가 등록된 디스크립터에 readiness 이벤트 발생
- 이벤트 수신 (Poller)
- JDK 내부 전담 스레드 Poller가 신호를 받아 끝난 가상 스레드를 식별
- unpark
- Poller가 해당 가상 스레드를 깨워 실행 대기열(스케줄러 큐)에 재등록
- 재마운트
- 비어 있는 캐리어에 다시 올려 중단 지점(컨티뉴에이션)을 복구하고 멈췄던 곳부터 재개
- 처음 실행되던 캐리어와 다를 수 있음
가상 스레드는 스스로 끝났는지 계속 확인하지 않고, 멈춰서 기다리다가 운영체제가 보낸 완료 신호를 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 스레드를 점유한 채 대기오버커밋먼트(Overcommitment)
섹션 제목: “오버커밋먼트(Overcommitment)”캐리어를 놓지 못한 채 블로킹되는 가상 스레드가 생기면, 스케줄러가 임시 캐리어를 추가해 ForkJoinPool 크기를 parallelism 값 너머로 일시 확장하는 메커니즘이다.
| 프로퍼티 | 역할 | 기본값 |
|---|---|---|
| jdk.virtualThreadScheduler.parallelism | 평상시 캐리어 스레드 수 | CPU 코어 수 |
| jdk.virtualThreadScheduler.maxPoolSize | 일시 확장 가능한 최대 크기 | 256 |
- 상한 도달: 모든 캐리어가 블로킹되어
maxPoolSize(256)에 닿으면 더 이상 확장 불가, 처리량은 플랫폼 스레드 수준으로 하락 - 도달 가속:
synchronized구간이 잦거나 길수록 상한에 빠르게 도달 - 진단:
-Djdk.tracePinnedThreads=full또는 JFRjdk.VirtualThreadPinned이벤트로 피닝 위치 추적 - 근본 해결:
synchronized→ReentrantLock전환, 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+)
섹션 제목: “프레임워크 지원(Spring Boot 3.2+)”Spring Boot 3.2 버전부터는 복잡한 설정 없이 프로퍼티 하나로 모든 서블릿 처리 스레드를 가상 스레드로 전환할 수 있다.
- Tomcat이나 Jetty 같은 내장 웹 서버가 가상 스레드를 사용하여 요청 처리
- 기존의
@Async처리나 스케줄러 등도 가상 스레드 기반으로 동작
spring: threads: virtual: enabled: true가상 스레드 기반 Executor 사용
섹션 제목: “가상 스레드 기반 Executor 사용”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();}