Published on

Spring Boot 3 가상스레드 적용 후 성능저하 원인

Authors

서버 스레드가 부족해서 느린 줄 알고 Spring Boot 3에서 가상스레드(virtual threads)를 켰는데, 오히려 TPS가 떨어지거나 p95 지연이 늘어나는 경우가 있습니다. 이는 가상스레드가 만능이 아니라, 특정 병목을 더 빨리 “드러내거나” 더 크게 “증폭” 시키기 때문입니다.

이 글은 “가상스레드로 바꾸면 빨라진다”가 아니라, 왜 느려지는지를 원인별로 쪼개서 설명하고, Spring Boot 3 환경에서 재현·진단·개선까지 이어지도록 구성했습니다.

관련해서 블로킹 I/O를 어떻게 찾아야 하는지 더 깊게 보고 싶다면 Spring Boot 3 가상스레드 장애 - 블로킹 I/O 진단도 함께 참고하면 좋습니다.

가상스레드가 성능을 떨어뜨릴 수 있는 구조적 이유

가상스레드는 OS 스레드를 무한정 늘리는 대신, JVM이 관리하는 매우 가벼운 스레드를 대량으로 만들고, 블로킹 지점에서 캐리어 스레드(carrier thread) 를 다른 작업에 양보(unmount)할 수 있게 합니다.

하지만 다음 조건이 겹치면 성능이 악화될 수 있습니다.

  • 블로킹이 “양보”로 바뀌는 게 아니라 “고정(pinning)” 되어 캐리어 스레드를 붙잡는다
  • 스레드가 늘면서 DB/외부 API/락 같은 공유 자원이 먼저 포화된다
  • 컨텍스트 전환/스케줄링/스택 관리 비용이 누적된다
  • 기존 튜닝(톰캣 스레드 수, 커넥션 풀)이 “플랫폼 스레드 기준”으로 되어 있어 병목이 재배치된다

즉, 가상스레드는 CPU를 공짜로 만들어주지 않고, 대기 시간을 숨기는 방식도 “블로킹이 잘 풀리는 경우”에만 유효합니다.

원인 1) DB 커넥션 풀이 먼저 포화되어 대기열이 폭발

가상스레드를 켜면 동시에 더 많은 요청이 처리 파이프라인에 진입합니다. 그런데 DB 커넥션 풀(HikariCP)은 여전히 제한된 크기입니다. 결과적으로:

  • 예전: 톰캣 플랫폼 스레드가 적어서 DB로 동시에 들어가는 수가 제한됨
  • 이후: 가상스레드가 많아져 DB 커넥션 획득 대기가 급증

이때 관측되는 현상은 다음과 같습니다.

  • p95, p99 지연이 크게 증가
  • Hikari Connection is not available, request timed out 또는 커넥션 획득 대기 증가
  • DB CPU나 락 경합이 증가

진단 체크리스트

  • Hikari 메트릭에서 pending(대기), active(사용 중), idle(유휴) 확인
  • APM에서 “DB 커넥션 획득” 구간이 길어지는지 확인
  • DB 측에서 동시 쿼리 수, 락, 슬로우 쿼리 증가 확인

개선 방향

  • 커넥션 풀을 무턱대고 키우기 전에 DB가 감당 가능한 동시성을 먼저 판단
  • 애플리케이션 동시성을 “스레드 수”가 아니라 “리소스 수” 기준으로 제한

아래는 “DB 호출 동시성”을 제한하는 간단한 세마포어 패턴입니다.

import java.util.concurrent.Semaphore;

public class DbConcurrencyLimiter {
  private final Semaphore semaphore;

  public DbConcurrencyLimiter(int maxConcurrentDbCalls) {
    this.semaphore = new Semaphore(maxConcurrentDbCalls);
  }

  public <T> T execute(CheckedSupplier<T> supplier) throws Exception {
    semaphore.acquire();
    try {
      return supplier.get();
    } finally {
      semaphore.release();
    }
  }

  @FunctionalInterface
  public interface CheckedSupplier<T> {
    T get() throws Exception;
  }
}

가상스레드 환경에서는 특히 “외부 리소스”가 병목이 되기 쉬우므로, 스레드가 아니라 리소스별 동시성 제한이 성능 안정화에 더 직접적입니다.

원인 2) 동기화(synchronized) 때문에 가상스레드가 pinning 된다

가상스레드는 블로킹 시 캐리어 스레드에서 분리될 수 있지만, 특정 상황에서는 캐리어 스레드에 고정되는 pinning이 발생합니다. 대표 케이스가:

  • synchronized 블록 내부에서 블로킹 I/O 호출
  • 오래 걸리는 작업을 모니터 락을 잡은 채 수행

이 경우 가상스레드가 많아도 캐리어 스레드가 붙잡혀 실제 처리량이 떨어집니다.

위험 패턴 예시

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

  public String getOrLoad(String key) {
    synchronized (lock) {
      // 락 잡은 채로 원격 호출(블로킹)하면 pinning 유발 가능
      return remoteCall(key);
    }
  }

  private String remoteCall(String key) {
    // 예: RestTemplate, JDBC, 파일 I/O 등
    return "value";
  }
}

개선 예시

  • 락 범위를 최소화하고, 블로킹 작업을 락 밖으로 이동
  • ConcurrentHashMap + computeIfAbsent 등 락 경합이 적은 구조로 변경
import java.util.concurrent.ConcurrentHashMap;

public class BetterCacheClient {
  private final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();

  public String getOrLoad(String key) {
    return cache.computeIfAbsent(key, this::remoteCall);
  }

  private String remoteCall(String key) {
    return "value";
  }
}

원인 3) HTTP 클라이언트/SDK가 블로킹 + 풀 고정으로 병목을 만든다

가상스레드로 전환한 뒤 외부 API 호출이 많다면, 다음이 성능 저하의 단골 원인입니다.

  • 클라이언트 자체가 블로킹이고 커넥션 풀이 작음
  • DNS/프록시/TLS 핸드셰이크가 잦아짐(keep-alive 미설정)
  • 외부 API가 429나 지연을 유발하는데, 재시도 폭주로 동시성만 증가

특히 429나 제한이 있는 API를 호출할 때는 “스레드 증가”가 오히려 재시도 폭풍을 만들 수 있습니다. 이 경우에는 재시도/백오프/큐잉 설계가 필수입니다.

개선 방향

  • 커넥션 풀 크기와 keep-alive 설정 점검
  • 외부 API 호출 동시성 제한 + 지수 백오프 + jitter
  • 실패 시 즉시 재시도 대신 큐잉/서킷브레이커 적용

원인 4) 톰캣/서블릿 스레드 모델과의 기대 불일치

Spring MVC(서블릿 기반)에서 가상스레드를 켜면 “요청당 스레드” 모델 자체는 유지되지만, 스레드가 가벼워졌을 뿐입니다. 여기서 자주 생기는 착각이:

  • 예전에는 톰캣 스레드 수가 자연스러운 백프레셔 역할을 했는데
  • 가상스레드로 바꾸면서 백프레셔가 약해져, 뒤쪽(DB, 외부 API, 락)이 먼저 터짐

점검 포인트

  • 톰캣 accept 큐, request 처리 큐가 어떻게 변했는지
  • 애플리케이션이 “무제한 동시성”으로 동작하고 있지 않은지

해결은 단순히 스레드 수를 조절하는 게 아니라, 앞단에서 명시적 백프레셔를 거는 것입니다.

원인 5) 로그/메트릭/트레이싱이 병목이 된다

가상스레드는 요청 동시성이 커지기 쉬워서, 다음이 병목으로 튀어나올 수 있습니다.

  • 동기 로깅 appender(파일, 콘솔)로 인한 블로킹
  • 과도한 MDC 사용 및 문자열 포맷 비용
  • 트레이싱 에이전트가 스레드 로컬 기반으로 높은 오버헤드 유발

개선 방향

  • 비동기 로깅(예: Logback AsyncAppender) 사용
  • 고빈도 로그는 샘플링하거나 레벨 조정
  • 관측 도구를 켠 상태/끈 상태로 부하 테스트하여 오버헤드 분리

원인 6) CPU 바운드 작업이 섞여 있는데 스레드만 늘렸다

가상스레드는 I/O 대기 시간을 숨기는데 강점이 있지만, CPU 바운드 작업에는 “스레드를 늘릴수록” 컨텍스트 스위칭과 스케줄링 비용이 늘어 역효과가 날 수 있습니다.

대표 예:

  • 대용량 JSON 직렬화/역직렬화
  • 암호화/서명
  • 이미지 처리
  • 복잡한 정규식

개선 방향

  • CPU 바운드 구간은 스레드 수가 아니라 코어 수 기준으로 제한
  • 병렬화가 필요하면 ForkJoinPool 또는 작업 큐 기반으로 설계

원인 7) 부하 테스트 방법이 바뀌면서 “나빠진 것처럼” 보인다

가상스레드 전환 후 성능 저하라고 판단했는데, 실제로는 테스트 조건이 달라져서 착시가 생기는 경우도 많습니다.

  • 커넥션 재사용(keep-alive) 유무
  • 워밍업 부족으로 JIT 전 최적화 상태 측정
  • GC 로그/프로파일러/디버그 옵션으로 인한 오버헤드
  • 동일한 RPS인데 동시성(concurrency)이 달라짐

권장 측정 방법

  • 동일한 RPS 뿐 아니라 동일한 동시성에서도 비교
  • p50/p95/p99와 에러율을 함께 비교
  • DB, 외부 API, JVM(Heap/GC), OS(컨텍스트 스위치) 지표를 동시에 수집

Spring Boot 3에서 가상스레드 적용 및 확인 코드

Spring Boot 3.2+ 기준으로는 설정 하나로 서블릿 요청 처리에 가상스레드를 적용할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

적용 여부를 런타임에 빠르게 확인하는 가장 단순한 방법은 현재 스레드가 가상스레드인지 로깅하는 것입니다.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ThreadCheckController {

  @GetMapping("/thread")
  public String thread() {
    Thread t = Thread.currentThread();
    return "name=" + t.getName() + ", isVirtual=" + t.isVirtual();
  }
}

이 엔드포인트가 isVirtual=true를 반환한다고 해서 성능이 자동으로 좋아지는 것은 아니고, 앞서 말한 병목(풀/락/외부 API/로깅)이 같이 점검되어야 합니다.

실전 트러블슈팅 순서(가장 많이 맞는 흐름)

가상스레드 적용 후 성능이 떨어질 때, 현장에서 가장 빠르게 원인을 좁히는 순서는 보통 다음이 효율적입니다.

  1. DB 커넥션 풀 대기가 늘었는지 확인(Hikari 메트릭)
  2. 외부 API 호출이 있다면 커넥션 풀/429/재시도 폭주 여부 확인
  3. 스레드 덤프/프로파일로 synchronized 경합 및 pinning 의심 지점 찾기
  4. 로깅/트레이싱을 끄고 재측정하여 관측 오버헤드 분리
  5. CPU 바운드 구간이 있는지 확인하고 코어 수 기준 제한 적용

이 중 1번과 2번이 원인인 경우가 가장 많고, 가상스레드는 그 병목을 “숨기기”가 아니라 “노출”시키는 역할을 했을 가능성이 큽니다.

결론: 가상스레드는 동시성 확대 도구, 병목은 리소스에 있다

Spring Boot 3 가상스레드는 많은 I/O 바운드 요청을 더 적은 OS 스레드로 처리할 수 있게 해주지만, 동시에 다음을 요구합니다.

  • DB/외부 API/락/로깅 같은 공유 자원 병목을 먼저 해결
  • 무제한 동시성 대신 명시적 백프레셔와 동시성 제한
  • pinning을 유발하는 동기화 패턴 제거

가상스레드 전환은 “스레드 튜닝”이 아니라 “시스템 전체 동시성 설계”를 다시 보게 만드는 변화입니다. 성능 저하가 보인다면, 스레드 자체보다 커넥션 풀, 락, 외부 의존성, 관측 오버헤드부터 의심하는 것이 가장 빠른 길입니다.