Published on

Spring Boot 3 가상스레드 후 TPS 하락 원인

Authors

서버 애플리케이션에서 가상스레드(virtual thread)는 "스레드 수를 늘려도 부담이 적다"는 기대를 줍니다. 그래서 Spring Boot 3에서 가상스레드를 켠 뒤 TPS가 오르길 기대하지만, 실제로는 TPS가 하락하거나 P99 지연이 악화되는 사례가 꽤 있습니다.

핵심은 간단합니다. 가상스레드는 "스레드 생성/블로킹 비용"을 줄여줄 뿐, 시스템 전체 병목(커넥션 풀, 외부 API, DB 락, CPU, 로깅/관측, 동기식 경계)을 없애주지 않습니다. 오히려 병목 앞단의 동시성이 급격히 늘면서 병목이 더 빨리 포화되고, 큐잉 지연이 커져 TPS가 떨어질 수 있습니다.

이 글은 Spring Boot 3에서 가상스레드 적용 후 TPS가 하락하는 대표 원인을 "증상-진단-해결" 관점으로 정리합니다.

가상스레드 적용 지점 점검(가장 먼저)

Spring Boot 3에서 가상스레드를 켜는 방식은 여러 가지입니다. 가장 흔한 설정은 다음입니다.

# Spring Boot 3.2+
spring.threads.virtual.enabled=true

또는 직접 Executor를 만들 수도 있습니다.

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

여기서 중요한 점은 "어떤 작업이" 가상스레드로 실행되느냐입니다.

  • 웹 요청 처리 스레드(서블릿 컨테이너 스레드)가 가상스레드로 바뀌었는지
  • @Async, 스케줄러, 배치, 메시지 리스너 등 백그라운드 작업이 어떤 Executor를 쓰는지

가상스레드를 켰다고 해서 애플리케이션 내 모든 블로킹이 자동으로 최적화되지는 않습니다.

원인 1: 커넥션 풀(DB, HTTP) 포화로 큐잉 지연 증가

가상스레드를 켜면 동시에 더 많은 요청이 "일단 실행"됩니다. 문제는 DB 커넥션 풀(HikariCP)이나 외부 API용 HTTP 커넥션 풀이 그대로라면, 더 많은 요청이 커넥션을 기다리며 대기열을 만들고 지연이 폭증합니다.

전형적인 증상

  • TPS 감소 + P95/P99 급증
  • DB 커넥션 대기 시간 증가
  • Hikari 로그에서 커넥션 획득 지연

진단 포인트

Micrometer/Actuator를 쓰면 Hikari 메트릭으로 바로 확인할 수 있습니다.

  • hikaricp.connections.active
  • hikaricp.connections.pending
  • hikaricp.connections.timeout

로그로도 힌트를 얻습니다.

logging.level.com.zaxxer.hikari=DEBUG

해결 방향

  • 풀 크기를 무작정 키우기 전에 DB가 감당 가능한 동시 쿼리 수를 먼저 산정
  • 애플리케이션 동시성(요청 처리 스레드 수)을 풀 크기와 함께 설계
  • 느린 쿼리/락 경합을 먼저 제거(인덱스, 트랜잭션 범위 축소)

Hikari는 대체로 "가상스레드니까 커넥션도 엄청 늘려도 된다"가 아닙니다. DB는 물리 리소스이며, 동시 쿼리 과다로 오히려 TPS가 떨어질 수 있습니다.

원인 2: 동기식 외부 I/O가 병목인데 동시성만 늘어난 경우

가상스레드는 블로킹 I/O에 유리하지만, 외부 API가 느리거나 rate limit이 있는 환경에서는 동시성 증가가 곧 "더 많은 동시 대기"로 이어집니다. 결과적으로:

  • 외부 API가 포화
  • 타임아웃 증가
  • 재시도 폭증
  • 스레드 덤프에는 대기가 많지만 CPU는 낮음

해결 방향: 제한과 폴백이 먼저

가상스레드 환경에서는 특히 "무제한 동시 호출"이 위험합니다. 반드시 동시성 제한(벌크헤드)과 재시도 정책을 같이 둬야 합니다.

Resilience4j 예시:

BulkheadConfig config = BulkheadConfig.custom()
    .maxConcurrentCalls(50)
    .maxWaitDuration(Duration.ofMillis(200))
    .build();

Bulkhead bulkhead = Bulkhead.of("externalApi", config);

Supplier<String> supplier = Bulkhead.decorateSupplier(bulkhead, () -> client.call());

재시도/폴백 설계는 외부 장애 시 TPS 하락을 막는 핵심입니다. 관련해서는 OpenAI Responses API 503 멈춤 - 재시도·폴백 설계처럼 "재시도 폭주"를 막는 패턴이 그대로 적용됩니다.

원인 3: 스케줄러/@Async가 플랫폼 스레드에 남아 혼합 실행

웹 요청은 가상스레드로 바뀌었는데, 내부에서 @Async나 스케줄러가 여전히 플랫폼 스레드 풀(예: 고정 200개)을 쓰면 다음 문제가 생깁니다.

  • 요청은 많이 들어오는데 백그라운드 작업 큐가 밀림
  • 큐잉 지연이 증가하며 전체 TPS 하락
  • 특정 단계에서 병목이 발생해 "가상스레드의 이점"이 사라짐

진단

  • @Async Executor가 무엇인지 확인
  • 스케줄러 쓰레드 수 확인
  • 큐 길이/대기 시간 메트릭(없다면 임시로 로깅)

해결

@Async와 스케줄러도 명시적으로 가상스레드 Executor를 사용하게 구성합니다.

@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Bean(destroyMethod = "close")
    ExecutorService virtualExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }

    @Override
    public Executor getAsyncExecutor() {
        return virtualExecutor();
    }
}

단, 모든 비동기 작업을 가상스레드로 바꾸는 게 정답은 아닙니다. CPU 바운드 작업(압축, 암호화, 대규모 JSON 변환 등)은 가상스레드로 동시성을 늘리면 오히려 컨텍스트 스위칭과 캐시 미스가 늘 수 있어, 제한된 플랫폼 스레드 풀로 처리하는 편이 나을 때도 있습니다.

원인 4: 동기 로깅/관측(Tracing, MDC) 오버헤드가 증폭

가상스레드로 동시 요청 수가 늘면 "요청당 고정 비용"이 그대로 증폭됩니다.

  • JSON 로그 직렬화 비용
  • 동기식 appender(파일, 네트워크)
  • 분산 트레이싱 span 생성 비용
  • MDC 복사 비용

특히 로깅이 동기식이면 TPS 하락이 매우 흔합니다.

진단

  • CPU 프로파일에서 로깅/직렬화 비중 확인
  • 로그 I/O 대기 증가 확인
  • 트레이싱 샘플링 비율이 과도한지 확인

해결

  • 비동기 로깅(Logback AsyncAppender) 적용
  • 트레이싱 샘플링 조정
  • 고카디널리티 라벨/태그 제거

Logback 비동기 예시:

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

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

원인 5: JDBC/드라이버/네이티브 호출이 핀(pin)되어 캐리어 스레드를 묶음

가상스레드는 블로킹 시 "파킹"되며 캐리어(플랫폼) 스레드를 반환하는 게 이상적입니다. 하지만 다음 상황에서는 가상스레드가 캐리어를 오래 붙잡는(핀) 일이 생길 수 있습니다.

  • synchronized 블록 안에서 블로킹 I/O
  • 특정 네이티브 호출
  • 일부 라이브러리의 모니터 락 사용 패턴

이 경우 "가상스레드가 많아도" 실제 캐리어 스레드가 묶여 전체 처리량이 떨어집니다.

진단

  • JFR(Java Flight Recorder)에서 Virtual Thread 관련 이벤트 확인
  • 스레드 덤프에서 특정 락 경합 확인

JFR 실행 예:

jcmd $PID JFR.start name=vt settings=profile filename=vt.jfr
# 재현 후
jcmd $PID JFR.stop name=vt

분석 시에는 Virtual Thread pinning, monitor contention, socket read/write 대기 등을 함께 봅니다.

해결

  • 블로킹 호출을 synchronized 내부에서 하지 않기
  • 락 범위 축소
  • 라이브러리 업데이트(가상스레드 친화 버전)

원인 6: Tomcat/서블릿 설정과의 부조화(백프레셔 부재)

가상스레드는 "요청을 더 많이 동시에 처리"하게 만들 수 있지만, 백프레셔가 없으면 병목으로 더 많은 요청이 밀려 들어가서 전체 시스템이 불안정해집니다.

특히 다음 조합에서 문제가 자주 보입니다.

  • 요청 수 급증
  • DB/외부 API 병목
  • 타임아웃/재시도
  • 큐잉 지연으로 커넥션이 더 오래 점유

결과적으로 TPS는 떨어지고 에러율은 오릅니다.

해결 방향

  • 서버 레벨에서 동시 처리 상한 설정(커넥션, 요청 큐)
  • 애플리케이션 레벨에서 bulkhead, rate limiting
  • 타임아웃을 "짧고 일관되게" 설정하고 재시도는 제한

원인 7: 부하 테스트 방식이 바뀌어 결과가 왜곡됨

가상스레드 적용 전후 비교에서 흔히 놓치는 것이 테스트 조건입니다.

  • 워밍업 부족(JIT, 캐시)
  • 커넥션 재사용 여부 차이
  • 클라이언트 측 병목(로드 제너레이터 CPU, 소켓)
  • GC/메모리 설정이 바뀜

가상스레드로 동시성이 늘면 응답 지연이 커져 클라이언트가 더 많은 소켓을 열고, 로드 제너레이터가 먼저 병목이 되는 경우도 많습니다.

권장 체크리스트

  • 동일한 RPS 목표로 고정한 뒤 지연/에러율 비교
  • 동일한 DB/외부 API 조건에서 비교
  • 서버/클라이언트 모두 CPU, 네트워크, 소켓 상태 확인

실전 처방: "동시성"이 아니라 "병목"부터 고치기

가상스레드 적용 후 TPS가 떨어졌다면, 다음 순서로 접근하는 것이 가장 안전합니다.

  1. DB 커넥션 대기(pending)와 쿼리 지연부터 확인
  2. 외부 API 호출 동시성 제한(bulkhead) 적용
  3. 타임아웃/재시도 정책 정리(폭주 방지)
  4. 로깅/트레이싱 오버헤드 줄이기(비동기 로깅, 샘플링)
  5. JFR로 핀/락 경합 확인
  6. @Async/스케줄러/메시지 리스너 Executor 일관성 점검

DB가 병목인 경우에는 DB 유지보수(테이블 bloat, vacuum, 인덱스 상태)가 TPS에 직접 영향을 줍니다. Postgres를 쓴다면 PostgreSQL VACUUM 안 먹을 때 - bloat·autovacuum 튜닝 같은 관점으로 "DB가 실제로 처리 가능한 TPS"를 먼저 회복시키는 것이 우선입니다.

최소 재현용 예제: 커넥션 풀 포화로 TPS가 떨어지는 패턴

아래는 요청마다 DB를 조회하고 200ms 정도 블로킹이 발생하는 상황을 가정한 단순 예제입니다.

@RestController
@RequiredArgsConstructor
public class DemoController {

    private final JdbcTemplate jdbcTemplate;

    @GetMapping("/demo")
    public String demo() {
        // 느린 쿼리를 가정
        Integer one = jdbcTemplate.queryForObject("select 1", Integer.class);
        return "ok-" + one;
    }
}

가상스레드를 켜면 동시에 더 많은 요청이 이 엔드포인트를 때리게 되고, Hikari 풀이 작으면 getConnection 대기가 늘어납니다. 이 때 TPS가 떨어지는 이유는 "스레드가 부족해서"가 아니라 "커넥션이 부족해서"입니다.

해결은 단순히 풀을 키우는 게 아니라, 다음 중 하나(또는 조합)입니다.

  • 쿼리 자체를 빠르게(인덱스, 쿼리 개선)
  • 트랜잭션 범위를 줄이기
  • 캐시 적용
  • 애플리케이션 동시성 제한

결론

Spring Boot 3 가상스레드는 강력하지만, TPS를 자동으로 올려주는 스위치가 아닙니다. 가상스레드로 인해 "대기 중인 작업"을 더 많이 만들어낼 수 있고, 그 결과 커넥션 풀/외부 API/락/로깅 같은 병목이 더 빨리 포화되어 TPS가 하락할 수 있습니다.

가상스레드 적용 후 TPS가 떨어졌다면, 스레드 모델을 의심하기 전에 먼저 병목을 계측으로 확인하세요. Hikari pending, 외부 API 동시성, 타임아웃/재시도, 로깅/트레이싱 비용, JFR의 핀/락 경합을 순서대로 보면 대부분의 원인은 재현 가능하게 드러납니다.