가상 스레드 자료를 찾다 보면 “synchronized는 캐리어 스레드를 고정하니 ReentrantLock으로 바꿔라”는 조언이 계속 나옵니다. JEP를 확인해 보니 JDK 24부터는 틀린 얘기였습니다.
국내 자료 대부분이 JDK 21이 나온 2023년에 쓰였는데, 그 사이 JEP 491이 들어가면서 전제가 바뀌었습니다. 진단 방법으로 흔히 안내되는 -Djdk.tracePinnedThreads는 아예 제거됐고요.
그래서 두 가지를 정리했습니다. 지금 검색되는 조언 중 뭐가 낡았는지, 그리고 pinning이 해결된 뒤에 병목이 어디로 옮겨가는지.
지금 검색되는 조언 중 낡은 것들
JEP 491이 JDK 24에 반영되면서 달라진 것들입니다.
| 흔히 보이는 조언 | JDK 24 이후 사실 |
|---|---|
“synchronized 블록 안에서 블로킹하면 캐리어가 고정되니 ReentrantLock으로 바꿔라” |
고정되지 않습니다. JVM 모니터 구현이 가상 스레드를 인식하도록 재작성됐습니다 |
“Object.wait()도 캐리어를 고정한다” |
고정되지 않습니다. 타임드 변형(wait(long))까지 unmount 됩니다 |
“-Djdk.tracePinnedThreads로 진단하라” |
이 시스템 프로퍼티는 제거됐습니다. 더 이상 존재하지 않습니다 |
| “가상 스레드를 켰으니 커넥션 풀도 크게 늘려라” | 뒤에서 따로 다룹니다. 공식 권고와 정반대입니다 |
소스를 고칠 필요가 없다는 게 핵심입니다. JEP 491은 JVM 구현 변경이라 synchronized를 쓰던 코드를 그대로 두고 JDK만 24 이상으로 올리면 됩니다. 이미 ReentrantLock으로 바꿔놨다면 굳이 되돌릴 것까지는 없지만, 아직 안 했다면 그 작업은 접어도 됩니다.
그래도 남아 있는 pinning 세 가지
JEP 491이 “거의 모든” 경우를 없앴지만 전부는 아닙니다. 남은 건 세 가지이고, 셋 다 스택에 네이티브 프레임이 있는 경우입니다.
- 클래스나 인터페이스의 심볼릭 참조를 해석하면서 클래스 로딩 중 블로킹될 때
- 클래스 초기화자(static initializer) 안에서 블로킹할 때
- 다른 스레드가 클래스를 초기화하는 것을 기다릴 때
실무에서 이게 문제가 되는 경우는 드뭅니다. 다만 애플리케이션 기동 직후 첫 요청들이 유독 느린 현상이 있다면 클래스 로딩과 겹친 것일 수 있습니다. 워밍업으로 대응하는 편이 낫습니다.
진단 방법이 바뀌었다
-Djdk.tracePinnedThreads가 제거됐으니 대체 수단을 알아야 합니다. JFR 이벤트 jdk.VirtualThreadPinned 를 봅니다.
JEP 491 이후 이 이벤트는 정보가 더 풍부해졌습니다. 고정된 이유와 캐리어 스레드의 신원까지 함께 담습니다.
# 기록
java -XX:StartFlightRecording=duration=60s,filename=vt.jfr -jar app.jar
# 확인
jfr summary vt.jfr
jfr print --events jdk.VirtualThreadPinned vt.jfr
이벤트가 아예 안 잡히면 pinning은 문제가 아니라는 뜻입니다. 그러면 다음 단계로 넘어갑니다.
병목은 어디로 옮겨갔나
가상 스레드가 푸는 문제는 딱 하나입니다. “동시 요청 수가 스레드 수에 묶이는 것.” 톰캣 스레드 200개로 동시 요청 200개가 한계였던 구조가 풀립니다.
그런데 요청이 스레드를 얻은 다음에는 여전히 뭔가를 기다립니다. 그 뭔가의 상한이 새 병목이 됩니다.
① DB 커넥션 풀 — 대부분 여기입니다
가상 스레드 1만 개가 동시에 살아 있어도 HikariCP 커넥션이 10개면, 9,990개는 커넥션을 기다리며 줄을 섭니다. 처리량은 커넥션 10개가 정합니다. 스레드를 늘려도 이 숫자는 안 변합니다.
바뀌는 것은 대기가 어디서 일어나는지입니다. 예전에는 톰캣 스레드 풀 앞에서 기다렸고 이제는 커넥션 풀 앞에서 기다립니다. 총 처리 시간은 비슷한데 큐가 애플리케이션 안쪽으로 들어온 셈입니다. 이게 오히려 나쁜 경우도 있습니다 — 톰캣 큐가 꽉 차면 빠르게 거절(503)했는데, 이제는 전부 받아들여 놓고 안에서 하염없이 기다립니다.
② CPU 바운드 작업 — 도움이 되지 않습니다
가상 스레드 스케줄러는 work-stealing ForkJoinPool이고, 병렬도 기본값은 Runtime.availableProcessors() 입니다. 4코어 머신이면 캐리어 스레드 4개가 가상 스레드 수백만 개를 나눠 태웁니다.
즉 실제로 CPU를 쓰는 일은 코어 수만큼만 동시에 진행됩니다. 이미지 리사이징, 암복호화, 대량 직렬화처럼 CPU를 태우는 작업은 가상 스레드로 바꿔도 빨라지지 않습니다. 필요하면 jdk.virtualThreadScheduler.parallelism과 jdk.virtualThreadScheduler.maxPoolSize로 조정할 수 있지만, 코어보다 크게 잡으면 컨텍스트 스위칭만 늘어납니다.
가상 스레드는 I/O 대기가 많은 작업에만 효과가 있습니다.
③ 외부 API의 처리율 제한
호출하는 쪽이 초당 100건까지만 받는다면, 가상 스레드 1만 개는 429 응답 9,900개를 더 받아오는 결과가 됩니다. 이런 경우는 동시성을 의도적으로 제한해야 합니다.
④ 스레드 풀로 하던 동시성 제한이 사라진다
이게 조용히 사고를 만드는 항목입니다. 많은 코드가 스레드 풀 크기를 사실상 처리율 제한 장치로 써왔습니다. “이 작업은 동시에 5개만” 같은 제약을 Executors.newFixedThreadPool(5)로 표현한 것입니다.
가상 스레드로 바꾸면 이 제약이 없어집니다. Spring Boot는 이 지점을 문서에 명시하고 있습니다. spring.threads.virtual.enabled=true를 켜면 TaskScheduler가 SimpleAsyncTaskScheduler로 바뀌는데, 이 구현은 풀 관련 설정을 전부 무시합니다.
이
SimpleAsyncTaskScheduler는 풀링 관련 속성을 무시합니다.
spring.task.scheduling.pool.size=5를 걸어두고 그것에 의존하던 코드가 있다면, 속성은 그대로 남아 있고 아무 오류도 없는데 제한만 사라집니다. 동시성 제한이 필요하면 Semaphore처럼 명시적인 수단으로 옮겨야 합니다.
// 스레드 풀 크기에 의존하던 제한
private final ExecutorService pool = Executors.newFixedThreadPool(5);
// 가상 스레드 환경에서는 제한을 명시적으로 표현한다
private final Semaphore limit = new Semaphore(5);
void callExternalApi(Request req) throws InterruptedException {
limit.acquire();
try {
client.send(req);
} finally {
limit.release();
}
}
“풀을 늘려라”는 정확히 거꾸로다
가상 스레드 글에서 자주 보이는 조언이 “동시 요청이 늘어나니 HikariCP maximum-pool-size를 50~150으로 올려라” 입니다. HikariCP가 직접 문서화한 권고는 반대 방향입니다.
HikariCP 공식 풀 사이징 문서의 공식은 이렇습니다. PostgreSQL 프로젝트에서 온 것이고 다른 DB에도 출발점으로 쓸 수 있다고 밝히고 있습니다.
connections = (core_count * 2) + effective_spindle_count
4코어에 디스크 1개면 (4 × 2) + 1 = 9입니다. 문서는 이 정도 풀로 6,000 TPS에서 동시 사용자 3,000명을 처리할 수 있다고 적고 있습니다.
왜 작은 풀이 더 빠른가
DB 서버도 코어가 한정돼 있습니다. 커넥션이 코어 수를 넘어서면 DB 쪽에서 컨텍스트 스위칭이 발생하고, 그 비용이 처리량을 깎습니다. 문서가 인용하는 사례가 인상적입니다.
- Oracle Real-World Performance 자료: 풀 크기만 줄여서 응답 시간이 약 100ms → 약 2ms (50배 개선)
- PostgreSQL 벤치마크: TPS가 커넥션 50개 부근에서 평탄해짐
- Oracle 사례에서 최적값이 96 커넥션이었는데, 문서는 그조차 과할 수 있다고 봅니다
실제로 벌어진 일
HikariCP 저장소에 보고된 사례가 이 조합의 위험을 잘 보여줍니다. JDK 21, Spring Boot 3.2, 4코어 8GB 파드에 풀 최대 3,000개를 설정하고 가상 스레드를 켠 결과입니다.
가상 스레드를 끄면 10,000 TPS 이상 나왔는데, 켜니까 약 2,000 TPS 로 떨어졌습니다.
CPU는 0.5코어만 쓰고 메모리도 1GB 정도만 쓰는 상태였습니다. 자원이 남는데 처리량이 안 나오는 전형적인 대기 병목입니다.
주목할 점은 풀 크기 3,000이 공식 권고치(4코어면 9)의 300배가 넘는다는 것입니다. 이 사례는 “가상 스레드와 HikariCP는 상성이 나쁘다”로 요약되곤 하는데, 정확히는 애초에 과대 설정된 풀이 가상 스레드 때문에 실제로 채워지기 시작한 것입니다. 예전에는 톰캣 스레드 200개가 앞에서 막아줘서 풀 3,000개가 드러나지 않았을 뿐입니다.
이 사례는 메인테이너의 최종 진단이 공개되지 않은 보고 단계의 이슈입니다. 다만 JDK 21 환경이라
synchronizedpinning 요인이 함께 작용했을 가능성이 있고, 그 요인은 JDK 24 이후 사라졌습니다. 풀 과대 설정 문제는 그와 별개로 남습니다.
가상 스레드는 가려져 있던 설정 오류를 드러냅니다. 켜기 전에 풀 크기가 근거 있는 값인지 먼저 확인하는 편이 낫습니다.
Spring Boot에서 켤 때 함께 바뀌는 것
spring.threads.virtual.enabled=true
Java 21 이상에서 동작하고, 이 한 줄로 다음이 함께 바뀝니다.
AsyncTaskExecutor 가 ThreadPoolTaskExecutor 에서 가상 스레드를 쓰는 SimpleAsyncTaskExecutor 로 바뀌고, TaskScheduler 도 같은 식으로 SimpleAsyncTaskScheduler 가 됩니다. 그리고 spring.task.scheduling.pool.size 같은 풀 관련 속성은 조용히 무시됩니다.
영향 범위는 생각보다 넓습니다. @EnableAsync 작업, @EnableScheduling 스케줄 작업, Spring MVC 비동기 요청 처리, WebFlux의 블로킹 실행, WebSocket 메시지 채널, JPA 부트스트랩 익스큐터, 백그라운드 빈 초기화가 모두 포함됩니다.
spring.task.scheduling.thread-name-prefix는 계속 동작하지만 pool.size는 무시된다는 점이 특히 헷갈리기 쉽습니다.
켜기 전 점검 순서
- JDK 24 이상인지 확인한다. JEP 491의 효과를 받으려면 24 이상이어야 합니다. 21~23에 머문다면
synchronizedpinning은 여전히 실재합니다. - 커넥션 풀 크기가 근거 있는 값인지 확인한다.
(코어 × 2) + 디스크 수를 출발점으로 잡고, 지금 설정된 값이 여기서 얼마나 벗어나 있는지 봅니다. 이게 이 작업에서 가장 중요한 한 가지입니다. - 스레드 풀 크기를 처리율 제한으로 쓰고 있는 곳을 찾는다.
newFixedThreadPool,spring.task.*.pool.size를 검색해 의도를 확인하고, 제한이 필요하면Semaphore로 옮깁니다. - CPU 바운드 작업을 분리한다. 가상 스레드로 이득이 없는 구간입니다.
- 부하 테스트에서 큐 지점을 관찰한다. 커넥션 획득 대기 시간(
hikaricp_connections_pending)과 활성 커넥션 수를 함께 봅니다. 처리량이 아니라 어디서 줄을 서는지를 봐야 원인이 보입니다. - JFR로
jdk.VirtualThreadPinned를 확인한다. 안 잡히면 pinning은 배제하고 넘어갑니다.
5번은 Prometheus와 Grafana로 모니터링을 구축해 두면 그대로 볼 수 있습니다. 커넥션 풀 자체가 어떻게 병목이 되는지는 Connection Pool 도입으로 API 지연을 잡은 기록에 정리해 두었습니다.
컨텍스트 전달에 ThreadLocal을 쓰고 있다면 Java 25의 Scoped Values를 함께 검토하세요. 가상 스레드가 많아질수록 ThreadLocal 복사본의 메모리 비용이 커집니다. Spring Boot 버전을 올리는 중이라면 Spring Boot 4.0 마이그레이션과 묶어서 한 번에 검증하는 편이 낫습니다.
자주 묻는 질문
Q. JDK 21에서 ReentrantLock으로 바꿔둔 코드를 되돌려야 하나요?
A. 그럴 필요는 없습니다. ReentrantLock은 여전히 정상 동작하고 tryLock 같은 기능도 씁니다. 다만 아직 안 바꿨다면 JDK 24 이상에서는 바꾸지 않아도 됩니다. synchronized를 걷어내는 것이 목적인 리팩터링이라면 우선순위를 내려도 됩니다.
Q. 커넥션 풀을 늘리면 안 된다는 게 절대 규칙인가요? A. 아닙니다. 공식은 출발점이고, DB 서버 사양과 쿼리 특성에 따라 달라집니다. 요점은 “가상 스레드를 켰다”가 풀을 늘릴 근거가 되지 않는다는 것입니다. 풀 크기는 DB가 감당할 수 있는 동시 작업 수로 결정해야 하고, 늘렸다면 반드시 측정해서 처리량이 실제로 올라갔는지 확인해야 합니다.
Q. 가상 스레드를 켰더니 응답 시간이 더 나빠졌습니다.
A. 요청을 전부 받아들이고 안에서 대기하게 되면서 평균 응답 시간이 늘어난 경우일 수 있습니다. 예전에는 톰캣 큐가 꽉 차면 빠르게 거절했지만 이제는 받아놓고 기다립니다. 커넥션 획득 타임아웃(spring.datasource.hikari.connection-timeout)을 짧게 잡아 빠르게 실패시키는 것이 오히려 나은 경우가 많습니다.
이 글은 JEP 491, JEP 444, HikariCP 풀 사이징 문서, Spring Boot 공식 문서를 2026년 9월 기준으로 정리한 것입니다. 인용한 처리량 수치는 각 출처가 제시한 값이며 환경에 따라 달라집니다. 적용 전 본인 환경에서 측정하시기 바랍니다.
참고 자료
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 444: Virtual Threads
- HikariCP — About Pool Sizing
- Spring Boot — Task Execution and Scheduling
- HikariCP issue #2151 — 가상 스레드 환경 성능 보고
“가상 스레드를 켰는데 왜 빨라지지 않는가: JEP 491 이후 이동한 병목”에 대한 1개의 생각