Published on

Spring Boot 3 가상스레드+WebFlux p99 병목 튜닝

Authors

서버 지연 최적화에서 평균은 쉽게 내려가지만, 사용자 경험을 무너뜨리는 건 대부분 p95/p99 같은 꼬리 지연입니다. Spring Boot 3에서 가상스레드(virtual threads)와 WebFlux를 함께 쓰면 처리량은 좋아지는데, 특정 구간에서 p99가 오히려 악화되는 케이스가 자주 나옵니다. 이유는 간단합니다.

  • WebFlux는 이벤트 루프 기반이며, 블로킹이 섞이면 꼬리가 길어집니다.
  • 가상스레드는 블로킹 비용을 낮추지만, 블로킹 자체를 “없애는” 기술은 아닙니다.
  • 둘을 섞으면 스케줄링, 풀, 커넥션, 백프레셔가 꼬여서 p99가 튈 여지가 늘어납니다.

이 글은 “가상스레드를 켰는데 왜 p99가 튀지?”라는 질문에 대해, 병목을 분해해서 관측하고, 원인별로 튜닝하는 실전 체크리스트를 제공합니다.

관련해서 가상스레드 도입 시 흔한 장애 패턴은 아래 글도 함께 참고하면 좋습니다.


1) 먼저 결론: WebFlux에 가상스레드는 “만능 조합”이 아니다

아키텍처 선택을 단순화하면 다음과 같습니다.

  • 끝까지 논블로킹(reactive)으로 갈 수 있다: WebFlux + Netty 이벤트 루프 최적화가 정석
  • 외부 의존성 때문에 블로킹이 섞인다: MVC(서블릿) + 가상스레드가 대체로 단순하고 안정적
  • 이미 WebFlux이고, 일부만 블로킹이다: 블로킹 구간을 격리하고, 격리된 풀 및 커넥션을 튜닝해야 함

즉, WebFlux 위에서 가상스레드를 “전체 요청 모델”로 쓰기보다는, 블로킹 구간을 안전하게 격리하는 도구로 보는 편이 p99 튜닝에 유리합니다.


2) p99 병목을 만드는 5대 원인 지도

p99가 튀는 원인은 보통 아래 중 하나(혹은 복합)입니다.

  1. 이벤트 루프 블로킹: Netty 이벤트 루프에서 블로킹 호출이 실행됨
  2. boundedElastic 고갈: Schedulers.boundedElastic()이 포화되어 큐잉이 발생
  3. 커넥션 풀 병목: DB, Redis, HTTP 클라이언트 풀의 대기 시간이 꼬리에 반영
  4. GC 또는 메모리 압박: 버퍼/바이트 배열/대형 JSON로 할당이 급증
  5. 백프레셔/버퍼링 설계 미스: flatMap 동시성 과다, 무제한 prefetch로 순간 폭주

중요한 건 “어디서 기다렸는지”를 숫자로 확인하는 것입니다. 감으로 thread만 늘리면 평균은 내려가도 p99는 더 나빠질 수 있습니다.


3) 관측부터: p99를 쪼개는 최소 계측 세트

3-1. Micrometer 타이머로 구간별 p99 분해

요청 전체 p99만 보면 원인을 못 찾습니다. 최소한 아래처럼 외부 호출 단위로 타이머를 나누세요.

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;

import java.time.Duration;

@Component
public class ExternalTimers {
  private final Timer dbTimer;
  private final Timer httpTimer;

  public ExternalTimers(MeterRegistry registry) {
    this.dbTimer = Timer.builder("ext.db")
        .publishPercentiles(0.95, 0.99)
        .publishPercentileHistogram()
        .sla(Duration.ofMillis(50), Duration.ofMillis(100), Duration.ofMillis(300))
        .register(registry);

    this.httpTimer = Timer.builder("ext.http")
        .publishPercentiles(0.95, 0.99)
        .publishPercentileHistogram()
        .register(registry);
  }

  public Timer db() { return dbTimer; }
  public Timer http() { return httpTimer; }
}

그리고 reactive 체인에서 Timer.Sample로 감싸면 됩니다.

import io.micrometer.core.instrument.Timer;
import reactor.core.publisher.Mono;

public Mono<String> callExternal(Timer timer) {
  Timer.Sample sample = Timer.start();
  return Mono.fromSupplier(() -> "ok")
      .doOnSuccess(v -> sample.stop(timer))
      .doOnError(e -> sample.stop(timer));
}

핵심은 “요청 p99”가 아니라 “DB 대기 p99”, “HTTP 풀 대기 p99”처럼 대기 지점을 p99로 분리하는 것입니다.

3-2. BlockHound로 이벤트 루프 블로킹 탐지

WebFlux에서 가장 흔한 p99 폭탄은 이벤트 루프 블로킹입니다. 테스트 환경에서만이라도 BlockHound를 켜서, 블로킹 호출이 어디서 터지는지 잡아내세요.

import reactor.blockhound.BlockHound;
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;

@Configuration
public class BlockHoundConfig {
  @PostConstruct
  void install() {
    BlockHound.install();
  }
}

BlockHound는 운영에 상시 적용하기엔 부담이 있을 수 있으니, 최소한 부하 테스트 환경에서 재현용으로 사용하세요.


4) 병목 1: 이벤트 루프 블로킹이 p99를 터뜨리는 메커니즘

Netty 이벤트 루프 스레드는 요청을 “돌리는” 심장입니다. 여기에서 JDBC, 파일 I/O, 동기 HTTP 호출 같은 블로킹이 실행되면, 이벤트 루프가 멈추고 그 순간 들어온 요청들이 줄줄이 대기합니다. 평균은 괜찮아 보여도 p99는 급격히 악화됩니다.

4-1. 잘못된 예: reactive 체인에서 블로킹 호출

@GetMapping("/bad")
public Mono<String> bad() {
  return Mono.just("start")
      .map(v -> {
        // 예: JDBC, Thread.sleep, 동기 HTTP 등
        blockingCall();
        return "ok";
      });
}

4-2. 개선: 블로킹 구간을 격리한 스케줄러로 이동

가장 단순한 처방은 subscribeOn 또는 publishOn으로 블로킹 구간을 별도 풀로 보내는 것입니다.

import reactor.core.scheduler.Schedulers;

@GetMapping("/good")
public Mono<String> good() {
  return Mono.fromCallable(() -> {
        blockingCall();
        return "ok";
      })
      .subscribeOn(Schedulers.boundedElastic());
}

여기서 중요한 포인트는 “가상스레드를 켰으니 괜찮겠지”가 아니라, 이벤트 루프에서 블로킹을 제거하는 것입니다.


5) 병목 2: boundedElastic 포화와 큐잉이 p99를 만든다

Schedulers.boundedElastic()은 무한정 늘어나는 풀처럼 보이지만, 실제로는 제한이 있고 큐잉이 발생합니다. 블로킹 작업이 늘면 다음이 생깁니다.

  • 스레드 생성 및 컨텍스트 스위칭 증가
  • 작업 큐 대기 증가
  • 결과적으로 p99가 큐잉 시간만큼 튐

5-1. 블로킹 작업 전용 스케줄러를 분리

블로킹 성격이 다른 작업을 한 풀에 섞으면 꼬리가 더 길어집니다. 전용 풀을 분리해 격리하세요.

import reactor.core.scheduler.Scheduler;
import reactor.core.scheduler.Schedulers;

import java.util.concurrent.Executors;

public class SchedulersConfig {
  // 가상스레드 기반 Executor를 reactive에서 사용
  // 주의: Java 21 필요
  public static final Scheduler VT_SCHEDULER =
      Schedulers.fromExecutor(Executors.newVirtualThreadPerTaskExecutor());
}

그리고 블로킹 구간에만 적용합니다.

@GetMapping("/vt")
public Mono<String> vt() {
  return Mono.fromCallable(() -> {
        blockingCall();
        return "ok";
      })
      .subscribeOn(SchedulersConfig.VT_SCHEDULER);
}

이 방식의 장점은 블로킹 작업이 늘어도 플랫폼 스레드 고갈 위험을 줄이고, 큐잉보다는 가상스레드로 흡수할 여지가 커진다는 점입니다. 단, 아래 커넥션 풀 병목이 남아 있으면 p99는 여전히 튑니다.


6) 병목 3: 커넥션 풀 대기가 p99의 대부분인 경우

가상스레드를 켜면 동시에 더 많은 작업이 “시작”됩니다. 그런데 DB 커넥션 풀 크기나 HTTP 커넥션 풀이 그대로면, 대기가 늘어나 p99가 악화될 수 있습니다.

6-1. 증상

  • CPU는 여유 있는데 p99가 튐
  • 스레드는 많아졌는데 처리량이 선형으로 안 늘음
  • 관측해보면 DB 또는 외부 HTTP 호출에서 p99가 크게 증가

6-2. 해결 방향

  • 풀을 무작정 키우기보다, 동시성 상한을 먼저 설정
  • 외부 시스템별로 동시성 제한을 분리

예를 들어 WebFlux에서 flatMap 동시성이 무제한이면 순간 폭주가 발생합니다.

public Mono<Void> fanout(Flux<String> ids) {
  int concurrency = 32; // 외부 시스템이 감당 가능한 수준으로 제한
  return ids.flatMap(id -> callExternalService(id), concurrency).then();
}

HTTP 클라이언트도 풀과 대기 시간을 명시적으로 설계하세요.

import reactor.netty.http.client.HttpClient;
import reactor.netty.resources.ConnectionProvider;

ConnectionProvider provider = ConnectionProvider.builder("ext")
    .maxConnections(200)
    .pendingAcquireMaxCount(1000)
    .pendingAcquireTimeout(java.time.Duration.ofSeconds(2))
    .build();

HttpClient client = HttpClient.create(provider)
    .responseTimeout(java.time.Duration.ofSeconds(3));

pendingAcquireTimeout 같은 값이 없으면, 대기가 길어져 p99가 무너질 때까지 기다리는 형태가 됩니다. p99 관점에서는 “빨리 실패하고 재시도/백오프”가 더 낫기도 합니다.

외부 API가 병목이라면 재시도·백오프·큐 설계도 함께 고려해야 합니다.


7) 병목 4: GC와 메모리 압박이 만드는 tail latency

WebFlux는 버퍼링과 직렬화 비용이 꼬리에 영향을 크게 줍니다. 특히 아래 패턴에서 p99가 튑니다.

  • 큰 JSON 응답을 한 번에 메모리에 올림
  • collectList()로 대량 데이터를 한 번에 모음
  • 로그에 대형 payload를 문자열로 변환

7-1. 위험한 예: 무의미한 collectList()

public Mono<List<Item>> bad(Flux<Item> items) {
  return items.collectList();
}

7-2. 개선: 스트리밍 또는 페이지 단위로 제한

public Flux<Item> good(Flux<Item> items) {
  return items.limitRate(256);
}

또는 응답을 NDJSON 같은 스트리밍 포맷으로 바꾸는 것도 방법입니다. 핵심은 “평균 응답을 빠르게”가 아니라, 최악의 요청이 힙을 흔들지 않게 만드는 것입니다.


8) 병목 5: 백프레셔 설계 미스로 인한 순간 폭주

p99 튜닝에서 자주 놓치는 지점은 “내 서비스는 빠른데, 아래 의존성이 느린” 상황입니다. 이때 동시성이 과하면 큐가 생기고, 큐가 커지면 지연이 늘고, 지연이 늘면 타임아웃이 늘고, 타임아웃이 늘면 재시도가 폭주합니다.

8-1. 동시성, 프리패치, 버퍼를 명시하라

public Flux<Result> pipeline(Flux<Input> in) {
  return in
      .onBackpressureBuffer(10_000) // 무작정 크게 잡지 말고, 정책과 함께
      .flatMap(this::callExternal, 64) // 동시성 상한
      .limitRate(512); // 다운스트림 소비 속도에 맞춤
}

onBackpressureBuffer는 최후의 수단에 가깝습니다. 가능하면 flatMap 동시성을 낮추고, 외부 호출 자체를 큐잉하는 구조(예: 메시지 큐, 작업 큐)로 바꾸는 게 p99에는 더 효과적입니다.


9) 실전 튜닝 순서: 재현 가능한 p99 개선 프로세스

다음 순서로 진행하면 “무엇을 바꿨더니 p99가 내려갔는지”를 남길 수 있습니다.

9-1. 부하 모델 고정

  • RPS, payload 크기, 외부 의존성 지연(고정 또는 분포)을 고정
  • 워밍업 후 5~15분 측정

9-2. p99를 구간별로 쪼개기

  • 요청 전체 p99
  • DB p99
  • 외부 HTTP p99
  • 풀 대기(가능하면) p99

9-3. 이벤트 루프 블로킹 제거가 1순위

  • BlockHound로 탐지
  • 블로킹은 전용 스케줄러로 격리

9-4. 동시성 상한을 먼저 두고 풀을 맞추기

  • flatMap(..., concurrency)로 폭주 방지
  • 그 다음 커넥션 풀 크기 조정

9-5. 타임아웃과 실패 정책을 p99 기준으로 재설계

  • 무한 대기 금지
  • 빠른 실패 + 제한된 재시도 + 백오프

운영에서 타임아웃이 연쇄적으로 터져 Pod 재시작이나 장애로 이어지면, 쿠버네티스 진단도 함께 필요합니다.


10) 조합 가이드: 언제 WebFlux, 언제 가상스레드 MVC인가

마지막으로 선택 가이드를 정리합니다.

10-1. WebFlux가 잘 맞는 경우

  • DB도 R2DBC, 외부 호출도 논블로킹 클라이언트
  • 스트리밍 응답, SSE, WebSocket 중심
  • 이벤트 루프 블로킹을 조직적으로 관리 가능

10-2. MVC + 가상스레드가 잘 맞는 경우

  • JDBC, 동기 SDK, 레거시 라이브러리 비중이 큼
  • 팀이 reactive 디버깅/백프레셔에 익숙하지 않음
  • tail latency를 단순한 모델로 먼저 안정화하고 싶음

10-3. 이미 WebFlux라면

  • 가상스레드는 “전체”가 아니라 “블로킹 격리”에 쓰고
  • 동시성 상한과 커넥션 풀, 타임아웃을 p99 기준으로 다시 잡는 것이 현실적인 최적화 경로입니다.

마무리: p99는 스레드가 아니라 대기 지점의 합이다

Spring Boot 3에서 가상스레드와 WebFlux를 함께 쓸 때 p99가 튀는 이유는 보통 “스레드가 부족해서”가 아니라, 이벤트 루프 블로킹, 풀 대기, 커넥션 풀 대기, 백프레셔 미스 같은 대기 지점이 꼬리에 쌓이기 때문입니다.

  • 이벤트 루프에서 블로킹을 없애고
  • 블로킹은 전용 스케줄러로 격리하고
  • 동시성 상한을 먼저 둔 뒤
  • 커넥션 풀과 타임아웃을 p99 기준으로 재설계

이 4단계를 지키면, 평균을 건드리지 않고도 p99를 안정적으로 내릴 수 있습니다.