Published on

Spring Boot 3 가상스레드 적용 후 응답지연 튜닝

Authors

가상스레드(virtual thread)는 “스레드가 가벼워져서 동시성 처리가 쉬워진다”는 장점 때문에 Spring Boot 3에서 매력적인 선택지입니다. 하지만 운영에 올리면 p95, p99 응답지연이 기대만큼 줄지 않거나 오히려 늘어나는 경우가 많습니다. 이유는 단순합니다. 가상스레드는 스레드 대기 비용을 줄여줄 뿐, 병목 그 자체(DB, 락, 외부 API, 커넥션 풀, CPU, GC) 를 없애주지 않습니다. 오히려 “동시에 더 많이 때릴 수 있게” 만들어 병목을 더 크게 드러내기도 합니다.

이 글은 Spring Boot 3에서 가상스레드를 적용한 뒤 응답지연이 발생했을 때, 어디부터 의심하고 어떤 순서로 튜닝해야 하는지 실전 관점에서 정리합니다. JVM 튜닝/GC/Netty 같은 영역은 별도 글인 Spring Boot 3.x p99 지연 폭증? JVM·GC·Netty 튜닝 도 함께 참고하면 전체 그림이 더 잘 잡힙니다.

1) 가상스레드 적용이 “지연”을 악화시키는 대표 패턴

1-1. DB 커넥션 풀이 병목인데 동시성만 증가

가상스레드를 켜면 요청당 스레드를 더 쉽게 할당할 수 있어, 애플리케이션은 더 많은 동시 요청을 처리하려고 합니다. 그런데 DB 커넥션 풀 크기(maxPoolSize)는 그대로라면, 결국 커넥션 획득 대기열이 길어지고 p99가 튑니다.

증상

  • 애플리케이션 CPU는 여유 있는데 지연이 증가
  • HikariPool-1 - Connection is not available 같은 타임아웃 또는 커넥션 대기 증가
  • DB는 QPS는 크게 늘지 않는데 대기만 늘어남

핵심은 “스레드가 가벼워졌으니 커넥션도 충분히 늘리자”가 아니라, DB가 감당 가능한 동시성을 기준으로 앱의 동시성을 제한하는 것입니다.

1-2. 락 경합이 병목인데 요청이 더 동시에 몰림

락(테이블 락, row 락, 애플리케이션 레벨 락, 분산 락)이 있는 구간은 동시성이 늘수록 대기열이 기하급수적으로 커집니다. 가상스레드는 이 대기열을 “더 쉽게” 만들어서 지연을 악화시킬 수 있습니다.

특히 다음 상황에서 빈번합니다.

  • 트랜잭션 범위가 넓고, 같은 row를 자주 갱신
  • 애그리게이트 경계가 커서 한 요청이 여러 엔티티를 잠금

락 이슈가 의심되면 DDD Aggregate 경계 실수로 락 폭증한 장애 해결기 같은 관점(경계 축소, 쓰기 경로 분리, 경쟁 키 분산)을 함께 적용해야 합니다.

1-3. “블로킹 호출”이 많으면 가상스레드도 결국 대기

가상스레드는 블로킹 I/O에서 특히 강합니다. 다만, 다음처럼 “블로킹 + 병목” 조합이면 지연이 줄지 않습니다.

  • 외부 API 호출이 느린데 타임아웃/재시도가 과함
  • DNS, TLS 핸드셰이크, 프록시 등 네트워크 레벨에서 지연
  • 파일 I/O, S3 업/다운로드 등이 요청 경로에 있음

가상스레드는 대기 중인 스레드 수를 늘려줄 뿐, 외부 시스템이 느리면 전체 지연은 그대로입니다. 따라서 “동시성 증가”가 외부 시스템을 더 압박해 지연이 더 커질 수도 있습니다.

2) Spring Boot 3에서 가상스레드 활성화 체크

Spring Boot 3.2+ 기준으로 가장 단순한 활성화는 설정 한 줄입니다.

spring:
  threads:
    virtual:
      enabled: true

MVC(Tomcat) 기반이라면 요청 처리 스레드가 가상스레드로 전환됩니다. 다만, 다음을 꼭 확인하세요.

  • 실제로 가상스레드가 사용되는지 스레드 덤프에서 VirtualThread가 보이는지 확인
  • 커스텀 TaskExecutor를 등록했다면 그 executor가 플랫폼 스레드를 쓰고 있을 수 있음

가상스레드 executor를 명시적으로 쓰고 싶다면 다음처럼 구성할 수 있습니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

@Configuration
public class VirtualThreadConfig {

  @Bean
  public Executor applicationExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
  }
}

주의할 점은 “모든 비동기 작업을 무조건 가상스레드로”가 정답이 아니라는 것입니다. CPU 바운드 작업은 가상스레드로 늘려도 처리량이 늘지 않고 컨텍스트 스위칭 비용만 증가할 수 있습니다.

3) 튜닝의 출발점: 지표를 “대기열” 중심으로 재구성

가상스레드 도입 후 튜닝은 평균 응답시간보다 p95, p99대기열(queue) 을 봐야 합니다.

권장 관측 항목

  • HTTP 서버: 요청 수, p95/p99, 에러율, 타임아웃
  • DB 풀: active, idle, pending(대기), acquisition time
  • DB 자체: lock wait, deadlock, slow query, buffer pool hit ratio
  • 외부 호출: dependency별 latency histogram, timeout, retry 횟수
  • JVM: GC pause, allocation rate, thread count(플랫폼/가상)

Micrometer를 쓴다면 Hikari 지표는 기본적으로 노출하기 쉽습니다.

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

그리고 Prometheus에서 다음 지표를 우선 확인합니다.

  • hikaricp_connections_pending
  • hikaricp_connections_active
  • hikaricp_connections_acquire_seconds

pending이 늘고 acquire 시간이 길어지면, 응답지연의 1차 원인은 “스레드”가 아니라 “커넥션 대기”일 가능성이 큽니다.

4) DB 커넥션 풀 튜닝: 크게 늘리기 전에 해야 할 것

4-1. 풀을 늘리면 DB가 버티는가부터 계산

가상스레드로 앱 동시성이 늘었다고 해서 풀을 단순히 2배, 3배로 늘리면 DB CPU가 먼저 터질 수 있습니다.

실무 접근

  1. 목표 TPS와 평균 쿼리 시간(또는 트랜잭션 시간)을 측정
  2. Little’s Law로 필요한 동시 DB 작업 수를 근사
  3. 그 범위 내에서 풀을 조정하고, 초과 트래픽은 앱에서 제한

예를 들어 평균 트랜잭션이 50ms이고 목표가 1000 TPS라면, 필요한 동시성은 대략 1000 * 0.05 = 50 정도입니다. 이때 풀을 200으로 늘리는 건 대부분 과합니다.

4-2. “커넥션 점유 시간”을 줄이는 것이 1순위

풀 크기보다 더 중요한 건 커넥션을 잡고 있는 시간입니다.

  • 트랜잭션 범위 축소
  • 불필요한 SELECT ... FOR UPDATE 제거
  • N+1 제거, 쿼리 수 감소
  • 외부 API 호출을 트랜잭션 안에서 하지 않기

특히 “트랜잭션 안에서 외부 호출”은 가상스레드 환경에서 더 위험해집니다. 요청이 많이 들어오면 DB 커넥션이 외부 호출 대기 때문에 오래 점유되고, pending이 폭발합니다.

4-3. 데드락과 락 대기 시간을 함께 봐야 한다

가상스레드로 동시성이 늘면 데드락 재현 빈도도 늘 수 있습니다. MySQL이라면 1213 데드락을 반드시 모니터링하고, 인덱스/접근 순서/트랜잭션 범위를 재정리해야 합니다.

관련해서 MySQL InnoDB 데드락(1213) 재현과 해결 가이드 의 재현 방법과 해결 체크리스트는 가상스레드 적용 후에도 그대로 유효합니다.

5) 애플리케이션 레벨 동시성 제한이 필요할 때

가상스레드를 켜면 “서버가 더 많은 요청을 동시에 처리하려는 성향”이 강해집니다. 하지만 DB, 외부 API, 메시지 브로커 등 하위 시스템이 병목이라면 애플리케이션에서 동시성을 제한하는 게 전체 p99를 안정화합니다.

대표 패턴

  • 특정 엔드포인트만 DB를 무겁게 사용
  • 특정 고객/테넌트 키에 쓰기가 집중
  • 특정 외부 API가 느리거나 레이트 리밋이 빡셈

간단한 세마포어 기반 제한 예시입니다.

import java.util.concurrent.Semaphore;

public class ConcurrencyGate {
  private final Semaphore semaphore;

  public ConcurrencyGate(int permits) {
    this.semaphore = new Semaphore(permits);
  }

  public <T> T run(CheckedSupplier<T> supplier) throws Exception {
    semaphore.acquire();
    try {
      return supplier.get();
    } finally {
      semaphore.release();
    }
  }

  @FunctionalInterface
  public interface CheckedSupplier<T> {
    T get() throws Exception;
  }
}

그리고 DB-heavy한 서비스 로직에 적용합니다.

public class OrderService {
  private final ConcurrencyGate gate = new ConcurrencyGate(50);

  public OrderResult placeOrder(PlaceOrderCommand cmd) throws Exception {
    return gate.run(() -> {
      // 트랜잭션 처리
      // DB write + 재고 차감 등
      return doPlaceOrder(cmd);
    });
  }
}

이 방식은 “처리량 최대화”보다 “꼬리 지연(p99) 안정화”에 유리합니다. 가상스레드 적용 후 p99가 튀는 시스템은 대부분 이 안정화가 더 중요합니다.

6) 외부 API 호출 튜닝: 타임아웃과 재시도 정책 재설계

가상스레드는 블로킹 I/O에 유리하지만, 외부 호출이 느리면 대기 스레드가 늘어나고 그만큼 메모리와 컨텍스트가 누적됩니다. 따라서 다음이 필수입니다.

  • connect timeout, read timeout을 짧게(서비스 SLO 기준)
  • 재시도는 “무조건”이 아니라 에러 코드/메서드/멱등성 기준으로 제한
  • 서킷 브레이커로 느린 의존성을 격리

Spring에서 RestClient 또는 WebClient를 쓰더라도, 타임아웃을 명시하세요.

import org.springframework.web.client.RestClient;

import java.time.Duration;

public class PartnerClient {
  private final RestClient restClient;

  public PartnerClient(RestClient.Builder builder) {
    this.restClient = builder
        .baseUrl("https://api.partner.example")
        .requestFactory(requestFactory -> {
          requestFactory.setConnectTimeout(Duration.ofMillis(300));
          requestFactory.setReadTimeout(Duration.ofMillis(800));
        })
        .build();
  }
}

타임아웃을 “길게” 잡으면 장애 시 p99가 길게 늘어지고, 가상스레드는 그 대기를 더 많이 수용해 장애를 더 늦게 감지하게 만들 수 있습니다.

7) JPA/Hibernate 환경에서 자주 터지는 지연 요인

가상스레드 적용과 무관해 보이지만, 적용 후 트래픽이 늘면서 잠복해 있던 문제가 표면화됩니다.

  • N+1 쿼리로 인해 요청당 쿼리 수 폭증
  • Open Session In View로 인해 트랜잭션 경계가 애매해지고, 지연이 웹 레이어까지 전파
  • flush 타이밍이 늦어 락 점유 시간이 길어짐

권장 체크

  • 엔드포인트별 “요청당 쿼리 수”를 측정
  • 가장 느린 상위 5개 쿼리의 실행 계획을 확인
  • 쓰기 트랜잭션에서 불필요한 엔티티 로딩을 제거

가상스레드 튜닝의 핵심은 “스레드”가 아니라 “요청당 비용”을 줄이는 데 있다는 점을 계속 상기해야 합니다.

8) 운영에서 바로 쓰는 진단 순서(체크리스트)

  1. p95/p99가 튄 구간을 엔드포인트별로 분해
  2. 동일 시간대에 hikaricp_connections_pending 증가 여부 확인
  3. DB slow query, lock wait, deadlock(예: MySQL 1213) 확인
  4. 외부 API dependency latency histogram 확인
  5. 애플리케이션에서 동시성 제한(세마포어) 또는 레이트 리밋 적용 검토
  6. 트랜잭션 범위 축소, 쿼리 수 감소, 인덱스 보강
  7. 그 다음에야 JVM/GC/네트워크 튜닝을 미세 조정

특히 2번에서 pending이 튄다면 “가상스레드가 문제”가 아니라 “가상스레드가 병목을 증폭시켜 보여준 것”일 확률이 매우 높습니다.

9) 결론: 가상스레드는 만능이 아니라 병목 증폭기다

Spring Boot 3 가상스레드는 블로킹 I/O 기반 서버의 동시성 모델을 크게 단순화하고, 스레드 부족으로 인한 처리량 한계를 완화합니다. 하지만 응답지연 튜닝의 본질은 여전히 다음에 있습니다.

  • DB 커넥션 대기와 락 경합을 줄인다
  • 요청당 DB 점유 시간을 줄인다
  • 외부 의존성 타임아웃/재시도/격리를 재설계한다
  • 필요하면 애플리케이션에서 동시성을 제한해 p99를 안정화한다

가상스레드를 켠 뒤 지연이 늘었다면, “스레드 설정”을 만지기 전에 대기열 지표(커넥션 pending, 락 대기, 외부 호출 대기)를 먼저 잡아내는 것이 가장 빠른 해결 루트입니다.