가상 스레드를 켜기만 하면 서버가 무조건 빨라진다는 이야기가 돌아다닙니다. 절반만 맞는 말입니다. 가상 스레드(Virtual Threads)는 처리량(throughput)을 끌어올리는 기술이지, 단일 요청을 빠르게 만드는 기술이 아닙니다. 동작 원리를 모른 채 도입하면 오히려 pinning에 발목이 잡혀 기대했던 확장성이 나오지 않습니다.
이 글은 Java 21에서 정식 도입된 가상 스레드를 실무에 적용하는 방법과, 도입 전에 반드시 알아야 할 한계를 함께 다룹니다. Java 24에서 바뀐 pinning 동작과 Java 25 LTS까지의 흐름도 정리했으니, 도입 여부를 판단하는 기준으로 활용하시면 됩니다.
가상 스레드란 무엇인가
가상 스레드는 JVM이 직접 관리하는 경량 스레드입니다. 버추얼 스레드(Virtual Threads)라고도 부릅니다. 기존 자바 스레드(플랫폼 스레드)가 운영체제 스레드 1개와 1:1로 묶여 있던 것과 달리, 이 경량 스레드는 OS 스레드와 분리되어 있습니다. 수십만 개를 만들어도 메모리가 감당하는 구조라는 점이 핵심입니다.
동작 방식을 한 문장으로 요약하면 이렇습니다. 가상 스레드가 I/O 같은 블로킹 작업을 만나면, JVM이 그 스레드를 캐리어 스레드(carrier thread) 에서 잠시 떼어내(unmount) 대기시키고, 그 사이 캐리어 스레드는 대기 중인 다른 작업을 실행합니다. 블로킹이 끝나면 다시 캐리어 스레드에 붙어(mount) 작업을 이어갑니다.
여기서 캐리어 스레드는 실제 OS 스레드이며, 기본적으로 CPU 코어 수만큼의 ForkJoinPool로 운영됩니다. 즉 소수의 OS 스레드가 수많은 가상 스레드를 번갈아 태워 나르는 구조입니다. 블로킹 I/O가 많은 웹 서버일수록 이 방식의 효과가 큽니다.
플랫폼 스레드와 무엇이 다른가
버추얼 스레드와 플랫폼 스레드는 “스레드”라는 이름만 같을 뿐, 비용 구조와 용도가 다릅니다. 도입을 고민한다면 이 차이부터 명확히 잡아야 합니다.
| 구분 | 플랫폼 스레드 | 가상 스레드 |
|---|---|---|
| OS 스레드 매핑 | 1:1 | N:1 (캐리어가 번갈아 실행) |
| 생성 비용 | 높음 (MB 단위 스택) | 낮음 (필요 시 힙에 저장) |
| 동시 생성 가능 수 | 수천 개 수준 | 수십만~수백만 개 |
| 적합한 작업 | CPU 바운드 | I/O 바운드·블로킹 |
| 풀링(pooling) | 필요 | 불필요 (오히려 안티패턴) |
표에서 가장 자주 놓치는 항목이 마지막 줄입니다. 플랫폼 스레드는 생성 비용이 비싸 스레드 풀로 재사용했지만, 가상 스레드는 생성 비용이 거의 없으므로 풀링하지 않고 작업마다 새로 만드는 것이 정석입니다. 기존 습관대로 풀에 가두면 이 장점이 그대로 사라집니다.
실전 적용: 어떻게 켜고 쓰는가
적용은 생각보다 간단합니다. Spring Boot 3.2 이상에서는 설정 한 줄로 톰캣 요청 처리 스레드를 가상 스레드로 전환할 수 있습니다(Java 21 이상 필요).
# application.properties
spring.threads.virtual.enabled=true
이 설정을 켜면 톰캣이 HTTP 요청마다 가상 스레드를 할당합니다. 블로킹 방식의 JDBC, 외부 API 호출이 많은 서비스라면 코드를 거의 고치지 않고도 동시 처리량을 늘릴 수 있습니다.
직접 스레드를 다뤄야 한다면 표준 API를 사용합니다. 작업 단위마다 경량 스레드를 생성하는 전용 Executor가 제공됩니다.
// 작업마다 가상 스레드를 새로 생성하는 Executor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// 블로킹 I/O 작업 (DB 조회, HTTP 호출 등)
return callExternalApi();
});
}
} // try-with-resources 종료 시 모든 작업 완료 대기
단발성으로 하나만 띄울 때는 Thread.ofVirtual().start(runnable) 형태도 가능합니다. 어느 쪽이든 newFixedThreadPool 같은 고정 크기 풀로 감싸지 않는 것이 핵심입니다. 동시 실행 개수를 제한하고 싶다면 스레드 풀 대신 세마포어(Semaphore)로 제어하는 편이 가상 스레드 철학에 맞습니다.
한계 ① pinning, 그리고 Java 24에서 바뀐 것
가상 스레드의 가장 유명한 함정이 pinning입니다. 스레드가 캐리어에서 떨어지지 못하고 고정되는 현상으로, 이 상태에서 블로킹이 발생하면 캐리어 스레드가 묶여 버려 동시성이 떨어집니다. 가상 스레드를 도입했는데 처리량이 늘지 않는다면 십중팔구 여기가 원인입니다.
Java 21에서 pinning을 유발하는 대표 케이스는 synchronized 블록 안에서 블로킹 작업을 수행하는 경우였습니다. 그래서 초기 권장 사항은 핵심 경로의 synchronized를 ReentrantLock으로 교체하는 것이었습니다.
// Java 21 기준: synchronized 안에서 블로킹하면 pinning 발생
private final ReentrantLock lock = new ReentrantLock();
public void process() {
lock.lock(); // synchronized 대신 ReentrantLock 사용
try {
blockingIoCall(); // 이제 가상 스레드가 캐리어에서 안전하게 분리됨
} finally {
lock.unlock();
}
}
중요한 변화는 Java 24에서 나왔습니다. JEP 491이 적용되면서 synchronized 안에서 블로킹하더라도 가상 스레드가 캐리어를 정상적으로 반납하도록 개선되었습니다. JVM이 모니터 소유권을 캐리어가 아닌 가상 스레드 단위로 추적하게 바뀐 결과입니다. 2025년 9월 출시된 Java 25 LTS도 이 개선을 그대로 포함합니다.
다만 pinning이 완전히 사라진 것은 아닙니다. Java 24 이후에도 네이티브 메서드(JNI)나 외부 함수·메모리 API(FFM)를 호출하는 동안 블로킹되면 여전히 고정됩니다. 따라서 사용 중인 JDK 버전에 따라 대응 전략이 달라집니다.
pinning이 의심될 때는 측정이 우선입니다. Java 21에서는 -Djdk.tracePinnedThreads=full 옵션으로 로그를 확인하고, Java 24 이상에서는 JFR(Java Flight Recorder)의 jdk.VirtualThreadPinned 이벤트로 추적하는 것이 정확합니다. DB 동시성 제어 패턴을 어떻게 가져갈지 함께 검토하면 효과가 큽니다.
한계 ② CPU 바운드·ThreadLocal·풀링 안티패턴
pinning 외에도 자주 마주치는 한계가 셋 있습니다.
첫째, CPU 바운드 작업에는 이점이 없습니다. 가상 스레드는 블로킹 구간에 캐리어를 양보하는 방식으로 처리량을 높입니다. 양보할 블로킹 지점이 없는 순수 연산 작업은 가상 스레드든 플랫폼 스레드든 결국 CPU 코어 수만큼만 동시에 돕니다. 영상 인코딩, 대규모 계산 같은 작업은 가상 스레드로 옮길 이유가 없습니다.
둘째, ThreadLocal이 메모리를 압박할 수 있습니다. 경량 스레드도 ThreadLocal을 지원하지만, 수십만 개가 각자 값을 들고 있으면 메모리 사용량이 빠르게 늘어납니다. Java 25에서 정비된 스코프 값(Scoped Values)이 이 상황에서 더 가벼운 대안으로 제시됩니다.
셋째, 풀링은 안티패턴입니다. 앞서 언급했듯 가상 스레드를 고정 크기 풀에 넣으면 동시성이 풀 크기로 제한되어 도입 효과가 사라집니다. 기존 코드에 Executors.newFixedThreadPool이 깊게 박혀 있다면, 단순 치환이 아니라 동시성 제어 방식 자체를 다시 설계해야 합니다.
이 세 가지는 “가상 스레드 = 무조건 성능 향상”이라는 도입 초기의 오해와 정면으로 부딪히는 지점입니다. 효과를 보려면 워크로드의 성격부터 따져봐야 합니다.
언제 도입하고 언제 미뤄야 하는가
도입 판단은 워크로드 성격과 JDK 버전, 두 축으로 정리하면 깔끔합니다.
도입을 적극 고려할 상황은 다음과 같습니다. 요청당 외부 API 호출이나 DB 조회가 많은 I/O 바운드 서비스, 동시 접속이 많아 스레드 풀 고갈로 응답이 밀리는 서버, 그리고 코드 구조를 크게 바꾸지 않고 동시 처리량을 늘리고 싶은 경우입니다. 특히 Java 24 이상이라면 synchronized 때문에 머뭇거릴 이유가 줄었습니다.
반대로 도입을 미루는 편이 나은 경우도 분명합니다. CPU 바운드 비중이 큰 서비스, synchronized 블로킹이 핵심 경로에 깔려 있으면서 아직 Java 21에 머물러 있는 환경, 네이티브 호출이 잦은 시스템이 그렇습니다. 운영 중인 라이브러리가 가상 스레드 환경에서 검증되었는지도 확인이 필요합니다.
요약하면, 가상 스레드는 “블로킹 I/O가 많고 동시성이 병목인 서비스”라는 조건을 충족할 때 가장 큰 값을 합니다. 그 조건이 아니라면 도입 효과는 제한적입니다. 스레드 풀 고갈로 인한 응답 지연을 겪고 있다면 DB 계층 점검과 함께 검토하는 것을 권합니다.
자주 묻는 질문 (FAQ)
Q1. 가상 스레드를 쓰면 단일 요청도 빨라지나요?
아닙니다. 가상 스레드는 동시에 처리할 수 있는 요청 수, 즉 처리량을 늘립니다. 개별 요청의 응답 시간 자체는 크게 달라지지 않습니다. 단일 요청 속도가 문제라면 쿼리 최적화나 캐싱 같은 다른 접근이 필요합니다.
Q2. Java 21을 쓰는데 synchronized를 꼭 ReentrantLock으로 바꿔야 하나요?
핵심 경로에서 synchronized 안에 블로킹 I/O가 있다면 교체를 권합니다. Java 21에서는 이 패턴이 pinning을 유발하기 때문입니다. Java 24 이상으로 올릴 수 있다면 JEP 491 덕분에 이 작업의 시급성은 크게 줄어듭니다.
Q3. 가상 스레드도 스레드 풀로 관리해야 하나요?
권장하지 않습니다. 생성 비용이 거의 없으므로 작업마다 새로 만드는 newVirtualThreadPerTaskExecutor가 정석입니다. 동시 실행 개수를 제한하고 싶다면 풀 대신 세마포어를 사용하시면 됩니다.
Q4. 운영 중인 서비스에 바로 적용해도 안전한가요?
설정 전환은 쉽지만 검증 없는 전면 적용은 위험합니다. 부하 테스트로 처리량 변화와 pinning 발생 여부를 측정한 뒤, 트래픽 일부에 먼저 적용해 확인하는 단계적 전환을 추천합니다.
다시 짚어보면
가상 스레드는 블로킹 I/O가 병목인 서비스의 처리량을 끌어올리는 도구이며, 만능 성능 부스터가 아닙니다. 실전 적용은 Spring Boot 설정 한 줄이나 전용 Executor로 충분히 시작할 수 있지만, pinning·CPU 바운드·풀링 안티패턴이라는 한계를 함께 이해해야 기대한 결과가 나옵니다. 특히 JDK 버전에 따라 synchronized 대응 전략이 달라지므로, Java 24 이상으로의 업그레이드 여부까지 함께 검토하는 것이 가장 현실적인 도입 경로입니다.