Published on

Spring Boot 3 가상스레드 적용 후 지연 폭증 7가지

Authors

가상스레드(virtual thread)는 "스레드 수를 늘려도 OS 스레드가 폭증하지 않는다"는 점에서 서버 애플리케이션의 동시성 모델을 크게 단순화합니다. 하지만 Spring Boot 3에서 가상스레드를 적용한 뒤 p95, p99 지연이 오히려 튀거나, 트래픽이 늘수록 타임아웃이 폭증하는 사례도 적지 않습니다.

핵심은 간단합니다. 가상스레드는 블로킹을 싸게 만들어줄 뿐, 무한 동시성도 아니고, 모든 블로킹이 자동으로 안전해지는 것도 아닙니다. 특히 DB 커넥션, 외부 API, 로깅, 파일 I/O, 락 경합 같은 "실제 병목 자원"은 그대로이며, 가상스레드는 그 병목을 더 빨리 더 크게 두드릴 수 있습니다.

아래는 운영에서 자주 만나는 "가상스레드 적용 후 지연 폭증" 원인 7가지와 점검/해결 방법입니다.

지연 문제를 다룬 글로는 콜드 스타트/스케일 이슈 관점의 KServe LLM 서빙 503·스케일0 지연 해결법도 함께 참고하면, "지연이 어디서 만들어지는가"를 구조적으로 보는 데 도움이 됩니다.

1) DB 커넥션 풀 고갈: 가상스레드가 병목을 더 빨리 만든다

전형적인 증상

  • 트래픽 증가 시 p95/p99가 계단식으로 상승
  • 에러 로그에 커넥션 대기 타임아웃
  • CPU는 한가한데 요청이 오래 걸림

가상스레드를 켜면 동시에 처리 가능한 요청 수가 늘면서, DB 커넥션 풀(HikariCP) 같은 제한 자원을 더 공격적으로 사용합니다. 플랫폼 스레드 기반에서는 스레드 수가 자연스럽게 상한 역할을 했지만, 가상스레드에서는 그 상한이 느슨해져 풀 고갈이 더 자주 발생합니다.

점검 포인트

  • maximumPoolSize, connectionTimeout이 트래픽에 비해 작은지
  • 트랜잭션 범위가 커서 커넥션을 오래 잡고 있는지
  • N+1 쿼리로 커넥션 점유 시간이 늘었는지

해결 가이드

  • 풀을 무작정 키우기 전에 쿼리 시간/트랜잭션 범위부터 줄이기
  • 읽기 트래픽이 크면 리드 레플리카/캐시 도입
  • 커넥션 풀 메트릭을 반드시 대시보드에 올리기

(예시) Hikari 메트릭 로깅

import com.zaxxer.hikari.HikariDataSource;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class HikariPoolLogger {
  private final HikariDataSource ds;

  public HikariPoolLogger(HikariDataSource ds) {
    this.ds = ds;
  }

  @Scheduled(fixedDelay = 5000)
  public void log() {
    var mx = ds.getHikariPoolMXBean();
    System.out.printf(
        "active=%d idle=%d total=%d waiting=%d\n",
        mx.getActiveConnections(),
        mx.getIdleConnections(),
        mx.getTotalConnections(),
        mx.getThreadsAwaitingConnection()
    );
  }
}

2) 외부 API 호출 동시성 폭증: 다운스트림이 먼저 무너진다

전형적인 증상

  • 특정 외부 API 연동 구간에서만 지연 급증
  • 외부 서비스에서 429 또는 5xx 증가
  • 우리 쪽은 스레드가 충분한데 전체 요청이 느려짐

가상스레드는 블로킹 I/O 대기 중에도 플랫폼 스레드를 점유하지 않으므로, 같은 시간에 더 많은 외부 호출을 날릴 수 있습니다. 문제는 다운스트림(결제, 인증, 검색, 사내 API) 이 그 동시성을 감당하지 못하면, 큐잉과 재시도 때문에 지연이 폭발합니다.

해결 가이드

  • "요청당 외부 호출"이라면 반드시 동시성 제한(벌크헤드) 을 둡니다.
  • 타임아웃을 짧게, 재시도는 제한적으로(특히 POST) 적용합니다.

(예시) 세마포어로 외부 호출 동시성 제한

import java.util.concurrent.Semaphore;

public class DownstreamClient {
  private final Semaphore bulkhead = new Semaphore(50); // 동시 50개 제한

  public String call() throws Exception {
    if (!bulkhead.tryAcquire()) {
      throw new IllegalStateException("downstream overloaded");
    }
    try {
      // 실제 HTTP 호출
      return "ok";
    } finally {
      bulkhead.release();
    }
  }
}

3) "가상스레드인데도" 핀(pin) 발생: synchronized, JNI, 일부 I/O

전형적인 증상

  • 가상스레드를 켰는데도 처리량이 기대만큼 안 나옴
  • 특정 구간에서 지연이 갑자기 길어짐
  • 프로파일링에서 캐리어(플랫폼) 스레드가 막힘

가상스레드는 블로킹 시 캐리어 스레드에서 내려와야 이점이 큽니다. 그런데 다음 상황에서는 가상스레드가 캐리어에 될 수 있습니다.

  • synchronized 블록 안에서 블로킹
  • JNI 호출(네이티브 코드)로 오래 머무름
  • 특정 파일/소켓 I/O 패턴(환경/버전에 따라)

해결 가이드

  • 오래 걸리는 블로킹을 synchronized 내부에서 하지 않기
  • 락은 ReentrantLock 등으로 구조를 바꾸거나, 락 범위를 최소화

(나쁜 예) synchronized 안에서 블로킹

public class BadLocking {
  private final Object lock = new Object();

  public synchronized void handle() throws Exception {
    // 여기서 외부 HTTP/DB 호출 같은 블로킹을 하면 핀 가능성이 커짐
    Thread.sleep(100);
  }
}

(개선 예) 락 범위 최소화

import java.util.concurrent.locks.ReentrantLock;

public class BetterLocking {
  private final ReentrantLock lock = new ReentrantLock();

  public void handle() throws Exception {
    lock.lock();
    try {
      // 공유 상태 업데이트만 빠르게
    } finally {
      lock.unlock();
    }

    // 블로킹은 락 밖에서
    Thread.sleep(100);
  }
}

4) 제한 없는 동시성: "스레드"가 아니라 "큐"가 터진다

전형적인 증상

  • 순간 트래픽에서 지연이 급격히 늘고, 이후에도 회복이 느림
  • GC 시간 증가, 메모리 사용량 증가
  • 타임아웃이 연쇄적으로 발생

가상스레드는 생성 비용이 낮아 "요청마다 새 스레드"가 쉬워집니다. 하지만 요청 수가 늘면 결국 대기 중인 작업(큐)요청 컨텍스트(메모리) 가 쌓입니다. 즉 병목이 스레드에서 큐/메모리로 이동합니다.

해결 가이드

  • 엔드포인트별로 동시성 상한을 둡니다(세마포어, rate limiter)
  • 큰 응답/대용량 처리에는 스트리밍과 백프레셔를 고려합니다
  • 타임아웃을 전 계층에 일관되게 둡니다(클라이언트, 서버, 프록시)

이 관점은 캐시/상태 꼬임으로 지연이 길어지는 케이스와도 닮아 있습니다. 프론트엔드지만 "대기열이 쌓여 체감 지연이 늘어나는" 문제를 다룬 글로 Next.js 14 RSC 캐시 꼬임과 stale 데이터 해결법을 함께 읽어보면, 지연을 "리소스"가 아니라 "흐름"으로 보는 데 도움이 됩니다.

5) 로깅/관측(Observability) 오버헤드: MDC, 트레이싱, 동기 Appender

전형적인 증상

  • 부하 시 로그가 늘면 지연이 같이 늘어남
  • APM 트레이싱을 켠 환경에서만 지연이 큼
  • 파일 로그/네트워크 로그 전송이 병목

가상스레드 환경에서 요청 수가 늘면 로그/스팬 생성도 같이 늘어납니다. 특히 다음이 문제를 키웁니다.

  • 동기식 파일 appender
  • 네트워크 전송형 appender가 느릴 때
  • MDC 사용량이 많고 컨텍스트 복사가 잦을 때

해결 가이드

  • 비동기 로깅(AsyncAppender) 고려
  • 샘플링(특히 트레이싱) 적용
  • 고카디널리티 태그를 줄여 스팬 폭증 방지

(예시) Logback AsyncAppender 설정

<configuration>
  <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>app.log</file>
    <encoder>
      <pattern>%d{ISO8601} %-5level [%thread] %logger - %msg%n</pattern>
    </encoder>
  </appender>

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

  <root level="INFO">
    <appender-ref ref="ASYNC_FILE" />
  </root>
</configuration>

6) HTTP 클라이언트/커넥션 풀 설정 미스: keep-alive, 풀 사이즈, DNS

전형적인 증상

  • 외부 호출이 많을수록 지연 상승
  • TIME_WAIT 증가, 소켓 부족, DNS 지연
  • 같은 코드인데 가상스레드 적용 후 더 자주 터짐

가상스레드로 동시 요청이 늘면 HTTP 클라이언트의 커넥션 풀/keep-alive가 더 중요해집니다. 풀 설정이 작거나 keep-alive가 꺼져 있으면 매 요청마다 연결 수립 비용이 누적됩니다.

해결 가이드

  • 커넥션 풀을 명시적으로 구성
  • 타임아웃을 계층별로 설정
  • DNS 캐시/리졸브 지연도 점검

(예시) Java HttpClient 타임아웃 지정

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public class SimpleHttp {
  private final HttpClient client = HttpClient.newBuilder()
      .connectTimeout(Duration.ofSeconds(1))
      .build();

  public String get() throws Exception {
    var req = HttpRequest.newBuilder()
        .uri(new URI("https://example.com"))
        .timeout(Duration.ofSeconds(2))
        .GET()
        .build();

    return client.send(req, HttpResponse.BodyHandlers.ofString()).body();
  }
}

7) 워크로드 부적합: CPU 바운드 작업을 가상스레드로 "더 많이" 돌려서 느려짐

전형적인 증상

  • CPU 사용률이 급상승하면서 지연 폭증
  • GC 압박 증가
  • 이미지 처리, 암호화, 압축, 대규모 JSON 변환 등에서 특히 심함

가상스레드는 I/O 바운드에 강합니다. 반대로 CPU 바운드 작업은 코어 수가 상한이며, 가상스레드로 동시성을 늘리면 컨텍스트 전환/스케줄링/캐시 미스가 늘어 오히려 느려질 수 있습니다.

해결 가이드

  • CPU 바운드 작업은 별도의 제한된 스레드 풀로 격리
  • 요청 스레드(가상스레드)는 I/O 조율에 집중

(예시) CPU 작업 전용 풀로 격리

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CpuBound {
  // 코어 수 정도로 제한
  private final ExecutorService cpuPool = Executors.newFixedThreadPool(
      Math.max(2, Runtime.getRuntime().availableProcessors())
  );

  public void handle() {
    cpuPool.submit(() -> {
      // 압축/암호화/대규모 변환 같은 CPU 바운드 작업
      heavyCompute();
    });
  }

  private void heavyCompute() {
    // ...
  }
}

Spring Boot 3에서 가상스레드 적용 체크리스트

아래는 "가상스레드 켰는데 느려졌다"를 가장 빠르게 좁히는 체크리스트입니다.

  1. DB 풀 메트릭에서 threadsAwaitingConnection이 증가하는가
  2. 외부 API의 동시 호출 수가 늘었는가, 다운스트림이 429를 주는가
  3. synchronized 내부 블로킹, JNI, 락 경합이 있는가
  4. 엔드포인트별 동시성 상한이 있는가(없다면 가장 위험)
  5. 로깅/트레이싱을 켰을 때만 느린가(비동기/샘플링 필요)
  6. HTTP 커넥션 재사용이 잘 되는가(keep-alive, 풀)
  7. CPU 바운드 작업을 요청 경로에서 과도하게 병렬화했는가

(부록) 가상스레드 활성화 예시와 주의점

Spring Boot 3에서는 설정으로 가상스레드 기반 실행을 활성화할 수 있습니다. 다만 "켜는 것"보다 "병목 자원에 상한을 두는 것"이 더 중요합니다.

spring:
  threads:
    virtual:
      enabled: true

가상스레드는 스레드 모델을 단순하게 해주지만, 병목을 없애주지는 않습니다. 오히려 병목(커넥션 풀, 다운스트림, 락, 로깅)을 더 빨리 드러내는 "증폭기"가 될 수 있습니다. 적용 후 지연이 폭증했다면, 위 7가지를 순서대로 계측하면서 "어디서 대기열이 만들어지는지"를 찾는 것이 가장 빠른 해결책입니다.

추가로 운영 자동화/배포 파이프라인에서 장애 신호를 놓치지 않으려면 Argo CD sync 실패 - 비교/헬스체크 원인 9가지처럼 "헬스체크가 실패하는 구조"도 함께 점검해두면, 지연 폭증을 배포 이슈와 빠르게 분리하는 데 도움이 됩니다.