- Published on
Spring Boot 3 가상스레드로 응답지연 잡는 실전 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
가상스레드(virtual thread)는 "스레드 부족"으로 인한 대기 시간을 줄이는 데 강력하지만, 적용만으로 모든 응답지연이 사라지지는 않습니다. 특히 Spring Boot 3에서 가상스레드를 켠 뒤에도 p95, p99 응답 시간이 튀는 경우는 대개 다음 중 하나로 귀결됩니다.
- 가상스레드 위에서 블로킹 호출이 과도하게 길어짐(DB, 외부 API, 파일 I/O)
- 톰캣/서블릿 컨테이너 설정이 가상스레드에 맞게 조정되지 않음
@Async, 스케줄러, 커넥션 풀 등 주변 풀들이 병목- 관측(메트릭/트레이싱) 부재로 "어디서" 지연되는지 모름
이 글은 Spring Boot 3 + Spring MVC(서블릿 스택) + Tomcat 조합을 기준으로, 가상스레드 적용 시 흔히 겪는 응답지연을 원인별로 분해하고 재현-측정-개선 순서로 해결하는 실전 체크리스트를 제공합니다.
관련해서 DB 병목이 함께 보이면 Spring Boot 3 JPA N+1 폭발을 끝내는 법도 같이 점검하는 것을 권합니다.
1) 가상스레드가 해결하는 것과 못하는 것
가상스레드는 "요청당 스레드" 모델에서 플랫폼 스레드(커널 스레드)를 무한정 늘리지 않고도, 많은 동시성을 처리할 수 있게 해줍니다. 즉, 다음 상황에서 효과가 큽니다.
- 동시 요청이 많고
- 각 요청이 I/O 대기(네트워크, DB)로 시간을 보내며
- 플랫폼 스레드 부족으로 큐 대기가 생기던 경우
반면 아래는 가상스레드만으로 해결되지 않습니다.
- DB 커넥션 풀이 작아서 커넥션 대기 시간이 길다
- 외부 API가 느리거나 타임아웃이 길다
- 동기식 로깅, 대용량 직렬화, 암호화 등 CPU 작업이 길다
- 락 경합, 데드락, 트랜잭션 대기
핵심은 "스레드"가 병목인지, "다른 리소스"가 병목인지 분리하는 것입니다.
2) Spring Boot 3에서 가상스레드 켜는 방법과 확인
2.1 설정
Spring Boot 3.2+ 기준으로 가장 단순한 방법은 다음 설정입니다.
spring:
threads:
virtual:
enabled: true
이 설정은 서블릿 요청 처리와 @Async 등 프레임워크 내부 실행 경로에 가상스레드를 적용합니다(버전/환경에 따라 적용 범위가 다를 수 있으니 반드시 확인이 필요합니다).
2.2 정말 가상스레드로 돌고 있는지 로그로 확인
컨트롤러에서 현재 스레드 정보를 찍어보면 빠르게 확인할 수 있습니다.
@RestController
public class ThreadCheckController {
@GetMapping("/thread")
public String thread() {
Thread t = Thread.currentThread();
return "name=" + t.getName() + ", isVirtual=" + t.isVirtual();
}
}
응답에 isVirtual=true가 나오지 않으면, 설정이 적용되지 않았거나(프로파일/환경변수), 컨테이너/부트 버전 차이로 기대한 경로에 적용이 안 된 것입니다.
3) 가상스레드 적용 후에도 응답지연이 생기는 대표 원인 6가지
원인 1) DB 커넥션 풀 고갈로 인한 대기
가상스레드는 스레드 수를 늘려주지만, DB 커넥션 수는 늘려주지 않습니다. 동시 요청이 늘어나면 오히려 커넥션 풀 대기가 더 자주 발생해 p95, p99가 튈 수 있습니다.
증상
- CPU는 낮은데 응답이 느림
- 스레드 덤프에서 커넥션 획득 대기
- HikariCP 메트릭에서
pending증가
점검 포인트
- Hikari
maximumPoolSize가 트래픽/쿼리 시간에 비해 너무 작지 않은지 - 트랜잭션 범위가 불필요하게 넓지 않은지
- N+1, 느린 쿼리로 커넥션 점유 시간이 길지 않은지
Hikari 메트릭을 노출하고 보면 바로 드러납니다.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
그리고 Prometheus나 Actuator에서 다음을 확인합니다.
hikaricp.connections.activehikaricp.connections.pendinghikaricp.connections.timeout
N+1로 커넥션 점유가 길어지는 케이스는 매우 흔하니, 위에서 언급한 내부 글(Spring Boot 3 JPA N+1 폭발을 끝내는 법)로 함께 정리해두면 좋습니다.
원인 2) 외부 API 호출이 느린데 타임아웃이 길다
가상스레드 환경에서는 "느린 외부 API"가 더 많은 동시 요청에서 동시에 발생할 수 있습니다. 스레드가 많으니 더 많은 요청이 동시에 외부로 나가고, 그 결과 다음 문제가 생깁니다.
- 외부 API 제공자 측 레이트리밋/큐 대기
- 우리 서비스의 커넥션 풀(HTTP client pool) 고갈
- 타임아웃이 길어서 tail latency가 커짐
해결 포인트
- connect/read/response 타임아웃을 명시
- 호출 동시성 제한(벌크헤드)
- 재시도는 지수 백오프와 함께, 멱등성 보장 범위에서만
RestClient(Spring 6)나 WebClient를 쓰더라도 "블로킹" 방식으로 호출하면 가상스레드에서는 괜찮지만, 타임아웃이 길면 p99가 그대로 늘어납니다.
예시: RestClient에 타임아웃 명시(구현체에 따라 설정 방식이 다르므로 개념 예시로 봐주세요).
@Bean
RestClient restClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.requestFactory(requestFactory -> {
// 예: connectTimeout, readTimeout 등을 반드시 설정
})
.build();
}
원인 3) 톰캣 스레드/큐 설정을 그대로 두어 병목이 남는다
가상스레드를 켰다고 해서 톰캣의 모든 튜닝이 무의미해지는 것은 아닙니다. 특히 다음과 같은 설정이 tail latency에 영향을 줍니다.
- accept queue 관련 설정
- 커넥터 설정
- keep-alive 및 connection 처리
다만 "톰캣 작업 스레드 수"를 과거처럼 크게 늘리는 방식은 가상스레드와 목적이 겹치거나 부작용이 날 수 있습니다. 중요한 것은 어디에서 큐잉이 발생하는지를 먼저 보는 것입니다.
권장 접근
- 부하 테스트로 p95, p99가 튀는 구간에서
- 톰캣 액세스 로그의
D(요청 처리 시간)와 - 애플리케이션 내부 메트릭(핵심 의존성의 latency)
를 함께 확인해 "컨테이너 진입 전"인지 "진입 후"인지 분리합니다.
원인 4) @Async나 스케줄러가 플랫폼 스레드 풀에서 병목
가상스레드를 켜도, 코드 어딘가에서 여전히 플랫폼 스레드 풀을 쓰고 있다면 그 풀에서 병목이 생깁니다.
대표 케이스
@Async에 커스텀Executor를 물려놨는데 고정 스레드 풀TaskScheduler가 단일 스레드- 배치/이벤트 처리용 별도 풀의 큐가 쌓임
해결
@Async실행기를 가상스레드 기반으로 바꾸거나- 최소한 풀 크기, 큐 용량, 거부 정책을 트래픽에 맞게 재조정
가상스레드 기반 실행기 예시입니다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
}
주의: 무조건 가상스레드로 바꾸기 전에, 해당 작업이 CPU 바운드인지 I/O 바운드인지부터 구분해야 합니다. CPU 바운드는 가상스레드로 "동시성"만 늘면 오히려 컨텍스트 스위칭과 경쟁이 늘어날 수 있습니다.
원인 5) 동기 로깅, MDC, 트레이싱 전파 비용
응답지연의 의외의 범인이 로깅인 경우가 많습니다.
- 대량 로그를 동기 append
- JSON 로그 직렬화 비용
- 트레이싱 span 생성 비용
가상스레드 환경에서 요청 동시성이 증가하면 로그/트레이싱도 함께 증가해, I/O 또는 CPU를 잡아먹고 tail latency를 키울 수 있습니다.
대응
- 로그 레벨 재정의(핫패스에서
INFO남발 금지) - 비동기 로깅(예: Logback AsyncAppender) 검토
- 샘플링 기반 트레이싱 적용
원인 6) 락 경합, 트랜잭션 대기, DB 데드락
가상스레드는 락 경합을 해결하지 않습니다. 오히려 동시성이 늘면 경합이 더 잘 드러납니다.
- 동일 row 업데이트 경쟁
- 유니크 인덱스 충돌
- 갭락/넥스트키락으로 인한 대기
이 경우는 DB 레벨 분석이 필요합니다. 특히 MySQL을 쓴다면 데드락 로그를 기반으로 재현하는 루틴이 큰 도움이 됩니다.
4) "응답지연"을 제대로 쪼개는 측정 전략
가상스레드 튜닝은 감으로 하면 실패합니다. 최소한 아래 3가지를 분리해서 봐야 합니다.
- 컨테이너 진입 전 대기(accept queue, 커넥션 대기)
- 애플리케이션 내부 처리 시간
- 의존성(DB, 외부 API) 대기 시간
4.1 서버 타이밍을 로그로 남기기
필수는 아니지만, 간단한 서블릿 필터로 요청 처리 시간을 남기면 "애플리케이션 내부"의 분포를 빠르게 볼 수 있습니다.
@Component
public class LatencyLogFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
long start = System.nanoTime();
try {
filterChain.doFilter(request, response);
} finally {
long tookMs = (System.nanoTime() - start) / 1_000_000;
logger.info("method={} path={} status={} tookMs={} isVirtual={}",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
tookMs,
Thread.currentThread().isVirtual()
);
}
}
}
이 로그만으로도 "가상스레드로 도는 요청"과 "특정 엔드포인트"의 p99를 비교할 수 있습니다.
4.2 의존성별 타이머(메트릭) 추가
DB 호출, 외부 API 호출에 타이머를 붙이면 "어디가 느린지"가 명확해집니다. Micrometer를 사용하면 다음처럼 감쌀 수 있습니다.
@Service
public class ExternalCallService {
private final RestClient restClient;
private final MeterRegistry registry;
public ExternalCallService(RestClient restClient, MeterRegistry registry) {
this.restClient = restClient;
this.registry = registry;
}
public String call() {
Timer timer = registry.timer("dep.external_api.latency", "api", "example");
return timer.record(() ->
restClient.get().uri("/v1/data").retrieve().body(String.class)
);
}
}
이후 p95, p99가 외부 API에서 발생하는지, 애플리케이션 내부에서 발생하는지 분리됩니다.
5) 실전 처방: 가상스레드 환경에서 응답지연 줄이는 체크리스트
5.1 타임아웃을 먼저 줄여 tail을 잘라내기
- DB 쿼리 타임아웃
- HTTP client 타임아웃
- 서킷 브레이커(또는 최소한 실패 빠르게)
p99가 크면 사용자 경험이 급격히 나빠집니다. 타임아웃을 합리적으로 설정해 "무한 대기"를 제거하는 것만으로도 체감이 큽니다.
5.2 커넥션 풀을 "동시성"에 맞게 재산정
가상스레드로 동시 요청이 늘면 커넥션 풀도 함께 재검토해야 합니다.
maximumPoolSize를 무작정 키우기보다- DB가 감당 가능한 동시 커넥션 수와
- 평균 쿼리 시간, 트래픽 피크를 기반으로
산정해야 합니다.
5.3 트랜잭션 범위를 줄이고, 느린 쿼리를 제거
- 불필요한
@Transactional(특히 read 작업에서 쓰기 락 유발) 제거 - N+1 제거, 인덱스 점검
- 페이징/정렬 쿼리의 실행 계획 확인
5.4 CPU 바운드 작업은 별도 풀로 격리
가상스레드는 I/O 대기에 강합니다. 반대로 CPU 바운드 작업을 무제한 동시 실행하면 CPU 경합으로 전체 tail이 늘 수 있습니다.
- 이미지 처리, 암호화, 대규모 JSON 변환 등은
- 제한된 플랫폼 스레드 풀로 격리하거나
- 비동기 파이프라인으로 분리
이때 벌크헤드 패턴이 잘 먹힙니다.
5.5 부하 테스트 시나리오를 "현실"에 맞추기
가상스레드는 특정 부하 패턴에서만 이점을 극대화합니다.
- 동시 사용자 수
- 요청 분포(핫 엔드포인트)
- 외부 의존성의 지연/실패 주입
을 포함해 테스트해야 합니다. 단순히 RPS만 올리면, 실제 장애 패턴(p99 튐, 외부 API 느려짐)과 다른 결론이 나올 수 있습니다.
6) 흔한 오해: WebFlux로 바꾸면 더 빨라지나
"가상스레드로도 느리니 WebFlux로 가자"는 결론은 성급할 수 있습니다.
- WebFlux는 이벤트 루프 기반이며, 블로킹 호출을 섞으면 오히려 더 위험합니다.
- 가상스레드 + MVC는 기존 코드(블로킹 기반)를 유지하면서 동시성을 올리는 데 실용적입니다.
현재 병목이 DB/외부 API/락 경합이라면, 스택 변경보다 병목 제거가 먼저입니다.
7) 결론: 가상스레드는 "스레드" 병목을 없애는 도구일 뿐
Spring Boot 3에서 가상스레드를 적용했는데도 응답지연이 남는다면, 대부분은 다음 순서로 해결됩니다.
isVirtual=true로 적용 여부부터 확인- p95, p99를 엔드포인트별로 분해
- Hikari pending, 외부 API latency 같은 의존성 메트릭으로 병목 위치 특정
- 타임아웃/풀/쿼리/락 경합을 우선순위로 제거
가상스레드는 "스레드가 부족해서 생기는 큐 대기"를 크게 줄여주지만, 그 다음 병목(커넥션 풀, 느린 쿼리, 외부 API, 로깅, 락 경합)을 더 선명하게 드러냅니다. 결국 응답지연 해결의 핵심은 가상스레드 자체가 아니라, 지연을 구성 요소별로 쪼개고 병목을 제거하는 관측과 튜닝입니다.