가상 스레드는 정확히 무엇을 가상화하는가
이름부터 다시 짚기
"가상 스레드"라는 이름 때문에 "진짜 스레드가 아니라 흉내만 내는 것"이라고 오해하기 쉽습니다. 정확히는 반대에 가깝습니다. 가상 스레드는 Thread API 를 그대로 구현한 진짜 스레드입니다. Thread.start(), Thread.join(), Thread.currentThread() 모두 동일하게 동작합니다. 차이는 이 스레드가 OS 커널이 아니라 JVM 이 관리하는 자원이라는 점입니다.
Thread virtualThread = Thread.ofVirtual().start(() -> {
System.out.println("가상 스레드에서 실행: " + Thread.currentThread());
});
virtualThread.join();Thread.ofVirtual() 로 만든 스레드는 Thread.ofPlatform() 으로 만든 기존 스레드와 API 레벨에서 거의 구분되지 않습니다. 이게 핵심 설계 목표였습니다 — 기존 동기 코드를 고치지 않고, 실행 방식만 바꾸는 것.
캐리어 스레드라는 중간 계층
가상 스레드는 결국 어딘가의 CPU 코어에서 실행돼야 합니다. 그 실행을 실제로 담당하는 OS 스레드를 캐리어 스레드(carrier thread)라고 부릅니다. 기본적으로 이 캐리어 스레드들은 ForkJoinPool 로 구성되고, 개수는 보통 CPU 코어 수만큼입니다.
가상 스레드가 블로킹 작업(예: 소켓에서 데이터 읽기)을 만나면, JVM 은 그 가상 스레드를 캐리어 스레드에서 떼어내 대기 상태로 보관하고, 캐리어 스레드는 곧바로 다른 가상 스레드를 실행합니다. 이 과정을 마운트(mount)/언마운트(unmount)라고 부릅니다. 블로킹 작업이 끝나면 가상 스레드는 (같은 캐리어 스레드일 필요 없이) 사용 가능한 캐리어 스레드에 다시 마운트됩니다.
sequenceDiagram participant VT1 as 가상 스레드 A participant VT2 as 가상 스레드 B participant CT as 캐리어 스레드 (OS 스레드) VT1->>CT: 마운트, 실행 시작 VT1->>VT1: DB 조회 요청 (블로킹 지점) VT1-->>CT: 언마운트 (대기 상태로 전환) CT->>VT2: 다른 가상 스레드 마운트 Note over VT1: 커널 스레드 점유 없이 대기 VT1->>CT: DB 응답 도착, 재마운트 VT1->>CT: 실행 재개
이게 왜 확장성을 만드는지 보이시나요? 캐리어 스레드 하나가 수천 개의 가상 스레드를 "번갈아" 실행할 수 있습니다. 어느 순간에도 실제로 실행 중인 건 코어 수만큼뿐이지만, 블로킹된 가상 스레드는 캐리어 스레드를 붙잡지 않고 훨씬 가벼운 자료구조로 대기합니다. 100만 개의 가상 스레드를 띄우는 벤치마크가 가능한 이유입니다 — 그중 대부분은 어차피 I/O 를 기다리며 언마운트된 상태니까요.
스택은 힙에 있다
플랫폼 스레드는 고정 크기 스택을 OS 가 예약합니다. 가상 스레드의 스택은 이와 다르게 JVM 힙 내부에 저장되는 연속되지 않은(청크 단위) 구조로 구현되어 있고, 필요할 때 커지고 줄어듭니다. 시작 크기가 작기 때문에(수백 바이트에서 시작해 필요시 확장) 수백만 개를 만들어도 메모리 사용량이 플랫폼 스레드만큼 폭발하지 않습니다.
이 구현 세부사항을 외울 필요는 없지만, 한 가지는 기억해 둘 만합니다 — 가상 스레드의 스택 트레이스가 매우 깊어질 수 있는 재귀 코드에서는 여전히 StackOverflowError 가 날 수 있습니다. 가상 스레드가 스택 크기 제약을 완전히 없애는 건 아닙니다.
정말로 코드를 안 바꿔도 되는가
다음 두 코드를 비교해 보세요.
// 플랫폼 스레드
Thread.ofPlatform().start(() -> handleRequest(request));
// 가상 스레드
Thread.ofVirtual().start(() -> handleRequest(request));handleRequest 내부 로직은 한 글자도 바꾸지 않았습니다. 이게 가상 스레드가 지키려는 약속입니다 — 비동기 콜백도, 리액티브 체인도 필요 없이 동기 코드 그대로 확장성을 얻는 것. 대부분의 경우 실제로 이렇게 됩니다. 다만 "대부분"이라고 쓴 이유가 있습니다. 코드 안에 synchronized 블록이 있거나, 네이티브 메서드를 호출하거나, ThreadLocal 을 무겁게 쓰고 있다면 이 약속이 깨지는 지점이 생깁니다. 다음 두 장에서 그 지점을 하나씩 짚습니다.