Published on

Spring Boot 3 가상 스레드 병목 진단·튜닝 가이드

Authors

서버에 가상 스레드(virtual thread)를 적용하면 “스레드 풀 고갈”로 인한 대기열이 줄어들고 동시성은 크게 올라갈 수 있습니다. 하지만 실무에서는 virtual.threads.enabled=true 한 줄로 끝나지 않습니다. 오히려 병목이 CPU, DB 커넥션 풀, 동기 I/O, 락 경합, 로그/관측 오버헤드 같은 곳으로 이동하면서 “가상 스레드인데도 느린” 상황이 자주 나옵니다.

이 글은 Spring Boot 3에서 가상 스레드를 사용 중이거나 도입하려는 팀이 병목을 진단하고 튜닝하는 실전 체크리스트를 갖추도록 돕는 것을 목표로 합니다.

1) 가상 스레드가 해결하는 문제와 못 푸는 문제

가상 스레드가 강한 영역

  • 요청 처리 스레드가 블로킹 I/O(네트워크, 파일, DB 드라이버의 블로킹 호출 등)에서 오래 대기하는 워크로드
  • 기존 플랫폼 스레드 풀(예: Tomcat maxThreads)이 부족해 큐잉이 발생하는 상황
  • 동시 접속이 많고 각 요청이 “대기 시간이 긴” 형태인 API

가상 스레드가 약한 영역(병목이 그대로 남음)

  • CPU 바운드: JSON 직렬화/역직렬화, 암복호화, 압축, 대규모 컬렉션 처리 등
  • 공유 자원 한계: DB 커넥션 풀, 외부 API rate limit, Redis 단일 스레드 처리, 파일 핸들 제한
  • 락 경합: synchronized, ReentrantLock, 세션/캐시 전역 락
  • 관측/로깅 비용: 과도한 MDC, 대량 로그, 고카디널리티 메트릭

핵심은 “스레드가 늘었다”가 아니라 “대기 모델이 바뀌었다”입니다. 가상 스레드는 대기 중인 스레드를 저렴하게 만들어주지만, 진짜 병목 자원(DB, CPU, 락)은 그대로입니다.

2) Spring Boot 3에서 가상 스레드 활성화 포인트

Spring Boot 3.2+ 기준으로 다음 설정으로 서블릿 스택(Tomcat/Jetty/Undertow) 요청 처리를 가상 스레드로 전환할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

가상 스레드 활성화 후 반드시 확인할 것:

  • 실제 요청 처리 스레드가 가상 스레드인지(스레드 덤프/JFR)
  • DB/외부 호출이 블로킹인지(리액티브 WebClient를 쓰더라도 최종 대기 지점이 어디인지)
  • 커넥션 풀/클라이언트 풀 크기와의 상호작용

3) “가상 스레드인데도 느리다” 1차 분류법

장애/성능 이슈를 빠르게 분류하려면, 지표를 아래 3가지로 나눠 보세요.

(A) CPU가 꽉 찼는가

  • CPU usage가 지속적으로 높고, 로드 평균이 코어 수를 초과
  • 지연이 늘어날수록 처리량이 같이 감소

(B) 대기(Wait)가 늘었는가

  • CPU는 여유인데 지연이 증가
  • 스레드 덤프에서 WAITING / TIMED_WAITING이 많음
  • DB 커넥션 대기, 락 대기, 외부 API 대기 가능성

(C) 큐잉이 늘었는가

  • Tomcat accept 큐, DB pool wait queue, HTTP client pool queue 등
  • “대기열이 생기는 지점”이 어디인지가 핵심

이 분류가 중요한 이유는, 가상 스레드 튜닝이 대개 (B)/(C)에서 효과가 좋고, (A)는 알고리즘/GC/직렬화 최적화로 가야 하는 경우가 많기 때문입니다.

4) 진단 도구: 스레드 덤프, JFR, 메트릭을 같이 본다

4.1 스레드 덤프에서 보는 포인트

가상 스레드 환경에서는 스레드 수가 많아 덤프가 복잡해질 수 있습니다. 그래도 다음을 보면 방향이 잡힙니다.

  • 같은 스택 트레이스가 대량 반복되는가(특정 I/O나 락에서 대기)
  • DB 커넥션 획득 대기 스택이 많은가
  • 특정 synchronized 블록/락 획득 대기가 많은가

덤프는 jcmd로 얻는 편이 실무에서 다루기 쉽습니다.

jcmd <pid> Thread.print -l > thread-dump.txt

주의: 본문에서 <pid> 같은 형태는 MDX에서 태그로 오인될 수 있으니, 위처럼 반드시 백틱 코드 블록 안에서만 사용하세요.

4.2 JFR(Java Flight Recorder)로 “어디서 막히는지” 확정

가상 스레드 병목은 “커넥션 풀 대기”나 “락 경합”처럼 이벤트로 보면 명확해집니다. JFR은 다음을 특히 유용하게 제공합니다.

  • Java Monitor Blocked(모니터 락 경합)
  • Thread Park(대기/park)
  • 소켓 I/O, 파일 I/O, GC, CPU 샘플
jcmd <pid> JFR.start name=perf settings=profile duration=120s filename=recording.jfr

2분 정도만 떠도, 병목이 DB인지 락인지 외부 I/O인지 윤곽이 나옵니다.

4.3 메트릭: “대기열”을 수치로 잡아라

  • Tomcat: active threads, request duration, error rate
  • HikariCP: active, idle, pending threads(커넥션 대기)
  • 외부 API: client pool 대기, 타임아웃, 재시도 횟수

DB 쪽이 의심된다면, 데드락/락 대기까지 포함해 원인 추적이 필요할 수 있습니다. 이 경우 MySQL InnoDB 데드락 원인 추적과 인덱스 튜닝도 함께 보면 “DB에서 막히는 지연”을 더 빨리 좁힐 수 있습니다.

5) 대표 병목 1: DB 커넥션 풀(HikariCP) 대기

가상 스레드를 켜면 동시에 더 많은 요청이 “DB를 치려고” 합니다. 결과적으로 가장 흔한 병목은 HikariCP 풀 고갈입니다.

증상

  • CPU는 낮은데 응답 지연이 증가
  • Hikari 메트릭에서 pending이 증가
  • 스레드 덤프에서 커넥션 획득 대기 스택이 반복

튜닝 체크리스트

  1. 풀 사이즈를 무작정 키우기 전에 DB가 감당 가능한지 확인
  2. 트랜잭션 범위를 줄이고, 불필요한 DB 왕복 제거
  3. N+1 제거, 인덱스/쿼리 튜닝
  4. 타임아웃/리트라이 정책 점검(리트라이는 풀 고갈을 악화)

예시 설정:

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      max-lifetime: 1800000
      leak-detection-threshold: 30000

leak-detection-threshold는 커넥션 릭을 빠르게 잡는 데 유용하지만, 너무 낮추면 노이즈가 늘 수 있습니다.

6) 대표 병목 2: 동기 HTTP 클라이언트 풀/타임아웃 설계

서버가 외부 API를 호출하는 구조에서는, 가상 스레드가 “외부 호출 대기”를 싸게 만들어주긴 하지만, 결국 외부 API가 느리면 동시 요청이 폭증하면서 다음 문제가 생깁니다.

  • 외부 호출이 쌓여서 서버 메모리/스케줄링 부담 증가
  • 재시도 폭풍으로 외부/내부 모두 악화
  • 커넥션 풀/소켓 리소스 고갈

실전 처방

  • 호출 단에 동시성 제한(벌크헤드) 를 둔다
  • 타임아웃을 “짧고 명확하게” 설정한다
  • 재시도는 조건부로, 지수 백오프 + 상한을 둔다

예: Semaphore로 엔드포인트별 동시성 제한

import java.util.concurrent.Semaphore;

public final class Bulkhead {
    private final Semaphore semaphore;

    public Bulkhead(int maxConcurrent) {
        this.semaphore = new Semaphore(maxConcurrent);
    }

    public <T> T call(CheckedSupplier<T> supplier) throws Exception {
        boolean acquired = semaphore.tryAcquire();
        if (!acquired) {
            throw new IllegalStateException("Too many concurrent calls");
        }
        try {
            return supplier.get();
        } finally {
            semaphore.release();
        }
    }

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

가상 스레드는 “대기 비용”을 줄여줄 뿐, 외부 시스템의 처리량을 늘려주지 않습니다. 동시성 제한을 안 두면 병목이 증폭됩니다.

7) 대표 병목 3: 락 경합과 synchronized 구간

가상 스레드는 락을 “더 잘게 쪼개주지” 않습니다. 오히려 요청 동시성이 늘면서 락 경합이 더 자주 드러납니다.

흔한 패턴

  • 전역 캐시 업데이트에 synchronized 사용
  • 세션/토큰 갱신 로직이 단일 락으로 보호됨
  • 로깅/메트릭 기록 시 공유 자료구조 경합

해결 방향

  • 락 범위를 줄이거나, lock striping(키 기반 분할 락) 적용
  • 단일 공유 Map에 대한 경쟁을 줄이기 위해 ConcurrentHashMap + 원자적 연산 사용
  • 가능하면 불변(immutable) 구조로 바꾸고 교체(swap)하는 방식 고려

JFR에서 Java Monitor Blocked 이벤트가 많이 보이면 이 축을 최우선으로 보세요.

8) Tomcat/서블릿 관점 튜닝: 스레드가 아니라 커넥션과 큐

가상 스레드를 쓰면 Tomcat의 “요청 처리 스레드”는 훨씬 유연해지지만, 다음은 여전히 병목이 될 수 있습니다.

  • accept 큐/커넥션 수 한계
  • keep-alive로 인한 커넥션 점유
  • 큰 요청/응답 바디로 인한 I/O 및 메모리 압박

점검 포인트:

  • keep-alive timeout이 과도하게 길지 않은지
  • 업로드/다운로드가 큰 API에 대해 별도 제한이 필요한지
  • 프록시/로드밸런서와의 타임아웃 정합성

쿠버네티스/EKS 환경에서는 L7/L4 레벨의 설정과 맞물려 4xx/5xx나 재시도가 병목을 키우기도 합니다. 인프라 단에서 이상 징후가 있다면 EKS AWS Load Balancer Controller 설치 후 403 해결처럼 “앞단에서 무슨 일이 벌어지는지”도 같이 확인하는 게 좋습니다.

9) 관측/로깅: 가상 스레드에서 비용이 커지는 지점

가상 스레드는 요청 수용량을 올리기 때문에, 같은 QPS라도 “동시에 열려 있는 요청 수”가 늘 수 있습니다. 이때 관측 비용이 급격히 커질 수 있습니다.

  • 과도한 DEBUG 로그가 I/O 병목으로 변함
  • MDC에 큰 값을 넣거나, 고카디널리티 라벨을 메트릭에 붙임
  • 트레이싱 샘플링이 너무 높아 수집/전송이 병목

실전 팁:

  • 에러/슬로우 쿼리/슬로우 요청 중심으로 로그를 재설계
  • 메트릭 라벨은 낮은 카디널리티로 유지
  • 트레이싱은 샘플링과 예외 규칙을 둔다

장애 시 “프로세스가 계속 재시작”되는 형태로 나타나면, 성능 문제와 별개로 런타임/메모리/헬스체크 실패 추적이 필요합니다. 이때는 systemd 서비스가 계속 재시작될 때 원인 추적법 같은 접근이 원인 분리에 도움이 됩니다.

10) 실전 튜닝 시나리오: 병목을 옮기지 말고 제거하기

가상 스레드 도입 후 흔한 흐름은 다음과 같습니다.

  1. Tomcat 스레드 고갈 문제는 사라짐
  2. DB 커넥션 풀이 병목으로 부상
  3. 풀을 키우면 DB 락/쿼리 지연이 증가
  4. 외부 API 호출이 늘면서 타임아웃/재시도 폭발
  5. 로그/트레이싱 비용이 커지며 CPU와 I/O가 상승

따라서 튜닝은 “한 군데를 키우는 것”이 아니라, 병목의 근본 원인을 제거하는 방식이 되어야 합니다.

권장 순서(체크리스트)

  • JFR 2분: 락/대기/CPU 샘플로 1차 확정
  • Hikari 메트릭: pending 증가 여부 확인
  • 슬로우 쿼리/인덱스: DB 시간을 줄일 여지가 있는지
  • 외부 호출: 타임아웃, 재시도, 동시성 제한 적용
  • 로깅/관측: 비용 상한 설정

11) 가상 스레드 적용 시 코드 레벨 주의점

블로킹 호출을 “숨기지” 말 것

가상 스레드는 블로킹을 견딜 수 있지만, 블로킹이 과도하면 결국 외부 자원/풀에서 막힙니다. 특히 다음은 성능 문제를 숨기기 쉽습니다.

  • 무제한 동시성으로 외부 API 호출
  • 트랜잭션 안에서 외부 호출
  • 한 요청에서 DB를 여러 번 왕복

트랜잭션 범위 최소화 예시

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentClient paymentClient;

    public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
        this.orderRepository = orderRepository;
        this.paymentClient = paymentClient;
    }

    @Transactional
    public Long createOrderDraft(CreateOrderCommand cmd) {
        Order order = Order.draft(cmd);
        orderRepository.save(order);
        return order.getId();
    }

    public void requestPayment(Long orderId) {
        // 트랜잭션 밖에서 외부 호출
        paymentClient.request(orderId);
    }
}

외부 호출을 트랜잭션 밖으로 빼면 DB 커넥션 점유 시간이 줄어 풀 병목이 완화됩니다.

12) 결론: “가상 스레드 튜닝”의 중심은 DB·락·외부 I/O다

Spring Boot 3 가상 스레드는 서버 동시성의 기본기를 크게 올려주지만, 그 자체가 성능을 보장하진 않습니다. 가상 스레드를 켠 뒤 성능이 기대만큼 나오지 않는다면 다음을 우선순위로 보세요.

  • DB 커넥션 풀 대기(가장 흔함)
  • 락 경합(synchronized/공유 자원)
  • 외부 API 호출의 동시성 제한과 타임아웃/재시도
  • 관측/로깅 비용

가상 스레드는 “스레드 부족”을 해결해주고, 그 다음 단계의 병목을 드러나게 합니다. 그 드러난 병목을 JFR과 메트릭으로 빠르게 확정하고, 풀/락/쿼리/외부 호출 정책을 함께 튜닝하는 것이 가장 안전한 접근입니다.