1. 개요
Java 21에서 정식화된 가상 스레드(virtual thread)는 Spring Boot 3.2+ 에서 spring.threads.virtual.enabled=true 한 줄로 켜진다. "켜면 처리량이 오른다"는 기대가 흔하다. 운영 중인 서비스(Spring Boot 4 / JDK 25 - vt pinning issue 해소)에 적용하면 실제로 얼마나 빨라질까?
결론은 단순하다.
처리량은 거의 오르지 않는다. 가상 스레드가 효과를 내는 조건은 "병목이 스레드일 때"로 한정되며, 잘 짜인 서비스는 대개 스레드보다 먼저 다른 자원에서 막힌다. 그리고 효과가 실제로 나타나는 지점은 처리량이 아니라 장애 격리다.
2. 가상 스레드는 언제 효과가 있나
서버는 보통 요청 1개에 스레드 1개를 할당하는 thread-per-request 모델로 동작한다(Tomcat 기본 worker 200개). 이 스레드는 요청이 끝날 때까지 점유되며, DB나 외부 API의 응답을 기다리는 blocking I/O 구간에도 묶여 있다.
CPU는 놀아도 스레드는 잡혀 있는 것이다.
동시 요청이 200개를 넘으면 그 다음 요청은 큐에서 대기한다.
가상 스레드는 이 구조를 바꾼다. blocking I/O를 만나면 가상 스레드는 실제 OS 스레드(carrier)에서 내려오고(unmount), carrier는 그동안 다른 가상 스레드를 실행한다. I/O가 끝나면 다시 올라탄다(mount).
그래서 적은 수의 OS 스레드(기본값은 CPU 코어 수)로 수천 개의 I/O 대기 요청을 동시에 들고 있을 수 있다.
여기서 핵심이 나온다. 가상 스레드가 늘려주는 것은 "동시에 들고 있을 수 있는 I/O 대기 요청 수"이지, CPU 처리 속도도 DB 처리량도 아니다.
정리하면 다음과 같다.
| 병목 지점 | 가상 스레드 효과 |
|---|---|
| 스레드 풀(worker) 고갈 | 있음 — 대기를 OS 스레드 없이 흡수 |
| CPU | 없음 — carrier도 결국 코어 수만큼만 실행 |
| DB 커넥션 풀 | 없음 — 풀 크기가 한계 |
| 동시성 제한(bulkhead 등) | 없음 — 세마포어가 한계 |
즉 가상 스레드는 "스레드가 병목인 워크로드"에서만 의미가 있다.
3. 실제 서비스는 대개 Thread가 병목지점이 아니다.
잘 구성된 서비스는 외부 의존성마다 보호장치를 둔다. 측정 대상인 인증 도메인 서비스는 다음과 같았다.
- 외부 API 호출에 Resilience4j
@Bulkhead(동시 호출 50) — downstream 보호용 - DB는 HikariCP 커넥션 풀(max 10)
- HTTP 클라이언트 커넥션 풀(100 per endpoint)
이 상한들은 전부 Tomcat worker 200보다 작다. 부하를 올려도 스레드가 마르기 전에 bulkhead(50)나 DB 풀(10)에서 먼저 막힌다. 그래서 일반적인 부하 테스트로는 가상 스레드 on/off 차이가 거의 나지 않는다. 스레드가 병목이 아닌 서비스에서 가상 스레드는 처리량을 올리지 못한다 — 이것이 첫 번째 시사점이다.
참고로 "가상 스레드를 켜면 bulkhead가 필요 없다"는 말은 틀리다. bulkhead는 downstream을 과부하로부터 보호하고, 정원 초과 시 빠른 실패와 폴백을 제공한다. 가상 스레드는 스레드 풀이 주던 암묵적 동시성 상한을 없애므로, 오히려 동시성 제한을 명시적 세마포어(= bulkhead)로 다시 넣어야 한다. 둘은 푸는 문제가 다르다.
4. 가상 스레드가 빛나는 조건을 만들어 검증한다
처리량이 아니라 "스레드 풀 고갈"이 병목이 되는 상황을 의도적으로 만들고, on/off를 비교했다. 테스트 구성은 다음과 같다.
[점유원 hog] 동시성 제한 없는 외부호출 + 2.5s 지연 -> worker 스레드를 오래 점유
[피해자 victim] 헬스체크(I/O 0) + 단순 DB 조회 API -> worker를 받을 수 있나?
변수: spring.threads.virtual.enabled = false / true (나머지 설정은 운영과 동일)
- 점유원(hog): 동시성 제한이 없는 외부 호출 경로를 찾는다. 이 서비스에서는 요청 인터셉터에서 컨트롤러 진입 전에 동기로 실행되는 OAuth2 토큰 원격검증만 bulkhead가 없었다. 이 호출의 응답을 WireMock으로 2.5초 지연시키면 요청 하나가 worker 스레드를 2.5초 동안 점유한다. 이를 높은 RPS로 올려 worker 200개를 고갈시킨다.
- 피해자(victim): 점유원과 무관한 두 엔드포인트를 동시에 낮은 부하(각 20 RPS)로 호출한다. 하나는 I/O가 전혀 없는 헬스체크, 하나는 단순 DB 조회 API다. 이들이 죽는지 사는지가 측정 대상이다.
- 변수:
spring.threads.virtual.enabled만 false / true로 바꿔 두 번 측정한다. bulkhead 50, Tomcat 200, 커넥션 풀 등 나머지 설정은 운영과 동일하게 유지한다.
도구는 k6(부하 생성), WireMock(외부 의존성 모킹·지연 주입), Prometheus/Grafana(지표 수집)를 사용했다.
5. 결과
전체 집계는 다음과 같다.
| 항목 | VT off | VT on |
|---|---|---|
| 총 요청 | 116,096 | 146,396 |
| P50 | 4.76 s | 1.05 s |
| P95 | 6.10 s | 3.57 s |
다만 집계 실패율은 점유원(hog) 자신의 실패가 지배하므로 그대로 읽으면 오해한다. 엔드포인트별로 분해해야 한다. 아래가 핵심이다.
| 엔드포인트 | 지표 | VT off | VT on |
|---|---|---|---|
| 헬스체크 (I/O 없음) | 성공률 | 36.5% | 약 100% |
| 헬스체크 | p95 | 5.14 s | 1.14 ms |
| 헬스체크 | 평균 | 3.15 s | 0.76 ms |
| DB 조회 API | 성공률 | 36.5% | 약 100% |
| DB 조회 API | p95 | 5.14 s | 1.90 ms |
I/O가 전혀 없는 헬스체크가 VT off에서 p95 5.14초, 실패율 63%를 기록했다. 자기 자신은 아무 일도 하지 않는데, 무관한 점유원이 worker 스레드를 모두 점유해 스레드를 받지 못한 것이다. VT on에서는 같은 부하인데 p95 1.14ms, 실패율 0%로 정상이었다.
이때 CPU 사용률은 약 30%였다. 자원은 남는데 모든 요청이 느리고 실패하는 상태 — 전형적인 스레드 고갈이다.
6. 해석: 처리량이 아니라 격리
점유원 자신의 처리량은 on/off에서 차이가 없었다. 그 경로의 외부 호출 풀(100)이 한계여서 가상 스레드와 무관하기 때문이다. 가상 스레드가 한 일은 점유원의 blocking 대기를 OS 스레드 없이 흡수해 worker 풀을 비워둔 것이고, 그 덕분에 무관한 엔드포인트가 영향을 받지 않았다.
정리하면 가상 스레드의 실제 이득은 두 가지로 갈린다.
- 처리량 향상: 병목이 스레드가 아니면 오르지 않는다.
- 장애 격리: 동시성 제한이 없는 느린 의존성이 스레드 풀을 고갈시켜 전체 서비스를 함께 마비시키는 것을 막는다. 위 측정에서 확인된 효과가 이것이다.
7. 정리
| 상황 | 가상 스레드 |
|---|---|
| 병목이 스레드 풀 | 효과 있음 (동시 수용량↑ 또는 격리) |
| 병목이 CPU / DB 풀 / bulkhead | 처리량 효과 없음 |
| 동시성 제한 없는 느린 외부 호출이 존재 | 장애 격리 안전망으로 가치 |
가상 스레드를 켤지는 "이 서비스가 실제로 스레드 고갈에 닿는가"로 판단한다. 피크에도 worker 스레드가 한계에 닿지 않으면 처리량 이득은 없다. 그러나 동시성 제한이 빠진 느린 외부 호출이 하나라도 있으면 장애 시 그것이 전체를 마비시킬 수 있고, 가상 스레드는 그 영향 범위(blast radius)를 막는 안전망이 된다. 같은 보호를 해당 호출에 bulkhead를 추가해 얻을 수도 있으므로, 둘 중 무엇으로 갈지는 선택의 문제다.
'Back-end' 카테고리의 다른 글
| 단일 인스턴스 배치를 멀티 인스턴스 구조로 리팩터링: 설계 옵션 비교와 선택 (0) | 2026.06.28 |
|---|---|
| 분산 환경 지연 작업 처리: 설계 대안 비교와 Redisson RDelayedQueue 선택 (0) | 2026.06.28 |
| Java - NonWritableChannelException (0) | 2026.06.28 |
| Java try-with-resource (0) | 2026.06.28 |
| SAS에서의 Client - Server Binary file 전송 (0) | 2026.06.28 |