본문 중간의 쿠팡 추천 상품 구매시 쿠팡 파트너스에서 일정액의 수수료를 제공받습니다.

Java virtual thread는 많은 수의 동시 작업을 기존 thread-per-request 스타일로 표현하기 위한 가벼운 java.lang.Thread 구현입니다. Java 21에서 정식 기능이 되었고, Java SE 25 문서 기준으로도 Thread.ofVirtual()와 Executors.newVirtualThreadPerTaskExecutor()가 표준 API로 제공됩니다.
선택 기준은 분명합니다. 네트워크 호출, 파일 읽기, 데이터베이스 응답 대기처럼 대부분의 시간이 I/O 대기에 쓰이는 작업에는 잘 맞습니다. 반대로 CPU를 오래 점유하는 계산 작업을 더 빠르게 만들기 위한 병렬 처리 도구로 보면 안 됩니다. virtual thread는 blocking 코드를 더 많이 동시에 다루기 쉽게 만드는 기능이지, CPU 코어 수를 늘리는 기능이 아닙니다.
virtual thread는 무엇이 다른가
기존 platform thread는 보통 운영체제 스레드에 1:1로 대응됩니다. 그래서 스레드를 많이 만들면 OS 스레드, 스택, 스케줄링 비용이 함께 커집니다. Java SE 25 Thread 문서는 platform thread가 모든 종류의 작업에 적합하지만 제한된 자원일 수 있다고 설명합니다.
virtual thread도 Thread 객체입니다. 다만 OS가 직접 관리하는 스레드가 아니라 Java runtime이 스케줄링하는 사용자 모드 스레드에 가깝습니다. runtime은 virtual thread를 carrier라고 부르는 platform thread 위에 올려 실행하고, blocking I/O 같은 지점에서 carrier를 다른 virtual thread가 쓰도록 비울 수 있습니다.

이 차이 때문에 virtual thread의 장점은 "스레드를 아껴 쓰는 코드"보다 "동시 작업을 작업 수만큼 자연스럽게 표현하는 코드"에서 잘 드러납니다. 작업 하나를 virtual thread 하나로 두면 호출 흐름은 동기식으로 읽히면서도, runtime은 대기 시간을 더 효율적으로 다룰 수 있습니다.
어떻게 만들까
가장 직접적인 API는 Thread.ofVirtual()입니다. Thread.Builder.OfVirtual은 Java 21부터 제공되며, 이름 지정이나 ThreadFactory 생성처럼 thread builder 패턴을 사용할 수 있습니다.
Thread thread = Thread.ofVirtual()
.name("worker")
.start(() -> handleRequest());
thread.join();
서버나 서비스 코드에서는 보통 executor 형태가 더 다루기 쉽습니다. Executors.newVirtualThreadPerTaskExecutor()는 작업을 제출할 때마다 새 virtual thread를 시작하는 ExecutorService를 반환합니다. 공식 API 문서도 이 executor가 task마다 새 virtual thread를 만든다고 설명합니다.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> user = executor.submit(() -> fetchUser());
Future<String> order = executor.submit(() -> fetchOrder());
return user.get() + order.get();
}
여기서 중요한 점은 "작은 virtual thread pool을 만든다"가 아니라는 점입니다. Java SE 25 virtual thread 가이드는 virtual thread를 pool로 관리하지 말고, 동시 작업 하나를 virtual thread 하나로 표현하라고 안내합니다. 외부 API를 10개까지만 동시에 호출해야 한다면 thread pool 크기로 제한하기보다 Semaphore 같은 동시성 제한 도구를 쓰는 편이 개념에 맞습니다.
I/O 대기에서는 왜 효과가 큰가
virtual thread가 실행되려면 어느 순간에는 carrier platform thread 위에 올라가야 합니다. 하지만 blocking I/O처럼 실제로 CPU를 쓰지 않고 기다리는 구간에서는 virtual thread가 carrier에서 내려올 수 있습니다. 그러면 같은 carrier가 다른 virtual thread를 실행할 수 있습니다.

그래서 HTTP 요청을 받고 데이터베이스와 외부 API를 기다리는 일반적인 서버 작업에서는 thread-per-request 모델을 다시 검토할 수 있습니다. 콜백이나 reactive 체인으로 모든 흐름을 바꾸지 않아도, 동기식 코드 형태를 유지하면서 더 많은 대기 작업을 표현할 수 있기 때문입니다.
반면 CPU 연산이 길게 이어지는 작업은 다릅니다. virtual thread가 CPU를 계속 쓰는 동안에는 carrier도 계속 점유됩니다. 이런 작업은 ForkJoinPool, 병렬 스트림, 명시적인 bounded executor, 작업 큐처럼 CPU 병렬 처리와 back pressure를 설계하는 도구가 여전히 필요합니다.
pinning은 어떻게 봐야 할까
pinning은 virtual thread가 carrier에서 내려오지 못하는 상태를 말합니다. Java SE 25 virtual thread 문서는 native method나 foreign function을 실행할 때 virtual thread가 pinned 상태가 될 수 있고, pinning이 프로그램을 틀리게 만들지는 않지만 확장성을 방해할 수 있다고 설명합니다.
여기서 버전 구분이 중요합니다. Java 21 시점의 많은 설명은 synchronized 블록 안에서 blocking하면 carrier가 묶일 수 있다는 점을 크게 다뤘습니다. 하지만 JDK 24의 "Synchronize Virtual Threads without Pinning" 변경으로, synchronized 메서드와 문장에서 block하는 virtual thread가 underlying platform thread를 다른 virtual thread가 쓸 수 있게 release하도록 개선되었습니다.
따라서 2026년 기준으로는 "virtual thread에서는 synchronized를 무조건 피한다"보다 정확한 기준이 필요합니다. JDK 24 이상을 전제로 한다면 synchronized 관련 pinning 위험은 크게 줄었고, native method, foreign function, 관찰 가능한 pinned event를 중심으로 점검하는 편이 맞습니다. Java 21이나 22처럼 오래된 런타임을 운영 중이라면 synchronized block 안의 긴 blocking I/O도 별도로 확인해야 합니다.
운영에서 무엇을 확인할까
첫째, virtual thread를 쓰는 목적을 "스레드 수 절약"이 아니라 "동시 작업 표현"으로 잡습니다. 기존 fixed thread pool의 크기만 virtual thread로 바꾸는 식이면 효과가 작을 수 있습니다.
둘째, 제한해야 하는 것은 thread 개수가 아니라 외부 자원 접근량입니다. 데이터베이스 커넥션, 외부 API rate limit, 파일 핸들 같은 자원은 virtual thread가 많아져도 무한히 늘어나지 않습니다. 이런 곳에는 connection pool, semaphore, timeout, circuit breaker 같은 제한 장치가 필요합니다.
셋째, ThreadLocal 사용량을 확인합니다. virtual thread도 thread local을 지원하지만, 작업 수가 매우 많아지면 thread local 값의 수명과 메모리 사용량이 운영 문제가 될 수 있습니다. 요청 컨텍스트 전달이 목적이라면 Java 25의 ScopedValue 같은 대안도 함께 검토할 수 있습니다.
넷째, 관찰 도구를 준비합니다. Java SE 25 문서는 JFR의 jdk.VirtualThreadPinned 이벤트가 기본 활성화되어 있고, threshold는 20ms라고 설명합니다. jcmd Thread.dump_to_file로 platform thread와 virtual thread를 포함한 thread dump를 볼 수도 있습니다.

| 상황 | virtual thread 판단 | 확인할 점 |
|---|---|---|
| 요청마다 외부 I/O 대기 | 좋은 후보 | timeout, 커넥션 풀, 동시 호출 제한 |
| CPU 계산이 오래 지속 | 주 목적과 다름 | CPU 코어 수 기준 병렬 처리 설계 |
| 작업 수를 제한해야 함 | thread pool보다 별도 제한 | Semaphore, 큐, rate limit |
| native 또는 foreign function 호출 | pinning 점검 필요 | JFR pinned event, thread dump |
| Java 21-23에서 synchronized blocking | 주의 필요 | JDK 24 이상 개선 여부 확인 |
FAQ
virtual thread를 pool로 묶어야 하나?
대부분은 아닙니다. 공식 가이드는 virtual thread가 scarce resource가 아니므로 pool로 관리하지 말라고 설명합니다. 동시 실행량 제한이 필요하면 thread pool 크기가 아니라 semaphore나 외부 자원 풀을 기준으로 제한합니다.
virtual thread를 쓰면 모든 blocking 코드가 빨라지나?
그렇지 않습니다. I/O 대기처럼 carrier를 비울 수 있는 blocking에서는 효과가 크지만, CPU를 계속 쓰는 blocking 또는 native/foreign 호출에 묶인 구간은 다르게 봐야 합니다.
Java 21에서도 바로 써도 되나?
Java 21에서 virtual thread는 정식 기능입니다. 다만 JDK 24에서 synchronized 관련 pinning 개선이 들어갔으므로, 운영 기준은 실행 중인 JDK 버전에 맞춰 잡아야 합니다.
정리
Java virtual thread의 핵심은 동시 작업을 가볍게 표현하는 것입니다. 플랫폼 스레드를 아껴 쓰려고 작은 pool을 튜닝하던 방식에서 벗어나, 요청이나 작업 하나를 thread 하나로 두되 runtime이 대기 구간의 carrier 사용을 더 효율적으로 처리하게 맡기는 접근입니다.
선택 기준은 세 가지입니다. I/O 대기 중심 서버 작업이면 좋은 후보입니다. CPU 장시간 작업이면 별도 병렬 처리 전략을 봅니다. 운영에서는 외부 자원 제한, ThreadLocal 사용량, JFR의 pinned event, JDK 버전별 pinning 차이를 함께 확인합니다.
참고 자료
'프로그래밍 > C, C++, Java, Python' 카테고리의 다른 글
| Java ScopedValue 기준 (0) | 2026.09.22 |
|---|---|
| Python TypeIs 기준 (1) | 2026.09.19 |
| Python TaskGroup 기준 (0) | 2026.09.18 |
| Python eager task factory 기준 (0) | 2026.09.18 |
| Python asyncio.Runner 기준 (0) | 2026.09.16 |





