자바 스레드만 알던 개발자가 가상 스레드를 파헤쳐 봤습니다

자바 개발자라면 new Thread(() -> {...}).start()를 수도 없이 써봤을 겁니다. 스레드 풀을 만들고, ExecutorService에 작업을 던지고, Future로 결과를 받는 패턴은 눈 감고도 칠 수 있을 정도입니다. 그런데 어느 날 동료가 "가상 스레드 써봤어?"라고 물어왔을 때, 솔직히 말하면 제대로 대답하기 어려웠습니다.
Java 21에서 정식으로 들어왔다는 건 알고 있었습니다. Project Loom이라는 이름도 들어봤습니다. 그런데 "그래서 기존 스레드랑 뭐가 다른데?"라는 질문에 "가볍다"라는 답밖에 못 하는 자신을 발견하고, 이참에 제대로 파고들어 보기로 했습니다. JEP 444 공식 문서부터 시작해서 실제 프로덕션에 적용한 개발자들의 사례까지, 가상 스레드를 제대로 들여다봤습니다.
자바에는 두 종류의 스레드가 있습니다
Java 21부터 자바의 스레드는 두 종류입니다. 플랫폼 스레드와 가상 스레드. 우리가 그동안 쓰던 Thread는 전부 플랫폼 스레드입니다.
플랫폼 스레드는 OS 스레드와 1:1로 매핑됩니다. Java 1.0부터 이 방식이었고, 20년 넘게 변하지 않았습니다. new Thread()를 호출하면 JVM이 운영체제에 커널 스레드 하나를 요청하거든요. 이 커널 스레드가 실제로 CPU에서 코드를 실행합니다. 자바 스레드는 그냥 이 OS 스레드를 감싼 래퍼였던 셈입니다.
문제는 비용이죠. 플랫폼 스레드 하나를 만드는 데 약 100~300 마이크로초의 커널 시스콜이 필요하고, 스레드 하나당 기본 스택 메모리가 약 2MB입니다. 스레드를 1,000개만 만들어도 스택 메모리가 2GB에 달합니다. OS 스케줄러도 스레드가 수천 개를 넘기면 성능이 급격히 떨어지고요. 결국 자바 세계에서 "스레드는 비싼 자원이니까 풀링해서 재사용하자"는 상식이 만들어진 겁니다.
가상 스레드는 이 구조를 뒤집습니다. OS 스레드가 아니라 JVM이 직접 관리하는 스레드거든요. OS에 커널 스레드 생성을 요청하지 않습니다. 힙 메모리에 가볍게 만들어지고, JVM의 스케줄러가 직접 스케줄링합니다. M개의 가상 스레드를 N개의 OS 스레드 위에서 돌리는 M:N 모델이죠.
다음 그림은 플랫폼 스레드의 1:1 모델과 가상 스레드의 M:N 모델을 비교한 것입니다.

왼쪽의 플랫폼 스레드는 자바 스레드 하나가 OS 스레드 하나를 점유하고 각각 CPU 코어에 매핑됩니다. 스레드당 약 2MB의 스택 메모리가 필요합니다. 오른쪽의 가상 스레드는 8개의 가상 스레드가 2개의 캐리어 스레드 위에서 번갈아 실행되며, 힙 메모리에 수 KB만 차지합니다.
JEP 444 공식 문서에서 가장 인상 깊었던 문장이 있습니다. "가상 스레드는 풀링하지 마십시오. 가상 스레드는 희소 자원이 아닙니다." 그동안 스레드 풀을 만들고, 사이즈를 고민하고, 큐가 넘치면 어떻게 할지 전략을 짜던 그 모든 노력이, 가상 스레드 세계에서는 불필요합니다.
플랫폼 스레드와의 결정적인 차이
둘의 차이를 코드로 먼저 확인해 보겠습니다.
// 플랫폼 스레드 확인 Thread platformThread = Thread.ofPlatform().start(() -> { System.out.println("플랫폼 스레드: " + Thread.currentThread()); System.out.println("isVirtual: " + Thread.currentThread().isVirtual()); }); platformThread.join(); // 가상 스레드 확인 Thread virtualThread = Thread.ofVirtual().start(() -> { System.out.println("가상 스레드: " + Thread.currentThread()); System.out.println("isVirtual: " + Thread.currentThread().isVirtual()); }); virtualThread.join();
실행하면 플랫폼 스레드는 isVirtual: false, 가상 스레드는 isVirtual: true를 출력합니다. Thread.currentThread()의 출력 형태도 다릅니다. 플랫폼 스레드는 Thread[#21,Thread-0,5,main] 같은 형태이고, 가상 스레드는 VirtualThread[#23]/runnable@ForkJoinPool-1-worker-1 같은 형태입니다. 뒤에 ForkJoinPool이 보이는데, 이것이 가상 스레드의 핵심 구조와 직결됩니다. 잠시 후에 자세히 다루겠습니다.
결정적인 차이는 블로킹 시 동작입니다. 플랫폼 스레드가 Thread.sleep()이나 I/O 대기로 블로킹되면, 그 OS 스레드 전체가 놀고 있게 됩니다. 다른 작업을 실행할 수 없습니다. 하지만 가상 스레드가 블로킹되면, JVM이 해당 가상 스레드를 실행하던 OS 스레드(캐리어 스레드)에서 떼어냅니다. 그 OS 스레드는 즉시 다른 가상 스레드의 작업을 실행할 수 있습니다. 블로킹이 끝나면 JVM 스케줄러가 다시 (보통 다른) 캐리어 스레드에 올려서 이어서 실행합니다.
웹 애플리케이션을 생각해 보면 이 차이가 왜 중요한지 바로 이해됩니다. 전형적인 thread-per-request 모델에서 요청 하나가 들어오면 스레드 하나를 할당합니다. 그 스레드는 DB 쿼리를 날리고, 외부 API를 호출하고, 응답을 기다립니다. 이 대기 시간 동안 플랫폼 스레드는 아무 일도 못 하고 OS 자원만 차지하고 앉아 있습니다. 스레드 풀 200개를 만들어 놔도, 200개 요청이 전부 DB 응답을 기다리고 있으면 201번째 요청은 큐에서 대기해야 합니다. 가상 스레드라면 200개가 전부 DB 응답을 기다리는 동안 OS 스레드는 해제되어 있으므로, 201번째 요청을 처리할 가상 스레드가 바로 생성되어 실행될 수 있습니다.
가상 스레드 만들기
가상 스레드를 만드는 방법은 세 가지입니다.
첫 번째는 Thread.ofVirtual()을 사용하는 방법입니다.
// 가장 기본적인 형태 Thread vt = Thread.ofVirtual().start(() -> { System.out.println("가상 스레드에서 실행 중"); }); // 이름을 붙이고 싶으면 Thread vt2 = Thread.ofVirtual() .name("my-virtual-thread") .start(() -> { System.out.println(Thread.currentThread().getName()); }); // 자동 번호 붙이기 Thread.Builder builder = Thread.ofVirtual().name("worker-", 0); for (int i = 0; i < 5; i++) { builder.start(() -> { System.out.println(Thread.currentThread().getName()); // worker-0, worker-1, worker-2, ... }); }
두 번째는 Thread.startVirtualThread()입니다. 가장 간결합니다.
Thread.startVirtualThread(() -> { System.out.println("간단하게 가상 스레드 시작"); });
세 번째는 Executors.newVirtualThreadPerTaskExecutor()입니다. 기존 ExecutorService 코드와 호환되면서 가상 스레드의 장점을 가져갈 수 있어서 실무에서 가장 많이 쓰이는 방식입니다.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<Future<String>> futures = new ArrayList<>(); for (int i = 0; i < 10_000; i++) { int taskId = i; futures.add(executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return "Task " + taskId + " 완료"; })); } for (Future<String> future : futures) { System.out.println(future.get()); } }
이 코드는 1만 개의 작업을 만들어서 각각 1초씩 대기한 뒤 결과를 반환합니다. newVirtualThreadPerTaskExecutor()는 작업을 제출할 때마다 새로운 가상 스레드를 만듭니다. "작업 하나에 스레드 하나"라는 이름 그대로입니다. try-with-resources로 감싸면 모든 작업이 끝날 때까지 기다렸다가 자동으로 정리됩니다.
중요한 점은 이 이름에 "풀"이라는 단어가 없다는 겁니다. 풀이 아닙니다. 재사용 안 합니다. 매번 새로 만듭니다. 가상 스레드는 그래도 괜찮을 만큼 가볍기 때문이죠.
처리량과 확장성
직접 간단한 벤치마크를 돌려봤습니다. 외부 API 호출을 시뮬레이션하기 위해 각 작업에서 100밀리초의 블로킹 대기를 넣었습니다.
import java.time.Duration; import java.time.Instant; import java.util.concurrent.*; import java.util.stream.IntStream; public class VirtualThreadBenchmark { static final int TASK_COUNT = 100_000; public static void main(String[] args) throws Exception { // 플랫폼 스레드 (풀 사이즈 200) Instant start1 = Instant.now(); try (var executor = Executors.newFixedThreadPool(200)) { var futures = IntStream.range(0, TASK_COUNT) .mapToObj(i -> executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return i; })) .toList(); for (var f : futures) f.get(); } Duration platform = Duration.between(start1, Instant.now()); // 가상 스레드 Instant start2 = Instant.now(); try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { var futures = IntStream.range(0, TASK_COUNT) .mapToObj(i -> executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return i; })) .toList(); for (var f : futures) f.get(); } Duration virtual = Duration.between(start2, Instant.now()); System.out.println("플랫폼 스레드 (풀=200): " + platform.toMillis() + "ms"); System.out.println("가상 스레드: " + virtual.toMillis() + "ms"); } }
10만 개 작업, 100ms 블로킹 기준으로 플랫폼 스레드(풀 200개)는 약 50초가 걸립니다. 200개씩 동시에 실행하니까 100ms x (100,000 / 200) = 50초라는 단순 계산과 거의 일치합니다. 가상 스레드는 약 1~2초 만에 끝납니다. 10만 개가 거의 동시에 블로킹에 들어갔다가 100ms 후에 전부 깨어나기 때문입니다.
프로덕션 환경의 벤치마크는 더 의미 있습니다. Kloia의 2025년 12월 분석에 따르면, 10만 개 I/O 바운드 태스크 기준으로 플랫폼 스레드가 7,874ms, 가상 스레드가 913ms로 약 8.6배 차이를 보였습니다. HTTP 서버 벤치마크에서는 플랫폼 스레드(풀 200개)가 2,300 req/sec에 p99 지연 45ms였던 반면, 가상 스레드는 14,500 req/sec에 p99 지연 12ms를 기록했습니다.
확장성의 근본 원칙
이 성능 차이가 어디서 오는지 짚어보겠습니다. 가상 스레드가 마법처럼 CPU를 더 빠르게 돌리는 것이 아닙니다. 가상 스레드의 핵심은 "블로킹 시간에 OS 스레드를 낭비하지 않는 것"입니다.
플랫폼 스레드 200개짜리 풀에서 전부 DB 응답을 기다리고 있으면, 그 200개의 OS 스레드는 CPU 한 사이클도 쓰지 않으면서 자원을 차지하고 있습니다. 201번째 요청은 이 중 하나가 풀릴 때까지 대기합니다.
가상 스레드에서는 200개가 DB 응답을 기다리는 순간, 200개 전부 캐리어 스레드에서 언마운트됩니다. 캐리어 스레드(기본적으로 CPU 코어 수만큼)는 즉시 201번째, 202번째 가상 스레드의 코드를 실행할 수 있습니다.
그래서 가상 스레드의 강점은 I/O 바운드 워크로드에서 나옵니다. CPU를 꽉 채워서 계산하는 작업에서는 가상 스레드가 이점을 주지 않습니다. 오히려 InfoQ의 분석에 따르면 2-CPU 환경에서 CPU 바운드 작업을 가상 스레드로 돌리면 전통적 스레드 풀 대비 50~55% 수준의 처리량밖에 나오지 않았습니다. ForkJoinPool 스케줄러와 OS 스케줄러 사이의 상호작용 오버헤드 때문입니다.
정리하면, 가상 스레드는 "대기 시간이 많은 작업을 대량으로 처리"하는 시나리오에서 빛납니다. 웹 서버, API 게이트웨이, 마이크로서비스 호출처럼 네트워크 I/O가 지배적인 작업입니다. 행렬 계산이나 이미지 처리 같은 CPU 집약적 작업에는 기존 플랫폼 스레드 풀이 더 적합합니다.
내부를 들여다봅니다
가상 스레드가 어떻게 이런 일을 해내는지 내부 구조를 살펴보겠습니다. 이 부분을 이해하면 가상 스레드를 쓸 때 어떤 상황에서 문제가 생길 수 있는지 예측할 수 있습니다.
캐리어 스레드와 ForkJoinPool
가상 스레드의 실제 코드는 결국 OS 스레드 위에서 실행됩니다. 이 역할을 하는 OS 스레드를 "캐리어 스레드"라고 부릅니다. JVM은 가상 스레드 전용 ForkJoinPool을 유지하고, 이 풀의 워커 스레드들이 캐리어 역할을 합니다. FIFO 모드로 동작하며, 병렬 스트림이 사용하는 common pool과는 별도입니다.
기본적으로 캐리어 스레드 수는 사용 가능한 CPU 코어 수와 같습니다. 8코어 머신이면 캐리어 스레드 8개고요. 이 8개의 OS 스레드 위에서 수만, 수십만 개의 가상 스레드가 번갈아 실행됩니다. jdk.virtualThreadScheduler.parallelism 시스템 프로퍼티로 이 숫자를 조정할 수 있지만, 대부분의 경우 기본값이면 충분합니다.
마운팅과 언마운팅
가상 스레드가 실행될 차례가 오면, JVM 스케줄러가 해당 가상 스레드를 캐리어 스레드에 "마운팅"합니다. 이 순간 캐리어 스레드가 가상 스레드의 코드를 실행하기 시작합니다.
가상 스레드가 블로킹 연산을 만나면 "언마운팅"됩니다. Thread.sleep(), 소켓 I/O, BlockingQueue.take() 같은 JDK의 블로킹 API를 호출하면 자동으로 일어나죠. 언마운팅이 되면 캐리어 스레드는 해방되어 다른 가상 스레드를 실행할 수 있습니다.
블로킹이 끝나면 (소켓에 데이터가 도착하면, sleep 시간이 지나면) 해당 가상 스레드가 다시 스케줄러에 제출되고, 어떤 캐리어 스레드든 하나를 잡아서 마운팅 후 이어서 실행됩니다. 이 과정이 빈번하게, 투명하게, OS 스레드를 블로킹하지 않으면서 일어납니다.
다음 그림은 가상 스레드의 마운팅과 언마운팅 생명주기를 4단계로 보여줍니다.

1단계에서 가상 스레드가 캐리어 스레드에 올라타 CPU를 사용합니다. 2단계에서 DB 쿼리나 sleep 같은 블로킹 I/O를 만나면, 3단계에서 가상 스레드가 캐리어에서 떨어져 힙 메모리의 대기 영역으로 이동합니다. 이때 캐리어 스레드는 즉시 다른 가상 스레드(VT-B)를 실행할 수 있습니다. 4단계에서 블로킹이 끝나면 원래 가상 스레드는 다른 캐리어 스레드에 다시 마운팅되어 실행을 이어갑니다.
스택 프레임과 메모리 관리
플랫폼 스레드의 스택은 OS가 할당한 고정 크기 메모리 블록입니다. 기본 2MB이고, 스레드가 살아있는 동안 그대로 차지하죠. 스택을 거의 안 쓰는 스레드도 2MB를 먹고 있습니다.
가상 스레드는 다릅니다. 스택 프레임이 자바 힙에 저장되거든요. 내부적으로 각 가상 스레드는 Continuation 객체를 갖고 있습니다. 블로킹으로 언마운팅될 때, JVM은 캐리어 스레드의 스택 프레임을 이 Continuation 객체(힙 메모리)로 복사합니다. 다시 마운팅될 때는 Continuation에서 (아마도 다른) 캐리어 스레드의 스택으로 복사합니다.
이 구조의 장점은 두 가지입니다. 첫째, 스택 크기가 동적입니다. 깊은 재귀를 타면 커지고, 스택 프레임이 적으면 작아집니다. 둘째, 힙에 저장되므로 GC의 관리를 받습니다. 가상 스레드가 종료되면 해당 메모리가 자연스럽게 회수됩니다.
Kloia의 벤치마크에 따르면, 가상 스레드는 플랫폼 스레드 메모리의 약 1/120 수준만 사용합니다. 고부하 상황에서는 메모리 사용량이 거의 절반 수준까지 내려갔습니다.
블로킹 연산 처리
JDK의 블로킹 API들은 가상 스레드를 인식하도록 수정되었습니다. java.net.Socket, java.nio.channels, Thread.sleep(), BlockingQueue, Lock 등이 가상 스레드에서 호출되면 OS 스레드를 블로킹하지 않고 가상 스레드만 파킹합니다.
개발자가 특별히 신경 쓸 필요가 없습니다. 기존에 쓰던 블로킹 코드를 그대로 가져와서 가상 스레드에서 실행하면 JVM이 알아서 처리합니다. 이것이 가상 스레드의 가장 큰 매력입니다. 리액티브 프로그래밍처럼 코드를 전면적으로 바꿀 필요 없이, 기존 동기 코드를 유지하면서 비동기의 성능을 얻을 수 있습니다.
동기 코드로 비동기 성능을 얻습니다
Spring WebFlux나 Reactor를 써본 분이라면 공감할 텐데, 리액티브 코드는 강력하지만 읽기 어렵습니다. Mono.flatMap().zipWith().switchIfEmpty() 체인이 길어지면 디버깅도 고통스럽습니다. 스택 트레이스도 의미 없는 리액터 내부 프레임으로 가득 차 있습니다.
가상 스레드는 이 문제를 근본적으로 해결합니다. 동기 코드를 그대로 쓰면 됩니다.
// 리액티브 방식 Mono<User> user = webClient.get() .uri("/users/{id}", userId) .retrieve() .bodyToMono(User.class); Mono<List<Order>> orders = webClient.get() .uri("/orders?userId={id}", userId) .retrieve() .bodyToFlux(Order.class) .collectList(); return Mono.zip(user, orders) .map(tuple -> new UserProfile(tuple.getT1(), tuple.getT2()));
// 가상 스레드 방식 - 동기 코드와 똑같습니다 User user = restClient.get() .uri("/users/{id}", userId) .retrieve() .body(User.class); List<Order> orders = restClient.get() .uri("/orders?userId={id}", userId) .retrieve() .body(new ParameterizedTypeReference<List<Order>>() {}); return new UserProfile(user, orders);
두 코드의 처리량 차이는 거의 없습니다. 하지만 아래 코드가 읽기 쉽고, 디버깅하기 쉽고, 스택 트레이스가 깨끗합니다.
그렇다면 리액티브는 이제 필요 없을까요? 그렇지 않습니다. 리액티브가 여전히 유리한 영역이 있습니다. WebSocket이나 Server-Sent Events처럼 지속적으로 데이터를 스트리밍하는 경우, Reactor의 Flux가 제공하는 배압(backpressure) 제어는 가상 스레드로 대체하기 어렵습니다. 소비자가 느리면 생산자를 자동으로 늦추는 이 메커니즘은 리액티브 스트림의 핵심 가치입니다. 가상 스레드는 요청-응답 패턴에 적합하지, 연속적인 데이터 흐름을 제어하는 도구가 아닙니다. 또한 이미 리액티브로 잘 동작하는 시스템을 가상 스레드로 전환할 이유도 없습니다. 마이그레이션 비용 대비 얻는 것이 코드 가독성 정도라면 현실적으로 ROI가 맞지 않습니다.
구조적 동시성으로 작업을 묶습니다
여기서 한 단계 더 나아간 것이 구조적 동시성입니다. StructuredTaskScope는 동시 작업을 하나의 논리적 단위로 묶어줍니다. Java 21에서 프리뷰로 처음 등장했고(당시 StructuredTaskScope.ShutdownOnFailure 같은 서브클래스 방식), Java 25에서 다섯 번째 프리뷰까지 왔습니다. 아래 예제는 Java 25 프리뷰 API 기준입니다. Java 21에서 시도하려면 --enable-preview 플래그가 필요하고 API 형태가 다르므로 해당 버전의 Javadoc을 확인하시기 바랍니다.
위의 "사용자 정보 + 주문 목록 동시 조회" 예제를 구조적 동시성으로 작성하면 이렇습니다.
// Java 25 프리뷰 - StructuredTaskScope 사용 try (var scope = StructuredTaskScope.open()) { Subtask<User> userTask = scope.fork(() -> restClient.get() .uri("/users/{id}", userId) .retrieve() .body(User.class) ); Subtask<List<Order>> ordersTask = scope.fork(() -> restClient.get() .uri("/orders?userId={id}", userId) .retrieve() .body(new ParameterizedTypeReference<List<Order>>() {}) ); scope.join(); // 두 작업 모두 완료될 때까지 대기 return new UserProfile(userTask.get(), ordersTask.get()); }
scope.fork()가 호출될 때마다 새 가상 스레드가 만들어져서 작업을 실행합니다. scope.join()은 fork된 모든 작업이 끝날 때까지 기다립니다. try-with-resources로 감싸져 있으므로 스코프를 벗어나면 자동 정리됩니다.
구조적 동시성의 장점은 예외 처리와 취소에서 드러납니다. 두 작업 중 하나가 예외를 던지면 나머지 작업도 자동으로 취소됩니다. "사용자 조회는 성공했는데 주문 조회가 실패한" 상태에서 어떻게 할지 고민할 필요가 없습니다. 스레드 누수도 원천 차단됩니다. 스코프가 닫히면 그 안에서 만든 모든 가상 스레드가 정리됩니다.
세마포어로 자원을 보호합니다
가상 스레드는 저렴하므로 수십만 개를 만들 수 있습니다. 그런데 문제가 있습니다. DB 커넥션 풀은 보통 10~50개입니다. 외부 API는 초당 요청 제한이 있습니다. 가상 스레드 10만 개가 동시에 DB에 쿼리를 날리면 커넥션 풀이 폭발합니다.
기존에는 스레드 풀 크기로 이걸 간접적으로 제어했습니다. 풀 200개면 동시 DB 접근도 최대 200개입니다. 그런데 가상 스레드에서는 스레드 수 자체로는 제어가 안 됩니다. 여기서 Semaphore가 등장합니다.
세마포어란
java.util.concurrent.Semaphore는 동시에 접근할 수 있는 스레드 수를 제한하는 동시성 도구입니다. 허가증(permit) 개념으로 동작합니다. 세마포어를 10개 허가증으로 만들면, 동시에 10개 스레드만 보호 구간에 진입할 수 있습니다. 11번째 스레드는 누군가 허가증을 반납할 때까지 대기합니다.
public class ExternalApiClient { // 외부 API가 동시 10개 요청까지만 허용한다고 가정 private static final Semaphore RATE_LIMITER = new Semaphore(10); private final HttpClient httpClient = HttpClient.newHttpClient(); public String callExternalApi(String url) throws Exception { RATE_LIMITER.acquire(); // 허가증 획득 (없으면 대기) try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .build(); HttpResponse<String> response = httpClient.send( request, HttpResponse.BodyHandlers.ofString() ); return response.body(); } finally { RATE_LIMITER.release(); // 반드시 반납 } } }
가상 스레드 1만 개가 callExternalApi()를 동시에 호출해도, 실제로 외부 API에 도달하는 요청은 동시에 10개뿐입니다. 나머지 9,990개는 RATE_LIMITER.acquire()에서 가상 스레드 상태로 대기합니다. OS 스레드를 블로킹하지 않으므로 대기 비용이 매우 낮습니다.
중요한 점이 하나 있습니다. DB 커넥션 풀(HikariCP 같은)은 자체적으로 세마포어 역할을 합니다. 커넥션 풀을 10개로 설정하면 11번째 스레드는 커넥션이 반환될 때까지 알아서 대기합니다. 이 경우에 추가로 세마포어를 감쌀 필요가 없습니다. Oracle의 공식 문서에서도 이 점을 명확히 하고 있습니다. 커넥션 풀이 서비스 과부하를 유발한다면, 세마포어를 추가하는 대신 커넥션 풀 크기를 줄이는 것이 맞습니다.
공정성 옵션
세마포어를 만들 때 new Semaphore(10, true)처럼 두 번째 인자로 true를 주면 FIFO 순서를 보장합니다. 먼저 acquire()를 호출한 스레드가 먼저 허가증을 받습니다. 자원 접근 제어 용도로 쓴다면 공정성을 켜는 것이 좋습니다. 특정 스레드가 계속 기아 상태에 빠지는 것을 방지해 줍니다.
가상 스레드의 한계
가상 스레드에 장점만 있는 것은 아닙니다. 명확한 한계가 있고, 이를 모르고 쓰면 오히려 성능이 나빠질 수 있습니다.
가상 스레드 고정 문제
가상 스레드의 가장 유명한 한계가 "고정(pinning)"입니다. 가상 스레드가 캐리어 스레드에서 언마운트되지 못하고 붙어 있는 상태를 말합니다. 고정된 가상 스레드는 플랫폼 스레드와 다를 바 없이 OS 스레드를 점유합니다.
Java 21~23에서 고정이 발생하는 대표적인 경우는 synchronized 블록 안에서 블로킹하는 것이었습니다.
// Java 21-23에서 고정을 유발하는 코드 private final Object lock = new Object(); public void problematicMethod() { synchronized (lock) { // 이 안에서 블로킹하면 가상 스레드가 고정됩니다 Thread.sleep(Duration.ofSeconds(1)); // 또는 DB 호출, 소켓 I/O 등 } }
이유는 이렇습니다. JVM은 synchronized의 모니터 잠금을 캐리어 스레드의 ID로 기록합니다. 가상 스레드를 언마운트하면 다른 가상 스레드가 같은 캐리어에 올라타서 같은 synchronized 블록에 들어갈 수 있게 됩니다. 이를 방지하기 위해 JVM이 가상 스레드를 캐리어에 고정시킨 겁니다.
고정 자체가 프로그램을 틀리게 만들지는 않습니다. 다만 확장성을 떨어뜨립니다. 캐리어 스레드가 8개인데 8개 가상 스레드가 전부 고정되어 있으면, 나머지 가상 스레드는 실행될 수 없습니다.
ReentrantLock으로 고정 문제 해결하기
Java 21~23에서의 해결책은 synchronized를 ReentrantLock으로 교체하는 것이었습니다.
// synchronized 대신 ReentrantLock 사용 private final ReentrantLock lock = new ReentrantLock(); public void betterMethod() { lock.lock(); try { Thread.sleep(Duration.ofSeconds(1)); // ReentrantLock은 JVM이 관리하므로 고정이 발생하지 않습니다 } finally { lock.unlock(); } }
ReentrantLock은 JVM 레벨에서 관리되므로, 가상 스레드가 락을 잡은 상태에서 블로킹되어도 캐리어에서 정상적으로 언마운트됩니다. 다만 try-finally 패턴을 반드시 써야 하므로 synchronized보다 코드가 장황해지는 단점이 있었습니다.
Java 21이 나오자 커뮤니티에서 "synchronized 박멸 운동"이 벌어졌습니다. 수많은 라이브러리와 프레임워크가 내부의 synchronized를 ReentrantLock으로 교체하기 시작했습니다.
Java 24에서 근본적으로 해결되었습니다
JEP 491이 Java 24에 포함되면서, synchronized 블록에서도 가상 스레드가 캐리어 독립적으로 모니터를 획득하고 해제할 수 있게 되었습니다. 코드 변경 없이 JVM 레벨에서 해결된 것입니다.
JEP 491의 저자들은 더 이상 synchronized를 ReentrantLock으로 교체할 필요가 없다고 명시했습니다. 이미 교체한 코드를 다시 되돌릴 필요도 없습니다. 새 코드를 작성할 때는 Java Concurrency in Practice의 기존 조언을 따르면 됩니다. 간단한 경우에는 synchronized를, 공정성이나 시간 제한 락 같은 고급 기능이 필요하면 ReentrantLock을 쓰면 됩니다.
그래도 남아있는 고정 케이스
Java 24 이후에도 고정이 발생하는 경우가 있습니다.
JNI/네이티브 메서드 호출이 대표적입니다. JNI를 통해 네이티브 코드를 호출하고 그 안에서 블로킹이 발생하면, JVM이 안전하게 가상 스레드를 중단시킬 수 없으므로 캐리어가 고정됩니다. 클래스 로딩 과정에서 심볼릭 참조를 해석할 때 블로킹되는 경우, 클래스 초기화자 안에서 블로킹되는 경우도 네이티브 프레임이 스택에 있기 때문에 고정됩니다.
실무에서 이런 경우를 만날 확률은 낮지만, JNI를 사용하는 라이브러리(일부 암호화 라이브러리, 네이티브 DB 드라이버 등)를 쓸 때는 인지하고 있어야 합니다.
ThreadLocal, 가상 스레드에서는 독이 됩니다
ThreadLocal은 스레드별로 독립적인 변수를 유지할 때 사용하는, 자바 동시성의 기본 도구입니다. 요청 컨텍스트, 트랜잭션 정보, 사용자 인증 정보 등을 전달할 때 널리 쓰입니다. 그런데 가상 스레드 환경에서 ThreadLocal은 심각한 문제를 일으킵니다.
문제 1: 메모리 폭발
ThreadLocal에 저장된 값은 스레드가 살아있는 동안 유지됩니다. 플랫폼 스레드가 200개인 스레드 풀에서는 ThreadLocal 인스턴스 200개만 존재합니다. 그런데 가상 스레드는 요청마다 새로 만들어지므로, 10만 개 요청이 들어오면 ThreadLocal 인스턴스도 10만 개 생깁니다.
특히 ThreadLocal에 비싼 객체를 캐싱하는 패턴이 문제가 됩니다. SimpleDateFormat이나 DB 커넥션 같은 것을 ThreadLocal에 넣어두면, 가상 스레드마다 하나씩 생기면서 메모리를 급격히 잡아먹습니다.
문제 2: 상속 비용
자식 스레드가 부모의 ThreadLocal 값을 상속받을 때, 부모가 한 번이라도 set()한 모든 ThreadLocal 변수의 저장소를 복사합니다. 가상 스레드 수십만 개가 자식 스레드를 만들면 이 복사 비용이 치명적입니다.
문제 3: 메모리 누수
ThreadLocal.remove()를 호출하지 않으면 값이 스레드 수명 동안 유지됩니다. 플랫폼 스레드 풀에서는 스레드가 재사용되므로 다음 작업에서 이전 값이 남아있을 수 있고, 가상 스레드에서는 스레드 자체가 GC 대상이 될 때까지 값이 남아있어 메모리를 잡아먹습니다.
대안: ScopedValue
여러 차례 프리뷰를 거치고 있는 ScopedValue(JEP 506, Java 25에서 다섯 번째 프리뷰)가 ThreadLocal의 대안입니다.
// ThreadLocal 방식 private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>(); void handleRequest(String userId) { CURRENT_USER.set(userId); try { processRequest(); } finally { CURRENT_USER.remove(); // 까먹으면 메모리 누수 } } void processRequest() { String user = CURRENT_USER.get(); // 어디서든 접근 가능 }
// ScopedValue 방식 private static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance(); void handleRequest(String userId) { ScopedValue.where(CURRENT_USER, userId).run(() -> { processRequest(); }); // 스코프 벗어나면 자동 정리. remove() 불필요 } void processRequest() { String user = CURRENT_USER.get(); // 스코프 안에서만 접근 가능 }
ScopedValue는 불변입니다. set()으로 값을 바꿀 수 없습니다. 스코프가 끝나면 자동으로 정리됩니다. 자식 스레드에 추가 비용 없이 공유됩니다. ThreadLocal의 모든 문제를 구조적으로 해결합니다.
가상 스레드로 마이그레이션할 때 ThreadLocal 사용 현황을 확인하고 싶다면, -Djdk.traceVirtualThreadLocals JVM 옵션을 켜면 가상 스레드가 ThreadLocal을 변경할 때마다 스택 트레이스가 출력됩니다.
모니터링
가상 스레드가 수만 개 돌아가는 환경에서 모니터링은 어떻게 해야 할까요? 기존 도구들이 그대로 통하는 것과 아닌 것이 있습니다.
jcmd 스레드 덤프
기존 jstack은 플랫폼 스레드만 단순 나열해서 보여줍니다. 수십 개에서 수백 개의 스레드에는 괜찮지만, 수만 개의 가상 스레드에는 적합하지 않습니다.
새로운 스타일의 스레드 덤프는 jcmd로 얻습니다.
# JSON 형식 스레드 덤프 (가상 스레드 포함) jcmd <PID> Thread.dump_to_file -format=json /tmp/thread_dump.json # 텍스트 형식 jcmd <PID> Thread.dump_to_file -format=text /tmp/thread_dump.txt
이 새로운 형식의 스레드 덤프는 가상 스레드를 플랫폼 스레드와 함께, 의미 있는 그룹으로 묶어서 보여줍니다. 애플리케이션을 일시 중지시키지 않고 생성됩니다. 다만 기존 스레드 덤프에서 볼 수 있던 락 정보, 스레드 상태, 힙 통계 같은 정보는 포함되지 않습니다.
JDK Flight Recorder
JFR은 가상 스레드를 위한 4개의 새 이벤트를 제공합니다.
jdk.VirtualThreadStart와 jdk.VirtualThreadEnd는 가상 스레드의 시작과 종료를 기록합니다. 기본적으로 비활성이며, 가상 스레드가 수만 개 생성되는 환경에서 켜면 오버헤드가 상당하므로 필요할 때만 활성화합니다.
jdk.VirtualThreadPinned는 가상 스레드가 고정된 채로 파킹되었을 때 기록됩니다. 기본 활성이며 임계값이 20ms입니다. 20ms 이상 고정된 경우에만 기록된다는 뜻입니다. Java 21~23에서 synchronized 고정 문제를 추적할 때 핵심적인 이벤트였고, Java 24 이후에도 JNI 관련 고정을 감지하는 데 유용합니다.
jdk.VirtualThreadSubmitFailed는 가상 스레드의 시작이나 재개가 실패했을 때 기록됩니다. 주로 자원 부족 상황에서 발생합니다. 기본 활성입니다.
Java 21~23에서 고정을 추적하는 또 다른 방법은 -Djdk.tracePinnedThreads=full JVM 옵션이었습니다. 고정이 발생하면 스택 트레이스를 출력해 줬습니다. Java 24부터는 synchronized 고정이 해결되어 해당 옵션은 제거되었고, 대신 JFR의 jdk.VirtualThreadPinned 이벤트를 활용합니다.
HotSpotDiagnosticMXBean
프로그래밍 방식으로 스레드 덤프를 생성하려면 com.sun.management.HotSpotDiagnosticMXBean을 사용합니다. 새로운 스타일의 스레드 덤프(가상 스레드 포함)를 코드에서 직접 트리거할 수 있습니다.
import com.sun.management.HotSpotDiagnosticMXBean; import java.lang.management.ManagementFactory; HotSpotDiagnosticMXBean bean = ManagementFactory.getPlatformMXBean( HotSpotDiagnosticMXBean.class ); bean.dumpThreads("/tmp/thread_dump.json", HotSpotDiagnosticMXBean.ThreadDumpFormat.JSON);
참고로 java.lang.management.ThreadMXBean은 플랫폼 스레드만 지원합니다. findDeadlockedThreads() 같은 메서드도 플랫폼 스레드 간의 데드락만 찾습니다. 가상 스레드의 데드락은 감지하지 못합니다.
실전: JFR로 고정 이벤트 추적하기
실제로 JFR을 사용해 가상 스레드 고정을 추적하는 흐름을 보겠습니다.
# 1. JFR 녹화 시작 (60초 동안) jcmd <PID> JFR.start name=vthread-monitor duration=60s filename=/tmp/vthread.jfr # 2. 녹화 완료 후 VirtualThreadPinned 이벤트 확인 jfr print --events jdk.VirtualThreadPinned /tmp/vthread.jfr # 3. 특정 임계값 이상의 고정만 필터링 (예: 100ms 이상) jfr print --events jdk.VirtualThreadPinned --stack-depth 10 /tmp/vthread.jfr
고정 이벤트가 출력되면 스택 트레이스에서 어떤 synchronized 블록(Java 24 미만) 또는 JNI 호출이 원인인지 바로 확인할 수 있습니다. 애플리케이션 시작 시 JFR을 상시 녹화로 걸어두는 것도 좋은 방법입니다. -XX:StartFlightRecording=filename=/tmp/app.jfr,settings=default JVM 옵션을 추가하면 됩니다.
한 가지 유의할 점
현재의 모니터링 도구들(JFR, jstack 등)은 주로 네이티브 스레드를 대상으로 설계되었습니다. 가상 스레드에 대한 지원은 점진적으로 확대되고 있지만, 아직 플랫폼 스레드만큼 세밀한 모니터링이 가능하지는 않습니다. JFR의 가상 스레드 이벤트로 생성/종료/고정/실패 통계는 볼 수 있지만, 개별 가상 스레드의 상세한 상태를 실시간으로 추적하는 것은 아직 제한적입니다.
마이그레이션, 어떻게 시작할까
여기까지 살펴본 가상 스레드의 장점은 결국 하나로 모입니다. 적은 OS 자원으로 더 많은 동시 요청을 처리하면서도 코드는 읽기 쉬운 동기 스타일 그대로 유지할 수 있다는 것이죠.
핵심은 확장성입니다. 가상 스레드는 "같은 코드를 더 빠르게 만들어 주는 것"이 아니라 "같은 하드웨어로 더 많은 동시 요청을 처리할 수 있게 해주는 것"입니다. 요청 하나의 처리 시간은 동일하지만, 동시에 처리할 수 있는 요청 수가 비약적으로 늘어납니다.
InfoQ의 가상 스레드 분석 기사에서 소개된 마이그레이션 사례를 보면, API 게이트웨이에서 FixedThreadPool(300)을 쓰다가 가상 스레드로 전환한 팀이 있었습니다. JDBC와 여러 업스트림 서비스로의 HTTP 호출이 주 작업이었는데, 스레드 풀 크기라는 병목이 사라지면서 동시 처리 가능한 태스크 수가 크게 늘어났습니다. 앞서 본 벤치마크 결과를 생각하면 이해가 됩니다. 200개 풀에서 10만 개 블로킹 작업을 50초 걸려 처리하던 것이, 가상 스레드로는 1~2초 만에 끝났으니까요.
마이그레이션은 한 번에 하지 않는 것이 좋습니다. 비핵심 경로(백그라운드 작업, 내부 API)부터 시작하고, 스트리밍 처리(WebSocket 등)는 리액티브를 유지하고, JDBC 드라이버와 HTTP 클라이언트가 가상 스레드를 지원하는지 확인하고, 고정 이벤트를 모니터링하면서 점진적으로 확대하는 것이 안전합니다.
Spring Boot를 사용한다면 전환이 매우 간단합니다.
# application.yml (Spring Boot 3.2+) spring: threads: virtual: enabled: true
이 한 줄이면 Tomcat이 요청마다 가상 스레드를 생성합니다. 기존 코드를 한 줄도 안 바꿔도 되고요.
다만 이 설정을 켜기 전에 확인해야 할 것들이 있습니다. ThreadLocal을 사용하는 곳이 있는지, synchronized 블록 안에서 I/O를 하는 코드가 있는지(Java 24 미만이라면), DB 커넥션 풀 크기가 적절한지(가상 스레드가 많아지면 커넥션 풀에 부하가 갈 수 있으므로)를 점검해야 합니다.
정리하며
가상 스레드를 조사하면서 가장 인상 깊었던 것은, 이것이 "새로운 프로그래밍 모델"이 아니라 "기존 프로그래밍 모델의 성능 제약을 제거한 것"이라는 점입니다. 리액티브 프로그래밍은 비동기 처리를 위해 코드 구조를 근본적으로 바꿔야 했습니다. 가상 스레드는 익숙한 동기 코드를 그대로 두고, JVM이 내부적으로 최적화합니다.
물론 만능은 아닙니다. CPU 바운드 작업에는 이점이 없고, ThreadLocal 사용에 주의해야 하고, Java 24 미만에서는 synchronized 고정 문제가 있습니다. 모니터링 도구도 아직 완전하지 않습니다. 하지만 이런 한계들은 대부분 인식하고 있으면 회피할 수 있는 수준이고, Java 24~25로 올라가면 상당 부분 해결됩니다.
자바 스레드만 알던 개발자로서 가상 스레드를 파헤쳐 본 소감은 이겁니다. 웹 서버나 API 서비스를 운영하고 있다면, 비핵심 경로 하나에 먼저 적용해 보시기 바랍니다. Spring Boot 기준으로 설정 한 줄이고, 마음에 안 들면 끄면 됩니다.
참고 자료
- JEP 444: Virtual Threads - 가상 스레드 공식 스펙
- JEP 491: Synchronize Virtual Threads without Pinning - synchronized 고정 해결
- Oracle Java 21 Virtual Threads 가이드 - 공식 사용 가이드
- Inside.java - Managing Throughput with Virtual Threads - 세마포어를 이용한 처리량 관리
- Kloia - Benchmarking Java Virtual Threads - 종합 벤치마크 분석
- Mike My Bytes - Java 24 Thread Pinning Revisited - 고정 문제의 진화
- JEP 506: Scoped Values - ThreadLocal 대안
- HappyCoders.eu - Virtual Threads Deep Dive - 가상 스레드 심층 분석






댓글
댓글을 작성하려면 이 필요합니다.