Published on

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

Authors

서버 동시성 문제를 해결하려고 Spring Boot 3 가상스레드(virtual threads)를 켰는데, 오히려 지연이 늘거나 데드락처럼 보이는 정체가 생기는 경우가 있습니다. 가상스레드는 "스레드를 싸게" 만들어 주지만, 블로킹 호출 자체를 없애주지는 않습니다. 즉, 병목의 위치가 CPU에서 I/O, 락, 커넥션 풀로 이동하면서 증상이 더 선명하게 드러납니다.

이 글에서는 Spring Boot 3에서 가상스레드 적용 시 자주 만나는 데드락·락 경합·성능 역전 패턴을 정리하고, 재현 가능한 튜닝 포인트를 코드와 함께 설명합니다.

가상스레드 적용의 핵심 전제: 병목은 사라지지 않고 이동한다

가상스레드는 플랫폼 스레드(캐리어 스레드) 위에서 스케줄링되는 경량 스레드입니다. 요청당 스레드를 만들어도 부담이 적어져서, 전통적인 스레드 풀 고갈 문제는 줄어듭니다.

하지만 다음은 그대로입니다.

  • DB 커넥션 풀 크기는 유한하다
  • 트랜잭션이 길어지면 락 점유 시간이 늘어난다
  • 동기 I/O 호출은 여전히 블로킹이다
  • 특정 락(예: synchronized, ReentrantLock)은 경합 시 대기열을 만든다

따라서 가상스레드 도입 후에 흔히 나타나는 현상은 다음과 같습니다.

  • "스레드 고갈" 대신 "DB 커넥션 대기"가 폭증
  • TPS는 늘지 않는데 동시 요청만 더 들어와서 DB 락 경합이 증가
  • 잘못된 락 순서로 인해 애플리케이션 레벨 데드락이 더 자주 재현

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

Spring Boot 3.2 이상에서는 설정 한 줄로 톰캣 요청 처리에 가상스레드를 사용할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

다만 여기서 중요한 포인트는 "요청 처리 스레드"만 가상스레드로 바뀐다는 점입니다. 애플리케이션 내부에서 별도의 Executor를 쓰거나, 스케줄러/비동기 작업을 별도로 구성했다면 그쪽은 그대로 플랫폼 스레드 풀일 수 있습니다.

데드락으로 보이는 3가지 대표 원인

가상스레드를 켠 뒤 "데드락"이라는 표현이 나오는 상황은 실제로는 크게 세 부류입니다.

  1. 애플리케이션 락 데드락
  2. DB 트랜잭션 데드락
  3. 커넥션 풀/외부 의존성 대기열 정체(데드락처럼 보이는 정체)

각각의 특징과 튜닝 방법이 다릅니다.

1) 애플리케이션 레벨 데드락: 락 순서가 엇갈릴 때

가상스레드는 동시 실행 수를 크게 늘릴 수 있으므로, 기존에는 "운 좋게" 잘 안 터지던 락 순서 문제가 더 자주 드러납니다.

예를 들어 두 락을 서로 다른 순서로 획득하면 데드락이 발생할 수 있습니다.

import java.util.concurrent.locks.ReentrantLock;

public class DeadlockExample {
    private final ReentrantLock lockA = new ReentrantLock();
    private final ReentrantLock lockB = new ReentrantLock();

    public void task1() {
        lockA.lock();
        try {
            sleep(50);
            lockB.lock();
            try {
                // do work
            } finally {
                lockB.unlock();
            }
        } finally {
            lockA.unlock();
        }
    }

    public void task2() {
        lockB.lock();
        try {
            sleep(50);
            lockA.lock();
            try {
                // do work
            } finally {
                lockA.unlock();
            }
        } finally {
            lockB.unlock();
        }
    }

    private static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

해결 원칙

  • 락 획득 순서를 전역적으로 통일합니다. 예: 항상 A를 잡고 B를 잡는다.
  • 가능하면 락 범위를 줄이고, 락 내부에서 I/O(특히 DB/HTTP)를 하지 않습니다.
  • tryLock과 타임아웃을 사용해 "영원히" 대기하지 않게 만듭니다.
if (lockA.tryLock(100, java.util.concurrent.TimeUnit.MILLISECONDS)) {
    try {
        if (lockB.tryLock(100, java.util.concurrent.TimeUnit.MILLISECONDS)) {
            try {
                // do work
            } finally {
                lockB.unlock();
            }
        } else {
            // fallback or retry
        }
    } finally {
        lockA.unlock();
    }
}

관측 포인트

  • 스레드 덤프에서 서로가 서로의 락을 기다리는지 확인합니다.
  • 가상스레드는 수가 많으므로, 덤프가 방대해집니다. "어떤 락 객체"에서 대기 중인지 키워드로 필터링하는 습관이 필요합니다.

2) DB 트랜잭션 데드락: 동시성이 늘면 더 자주 터진다

가상스레드를 켜면 애플리케이션이 더 많은 동시 요청을 처리하려고 시도합니다. 이때 DB에서 다음이 증가합니다.

  • 동일 로우/인덱스 범위에 대한 업데이트 경쟁
  • 잠금 대기(lock wait) 및 데드락 빈도

특히 다음 패턴이 위험합니다.

  • 서로 다른 순서로 로우를 업데이트
  • 인덱스가 부족해 범위 잠금이 커짐
  • 트랜잭션이 길어서 락 점유 시간이 길어짐

DB 레벨 분석과 인덱스/쿼리 개선은 MySQL Deadlock 1213 재현·로그·인덱스로 해결에서 상세히 다뤘습니다.

애플리케이션에서 할 수 있는 튜닝

  • 트랜잭션 범위를 최소화하고, 트랜잭션 내부에서 외부 API 호출을 제거
  • 업데이트 순서를 통일(예: 항상 id 오름차순으로 갱신)
  • 재시도 정책을 "무조건"이 아니라 데드락 에러에만 제한적으로 적용

Spring에서 대표적으로는 다음처럼 데드락 계열 예외에만 재시도를 붙입니다.

import org.springframework.dao.DeadlockLoserDataAccessException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@EnableRetry
@Service
public class PaymentService {

    @Retryable(
        retryFor = DeadlockLoserDataAccessException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 50, multiplier = 2)
    )
    @Transactional
    public void confirmPayment(long paymentId) {
        // 짧고 결정적인 쿼리만 수행
        // 외부 HTTP 호출은 트랜잭션 밖으로 이동
    }
}

3) 데드락처럼 보이는 정체: DB 커넥션 풀 대기열 폭증

가상스레드 도입 후 가장 흔한 "성능 역전" 시나리오입니다.

  • 가상스레드로 인해 요청이 더 많이 동시에 들어옴
  • 각 요청이 DB 커넥션을 잡으려 함
  • 풀 크기는 그대로라서 대기열이 길어짐
  • 평균 지연이 급증하고 타임아웃이 발생

이건 엄밀한 의미의 데드락은 아니지만, 운영에서는 "다 멈춘 것 같다"로 관측됩니다.

증상

  • 애플리케이션 CPU는 낮은데 응답이 느림
  • HikariPool 관련 타임아웃 로그 증가
  • DB 커넥션 수가 상한에 붙어 있음

해결 방향

  • 풀 크기를 무작정 키우기 전에, 먼저 "동시 DB 사용량"을 제한합니다.
  • 트랜잭션 시간을 줄여 커넥션 점유 시간을 줄입니다.

동시 DB 진입 제한(세마포어)

특정 엔드포인트가 DB를 과도하게 두드리면, 가상스레드가 그 트래픽을 전부 흡수하면서 DB가 먼저 무너집니다. 이때는 애플리케이션에서 "DB에 들어가는 동시성"을 제한하는 게 효과적입니다.

import java.util.concurrent.Semaphore;

public class DbConcurrencyGate {
    private final Semaphore semaphore;

    public DbConcurrencyGate(int maxConcurrentDbOps) {
        this.semaphore = new Semaphore(maxConcurrentDbOps);
    }

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

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

위 코드에서 제네릭 표기 T 는 MDX에서 오해될 수 있으니 반드시 인라인 코드로 감싸야 합니다.

이 방식은 "서버는 살아있는데 DB가 죽는" 상황을 줄이고, 과부하 시 빠르게 백프레셔(backpressure)를 걸 수 있습니다.

성능 튜닝 체크리스트: 가상스레드에서 특히 중요한 것들

1) 타임아웃을 계층별로 명시하라

가상스레드는 대기 비용이 낮아 "타임아웃 없이 무한 대기"가 더 오래 지속될 수 있습니다. 다음 타임아웃을 전부 명시하세요.

  • DB: 커넥션 획득 타임아웃, 쿼리 타임아웃
  • HTTP 클라이언트: connect/read timeout
  • 애플리케이션: 요청 처리 타임아웃, 서킷브레이커

예: RestClient 또는 WebClient에서 타임아웃을 강제합니다.

import org.springframework.web.client.RestClient;

RestClient client = RestClient.builder()
    .requestFactory(requestFactory -> {
        requestFactory.setConnectTimeout(java.time.Duration.ofSeconds(2));
        requestFactory.setReadTimeout(java.time.Duration.ofSeconds(3));
    })
    .build();

2) "가상스레드니까 블로킹 OK"를 오해하지 말 것

블로킹이 허용되는 것과, 블로킹이 공짜인 것은 다릅니다.

  • 블로킹이 길어지면 그만큼 요청이 더 쌓이고, DB/외부 API에 더 큰 동시 부하를 줍니다.
  • 결과적으로 다운스트림이 병목이면 더 빨리 포화됩니다.

따라서 가상스레드 도입 후에는 특히 다음을 점검해야 합니다.

  • 외부 API 호출의 QPS 제한, 큐잉, 백오프
  • DB로 향하는 동시성 제한

레이트리밋과 재시도/백오프 설계는 다음 글도 참고할 만합니다: OpenAI 429/RateLimitError 재시도·백오프·큐 설계

3) 플랫폼 스레드 핀ning(pin) 이슈를 의식하라

가상스레드는 특정 상황에서 캐리어 스레드를 "붙잡아" 둘 수 있습니다. 대표적으로는 다음과 같은 경우가 언급됩니다.

  • synchronized 블록 내부에서 블로킹 I/O
  • 일부 네이티브 호출 또는 오래된 드라이버

실무 팁은 단순합니다.

  • 큰 범위의 synchronized를 피하고, 가능하면 더 세밀한 락으로 교체
  • 락 내부에서 DB/HTTP 호출 금지
  • 드라이버와 라이브러리를 최신으로 유지

4) 관측(Observability) 없이 튜닝하지 말 것

가상스레드는 "스레드 수" 자체가 지표로서 의미가 약해집니다. 대신 다음을 보세요.

  • p95, p99 latency
  • DB 커넥션 사용률, 커넥션 획득 대기 시간
  • DB 락 대기 시간, 데드락 횟수
  • 외부 API별 타임아웃/에러율

Micrometer를 쓰고 있다면, Hikari 지표를 대시보드에 고정으로 올리세요.

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

안전한 적용 전략: 한 번에 전체 적용하지 않기

가상스레드 적용은 기능 플래그처럼 단계적으로 진행하는 편이 안전합니다.

  1. 특정 서비스 또는 특정 엔드포인트만 먼저 적용
  2. 부하 테스트로 DB 커넥션 대기/락 경합을 먼저 확인
  3. 동시성 제한(세마포어), 타임아웃, 재시도 정책을 정리
  4. 그 다음에 전체로 확대

특히 "가상스레드로 TPS가 늘 것"이라는 기대만으로 롤아웃하면, 실제 병목이 DB나 외부 API인 서비스에서는 장애를 더 빨리 유발할 수 있습니다.

결론: 가상스레드는 동시성의 "증폭기"다

Spring Boot 3 가상스레드는 요청 처리 모델을 단순화하고, 스레드 풀 튜닝 부담을 줄여줍니다. 하지만 동시에 병목을 증폭시킵니다.

  • 애플리케이션 락은 더 자주 경합하고 데드락이 더 잘 드러납니다.
  • DB는 커넥션 풀과 락 경합으로 먼저 포화될 수 있습니다.
  • 타임아웃과 백프레셔가 없으면 "멈춘 것 같은" 정체가 쉽게 발생합니다.

정리하면, 가상스레드 적용의 성패는 "스레드"가 아니라 트랜잭션 길이, DB 동시성, 락 설계, 타임아웃, 관측에 달려 있습니다. 위 체크리스트대로 병목을 계측하고, 병목 지점에만 정확히 칼을 대면 가상스레드는 매우 강력한 무기가 됩니다.