Back-end

java 25 가상스레드 적용 시 실제 서비스 기준으로는 얼만큼 성능이 향상될까?

dohyunKim 2026. 6. 28. 22:45

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를 추가해 얻을 수도 있으므로, 둘 중 무엇으로 갈지는 선택의 문제다.