- Published on
Spring Boot 3 가상스레드 적용 후 지연 폭증 진단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
가상스레드는 "스레드 수" 제약을 크게 완화하지만, "블로킹이 사라진다"는 뜻은 아닙니다. Spring Boot 3에서 가상스레드를 적용한 직후 p95, p99 지연이 오히려 폭증하는 경우는 꽤 흔합니다. 이유는 대개 다음 중 하나입니다.
- 가상스레드가 특정 구간에서 핀(pin) 되어 캐리어 스레드를 장시간 점유
- DB, HTTP, 메시징 등 외부 리소스 풀(커넥션 풀) 이 병목인데 요청 동시성만 증가
- 기존에 숨겨져 있던 블로킹 호출 이 동시성 증가로 더 자주, 더 길게 드러남
- 타임아웃이 느슨하거나 전파가 안 되어 대기열이 길어지고 꼬리 지연(tail latency) 이 커짐
이 글은 "왜 느려졌는지"를 감으로 추측하지 않고, 재현과 관측 지표를 기준으로 원인을 좁혀가는 진단 흐름을 제공합니다.
참고로 타임아웃 설계 자체가 꼬리 지연을 크게 좌우합니다. 가상스레드 적용 후 타임아웃 전파가 더 중요해지는 이유는 gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계 글의 관점과도 맞닿아 있습니다.
1) 먼저 확인: "가상스레드가 실제로 켜졌는가"
Spring Boot 3.2+ 기준으로 가장 흔한 적용 방식은 다음 중 하나입니다.
- 톰캣 요청 처리를 가상스레드로
@Async,TaskExecutor를 가상스레드 기반으로
설정 예시는 아래처럼 시작하는 경우가 많습니다.
spring:
threads:
virtual:
enabled: true
런타임에서 실제로 가상스레드가 쓰이는지 로그나 스레드 이름으로 확인합니다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@RestController
class ThreadCheckController {
private static final Logger log = LoggerFactory.getLogger(ThreadCheckController.class);
@GetMapping("/thread")
String thread() {
Thread t = Thread.currentThread();
log.info("threadName={}, isVirtual={}", t.getName(), t.isVirtual());
return t.getName() + " / virtual=" + t.isVirtual();
}
}
여기서 isVirtual=true 가 보이지 않는다면, "가상스레드 적용 후 느려졌다"가 아니라 "적용이 안 됐는데 다른 변경으로 느려졌다"일 수 있습니다.
2) 증상 분류: 평균이 아니라 p95, p99가 뛰는가
가상스레드 적용 후 지연 폭증은 평균보다 꼬리 지연에서 두드러집니다. 다음 질문으로 증상을 분류하면 원인 후보가 빠르게 좁혀집니다.
- CPU 사용률이 올라갔나, 아니면 낮은데 지연만 늘었나
- RPS는 늘었나, 아니면 동일한데 지연만 늘었나
- DB 커넥션 사용률, 대기 시간, 외부 HTTP 커넥션 풀 대기가 늘었나
- GC, 컨텍스트 스위칭, 커널 대기(
iowait)가 늘었나
여기서 핵심은 "스레드가 많아져서 처리량이 늘었다"가 아니라 "대기할 수 있는 주체가 많아져서 풀 대기가 폭발"하는 패턴이 자주 나온다는 점입니다.
3) 가장 흔한 원인 1: 커넥션 풀 병목이 꼬리 지연을 만든다
가상스레드는 요청마다 스레드를 쉽게 할당하므로, 애플리케이션 레벨 동시성이 급격히 증가합니다. 그런데 DB 커넥션 풀은 그대로면 어떤 일이 벌어질까요.
- 가상스레드 수천 개가 동시에 DB를 치려고 함
- HikariCP 같은 풀은 예를 들어 20개 커넥션만 제공
- 나머지 요청은 풀에서 커넥션을 기다리며 대기
- 대기가 누적되면 p95, p99가 급등
관측 포인트
- HikariCP 메트릭에서
pending또는 대기 관련 지표 - DB 서버에서 활성 커넥션 수가 상한에 붙는지
- 애플리케이션 로그에서 커넥션 획득 지연
즉시 할 수 있는 실험
- 부하 테스트에서 동시 요청 수를 줄이면 지연이 즉시 정상화되는지
- HikariCP
maximumPoolSize를 올렸을 때 지연이 줄어드는지
단, 풀을 무작정 키우면 DB가 먼저 죽습니다. 해결의 방향은 보통 아래 중 하나입니다.
- 애플리케이션 동시성을 제한하는 세마포어, 벌크헤드 적용
- 쿼리 튜닝, 인덱스 보강, N+1 제거
- 풀 크기는 DB가 감당 가능한 범위로만 확장
가상스레드는 "대기 비용"을 낮추지만, "리소스 한계"를 없애지는 못합니다.
4) 가장 흔한 원인 2: 가상스레드 핀(pin)으로 캐리어 스레드가 막힌다
가상스레드는 캐리어 스레드 위에서 스케줄링됩니다. 그런데 특정 상황에서는 가상스레드가 언마운트되지 못하고 캐리어 스레드에 붙어버리는데, 이를 핀이라고 부릅니다.
대표적인 핀 유발 패턴은 다음과 같습니다.
synchronized블록 안에서 블로킹 I/O 수행- 네이티브 호출, 일부 모니터 사용 패턴
- 오래된 라이브러리에서 모니터 락을 잡고 대기
핀의 증상
- CPU는 낮은데 처리량이 떨어지고 지연이 크게 증가
- 스레드는 많아 보이는데 실제로는 캐리어 스레드가 막혀 병목
JDK 플래그로 핀 감지
환경에 따라 다르지만, 핀 관련 진단을 위해 JDK 진단 옵션을 켜서 단서를 얻습니다.
java \
-Djdk.tracePinnedThreads=full \
-jar app.jar
로그에 핀 스택이 찍히면, 해당 코드 경로에서 synchronized 사용을 줄이거나 락 범위를 좁히고, 블로킹 호출을 락 밖으로 이동시키는 것이 1차 처방입니다.
문제 패턴 예시와 개선
class BadService {
private final Object lock = new Object();
String callExternal() {
synchronized (lock) {
// 락을 잡은 채로 블로킹 호출을 하면 핀 위험이 커짐
return blockingHttpCall();
}
}
}
개선은 "락 범위 최소화"가 기본입니다.
class BetterService {
private final Object lock = new Object();
String callExternal() {
String token;
synchronized (lock) {
token = computeToken();
}
return blockingHttpCallWithToken(token);
}
}
5) 가장 흔한 원인 3: 블로킹 호출이 늘어났는데 타임아웃이 느슨하다
가상스레드는 블로킹 자체를 허용하는 모델이라, 개발자가 "비동기 체인"을 짜지 않아도 된다는 장점이 있습니다. 하지만 그만큼 타임아웃과 취소가 부실하면 대기가 누적되고 꼬리 지연이 폭발합니다.
체크리스트
- DB 쿼리에 statement timeout이 있는가
- 외부 HTTP 클라이언트에 connect, read 타임아웃이 있는가
- 서버 요청 타임아웃과 하위 호출 타임아웃이 정렬되어 있는가
- 재시도가 있다면 지수 백오프와 최대 시도 횟수가 제한되어 있는가
예를 들어 외부 HTTP 호출에 타임아웃이 없다면, 가상스레드가 수천 개라도 결국 "끝나지 않는 대기"가 쌓여 시스템 전체가 굳습니다.
import java.net.http.*;
import java.time.Duration;
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(300))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/data"))
.timeout(Duration.ofMillis(800))
.GET()
.build();
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
타임아웃은 "빠른 실패"가 아니라 "시스템 보호 장치"입니다. 특히 꼬리 지연이 문제라면, 타임아웃 전파와 상한 설정이 가장 비용 대비 효과가 큽니다.
6) 스레드 덤프와 JFR로 "대기"의 정체를 잡는다
지연 폭증은 대개 "어디선가 기다린다"는 뜻입니다. 중요한 것은 무엇을 기다리는지입니다.
스레드 덤프
- 덤프에서
WAITING,TIMED_WAITING스레드가 어떤 락, 어떤 큐에서 대기하는지 - DB 커넥션 획득 대기라면 Hikari 관련 클래스가 반복 등장
- 락 경합이면 특정 모니터 소유자가 반복 등장
JFR(Java Flight Recorder)
JFR로 다음을 확인합니다.
- Monitor contention, synchronized 경합
- Socket read, file I/O 등 블로킹 이벤트
- CPU 샘플링으로 핫패스 확인
실무에서는 "가상스레드로 바꿨더니 느려짐"이라고 느끼지만, JFR을 보면 "커넥션 풀 대기" 또는 "락 경합"이 정직하게 드러나는 경우가 많습니다.
7) 톰캣, 서블릿, 필터 체인에서의 숨은 병목
Spring MVC 톰캣 환경에서 가상스레드를 켜면 요청 처리 스레드 모델이 바뀝니다. 이때 다음 병목이 지연을 키울 수 있습니다.
- 요청 바디를 한 번에 메모리로 읽는 필터, 로깅 필터
- 큰 응답을 생성하며 압축을 수행하는 구간
- 세션 저장소, 인증 토큰 검증에서 외부 I/O 호출
특히 "인증"은 모든 요청의 공통 경로라 꼬리 지연에 치명적입니다. EKS 같은 환경에서 STS 호출이 느려지는 패턴도 전체 지연을 밀어 올립니다. 클러스터 환경에서 인증 호출이 타임아웃이나 403으로 꼬이는 문제를 겪고 있다면 EKS IRSA에서 AssumeRoleWithWebIdentity 0s 타임아웃 해결 같은 케이스도 참고할 만합니다.
8) "동시성 무제한"을 피하라: 가상스레드에도 벌크헤드가 필요
가상스레드는 생성 비용이 낮아서 "요청당 스레드"가 가능해졌지만, 외부 자원은 여전히 제한적입니다. 따라서 다음과 같은 제한 장치가 필요합니다.
- DB 접근 동시성 제한
- 외부 API 호출 동시성 제한
- 큐 컨슈머의 동시 처리 제한
간단한 세마포어 벌크헤드 예시는 다음과 같습니다.
import java.util.concurrent.Semaphore;
class Bulkhead {
private final Semaphore sem = new Semaphore(50); // 외부 API 동시 호출 50 제한
<T> T call(Callable<T> action) throws Exception {
sem.acquire();
try {
return action.call();
} finally {
sem.release();
}
}
}
위 코드에서 제네릭 표기 T 는 MDX 렌더링에서 문제가 될 수 있으므로 반드시 인라인 코드로 감싸거나, 실제 글에서는 Callable 반환 타입을 구체화하는 방식으로도 작성할 수 있습니다.
예를 들어 아래처럼 반환 타입을 명시적으로 두는 방식이 더 안전합니다.
class Bulkhead {
private final Semaphore sem = new Semaphore(50);
String call(Callable<String> action) throws Exception {
sem.acquire();
try {
return action.call();
} finally {
sem.release();
}
}
}
핵심은 "가상스레드를 켰으니 무한 동시성을 허용"이 아니라, "병목 자원 앞에서 동시성을 설계"하는 것입니다.
9) 점검 순서 요약: 재현, 관측, 가설, 실험
지연 폭증을 빠르게 잡는 현실적인 순서는 다음과 같습니다.
- 부하 테스트로 지연 폭증을 재현하고, p95, p99를 수치로 고정
- DB 및 외부 호출의 풀 대기 메트릭을 확인
- JFR로 경합과 블로킹 이벤트를 확인
- 핀 감지 플래그로
synchronized기반 핀 여부 확인 - 타임아웃을 촘촘히 정렬하고, 재시도 정책을 제한
- 벌크헤드로 외부 자원 앞 동시성을 제한
이 과정을 밟으면 "가상스레드가 느리다"가 아니라, "가상스레드가 숨겨둔 병목을 드러냈다"는 결론에 도달하는 경우가 많습니다.
10) 결론: 가상스레드는 성능 기능이 아니라 모델 전환이다
Spring Boot 3 가상스레드는 스레드 모델을 단순화하고 확장성을 높여주지만, 병목의 위치를 바꿉니다.
- 예전에는 스레드 풀이 병목이었다면
- 이제는 커넥션 풀, 락, 외부 API, 타임아웃 설계가 병목이 됩니다
지연 폭증이 발생했다면, 가상스레드를 끄고 돌아가는 것보다 "무엇을 기다리는지"를 JFR과 메트릭으로 확인하고, 풀과 타임아웃, 벌크헤드로 시스템을 재설계하는 편이 장기적으로 더 큰 이득을 줍니다.
부하가 커질수록 꼬리 지연이 문제라면, 단순한 스레드 튜닝보다 "데드라인 전파"가 더 큰 차이를 만들 수 있습니다. 관련 설계 관점은 gRPC 타임아웃 지옥 탈출 - 데드라인 전파 설계 도 함께 참고해 두면 좋습니다.