- Published on
Spring Boot 3 가상스레드 병목·튜닝 9단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 가상스레드(virtual thread)를 적용하면 "스레드 풀 고갈"이 사라지고 동시성이 폭발적으로 늘 것처럼 보이지만, 실제 운영에서는 TPS가 기대만큼 오르지 않거나 P99 지연이 오히려 튀는 경우가 흔합니다. 이유는 간단합니다. 가상스레드는 스레드 비용을 낮출 뿐, I/O 자원(커넥션, 파일 디스크립터), 락 경합, 블로킹 경계, 외부 의존성 같은 병목을 없애지 못합니다.
이 글은 Spring Boot 3에서 가상스레드를 적용한 뒤 흔히 만나는 병목을 9단계로 진단하고 튜닝하는 실전 체크리스트입니다. 각 단계는 "증상"과 "확인 방법" 그리고 "조치"로 구성했습니다.
0. 전제: 가상스레드가 잘 맞는 워크로드
가상스레드는 대체로 다음 조건에서 효과가 큽니다.
- 요청당 블로킹 I/O가 많고(HTTP 호출, JDBC, 파일 I/O)
- CPU 사용률은 낮거나 중간이며
- 동시 요청이 많아 플랫폼 스레드(커널 스레드) 풀이 병목이던 시스템
반대로 CPU 바운드(암호화, 이미지 처리, 대규모 JSON 변환, 복잡한 알고리즘)가 주류라면 가상스레드로 얻는 이득은 제한적이고, 오히려 컨텍스트 전환 증가로 손해를 볼 수 있습니다.
1단계: "정말" 가상스레드로 처리 중인지 확인
가장 흔한 실패는 설정은 했는데 실제 요청 처리 경로가 여전히 플랫폼 스레드 풀에 묶여 있는 경우입니다. 특히 커스텀 TaskExecutor, 서드파티 라이브러리, 스케줄러가 섞이면 쉽게 어긋납니다.
체크 포인트
- 요청 처리 스레드 이름에
VirtualThread가 보이는가 @Async가 기본 executor를 타는가, 커스텀 풀을 타는가- 스케줄러/배치 스레드가 별도 풀로 남아 있지 않은가
코드: 가상스레드 적용(서블릿 스택 기준)
Spring Boot 3.2+에서는 다음 프로퍼티로 톰캣 요청 처리를 가상스레드로 전환할 수 있습니다.
spring.threads.virtual.enabled=true
코드: 런타임에서 스레드 타입 로깅
@Slf4j
@RestController
public class ThreadCheckController {
@GetMapping("/thread")
public String thread() {
Thread t = Thread.currentThread();
log.info("name={}, isVirtual={}", t.getName(), t.isVirtual());
return t.getName() + " / isVirtual=" + t.isVirtual();
}
}
조치
- 설정만으로 끝내지 말고, 핵심 엔드포인트에서 실제로 가상스레드가 찍히는지 먼저 보장하세요.
2단계: 관측부터 정리(지표·트레이스·스레드덤프)
가상스레드 환경에서는 "스레드 덤프 한 장"으로 끝나는 디버깅이 어려워집니다. 스레드 수가 많고, 블로킹이 정상일 수도 있기 때문입니다. 대신 지표 기반으로 병목을 좁혀야 합니다.
필수 지표
- CPU 사용률, 런큐(load average)
- GC 시간 및 STW 비율
- HTTP 서버: active requests, request latency(P50/P95/P99)
- DB: 커넥션 풀 사용률, 대기 시간, 쿼리 시간
- OS: 파일 디스크립터 사용량, 소켓 상태
조치
- Micrometer + Prometheus + Grafana(또는 APM)로 최소한
http.server.requests, JVM, Hikari 지표를 붙이세요. - JFR(Java Flight Recorder)을 켜서 블로킹/락/할당을 관측할 수 있게 하세요.
3단계: DB 커넥션 풀이 "새 병목"이 됐는지 확인
가상스레드로 동시성이 늘면 가장 먼저 터지는 곳이 DB입니다. 요청 스레드는 가상으로 가벼워졌지만, DB 커넥션은 여전히 제한된 물리 자원이라서 풀 대기 시간이 급증합니다.
증상
- TPS는 안 오르는데 동시 요청만 늘어남
- P99 지연이 계단식으로 증가
- Hikari
active가maximumPoolSize에 붙고pending이 증가
확인 방법
- HikariCP metrics에서
pending/active/timeout확인 - DB에서
max_connections, slow query, 락 대기 확인
조치
- 무작정 풀을 키우지 말고, 먼저 쿼리 시간과 트랜잭션 범위를 줄이세요.
- 풀을 키울 때는 DB의
max_connections, CPU, 메모리, WAL/락 상황을 같이 보세요.
Hikari 누수나 대기 문제를 더 깊게 다루는 글은 아래를 참고하세요.
코드: Hikari 타임아웃을 "빠르게" 실패시키기
대기열이 길어질수록 지연이 전파됩니다. 타임아웃을 짧게 두고 빠르게 실패시키는 전략이 때로는 더 안정적입니다.
spring.datasource.hikari.connection-timeout=1000
spring.datasource.hikari.maximum-pool-size=30
4단계: 파일 디스크립터(FD) 한도에 먼저 닿는지 확인
가상스레드로 동시 요청이 늘면 소켓도 늘고, 결과적으로 프로세스가 열 수 있는 파일 디스크립터 한도에 먼저 닿습니다. 이때 애플리케이션은 갑자기 네트워크 오류, DB 연결 실패, 로그 파일 쓰기 실패 같은 형태로 무너집니다.
증상
- 간헐적으로
Too many open files발생 - 외부 API 호출이 타임아웃/연결 실패로 급증
- DB 커넥션 생성 실패
확인 방법
lsof -p로 FD 개수 확인ulimit -n및 systemdLimitNOFILE확인
조치
- OS 및 컨테이너 런타임의 FD 제한을 올리고
- 커넥션 재사용(HTTP keep-alive, 커넥션 풀)을 강제하며
- 누수(응답 바디 미닫기, 스트림 미닫기)를 제거하세요.
운영에서 즉시 진단하는 방법은 아래 글이 실전적입니다.
5단계: HTTP 클라이언트가 병목인지(커넥션 풀, DNS, 타임아웃)
가상스레드 환경에서 외부 API 호출이 많다면, 병목은 애플리케이션 스레드가 아니라 HTTP 클라이언트의 커넥션 풀과 타임아웃 정책에서 생깁니다.
흔한 함정
- 요청당 새 커넥션 생성(keep-alive 미사용)
- 너무 큰 커넥션 풀로 원격 서버를 과부하
- 타임아웃 미설정으로 가상스레드가 "무한 대기"(리소스는 적지만 지연은 전파)
조치
- 커넥션 풀 상한을 두고, 요청 타임아웃을 반드시 설정하세요.
- 외부 의존성에는 bulkhead(동시 호출 제한)와 circuit breaker를 고려하세요.
코드: RestClient에 타임아웃 설정(예시)
아래는 Apache HttpClient 기반 request factory를 붙여 타임아웃을 강제하는 예시입니다.
@Bean
RestClient restClient() {
var requestFactory = new HttpComponentsClientHttpRequestFactory();
requestFactory.setConnectTimeout(500);
requestFactory.setConnectionRequestTimeout(500);
requestFactory.setReadTimeout(1500);
return RestClient.builder()
.requestFactory(requestFactory)
.build();
}
6단계: synchronized, ReentrantLock, DB 락으로 인한 "핀(pin)"과 경합
가상스레드는 블로킹 시 언마운트(unmount)되어 캐리어 스레드를 다른 작업에 양보할 수 있습니다. 하지만 특정 상황에서는 가상스레드가 캐리어에 핀되어 양보하지 못합니다. 대표적으로 모니터 락(synchronized)을 잡고 블로킹 I/O를 수행하는 패턴이 문제를 키웁니다.
증상
- CPU는 낮은데 지연이 길어짐
- 스레드는 많지만 처리량이 안 나옴
- 특정 락을 중심으로 대기열이 길어짐
확인 방법
- JFR에서 monitor contention, pinned virtual threads 관련 이벤트 확인
- 락을 잡은 상태로 I/O 호출하는 코드 탐색
조치
synchronized블록 안에서 I/O를 하지 않기- 공유 mutable state를 줄이고, 불변 객체/락 분할/
ConcurrentHashMap등으로 경합 완화 - DB 락(행 락, 갭 락)도 동일한 병목이므로 트랜잭션 범위를 최소화
코드: 나쁜 예와 개선 예
// 나쁜 예: 락 잡고 원격 호출
synchronized (lock) {
return restClient.get().uri(url).retrieve().body(String.class);
}
// 개선: I/O는 락 밖에서, 공유 상태는 최소화
String resp = restClient.get().uri(url).retrieve().body(String.class);
synchronized (lock) {
cache.put(key, resp);
}
return resp;
7단계: 스레드 수가 아니라 "동시성 제한"을 설계(bulkhead)
가상스레드로 "무한 동시성"이 가능해 보이지만, 실제로는 DB 커넥션, 외부 API QPS, 내부 큐, 파일 디스크립터 등 물리 한계가 있습니다. 따라서 가상스레드 환경일수록 명시적인 동시성 제한이 중요합니다.
증상
- 부하가 오를수록 전체 지연이 동반 상승
- 외부 API가 느려지면 내부까지 연쇄 타임아웃
조치
- 리소스별로 동시성 제한을 둡니다.
- DB 작업 동시성은 커넥션 풀 크기와 정렬
- 외부 API는 엔드포인트별 세마포어로 제한
- 제한에 걸리면 빠르게 실패하거나, 큐잉을 짧게
코드: 세마포어로 외부 API 동시 호출 제한
@Component
public class ExternalApiBulkhead {
private final Semaphore sem = new Semaphore(50);
public <T> T call(Callable<T> task) throws Exception {
if (!sem.tryAcquire(1, java.time.Duration.ofMillis(100))) {
throw new IllegalStateException("bulkhead full");
}
try {
return task.call();
} finally {
sem.release();
}
}
}
8단계: GC와 메모리 할당(특히 요청 폭증 시)
가상스레드 자체는 가볍지만 "요청을 더 많이 받아" 처리하게 되면서, 결과적으로 객체 할당이 늘고 GC가 병목이 되는 경우가 있습니다. 즉, 병목이 스레드에서 메모리/GC로 이동합니다.
증상
- CPU가 GC에 잡아먹힘
- P99가 주기적으로 튐(톱니 모양)
- Old gen 증가, promotion 실패
확인 방법
- JVM GC 로그, APM, JFR의 allocation profiling
조치
- 큰 객체 생성(대용량 JSON, 문자열 concat) 줄이기
- 압축/암호화 같은 CPU 작업은 별도 제한 또는 배치
- 필요 시 JVM 옵션과 힙 사이징 재조정(무작정 크게 말고, GC 패턴 기반)
9단계: 튜닝 후 "재현 가능한" 부하 테스트로 검증
가상스레드 튜닝은 한 번에 끝나지 않습니다. 반드시 재현 가능한 시나리오로 측정하고, 변경 전후를 비교해야 합니다.
권장 시나리오
- 정상 트래픽(평균) + 피크(버스트) + 장애(외부 API 2초 지연) 3종
- 성공률, P95/P99, 오류율, DB 대기, FD 사용량, GC 시간을 함께 기록
조치
- 목표를 "TPS" 하나로 두지 말고,
P99, 오류율, 자원 사용량을 함께 목표로 설정 - 외부 의존성이 느려질 때 시스템이 어떻게 degrade 되는지(빠른 실패 vs 무한 대기)까지 포함
정리: 9단계 체크리스트(요약)
- 가상스레드가 실제 요청 경로에 적용됐는지 로그로 확인
- 지표·트레이스·JFR로 관측 기반 디버깅 준비
- DB 커넥션 풀 대기가 새 병목인지 확인하고 쿼리/트랜잭션부터 최적화
- FD 한도(Too many open files)로 먼저 무너지는지 점검
- HTTP 클라이언트 커넥션 풀과 타임아웃 정책 정비
synchronized/락 경합 및 핀 가능성 제거(락 안에서 I/O 금지)- 리소스별 bulkhead로 동시성 제한 설계
- 처리량 증가로 인한 GC/할당 병목 점검
- 재현 가능한 부하 테스트로 전후 비교 및 회귀 방지
가상스레드는 "스레드 풀 고갈"을 해결하는 강력한 도구지만, 운영 병목은 대개 그 다음 레이어(커넥션, FD, 락, 외부 의존성)에서 발생합니다. 위 9단계를 순서대로 밟으면, "적용했는데 왜 느리지"에서 "어디가 막히는지 알고, 어떤 레버를 당길지"로 관점이 바뀌고 튜닝 속도가 빨라집니다.