Published on

Spring Boot 3 가상스레드 성능 튜닝 - 데드락·스루풋

Authors

서버 애플리케이션에서 동시성 문제는 늘 두 얼굴로 나타납니다. 하나는 스루풋이 기대만큼 안 나오는 문제, 다른 하나는 데드락 또는 데드락처럼 보이는 정체(stall) 입니다. Spring Boot 3에서 Java 21 기반 가상스레드(virtual thread) 를 켜면 “스레드 수 제한”은 크게 완화되지만, 그 자체가 곧바로 성능 향상이나 안정성을 보장하진 않습니다.

이 글은 Spring Boot 3에서 가상스레드를 사용하면서 실제로 자주 마주치는 병목을 블로킹 지점, 락 경합, 풀/커넥션 제한, 관측/진단 관점에서 정리하고, 데드락과 스루풋을 동시에 개선하는 튜닝 방법을 제시합니다.

참고로 네트워크 호출이 섞인 MSA라면 타임아웃이 “원인”이 아니라 “증상”인 경우가 많습니다. gRPC 환경에서는 이 글과 함께 gRPC MSA에서 DEADLINE_EXCEEDED 원인 9가지도 같이 보면 병목 지점을 더 빨리 좁힐 수 있습니다.

가상스레드의 핵심: 블로킹은 싸졌지만, 경합은 그대로다

가상스레드는 플랫폼 스레드(커널 스레드) 위에서 스케줄링되는 경량 스레드입니다. 전통적인 “요청당 스레드” 모델에서 가장 큰 비용이던 스레드 생성/전환 비용스레드 수 상한(메모리/스택) 문제를 크게 줄여줍니다.

하지만 다음은 그대로 남습니다.

  • 락 경합: synchronized, ReentrantLock, DB row lock, 분산락 등
  • 공유 자원 제한: DB 커넥션 풀, HTTP 커넥션 풀, 외부 API rate limit
  • CPU 한계: 압축/암호화/JSON 직렬화/정규식/템플릿 렌더링 등
  • 핀(pin) 문제: 특정 블로킹이 플랫폼 스레드를 붙잡아(핀) 가상스레드 장점을 상쇄

즉, 가상스레드는 “블로킹 IO가 많은 서버”에서 특히 유리하지만, 병목이 락/DB/CPU/풀 제한 쪽이면 기대만큼 스루풋이 오르지 않거나 오히려 지연이 악화될 수 있습니다.

Spring Boot 3에서 가상스레드 활성화 기준

가상스레드는 크게 두 군데에서 체감이 납니다.

  • 서블릿 기반(Spring MVC, Tomcat/Jetty/Undertow): 요청 처리 스레드를 가상스레드로
  • 비동기 실행(@Async, 스케줄러, 사용자 정의 Executor): 작업 실행 스레드를 가상스레드로

1) Spring MVC 요청 스레드를 가상스레드로

Spring Boot 3.2+에서는 설정으로 비교적 쉽게 켤 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은 “요청 처리”에서 가상스레드를 활용하도록 도와줍니다. 다만 애플리케이션이 실제로 이득을 보려면 요청 처리 경로에 블로킹 IO가 존재하고, 그 블로킹이 커넥션 풀/락 경합에 의해 병목이 나지 않아야 합니다.

2) @Async / Executor를 가상스레드로

서버가 요청 스레드만 가상스레드로 바뀌고, 내부 비동기 작업이 여전히 고정 풀이라면 병목이 남습니다.

@Configuration
public class ExecutorsConfig {

    @Bean
    public Executor taskExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

Executors.newVirtualThreadPerTaskExecutor()는 작업마다 가상스레드를 생성합니다. “작업이 블로킹 IO 중심”이면 유리하지만, CPU 바운드 작업까지 무작정 이 실행기로 보내면 컨텍스트 스위칭과 큐 적체로 오히려 손해가 날 수 있습니다.

스루풋이 안 오르는 6가지 대표 원인

가상스레드에서 성능이 안 나오는 패턴은 대부분 “스레드가 부족해서가 아니라, 다른 공유 자원이 부족해서”입니다.

1) DB 커넥션 풀이 상한선이 된다

요청 수천 개가 동시에 들어와도 DB 커넥션 풀이 10~30개면 DB 작업은 그 이상 병렬화되지 않습니다. 가상스레드는 “대기 스레드”를 싸게 만들어줄 뿐, DB 동시 실행량을 늘려주지 않습니다.

HikariCP 예시:

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000

튜닝 포인트:

  • 풀 사이즈를 늘리기 전에 DB가 감당 가능한 동시성인지 확인
  • 쿼리/인덱스/트랜잭션 범위를 먼저 줄여 커넥션 점유 시간을 감소
  • connection-timeout을 짧게 두어 “무한 대기”를 줄이고 장애 전파를 빠르게

MySQL이라면 트랜잭션이 길어질 때 TPS가 급락하는 전형적인 패턴이 있습니다. InnoDB의 히스토리 리스트가 폭증하면 잠금/정리 지연이 누적되어 체감상 데드락처럼 보이기도 합니다. 관련해서 MySQL 8.0 히스토리 리스트 폭증으로 TPS 급락 대응을 함께 참고하면 DB 병목 진단에 도움이 됩니다.

2) HTTP 클라이언트 커넥션 풀이 상한선이 된다

외부 API 호출이 많다면 DB보다 먼저 HTTP 커넥션 풀이 병목이 됩니다. 예를 들어 Apache HttpClient나 OkHttp는 기본 커넥션 제한이 보수적인 편이라, 가상스레드로 동시 요청을 늘려도 결국 커넥션 풀에서 대기합니다.

OkHttp 예시:

@Bean
OkHttpClient okHttpClient() {
    var dispatcher = new Dispatcher();
    dispatcher.setMaxRequests(512);
    dispatcher.setMaxRequestsPerHost(128);

    return new OkHttpClient.Builder()
            .dispatcher(dispatcher)
            .connectionPool(new ConnectionPool(200, 5, TimeUnit.MINUTES))
            .build();
}

튜닝 포인트:

  • 커넥션 풀을 늘리기 전에 외부 서비스의 rate limit타임아웃 정책부터 정리
  • 서버 측이 느릴 때 동시성만 늘리면 큐 적체로 p99이 폭발

3) 락 경합이 스루풋을 갉아먹는다

가상스레드에서 특히 위험한 착각은 “스레드가 많으니 동시 처리도 늘겠지”입니다. 하지만 임계 구역이 크면 스레드가 많을수록 경합 비용만 증가합니다.

대표적인 안티패턴:

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

    public void placeOrder(OrderRequest req) {
        synchronized (lock) {
            // DB 조회
            // 외부 결제 API 호출
            // DB 업데이트
        }
    }
}

이 코드는 사실상 단일 스레드 처리와 다를 바 없습니다. 가상스레드가 늘어도 synchronized 구간이 길면 스루풋은 안 오르고, 대기만 늘어납니다.

개선 방향:

  • 임계 구역을 최소화: “락이 필요한 데이터 구조 접근”만 감싸기
  • 외부 호출/DB 호출을 락 밖으로 빼기
  • 가능하면 락 대신 낙관적 락(version), 원자적 업데이트 SQL, 큐잉/단일 writer 패턴 고려

4) 트랜잭션 범위가 길어 데드락 확률이 상승한다

가상스레드로 동시 요청을 늘리면 DB에서 동시 트랜잭션 수도 늘어납니다. 이때 트랜잭션이 길거나 잠금 순서가 불안정하면 데드락 확률이 급상승합니다.

안티패턴(불필요하게 긴 트랜잭션):

@Transactional
public void checkout(Long userId) {
    var user = userRepository.findByIdForUpdate(userId);

    // 외부 결제 호출(수백 ms ~ 수초)
    paymentClient.pay(user);

    // 재고 업데이트
    inventoryRepository.decrease(user.getCartItems());
}

개선 방향:

  • 외부 호출은 트랜잭션 밖으로 분리
  • 꼭 필요한 row만 잠그고, 잠금 순서를 전 구간에서 일관되게
  • 재시도 정책(데드락/락 타임아웃)을 idempotent하게 설계

5) “핀(pin)”으로 플랫폼 스레드가 막힌다

가상스레드는 블로킹 시에 캐리어(플랫폼) 스레드를 반환하는 것이 이상적입니다. 하지만 특정 상황에서는 가상스레드가 블로킹하면서도 캐리어를 놓지 못해 이 발생할 수 있습니다.

대표적으로 다음을 점검합니다.

  • synchronized 블록 내부에서 블로킹 IO 수행
  • 오래된 라이브러리/드라이버에서 가상스레드 친화적이지 않은 블로킹

안전한 원칙은 간단합니다.

  • synchronized 안에서는 IO를 하지 않는다
  • 락은 짧게, IO는 락 밖에서

6) CPU 바운드 작업을 가상스레드로 무제한 확장한다

가상스레드는 “많이 만들어도 된다”이지 “CPU를 늘려준다”가 아닙니다. CPU 바운드 작업을 가상스레드로 무한히 늘리면 런큐가 길어지고 컨텍스트 스위칭이 증가해 p99이 악화될 수 있습니다.

권장 패턴:

  • IO 바운드: 가상스레드 executor
  • CPU 바운드: 고정 크기 풀(코어 수 기반), 배치/벡터화/캐시 활용

예시:

@Bean
Executor cpuBoundExecutor() {
    int n = Runtime.getRuntime().availableProcessors();
    return Executors.newFixedThreadPool(n);
}

@Bean
Executor ioBoundExecutor() {
    return Executors.newVirtualThreadPerTaskExecutor();
}

데드락 vs “데드락처럼 보이는 정체” 구분법

운영에서 “데드락이 났다”는 제보의 상당수는 실제 DB 데드락이 아니라 다음 중 하나입니다.

  • 커넥션 풀 고갈로 모든 요청이 대기
  • 외부 API 지연으로 요청 스레드가 대기(타임아웃이 길거나 무제한)
  • 락 경합으로 진행이 느려져 멈춘 것처럼 보임

1) DB 데드락은 DB 로그/메트릭으로 먼저 확인

MySQL 기준:

  • SHOW ENGINE INNODB STATUS에서 LATEST DETECTED DEADLOCK
  • innodb_deadlocks 관련 카운터

PostgreSQL 기준:

  • deadlock detected 로그
  • pg_locks로 대기 관계 확인

애플리케이션 로그에 데드락 예외가 찍히는지, DB에서 실제 deadlock이 감지되는지부터 분리해야 합니다.

2) 스레드 덤프로 “무엇을 기다리는지”를 본다

가상스레드 환경에서도 스레드 덤프는 강력합니다. 대기 상태가 다음 중 무엇인지가 핵심입니다.

  • DB 커넥션 획득 대기
  • HTTP 커넥션 획득 대기
  • ReentrantLock/synchronized 모니터 대기

운영에서 최소한 아래는 준비해두는 것을 권장합니다.

  • SIGQUIT 기반 스레드 덤프 수집
  • jcmd로 덤프 수집 자동화

스루풋 튜닝 체크리스트(우선순위 순)

1) 타임아웃을 “짧고 명시적으로”

가상스레드는 대기를 싸게 만들지만, 타임아웃이 없으면 무한 대기 작업이 계속 쌓여 시스템을 질식시킵니다.

  • DB: 커넥션 타임아웃, 쿼리 타임아웃
  • HTTP: connect/read/call 타임아웃
  • gRPC: deadline 전파

MSA라면 deadline 전파가 특히 중요합니다. 원인 분석은 gRPC MSA에서 DEADLINE_EXCEEDED 원인 9가지를 참고하세요.

2) 풀(커넥션/스레드/큐) 상한을 의도적으로 설계

가상스레드를 켜면 “스레드 풀 튜닝이 끝났다”라고 생각하기 쉬운데, 실제로는 커넥션 풀과 큐 길이가 더 중요해집니다.

  • HikariCP 풀 크기와 커넥션 점유 시간
  • HTTP 클라이언트 커넥션 풀
  • 메시지 컨슈머 동시성(카프카 등)

핵심은 “무한 확장”이 아니라 백프레셔(backpressure) 입니다.

3) 임계 구역 최소화 + 락 순서 통일

  • synchronized 내부에서 IO 금지
  • 여러 락을 잡아야 하면 락 획득 순서를 전 구간에서 동일하게
  • DB 업데이트 순서를 통일(예: 항상 A 테이블 후 B 테이블)

4) 트랜잭션을 짧게, 외부 호출은 밖으로

가상스레드로 동시성이 늘수록 트랜잭션이 길 때의 부작용(락 경합, 데드락, undo 증가)이 커집니다.

  • 외부 결제/알림/타사 API 호출은 트랜잭션 밖
  • 트랜잭션 내부는 “필수 DB 변경”만

5) 관측: p50이 아니라 p95/p99와 대기열을 본다

가상스레드는 평균(latency average)을 낮추기보다 꼬리 지연(tail latency) 을 바꾸는 경우가 많습니다.

필수로 보면 좋은 지표:

  • DB 커넥션 풀: active, pending, timeout
  • HTTP 클라이언트: pending acquire, pool utilization
  • JVM: CPU, GC pause
  • 애플리케이션: 요청별 p95/p99, 에러율, 타임아웃 수

실전 예시: “가상스레드 도입 후 p99 폭발” 패턴 고치기

상황:

  • 가상스레드 활성화 후 동시 요청이 늘어남
  • DB 풀은 그대로
  • 외부 API 타임아웃이 길고 재시도가 공격적

결과:

  • DB 커넥션 pending 증가
  • 외부 API 지연 시 요청이 계속 쌓임
  • p99 폭발, 결국 타임아웃/서킷 오픈

개선 절차:

  1. DB 풀 pending과 커넥션 점유 시간을 먼저 계측
  2. 외부 API 타임아웃을 줄이고, 재시도는 지수 백오프 + 최대 횟수 제한
  3. 트랜잭션에서 외부 호출 제거
  4. 락 경합 구간 축소
  5. 필요하면 요청 레벨에서 동시성 제한(세마포어 등)으로 백프레셔

세마포어로 특정 외부 호출 동시성 제한 예시:

@Component
public class PaymentGateway {
    private final Semaphore limit = new Semaphore(100);

    public PaymentResult pay(PaymentRequest req) throws InterruptedException {
        if (!limit.tryAcquire(1, TimeUnit.SECONDS)) {
            throw new RuntimeException("payment concurrency limited");
        }
        try {
            return doPay(req);
        } finally {
            limit.release();
        }
    }

    private PaymentResult doPay(PaymentRequest req) {
        // HTTP call
        return new PaymentResult();
    }
}

가상스레드 환경에서는 이런 “의도적 상한”이 시스템을 더 안정적으로 만들 때가 많습니다.

결론: 가상스레드는 만능이 아니라, 병목을 드러내는 도구다

Spring Boot 3의 가상스레드는 블로킹 IO 서버에 강력한 선택지입니다. 하지만 성능의 상한은 결국 다음이 결정합니다.

  • DB/HTTP 커넥션 풀 같은 공유 자원 상한
  • 트랜잭션/락 설계 같은 경합 구조
  • 타임아웃/재시도/백프레셔 같은 장애 전파 제어

가상스레드를 켠 뒤에는 “스레드 수” 대신 “대기열과 점유 시간”을 중심으로 관측하고, 락과 트랜잭션을 짧게 만들며, 풀과 타임아웃을 명시적으로 설계하는 쪽으로 튜닝 방향을 바꾸는 것이 핵심입니다.