Published on

Spring Boot 3 가상스레드 병목 잡는 7단계

Authors

서버에 가상스레드(virtual thread)를 적용하면 "스레드가 부족해서" 생기던 병목은 크게 줄어듭니다. 하지만 실무에서는 가상스레드를 켰는데도 처리량이 그대로거나, 오히려 지연이 튀고 CPU가 놀거나, DB 커넥션이 바닥나면서 5xx가 늘어나는 일이 흔합니다.

핵심은 간단합니다.

  • 가상스레드는 블로킹을 싸게 만들어줄 뿐, 블로킹 자체를 없애지 않습니다.
  • 병목은 스레드가 아니라 커넥션 풀, 락, 외부 API 지연, 동기 I/O, GC, 로깅, 관측 오버헤드로 이동합니다.

아래 7단계는 "가상스레드 병목"을 재현 가능하게 잡기 위한 순서입니다. 앞 단계에서 원인을 좁혀야 뒤 단계 최적화가 의미가 있습니다.

1단계: 가상스레드가 실제로 적용됐는지 확인

Spring Boot 3.2+에서는 설정 하나로 Tomcat 요청 처리 스레드를 가상스레드로 바꿀 수 있습니다.

# application.properties
spring.threads.virtual.enabled=true

하지만 다음 케이스에서 "켰다고 생각했는데" 일부만 적용되거나 아예 적용되지 않습니다.

  • Undertow, Jetty 등 다른 컨테이너를 쓰는 경우(지원 범위 확인 필요)
  • 비동기 서블릿, WebFlux, Netty 기반으로 이미 이벤트 루프 모델인 경우(가상스레드의 체감이 다를 수 있음)
  • @Async 실행기, 스케줄러, 커스텀 Executor가 플랫폼 스레드 풀로 남아있는 경우

가장 확실한 검증은 요청 처리 중인 스레드 이름과 타입을 로그로 찍는 것입니다.

@RestController
class ThreadProbeController {
  @GetMapping("/probe/thread")
  public Map<String, Object> probe() {
    Thread t = Thread.currentThread();
    return Map.of(
        "name", t.getName(),
        "isVirtual", t.isVirtual(),
        "state", t.getState().toString()
    );
  }
}

응답의 isVirtualtrue인지 먼저 확인하세요. 여기서부터가 출발점입니다.

2단계: "스레드 병목"인지 "리소스 병목"인지 분리

가상스레드를 켰는데도 느리다면, 병목은 보통 아래 중 하나입니다.

  • DB 커넥션 풀 고갈
  • 외부 API 호출 지연(HTTP, gRPC 등)
  • synchronized, ReentrantLock, 세마포어 같은 락 경합
  • 파일/네트워크 I/O 블로킹
  • 로깅/메트릭/트레이싱 동기 처리

먼저 지표로 분리해야 합니다.

  • CPU 사용률이 낮은데 지연이 크다: 대개 I/O 대기 또는 커넥션/락 대기
  • CPU가 높고 지연이 크다: 핫스팟 코드, 직렬화/역직렬화, 암호화, GC 가능성

Micrometer를 쓰고 있다면 최소한 다음을 대시보드에 올리세요.

  • HTTP 요청 지연 p50, p95, p99
  • DB 커넥션 풀 active, pending, max
  • JVM GC pause, heap 사용량

가상스레드 환경에서는 "스레드 수" 자체보다 대기열의 길이가 문제를 드러냅니다.

3단계: DB 커넥션 풀을 가상스레드에 맞게 재설계

가상스레드는 요청을 많이 동시에 받아도 "스레드"는 버팁니다. 대신 DB로 동시에 몰리면 HikariCP 커넥션 풀이 병목이 됩니다.

증상:

  • 응답 지연이 특정 구간부터 급격히 증가
  • Hikari Connection is not available 또는 Timeout after ...ms 발생

기본적인 방향은 이렇습니다.

  1. 커넥션 풀을 무작정 키우지 말고, 먼저 DB가 감당 가능한 동시 쿼리 수를 산정
  2. 애플리케이션에서 DB 동시성을 제한(벌크헤드)
  3. 느린 쿼리를 줄여 "커넥션 점유 시간"을 단축

예시 설정:

# HikariCP 예시 (값은 환경에 맞게)
spring.datasource.hikari.maximum-pool-size=30
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.connection-timeout=2000
spring.datasource.hikari.validation-timeout=1000
spring.datasource.hikari.leak-detection-threshold=5000

그리고 "가상스레드니까 동시 요청 무제한" 같은 접근은 위험합니다. DB가 병목이면 결국 tail latency가 폭발합니다.

간단한 벌크헤드(동시성 제한) 예시:

@Component
class DbBulkhead {
  private final Semaphore sem = new Semaphore(30); // DB 동시 접근 상한

  <T> T call(Callable<T> c) throws Exception {
    sem.acquire();
    try { return c.call(); }
    finally { sem.release(); }
  }
}

이 패턴은 "스레드"가 아니라 "DB"를 보호합니다. 가상스레드 환경에서 특히 효과가 큽니다.

4단계: 외부 API 호출 타임아웃과 커넥션 제한을 먼저 잡기

가상스레드는 블로킹 호출을 많이 감당할 수 있지만, 외부 API가 느리면 그만큼 "대기 중인 가상스레드"가 늘어나고 메모리/관측 오버헤드가 커집니다. 또한 외부 API 커넥션 풀(HTTP 클라이언트)이 병목이 될 수 있습니다.

필수 원칙:

  • connect timeout, read timeout을 반드시 설정
  • 호출별 deadline을 강제
  • 커넥션 풀의 max connections, pending acquire 대기 시간을 관측

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

@Bean
RestClient restClient() {
  var factory = new HttpComponentsClientHttpRequestFactory();
  factory.setConnectTimeout(1000);
  factory.setConnectionRequestTimeout(500);
  factory.setReadTimeout(2000);
  return RestClient.builder().requestFactory(factory).build();
}

외부 호출이 gRPC라면 deadline과 keepalive, LB 설정이 tail latency에 큰 영향을 줍니다. 관련해서는 아래 글이 체크리스트 형태라 같이 보면 좋습니다.

언어가 Go가 아니어도 "deadline을 명시하고, 대기열을 줄이며, keepalive로 커넥션 상태를 관리"하는 원칙은 동일합니다.

5단계: synchronized, ThreadLocal, 글로벌 락 경합 제거

가상스레드에서 가장 당황스러운 병목 중 하나가 "락"입니다. 요청은 많아졌는데, 임계 구역이 짧더라도 경쟁이 심해지면 전체가 직렬화됩니다.

의심 포인트:

  • synchronized 메서드/블록
  • ConcurrentHashMap.computeIfAbsent 내부에서 무거운 작업
  • 전역 캐시 갱신 락
  • 로거/트레이서의 컨텍스트 저장을 잘못해서 락이 생기는 경우

간단한 예로, 아래 코드는 캐시 미스 때 외부 호출이 락 안에서 일어나 병목이 됩니다.

synchronized String getToken() {
  if (tokenExpired()) {
    token = fetchFromAuthServer(); // 느린 I/O
  }
  return token;
}

개선 방향:

  • 락 안에서는 "상태 확인"까지만 하고, 느린 작업은 락 밖으로
  • CAS, ReadWriteLock, 또는 "단일 플라이트"(한 번만 갱신) 패턴 적용
private final AtomicReference<String> tokenRef = new AtomicReference<>();
private final ReentrantLock refreshLock = new ReentrantLock();

String getToken() {
  String t = tokenRef.get();
  if (t != null && !tokenExpired(t)) return t;

  if (refreshLock.tryLock()) {
    try {
      String cur = tokenRef.get();
      if (cur != null && !tokenExpired(cur)) return cur;
      String newToken = fetchFromAuthServer();
      tokenRef.set(newToken);
      return newToken;
    } finally {
      refreshLock.unlock();
    }
  }

  // 다른 스레드가 갱신 중이면 잠깐 대기 후 재시도
  LockSupport.parkNanos(1_000_000);
  return tokenRef.get();
}

완벽한 구현은 상황마다 다르지만, 요지는 "가상스레드가 늘수록 락 경합은 더 빨리 병목이 된다"입니다.

6단계: 로그/관측(Tracing, MDC)로 인한 동기 I/O 병목 제거

가상스레드 적용 후 TPS가 안 오르는 시스템에서 의외로 자주 발견되는 게 "로그"입니다.

  • 동기 파일 append
  • 과도한 JSON 로그 직렬화
  • 요청당 너무 많은 span/metric 기록
  • MDC 사용으로 인한 ThreadLocal 비용 증가

체크리스트:

  • 액세스 로그, 애플리케이션 로그의 출력량을 줄였는가
  • 비동기 로깅(Logback AsyncAppender 등)으로 전환했는가
  • p99 구간에서 로그 flush가 발생하지 않는가

예시(Logback 비동기 설정 스니펫):

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
  <queueSize>8192</queueSize>
  <discardingThreshold>0</discardingThreshold>
  <appender-ref ref="CONSOLE" />
</appender>

<root level="INFO">
  <appender-ref ref="ASYNC" />
</root>

관측도 마찬가지입니다. "측정"이 병목이 되면 최적화가 무의미해집니다. 필요한 구간만 샘플링하고, 고비용 태그(cardinality 높은 label)를 줄이세요.

프론트엔드에서도 과도한 관측/렌더링이 병목을 만드는 패턴이 있는데, 디버깅 접근(프로파일링으로 원인 분리)은 동일합니다.

7단계: 재현 가능한 부하 테스트와 프로파일링로 "병목 위치"를 고정

가상스레드 병목을 잡는 마지막 단계는 "감"이 아니라 재현입니다.

권장 흐름:

  1. k6, wrk, Gatling 등으로 동일 시나리오를 반복 실행
  2. p95, p99가 튀는 구간에서 스레드 덤프 또는 JFR을 확보
  3. DB slow query log, 외부 API latency, 커넥션 풀 pending을 함께 수집
  4. 개선 후 동일 부하로 회귀 테스트

JFR(Java Flight Recorder)은 특히 가상스레드 환경에서 유용합니다. 운영에서도 비교적 안전하게 켤 수 있고, 락 경합, I/O, GC, 메서드 핫스팟을 한 번에 볼 수 있습니다.

JDK 기본 명령 예시:

jcmd <pid> JFR.start name=vt settings=profile duration=60s filename=vt.jfr

위 명령의 <pid>처럼 부등호가 들어가는 표기는 MDX에서 JSX로 오인될 수 있으니, 문서에는 반드시 인라인 코드로 표기해야 합니다.

분석 시에는 다음을 특히 보세요.

  • Lock Instances, Java Monitor Blocked
  • Socket Read/Write, File Read/Write
  • JDBC 호출에서의 시간 비중
  • Allocation pressure(할당 폭증)로 인한 GC pause

그리고 "가상스레드가 많아지면 더 빨라질 것"이라는 기대를 버리고, 실제 병목 리소스(DB, 외부 API, 락, 로그)를 기준으로 목표치를 잡는 게 중요합니다.

실전 요약 체크리스트

  • 가상스레드가 실제 요청 처리에 적용됐는지 Thread.currentThread().isVirtual()로 검증
  • 스레드가 아니라 DB/외부 API/락/로그가 병목인지 지표로 분리
  • Hikari 풀 크기 조정만 하지 말고 DB 동시성 상한(벌크헤드)으로 보호
  • 외부 호출은 timeout, deadline을 강제하고 커넥션 풀 대기를 관측
  • synchronized 및 전역 락을 제거하고 느린 I/O를 락 밖으로 이동
  • 로깅/트레이싱은 비동기화, 샘플링, 고카디널리티 태그 축소
  • JFR + 부하테스트로 병목 위치를 고정하고 회귀 테스트

가상스레드는 "스레드 관리 비용"을 낮춰주는 강력한 도구지만, 결국 성능은 시스템의 가장 좁은 병목(커넥션, 락, I/O, 관측)이 결정합니다. 위 7단계를 순서대로 적용하면, "가상스레드를 켰는데도 느린" 상태에서 벗어나 무엇을 줄여야 p99가 내려가는지 명확해질 것입니다.