Published on

Spring Boot 3 가상스레드로 TPS 2배 튜닝 실전

Authors

서버 TPS를 올리는 가장 흔한 접근은 CPU를 더 주거나, 캐시를 붙이거나, 비동기 프레임워크로 갈아타는 것입니다. 그런데 Spring Boot 3와 Java 21 조합에서는 비교적 낮은 전환 비용으로 동시성 모델을 바꿔 TPS를 크게 끌어올리는 선택지가 생겼습니다. 바로 가상스레드(virtual threads, Project Loom) 입니다.

이 글은 “가상스레드 켰더니 빨라졌다” 수준을 넘어, 왜 TPS가 올라가는지, 어떤 조건에서 2배가 가능한지, 그리고 어디서 다시 병목이 생기는지를 실무 관점에서 단계적으로 정리합니다.

가상스레드 적용 후 장애나 성능 급락을 겪었다면 아래 글도 함께 보세요.

가상스레드로 TPS가 오르는 메커니즘

전통적인 Spring MVC + Tomcat 모델은 요청당 플랫폼 스레드(커널 스레드)를 하나씩 점유합니다. DB 조회, 외부 API 호출처럼 대기 시간이 긴 I/O가 많을수록, 플랫폼 스레드는 놀고 있는데도 점유 상태가 되어 동시 처리량이 스레드 수에 의해 제한됩니다.

가상스레드는 “요청당 스레드” 모델을 유지하면서도, I/O 대기 구간에서 스레드를 효율적으로 파킹해 더 많은 동시 요청을 처리할 수 있게 합니다.

정리하면 TPS가 2배 가까이 오르는 케이스는 대체로 아래 조건을 만족합니다.

  • 요청 처리의 상당 부분이 블로킹 I/O(DB, HTTP, Redis, 파일, 메시지 브로커 등) 대기
  • CPU 사용률이 이미 80% 이상으로 꽉 차 있지 않음
  • 스레드 풀 고갈, 큐잉, 컨텍스트 스위칭 비용이 기존에 병목

반대로 아래 상황에서는 체감이 작거나 오히려 악화될 수 있습니다.

  • CPU 바운드(암호화, 이미지 처리, 복잡한 계산) 비중이 큼
  • 커넥션 풀, 락, 외부 시스템 QPS 제한이 진짜 병목
  • synchronized/모니터 락 경합이 심해 가상스레드가 자주 pinning 됨

목표: “가상스레드만”으로 TPS 2배가 가능한가

가능합니다. 다만 “가상스레드 토글”만으로 2배가 나오는 경우는 보통 다음과 같이 기존 설정이 보수적일 때입니다.

  • Tomcat maxThreads 가 낮음
  • HikariCP maximumPoolSize 가 너무 작거나, 반대로 너무 커서 DB가 병목
  • 외부 API 호출 타임아웃이 길고 리트라이가 많아 요청이 쌓임

가상스레드는 동시성의 상한을 크게 올려주지만, 그 결과로 다른 병목(특히 DB) 을 더 빨리 드러나게 합니다. 그래서 2배 튜닝은 보통 “가상스레드 적용 + 병목 1~2개 정리”의 조합으로 달성됩니다.

적용 1단계: Spring Boot 3에서 가상스레드 켜기

가장 간단한 설정

Spring Boot 3.2+ 기준으로는 아래 설정만으로도 웹 요청 처리를 가상스레드로 전환할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은 요청 처리 스레드(서블릿 컨테이너 작업 스레드)를 가상스레드로 바꾸는 데 초점이 있습니다.

Java 버전 체크

가상스레드는 Java 21에서 정식 기능입니다. 런타임이 Java 21인지 반드시 확인하세요.

java -version

빌드 도구도 Java 21로 맞춥니다.

// Gradle Kotlin DSL 예시
java {
  toolchain {
    languageVersion.set(JavaLanguageVersion.of(21))
  }
}

적용 2단계: 성능 측정 기준을 먼저 고정하기

가상스레드는 “동시성”을 바꾸는 기능이라, 측정 기준이 흔들리면 결론이 왜곡됩니다. 최소한 아래는 고정하세요.

  • 동일한 데이터셋, 동일한 시나리오(예: 조회 80%, 쓰기 20%)
  • 동일한 인프라(코어 수, 메모리, DB 스펙)
  • 동일한 부하 도구 설정(동시 사용자 수, 램프업)
  • p50/p95/p99 레이턴시와 TPS를 함께 보기

부하 테스트는 k6 예시로도 충분합니다.

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    steady: {
      executor: 'constant-vus',
      vus: 200,
      duration: '2m',
    },
  },
};

export default function () {
  const res = http.get('http://localhost:8080/api/orders/123');
  check(res, { 'status is 200': (r) => r.status === 200 });
  sleep(0.1);
}

여기서 중요한 건 “가상스레드로 TPS가 올랐다”가 아니라, 어떤 자원이 병목에서 해방되었는지를 지표로 확인하는 것입니다.

튜닝 포인트 1: DB 커넥션 풀과 TPS의 상관관계

가상스레드를 켜면 동시에 더 많은 요청이 DB로 몰리기 쉽습니다. 이때 DB 커넥션 풀이 작으면 TPS는 금방 다시 막힙니다.

HikariCP 기본 튜닝 가이드

  • maximumPoolSize 를 무작정 키우면 DB가 먼저 죽습니다.
  • 목표는 “DB가 감당 가능한 범위에서 애플리케이션 큐잉을 줄이는 것”입니다.

예시:

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      validation-timeout: 1000
      max-lifetime: 1800000

실무 팁:

  • connection-timeout 을 줄이면, DB 병목 시 대기열이 애플리케이션 내부에 무한정 쌓이는 것을 막고 빠르게 실패시켜 상위에서 재시도/서킷브레이커로 제어할 수 있습니다.
  • TPS가 2배가 되었는데 p99이 악화됐다면, 커넥션 대기 시간이 늘었을 가능성이 큽니다.

관측해야 할 지표

  • HikariCP active/idle/pending
  • DB의 CPU, lock wait, slow query, connection count
  • 애플리케이션 p95/p99 latency

가상스레드의 장점은 “스레드가 부족해서 못 받는” 상황을 줄이는 것이지, DB를 더 빠르게 만드는 게 아닙니다. 병목이 DB로 이동하면 쿼리 튜닝이나 캐시가 다음 단계가 됩니다.

튜닝 포인트 2: 외부 HTTP 호출과 타임아웃 전략

가상스레드 환경에서 외부 호출이 많으면 “동시 요청이 더 잘 들어오는 만큼” 외부 시스템에 더 많은 부하를 줄 수 있습니다. 이때 타임아웃과 동시성 제한이 없으면 레이턴시 꼬리가 길어집니다.

RestClient 또는 WebClient 사용 시 주의

가상스레드에서는 블로킹 호출 자체가 문제는 아닐 수 있지만, 타임아웃이 길면 요청이 오래 살아남아 메모리 압박이 커집니다.

@Bean
RestClient restClient(RestClient.Builder builder) {
  return builder
      .requestFactory(new JdkClientHttpRequestFactory())
      .build();
}

public String callPartner(RestClient client) {
  return client.get()
      .uri("https://partner.example/api")
      .retrieve()
      .body(String.class);
}

그리고 타임아웃은 반드시 계층별로 둡니다.

  • 커넥션 타임아웃
  • 요청 전체 타임아웃
  • 읽기 타임아웃

프레임워크별 설정 방식이 다르므로, 사용하는 클라이언트에 맞춰 명시적으로 설정하세요.

동시성 제한(벌크헤드) 추가

가상스레드로 동시성이 확 늘면 외부 API가 병목이 되어 전체 TPS를 흔들 수 있습니다. Resilience4j 같은 라이브러리로 벌크헤드를 두면 안정적입니다.

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

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

Supplier<String> supplier = Bulkhead.decorateSupplier(bulkhead,
    () -> callPartner(restClient));

String body = Try.ofSupplier(supplier).get();

튜닝 포인트 3: synchronized 와 pinning 이슈 피하기

가상스레드는 특정 조건에서 캐리어 스레드를 오래 붙잡는 pinning 문제가 생길 수 있습니다. 대표적으로 모니터 락(synchronized)을 잡은 채로 블로킹 I/O를 하면 악화됩니다.

문제가 되는 패턴:

synchronized (lock) {
  // DB 호출, 외부 HTTP 호출 같은 블로킹 작업을 수행
  jdbcTemplate.queryForObject("select ...", String.class);
}

개선 방향:

  • 락 범위를 최소화하고, I/O는 락 밖으로 빼기
  • 가능하면 ReentrantLock 등으로 전환하고 임계영역을 짧게 유지
String key;
lock.lock();
try {
  key = computeKey();
} finally {
  lock.unlock();
}

// I/O는 락 밖에서
String value = jdbcTemplate.queryForObject(
    "select value from t where k = ?", String.class, key);

락 경합이 심한 서비스는 가상스레드 적용 후 “더 많은 요청이 더 빨리 락으로 몰리면서” TPS가 오히려 떨어질 수 있습니다.

튜닝 포인트 4: 스레드 로컬과 컨텍스트 전파 비용

가상스레드는 생성 비용이 낮지만, 요청 수가 많아지면 다음 비용이 티가 날 수 있습니다.

  • 과도한 ThreadLocal 사용
  • MDC 로깅 컨텍스트 전파
  • 대량의 요청 스코프 객체 생성

대응 방법:

  • MDC 키를 최소화
  • 로그 레벨/로그량을 부하 테스트 시 현실적으로 맞추기
  • 핫패스에서 불필요한 ThreadLocal 제거

튜닝 포인트 5: Tomcat, Jetty, Undertow 선택

Spring Boot 기본은 Tomcat이며, 가상스레드 적용도 잘 동작합니다. 다만 컨테이너별로 기본 스레드/큐 정책이 다르고, 애플리케이션 특성에 따라 결과가 달라질 수 있습니다.

실무에서는 우선 Tomcat으로 시작하고, 아래 조건일 때만 교체를 검토하는 편이 안전합니다.

  • 특정 컨테이너에서만 재현되는 스레드/큐 관련 이슈
  • 커넥션 처리 모델 차이로 p99이 크게 흔들리는 경우

컨테이너 교체는 변수가 많으므로, TPS 2배 목표에는 보통 “최후의 카드”로 두는 게 좋습니다.

“TPS 2배”를 만드는 실전 시나리오 예시

가상의 예시로, 주문 조회 API가 아래처럼 구성되어 있다고 가정하겠습니다.

  • DB 조회 1회(평균 20ms)
  • 외부 결제 상태 API 조회 1회(평균 50ms)
  • 애플리케이션 CPU 작업(평균 5ms)

기존 플랫폼 스레드 기반에서 동시 요청이 늘면, 스레드가 I/O 대기 중 묶이면서 큐잉이 발생합니다. 가상스레드로 전환하면 같은 CPU에서 더 많은 요청을 “동시에 대기”시킬 수 있어 TPS가 증가합니다.

하지만 이후 병목이 다음 순서로 이동하는 경우가 흔합니다.

  1. 스레드 풀 병목 해소로 TPS 상승
  2. DB 커넥션 풀 pending 증가
  3. DB 락/슬로우쿼리 또는 외부 API rate limit이 병목으로 부상

따라서 2배를 안정적으로 만들려면 “가상스레드 적용” 이후에 가장 크게 튀는 병목 1개만 잡아도 목표 달성이 가능한 경우가 많습니다.

체크리스트: 적용 전후로 반드시 확인할 것

  • Java 21 사용 여부
  • spring.threads.virtual.enabled 적용 여부
  • 부하 테스트 시나리오 고정
  • HikariCP pending이 급증하지 않는지
  • 외부 API 타임아웃/동시성 제한이 있는지
  • synchronized + I/O 패턴이 없는지
  • p95/p99 레이턴시가 TPS 상승과 함께 악화되지 않는지

마무리

Spring Boot 3의 가상스레드는 “비동기 전환” 같은 큰 리팩터링 없이도, I/O 중심 서비스의 동시성을 크게 끌어올릴 수 있는 강력한 옵션입니다. TPS 2배는 충분히 현실적인 목표지만, 가상스레드는 병목을 없애기보다 병목을 이동시키는 경향이 있으므로 커넥션 풀, 타임아웃, 락 경합 같은 기본기를 함께 점검해야 합니다.

가상스레드 적용 후 블로킹 I/O로 인한 장애 진단은 아래 글이 도움이 됩니다.