- Published on
Spring Boot 3 가상스레드 적용 후 지연·데드락 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버의 스레드 모델을 바꾸는 일은 성능 최적화라기보다 실행 모델(Execution Model)의 교체에 가깝습니다. Spring Boot 3(=Spring Framework 6, JDK 21 조합이 흔함)에서 가상 스레드(virtual thread)를 활성화하면, 블로킹 I/O에 대한 확장성은 좋아질 수 있지만 기존에 “스레드가 귀해서” 우연히 드러나지 않던 병목이 다른 형태의 지연/교착으로 나타나기도 합니다.
이 글은 “가상 스레드 적용 후 응답이 느려졌다”, “간헐적으로 요청이 멈춘다(데드락처럼 보인다)”, “CPU는 낮은데 처리량이 안 나온다” 같은 상황에서, 감(추측)이 아니라 **증거(스레드 덤프/프로파일/메트릭)**로 원인을 좁히는 진단 루틴을 제공합니다.
1) 먼저 확인: 가상 스레드가 실제로 어디에 적용됐나
Spring Boot 3는 웹 스택에 따라 적용 포인트가 다릅니다.
- Spring MVC(Tomcat/Jetty/Undertow): 요청 처리 스레드를 가상 스레드로 바꿀 수 있습니다.
- Spring WebFlux(Netty): 이벤트 루프 기반이라 “요청 스레드” 개념이 다릅니다. 가상 스레드로 바꾼다고 해결되는 문제가 제한적입니다(오히려 혼합 모델로 문제가 커질 수 있음).
Boot 설정 예시(MVC)
spring:
threads:
virtual:
enabled: true
또는 명시적으로 Executor를 주입해 컨트롤러/서비스에서 사용하는 비동기 실행기를 가상 스레드로 맞춥니다.
@Configuration
public class VirtualThreadConfig {
@Bean
Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
진단 포인트
- “가상 스레드가 켜졌는데도 효과가 없다”면, 실제로는 요청 처리 경로가 여전히 플랫폼 스레드(커널 스레드) 위에서 돌고 있거나, 병목이 I/O가 아니라 락/DB/외부 API/커넥션 풀일 가능성이 큽니다.
2) 가상 스레드 이후 ‘응답 지연’이 생기는 대표 원인 6가지
가상 스레드는 “스레드 수를 늘려도 부담이 적다”는 장점이 있지만, 그만큼 동시에 더 많은 작업이 더 빨리 병목 지점에 도착합니다. 그래서 지연이 생겼다면 아래를 의심하는 게 빠릅니다.
2.1 DB 커넥션 풀 고갈(HikariCP) → 대기열 지연
가상 스레드로 동시성이 늘면, DB 커넥션 풀은 더 빨리 소진됩니다. 결과적으로 응답 지연은 애플리케이션이 아니라 커넥션 풀에서 기다리는 시간이 됩니다.
증상
- CPU 낮음
- 스레드 덤프에서
com.zaxxer.hikari.pool.HikariPool.getConnection대기 - 메트릭에서
hikaricp.connections.active가max근처에 붙어 있음
대응
- 풀 사이즈를 무작정 키우기 전에 DB의 max connections, 쿼리 지연, 인덱스, 트랜잭션 범위부터 점검
- 애플리케이션 레벨에서 동시 요청을 제한(세마포어/벌크헤드)
// 간단한 벌크헤드(동시 DB 작업 제한)
public class DbBulkhead {
private final Semaphore sem = new Semaphore(50);
public <T> T call(Callable<T> c) throws Exception {
sem.acquire();
try { return c.call(); }
finally { sem.release(); }
}
}
2.2 HTTP 클라이언트 풀/커넥션 제한 → 외부 API 대기
외부 API 호출을 많이 하는 서비스는, 가상 스레드로 동시 호출이 늘면서 HTTP 커넥션 풀/라우팅 제한에 걸립니다.
- Apache HttpClient, OkHttp, Reactor Netty 등 각각 풀 정책이 다름
- 타임아웃이 길면 “멈춘 것처럼” 보이기 쉽습니다
대응 체크리스트
- connect/read/response timeout을 명시
- 풀 최대치/라우트별 최대치 확인
- 재시도 정책이 폭주(Thundering herd)시키지 않는지 확인
외부 API 재시도/백오프 설계는 별도 글도 참고할 수 있습니다: OpenAI 429·insufficient_quota 재시도와 백오프 설계
2.3 synchronized/ReentrantLock 경합 증가 → “데드락처럼 보이는” 정체
가상 스레드는 락을 “가볍게” 만들어주지 않습니다. 오히려 동시 접근이 늘어 락 경합이 급증할 수 있습니다.
자주 터지는 패턴
- 전역 캐시(Map) 보호를
synchronized로 처리 - 사용자/계정 단위 락을 잘못 잡아 순환 대기
- 트랜잭션 안에서 락을 잡고 외부 API 호출(최악)
대응
- 락 범위를 줄이고, 가능하면 lock-free/Concurrent 컬렉션 사용
- 순서가 있는 락(락 획득 순서를 전역적으로 통일)
- 트랜잭션 내부에서 네트워크 호출 금지
2.4 ThreadLocal 기반 컨텍스트/라이브러리 부작용
가상 스레드는 작업 단위로 많이 생성/종료됩니다. ThreadLocal을 “요청 스레드에 묶어” 쓰던 코드가 다음 문제를 일으킬 수 있습니다.
- 정리 누락으로 메모리/컨텍스트 오염
- MDC/보안 컨텍스트 전파가 기대와 다르게 동작
대응
- 요청 종료 시 ThreadLocal 정리 보장
- 관측/로그 컨텍스트는 프레임워크 제공 전파 기능 사용
2.5 ‘pinning’(고정) 이슈: 가상 스레드가 플랫폼 스레드를 붙잡음
가상 스레드는 보통 블로킹 시 마운트 해제(unmount)되어 플랫폼 스레드를 양보하지만, 특정 상황에서는 **가상 스레드가 플랫폼 스레드에 고정(pinning)**되어, 확장성이 급격히 떨어질 수 있습니다.
대표적으로:
synchronized블록/메서드 내부에서 블로킹 I/O- 네이티브 호출/특정 모니터 사용 패턴
증상
- 플랫폼 스레드 수가 제한된 상태에서 처리량 급락
- 스레드 덤프에서 가상 스레드가
synchronized내부에서 I/O 대기
대응
- 블로킹 I/O가 있는 경로에서
synchronized제거/축소 ReentrantLock등으로 구조 변경(상황에 따라)
2.6 WebFlux/Netty 이벤트 루프 블로킹
WebFlux는 이벤트 루프가 핵심이라, 가상 스레드를 일부에 도입해도 이벤트 루프에서 블로킹이 발생하면 전체가 느려집니다.
증상
- Netty eventLoop thread가 블로킹 스택을 가짐
- 특정 요청이 아니라 전체가 “느려짐”
대응
- 이벤트 루프에서는 절대 블로킹하지 않도록 분리
- 블로킹 작업은 별도 스케줄러/Executor로 격리
3) “데드락”인지 “단순 대기”인지: 스레드 덤프로 1차 판별
가장 먼저 할 일은 스레드 덤프 2~3개를 시간 간격을 두고 떠서, 동일한 스택에서 멈춰있는지 확인하는 것입니다.
3.1 jcmd로 스레드 덤프
# PID 확인
jcmd
# 스레드 덤프
jcmd <pid> Thread.print > threaddump.txt
3.2 데드락 자동 탐지
jcmd <pid> Thread.print -l | grep -i deadlock -n
해석 가이드
- JVM이 “Found one Java-level deadlock”를 보고하면 진짜 데드락 가능성이 큼
- 보고가 없더라도, 다수 스레드가 같은 락/조건에서 대기하면 락 경합 또는 자원 풀 고갈일 수 있음
가상 스레드 환경에서는 스레드 수가 많아 덤프가 방대해지므로, “요청이 멈춘 시각” 기준으로 해당 요청의 traceId/MDC와 함께 덤프를 매칭하는 습관이 중요합니다.
4) JFR로 ‘지연의 위치’를 잡아내기(가상 스레드 환경에 특히 유용)
스레드 덤프가 “어디서 기다리는지”를 보여준다면, JFR(Java Flight Recorder)은 “무엇 때문에 시간이 쓰였는지”를 계량적으로 보여줍니다.
4.1 운영에서도 가능한 저부하 JFR 수집
jcmd <pid> JFR.start name=vt-debug settings=profile duration=120s filename=vt-debug.jfr
수집 후 JDK Mission Control(JMC)로 열어서 다음을 봅니다.
- Lock Instances / Java Monitor Blocked: 락 경합 핫스팟
- Socket Read/Write, File Read/Write: I/O 지연
- Thread Park: 풀 대기/조건 대기
- (가능하면) Virtual Thread 관련 이벤트(사용 JDK/JMC 버전에 따라 표시 항목 다름)
팁
- “응답 지연”은 보통 CPU가 아니라 대기 시간입니다. JFR에서 blocked/parked 시간이 큰 구간을 찾으면 원인이 빨리 좁혀집니다.
5) 스프링 관측(Observability)로 ‘느린 요청의 공통점’ 찾기
가상 스레드 전환 후 문제는 대개 “특정 엔드포인트” 또는 “특정 의존성(DB/외부 API)”에서 터집니다. 따라서 분산 추적/메트릭이 있으면 진단 속도가 크게 올라갑니다.
5.1 Micrometer/Actuator로 최소 메트릭 확보
- HTTP 서버 요청 지연(p95/p99)
- HikariCP 풀(active/idle/pending)
- 외부 API client latency(가능하면)
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
5.2 느린 요청이 인프라 문제로 보일 때
가상 스레드 적용 직후 지연이 “앱 내부”가 아니라 Ingress/로드밸런서/네트워크에서 보일 수도 있습니다. 예를 들어 Ingress 503/502가 늘었다면 앱 스레드 모델보다 먼저 라우팅/헬스체크/타임아웃을 확인해야 합니다.
6) 실전 체크리스트: “가상 스레드로 바꿨더니 더 느려짐” 30분 루틴
6.1 0~5분: 사실 확인
- MVC인가 WebFlux인가?
- 가상 스레드가 실제로 요청 처리에 적용됐나?
- 지연이 특정 엔드포인트/특정 시간대/특정 사용자군에 집중되나?
6.2 5~15분: 병목 후보를 메트릭으로 제거
- Hikari pending 증가? → 풀/쿼리/트랜잭션 의심
- 외부 API latency 증가? → 타임아웃/풀/재시도 의심
- GC/메모리 문제? → JFR/GC 로그 확인
6.3 15~30분: 스레드 덤프 + JFR로 확정
- 스레드 덤프 2~3개 비교: 같은 락에서 멈추나?
- JFR에서 blocked/parked 상위 이벤트 확인
- pinning 징후(동기화 + 블로킹 I/O) 확인
7) 개선 패턴: 가상 스레드와 잘 맞는 설계로 정리
가상 스레드는 “블로킹을 허용하면서도 확장성 확보”가 목표입니다. 그래서 아래 패턴이 특히 중요합니다.
7.1 타임아웃/격리/벌크헤드는 필수
가상 스레드로 동시성이 커질수록, 느린 의존성이 전체를 끌어내릴 확률이 커집니다.
- 외부 API: 짧은 타임아웃 + 제한된 재시도
- DB: 짧은 트랜잭션 + 쿼리 최적화 + 풀 모니터링
- 공통: 엔드포인트별 동시성 상한(벌크헤드)
7.2 synchronized 블록 안에서 I/O 금지
아래 코드는 pinning/경합의 지뢰가 됩니다.
synchronized (lock) {
// DB/HTTP 같은 블로킹 I/O를 여기서 하면 위험
callExternalApi();
}
대신, 락은 상태 변경 최소 구간에만 사용합니다.
String token;
// 1) 상태 읽기/갱신만 락으로 보호
synchronized (lock) {
token = cachedToken;
}
// 2) 블로킹 I/O는 락 밖에서
var resp = callExternalApi(token);
7.3 풀(커넥션/스레드/큐) 크기는 “끝”이 아니라 “증거”로 조정
가상 스레드 도입 후 흔한 실수는 “스레드가 많아졌으니 풀도 키우자”입니다. 풀은 병목을 완화할 수도 있지만, DB/외부 시스템을 더 강하게 때려 전체 지연을 악화시킬 수도 있습니다. 반드시 메트릭(p95/p99, error rate, saturation)을 근거로 조정하세요.
8) 마무리: 결론은 ‘가상 스레드가 느린 게 아니라, 병목이 드러난 것’
Spring Boot 3에서 가상 스레드를 켰는데 응답 지연이나 데드락 의심 증상이 나타나면, 대부분은 다음 중 하나로 귀결됩니다.
- 커넥션 풀/외부 API 풀 같은 자원 상한에 더 빨리 도달했다
- 락/동기화 설계가 동시성 증가를 견디지 못했다
- WebFlux 이벤트 루프 블로킹 같은 모델 혼용 문제가 있었다
- pinning을 유발하는 코드 패턴이 숨어 있었다
스레드 덤프와 JFR, 그리고 최소한의 메트릭(Hikari/HTTP latency)만 갖추면 “감으로 튜닝”하지 않고도 원인을 빠르게 확정할 수 있습니다. 다음 단계로는, 문제를 재현 가능한 부하 테스트로 고정하고(동일 트래픽/동일 데이터), 변경 전후를 JFR/메트릭으로 비교해 개선이 실제로 지연(p99)을 줄였는지까지 확인하는 것을 권합니다.