Published on

Spring Boot 3 가상스레드 적용 후 TPS 급락 원인

Authors

서버 TPS가 한계에 부딪혀 Spring Boot 3 + Java 21의 가상 스레드(virtual thread)를 적용했는데, 기대와 달리 TPS가 급락하는 사례가 종종 있습니다.

가상 스레드는 **“스레드 수를 늘려도 OS 스레드(플랫폼 스레드)를 많이 쓰지 않는다”**는 장점이 있지만, 이는 어디까지나 블로킹이 ‘잘 격리’되고, 병목이 스레드가 아닌 다른 곳에 있을 때 효과가 큽니다. 반대로 기존에는 스레드 풀 크기(예: Tomcat maxThreads)가 자연스러운 백프레셔(backpressure) 역할을 하던 시스템에서 가상 스레드를 켜면, 숨겨진 병목(커넥션 풀, DB 락, 외부 API, 동기 로깅, CPU, 타임아웃) 이 한꺼번에 노출되며 TPS가 떨어질 수 있습니다.

아래는 “가상 스레드 적용 후 TPS 급락”을 만드는 대표 원인과, 짧은 시간에 원인 좁히는 방법입니다.

1) 가상 스레드는 ‘무한 동시성’이 아니다: 백프레셔 붕괴

전통적인 서블릿 모델(Tomcat)에서 maxThreads=200 같은 설정은 동시에 처리할 요청 수를 제한합니다. 이 제한이 DB 커넥션 풀(예: Hikari 20~50), 외부 API QPS 제한, 내부 락 경합을 보호해주는 역할을 합니다.

가상 스레드를 활성화하면(특히 “요청당 가상 스레드” 형태로) 애플리케이션은 훨씬 많은 요청을 동시에 “진입”시킵니다. 결과적으로:

  • DB 커넥션 풀이 먼저 바닥나며 대기열이 늘어남
  • 외부 API 타임아웃이 급증하며 재시도 폭풍
  • 락 경합/데드락 빈도 증가
  • GC/메모리 압박 증가

즉, TPS가 떨어진 게 아니라 평균/상위 지연이 폭증하면서 전체 처리량이 무너지는 패턴이 흔합니다.

체크 포인트

  • p95/p99 지연이 가상 스레드 적용 후 급증했는가?
  • DB 커넥션 풀 active=poolSize 상태가 오래 지속되는가?
  • 외부 호출 타임아웃/재시도 횟수가 늘었는가?

2) DB 커넥션 풀(HikariCP)이 병목으로 변한다

가상 스레드는 “대기”를 싸게 만들지만, DB 커넥션 수 자체는 늘어나지 않습니다.

요청 동시성이 늘어났는데 커넥션 풀이 그대로라면, 대부분의 요청은 다음 상태가 됩니다.

  • “가상 스레드가 DB 커넥션을 기다리며 주차(park)됨”
  • 응답 지연 증가 → 타임아웃 증가 → 재시도 증가 → 더 많은 동시성 유입

이때 TPS는 오히려 떨어집니다.

개선 접근

  • 커넥션 풀을 무작정 키우기 전에 DB가 감당 가능한 동시 쿼리 수를 먼저 산정
  • 애플리케이션 레벨에서 동시 DB 작업 수를 제한(세마포어/벌크헤드)
  • 쿼리/인덱스 튜닝으로 커넥션 점유 시간을 줄이기

DB 락/데드락이 같이 보인다면 아래 글의 방법으로 락 대기와 인덱스/트랜잭션 범위를 먼저 점검하는 편이 빠릅니다.

예시: DB 동시성 벌크헤드(세마포어)

가상 스레드를 쓰더라도 DB는 보호해야 합니다.

import java.util.concurrent.Semaphore;

@Service
public class UserQueryService {
    // DB 동시 접근 상한을 명시적으로 둔다 (예: 커넥션 풀의 70~90%)
    private final Semaphore dbBulkhead = new Semaphore(30);

    private final UserRepository repo;

    public UserQueryService(UserRepository repo) {
        this.repo = repo;
    }

    public User findUser(String id) {
        boolean acquired = false;
        try {
            dbBulkhead.acquire();
            acquired = true;
            return repo.findById(id).orElseThrow();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        } finally {
            if (acquired) dbBulkhead.release();
        }
    }
}

이 방식은 “가상 스레드로 무한히 밀어 넣는” 상황을 막아 TPS 급락을 완화합니다.

3) 외부 API/네트워크 타임아웃이 TPS를 갉아먹는다

가상 스레드를 켜면 외부 호출도 동시에 더 많이 발생합니다. 이때 외부 시스템이 감당 못하면:

  • 연결 대기(connect timeout)
  • 읽기 지연(read timeout)
  • 5xx/429 증가 및 재시도

가상 스레드 자체는 대기 비용을 줄이지만, 대기 시간이 길어지면 요청이 시스템에 더 오래 머물며(체류시간 증가) TPS는 감소합니다.

특히 쿠버네티스/EKS 환경에서는 L7/L4 타임아웃이 겹치며 증상이 악화됩니다. 예를 들어 ALB idle timeout, Envoy upstream timeout 등으로 “느린 요청”이 잘려나가면 재시도가 폭증합니다.

진단 팁

  • APM에서 “외부 호출 span”의 p95/p99를 먼저 확인
  • 커넥션 풀(HTTP client pool) 고갈 여부 확인
  • 재시도 정책(무지성 재시도) 존재 여부 확인

4) Pinning(플랫폼 스레드 점유)으로 가상 스레드 이점이 사라진다

가상 스레드는 블로킹 시 플랫폼 스레드를 양보(unmount)할 수 있어야 이점이 큽니다. 그런데 특정 상황에서는 가상 스레드가 플랫폼 스레드에 ‘고정(pinning)’ 되어, 블로킹이 발생해도 플랫폼 스레드를 계속 점유합니다.

대표적인 핀ning 유발 요인:

  • synchronized 블록/메서드 안에서 블로킹 I/O 수행
  • 네이티브 호출, 일부 JNI 연계
  • 오래된 드라이버/라이브러리의 모니터 락 사용 패턴

재현 예시: synchronized 안에서 sleep/IO

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

    public String bad() {
        synchronized (lock) {
            // 블로킹 작업이 락 내부에 있으면 pinning이 발생할 수 있다
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return "ok";
        }
    }
}

개선 방향

  • synchronized 구간에서 블로킹 호출 제거
  • 락 범위를 최소화하고, 필요 시 ReentrantLock/CAS 구조로 전환
  • JFR(Java Flight Recorder)로 Virtual Thread pinning 이벤트를 확인

5) 로그/메트릭이 동기(블로킹)로 바뀌며 병목이 된다

가상 스레드를 켜면 요청 수가 늘고, 그만큼 로그/메트릭 이벤트도 늘어납니다. 이때 다음이 병목이 될 수 있습니다.

  • 동기 파일 로그(디스크 flush)
  • 네트워크 로깅(예: TCP appender)
  • 과도한 MDC/JSON 직렬화 비용

증상은 “CPU는 남는데 TPS가 안 나옴”, “스레드 덤프에서 로깅 관련 호출이 많음” 등으로 나타납니다.

개선 방향

  • 비동기 로깅(예: Logback AsyncAppender) 검토
  • 로그 레벨/샘플링 적용
  • 고비용 payload 로깅 제거

6) CPU 바운드 작업이 섞여 있으면 컨텍스트 스위칭만 늘어난다

가상 스레드는 I/O 대기에 강하지만, CPU 바운드 작업을 빠르게 만들어주지 않습니다.

  • JSON 대량 직렬화/역직렬화
  • 암호화/서명
  • 이미지/압축
  • 대규모 컬렉션 연산

이런 작업이 많으면 동시성을 늘리는 것이 오히려 캐시 미스/스케줄링 비용을 키워 TPS가 떨어질 수 있습니다.

진단

  • JFR/프로파일러로 CPU hot spot 확인
  • p99에서 CPU 사용률이 100% 근처인지 확인

7) 설정 실수: “가상 스레드 ON”이 실제로는 혼합 모델

Spring Boot 3에서 가상 스레드를 켰다고 해도, 실제로는 다음처럼 혼합될 수 있습니다.

  • 서블릿 컨테이너는 가상 스레드인데, 내부 비동기 작업은 여전히 작은 고정 풀 사용
  • @Async 기본 executor가 병목
  • 스케줄러가 단일 스레드로 병목

즉, 요청 처리 스레드만 늘어나고, 핵심 작업은 작은 풀에서 대기하면서 전체 TPS가 떨어집니다.

예시: @Async executor를 가상 스레드로

import org.springframework.context.annotation.*;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.scheduling.annotation.*;

import java.util.concurrent.Executors;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        // Spring이 인식하는 AsyncTaskExecutor로 감싸서 제공
        var executor = Executors.newVirtualThreadPerTaskExecutor();
        return new TaskExecutorAdapter(executor);
    }
}

또는 스케줄링 작업도 병목이 될 수 있으니 스케줄러 풀 크기를 점검하세요.

8) 타임아웃/재시도 정책이 가상 스레드에서 더 치명적이 된다

가상 스레드는 “대기”를 쉽게 만들기 때문에, 타임아웃이 길고 재시도가 공격적이면 시스템이 요청으로 가득 차는 현상이 빨라집니다.

  • connect/read timeout이 과도하게 큼
  • 무제한 재시도 + 짧은 backoff
  • 서킷브레이커/벌크헤드 부재

이는 외부 API뿐 아니라 내부 DB/캐시에도 동일하게 적용됩니다.

개선 방향

  • 타임아웃을 “짧게, 명확하게”
  • 재시도는 조건부(멱등/일시 오류) + 지수 백오프 + 지터
  • 서킷브레이커/벌크헤드로 동시성 제한

9) 빠른 원인 규명을 위한 체크리스트(현장용)

가상 스레드 적용 후 TPS 급락을 30~60분 내에 좁히려면 아래 순서가 효율적입니다.

  1. 지연 분포 확인: p50은 비슷한데 p95/p99만 폭증이면 “대기/병목” 가능성이 큼
  2. DB 커넥션 풀: active/idle/pending, 커넥션 획득 시간(connection acquire time)
  3. 외부 호출: 타임아웃/재시도, upstream 5xx/504/503
  4. 락/데드락: DB 락 대기, 애플리케이션 synchronized 경합
  5. JFR: pinning 이벤트, CPU hot spot, GC pause
  6. 로깅/메트릭: 동기 I/O 여부, 로그량 급증 여부

10) 결론: “가상 스레드로 TPS를 올린다”가 아니라 “병목을 드러낸다”

Spring Boot 3의 가상 스레드는 I/O 대기 비용을 줄여 동시성을 더 안전하게 가져갈 수 있게 해줍니다. 하지만 동시에 기존 스레드 풀이 해주던 백프레셔가 사라지면서, DB/외부 API/락/타임아웃 같은 병목이 한꺼번에 수면 위로 올라옵니다.

따라서 TPS 급락을 해결하는 핵심은:

  • 병목 자원(DB 커넥션, 외부 API, 락)별로 명시적 동시성 제한(벌크헤드) 을 두고
  • 타임아웃/재시도 정책을 현실화하며
  • pinning을 유발하는 코드/라이브러리를 제거하고
  • JFR/APM으로 “어디서 기다리는지”를 수치로 확인하는 것

입니다.

가상 스레드는 만능 스위치가 아니라, 시스템의 병목을 더 정확히 보여주는 확대경에 가깝습니다. 확대경으로 드러난 병목을 하나씩 제거하면, 그때부터 가상 스레드의 장점(높은 동시성, 적은 플랫폼 스레드, 더 단순한 동기 코드)이 제대로 성과로 연결됩니다.