Published on

Spring Boot 3 가상스레드에서 HikariCP 교착 해결

Authors

서버 스레드 비용이 거의 0에 가까워지는 가상스레드(Virtual Thread, Project Loom) 는 Spring Boot 3에서 꽤 매력적인 옵션입니다. 하지만 가상스레드를 “그냥 켜기”만 하면, 기존에 플랫폼 스레드(커널 스레드) 기준으로 설계된 자원 제약—특히 DB 커넥션 풀(HikariCP)—과 충돌하면서 교착(deadlock)처럼 보이는 대기 상태를 만들 수 있습니다.

이 글에서는 다음을 다룹니다.

  • Spring Boot 3 + Virtual Threads에서 HikariCP가 왜 막히는지(교착처럼 보이는 이유)
  • 재현 가능한 전형적 패턴(트랜잭션/락/외부 호출/동시성)
  • HikariCP/스레드/트랜잭션 설정으로 해결하는 방법
  • 코드 레벨에서 “커넥션 점유 시간”을 줄이는 실전 팁

> DB 자체의 데드락(예: InnoDB)과는 결이 다릅니다. DB 데드락 로그로 원인 SQL을 찾는 방법은 별도로 정리해 둔 글을 참고하세요: MySQL InnoDB Deadlock 로그로 원인 SQL 찾기

1) 증상: “교착”처럼 보이는 HikariCP 대기

가상스레드를 적용한 뒤 흔히 보는 현상은 다음과 같습니다.

  • API가 간헐적으로(또는 급격히) 느려짐
  • 스레드 덤프를 보면 요청 처리 스레드가 엄청 많음(가상스레드라서)
  • 하지만 DB 커넥션 획득에서 대기
  • 로그에 다음과 같은 메시지 또는 유사 메시지
HikariPool-1 - Connection is not available, request timed out after 30000ms.

겉으로는 “교착”처럼 보이지만, 많은 경우는 (1) 커넥션 풀이 고갈되었고, (2) 커넥션을 오래 쥔 작업들이 풀을 점유해서 나머지가 줄줄이 대기하는 형태입니다. 가상스레드는 요청을 더 많이 동시에 처리하려고 시도하므로, 이 문제가 더 빨리/더 크게 드러납니다.

2) 왜 가상스레드에서 더 잘 터질까?

핵심은 동시성의 상한이 스레드에서 커넥션 풀로 이동한다는 점입니다.

  • 플랫폼 스레드 기반: 톰캣 워커 스레드 수가 동시 요청 수를 제한(예: 200)
  • 가상스레드 기반: 스레드는 거의 무한히 늘 수 있음 → 동시 요청 수가 더 커짐
  • 하지만 DB 커넥션 풀은 여전히 유한(예: 10~30)

즉, 이전에는 “서버 스레드”가 병목이라 커넥션 풀이 보호(?)되던 상황에서, 가상스레드 적용으로 DB 풀 고갈이 전면에 등장합니다.

여기에 다음 패턴이 결합하면 “교착”에 가까운 정체가 발생합니다.

  • 트랜잭션 범위가 불필요하게 큼(외부 API 호출/파일 IO/대기 포함)
  • N+1/느린 쿼리로 커넥션 점유 시간이 길어짐
  • 동일 요청 내에서 병렬 작업이 커넥션을 추가로 요구
  • 락 경합으로 쿼리가 블로킹되며 커넥션이 반환되지 않음

N+1 때문에 커넥션 점유 시간이 늘어나는 케이스도 흔합니다. JPA를 사용 중이라면 아래 글도 함께 보면 원인 제거에 도움이 됩니다.

3) 전형적인 “가상스레드 + HikariCP 교착” 패턴

패턴 A: 트랜잭션 안에서 외부 호출(Feign/HTTP)을 해버림

아래 코드는 매우 흔한 실수입니다.

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;
  private final PaymentClient paymentClient;

  @Transactional
  public void placeOrder(Long orderId) {
    // 1) DB 커넥션 획득 + 트랜잭션 시작
    Order order = orderRepository.findById(orderId)
        .orElseThrow();

    // 2) 트랜잭션을 잡은 채로 외부 HTTP 호출
    paymentClient.authorize(order.getUserId(), order.getAmount());

    // 3) 다시 DB 작업
    order.markPaid();
    orderRepository.save(order);
  }
}

가상스레드 환경에서는 동시 요청이 크게 늘 수 있고, 그만큼 트랜잭션(=커넥션 점유) 상태로 외부 호출을 기다리는 요청이 폭증합니다. 결과적으로 HikariCP 풀을 빠르게 고갈시켜 나머지는 커넥션 획득 대기 → 타임아웃으로 이어집니다.

Feign 타임아웃/재시도 설정이 부적절하면 이 현상은 더 악화됩니다(외부 호출이 길어지고 재시도가 겹침). 관련 체크 포인트는 아래 글이 유용합니다.

패턴 B: 요청 하나가 내부적으로 병렬 처리하며 커넥션을 여러 개 먹음

가상스레드로 바꾼 뒤 “그럼 병렬로 더 돌려보자”가 쉽게 나오는데, 아래처럼 하면 풀 고갈이 더 빨라집니다.

@Transactional(readOnly = true)
public Summary getSummary(Long userId) {
  try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var a = scope.fork(() -> repoA.findByUserId(userId));
    var b = scope.fork(() -> repoB.findByUserId(userId));
    var c = scope.fork(() -> repoC.findByUserId(userId));

    scope.join();
    scope.throwIfFailed();

    return new Summary(a.get(), b.get(), c.get());
  }
}
  • @Transactional은 보통 스레드 바인딩 기반으로 동작합니다.
  • fork된 작업들은 같은 트랜잭션 컨텍스트를 공유하지 못하거나(프레임워크/구성에 따라), 각자 커넥션을 잡아먹을 수 있습니다.
  • 요청 하나가 커넥션을 3개 이상 소모하면, 동시 요청이 조금만 늘어도 풀은 순식간에 끝납니다.

패턴 C: DB 락/슬로우쿼리로 커넥션이 반환되지 않음

DB에서 락 대기가 걸리면 애플리케이션은 커넥션을 쥔 채로 기다립니다. 가상스레드 환경에서는 대기 요청이 더 많이 쌓여 풀 고갈이 가속됩니다.

  • DB 데드락/락 경합과 애플리케이션 풀 고갈은 서로 증폭 관계일 수 있습니다.

4) 해결 전략: “풀 크기 늘리기”만으로는 부족

풀을 늘리는 것은 필요할 수 있지만, 가상스레드에서는 특히 커넥션 점유 시간을 줄이는 것이 1순위입니다. 아래 순서로 접근하는 것을 권장합니다.

  1. 트랜잭션 범위를 줄여 커넥션 점유 시간을 감소
  2. 외부 호출/IO를 트랜잭션 밖으로 이동
  3. 병렬 DB 호출을 제한(요청당 커넥션 소비량 상한)
  4. HikariCP 타임아웃/누수 탐지로 조기 감지
  5. 필요 시 풀 크기 조정(단, DB의 max_connections/리소스와 함께)

5) Spring Boot 3 가상스레드 설정(기본)

Spring Boot 3.2+에서는 다음 설정으로 서블릿(톰캣) 요청 처리를 가상스레드로 전환할 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

단, 가상스레드 활성화는 동시성 상한을 올리는 스위치에 가깝습니다. DB 풀/외부 호출/락 전략이 준비되지 않으면 문제를 더 빨리 드러내는 촉매가 됩니다.

6) HikariCP 설정: “막히는 걸 빨리 드러내고” “오래 쥐지 않게”

(1) connectionTimeout을 짧게: 무한 대기 방지

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 3000
      validation-timeout: 1000
  • connection-timeout을 30초로 두면 장애 시 요청이 30초씩 쌓이며 연쇄 타임아웃이 발생합니다.
  • 2~5초 정도로 줄이면 “즉시 실패 → 빠른 복구/폴백/서킷브레이커”로 전환이 쉬워집니다.

(2) leakDetectionThreshold로 커넥션 장기 점유 추적

spring:
  datasource:
    hikari:
      leak-detection-threshold: 5000
  • 5초 이상 반환되지 않은 커넥션 스택트레이스를 로그로 남겨서, 어떤 코드 경로가 커넥션을 오래 쥐는지 찾는 데 유용합니다.
  • 상시 활성화는 로그 비용이 있을 수 있으니, 문제 재현 환경/기간에만 켜는 식으로 운영하는 경우가 많습니다.

(3) maxLifetime/idleTimeout은 DB/네트워크 환경에 맞추기

NAT/LB/DB 설정에 따라 유휴 커넥션이 끊기는 환경이면, Hikari의 maxLifetime을 적절히 조정하지 않으면 커넥션 재생성 폭주로 지연이 커질 수 있습니다(교착처럼 보이는 지연 유발).

7) 코드 레벨 해법: 트랜잭션을 “작게”, 외부 호출은 “밖으로”

(1) 외부 호출을 트랜잭션 밖으로 분리

아래처럼 2단계로 나눕니다.

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;
  private final PaymentClient paymentClient;

  public void placeOrder(Long orderId) {
    // 1) 먼저 필요한 데이터만 짧게 읽고 트랜잭션 종료
    OrderSnapshot snap = loadSnapshot(orderId);

    // 2) 외부 호출은 트랜잭션 밖에서 수행
    paymentClient.authorize(snap.userId(), snap.amount());

    // 3) 승인 결과 반영만 짧게 트랜잭션으로
    markPaid(orderId);
  }

  @Transactional(readOnly = true)
  protected OrderSnapshot loadSnapshot(Long orderId) {
    Order o = orderRepository.findById(orderId).orElseThrow();
    return new OrderSnapshot(o.getId(), o.getUserId(), o.getAmount());
  }

  @Transactional
  protected void markPaid(Long orderId) {
    Order o = orderRepository.findById(orderId).orElseThrow();
    o.markPaid();
  }

  public record OrderSnapshot(Long orderId, Long userId, long amount) {}
}

핵심은 커넥션을 잡고 있는 시간을 외부 네트워크 지연과 분리하는 것입니다.

(2) 병렬 DB 호출을 제한하거나, 병렬화 대상을 DB가 아닌 것으로

정말 병렬화가 필요하다면:

  • DB 호출은 합쳐서 한 번에 가져오기(조인/배치)
  • 캐시/원격 호출 등 DB 커넥션을 쓰지 않는 작업만 병렬화
  • 또는 요청당 동시 DB 작업 수를 제한(세마포어)

예: 요청당 DB 동시 접근을 2개로 제한

@Component
public class DbConcurrencyGuard {
  private final Semaphore semaphore = new Semaphore(2);

  public <T> T withPermit(Callable<T> task) throws Exception {
    semaphore.acquire();
    try {
      return task.call();
    } finally {
      semaphore.release();
    }
  }
}
@RequiredArgsConstructor
@Service
public class SummaryService {
  private final DbConcurrencyGuard guard;
  private final RepoA repoA;
  private final RepoB repoB;

  public Pair<A, B> load(Long userId) throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
      var a = scope.fork(() -> guard.withPermit(() -> repoA.find(userId)));
      var b = scope.fork(() -> guard.withPermit(() -> repoB.find(userId)));
      scope.join();
      scope.throwIfFailed();
      return Pair.of(a.get(), b.get());
    }
  }
}

이 방식은 “가상스레드로 무한 확장”을 그대로 두면서도, DB라는 유한 자원에 대한 압력을 제어합니다.

8) 운영 관점 체크리스트(필수)

(1) 풀 고갈 vs DB 데드락을 구분

  • 애플리케이션 로그: Connection is not available... → 풀 고갈
  • DB 로그/메트릭: deadlock, lock wait timeout, slow query → DB 락/성능 이슈

둘이 함께 나타나기도 합니다. DB 락이 길어지면 커넥션이 반환되지 않아 풀 고갈이 동반됩니다.

(2) 메트릭으로 “커넥션 점유 시간”을 본다

Micrometer를 쓰면 Hikari 메트릭이 노출됩니다.

  • hikaricp.connections.active
  • hikaricp.connections.pending
  • hikaricp.connections.max

패턴은 보통 이렇습니다.

  • active가 max에 붙고
  • pending이 증가하며
  • API 지연이 폭증

(3) DB max_connections와 애플리케이션 인스턴스 수를 함께 계산

예:

  • Hikari max=30
  • 파드 10개면 최악 300 커넥션
  • DB가 200 max_connections면, 단순히 풀을 늘리면 오히려 장애가 커집니다.

9) 결론: 가상스레드의 병목은 “DB 풀”로 이동한다

Spring Boot 3에서 가상스레드를 적용하면 스레드 고갈 문제는 크게 줄지만, 그만큼 DB 커넥션 풀/트랜잭션 설계가 병목의 중심이 됩니다. HikariCP 교착처럼 보이는 상황의 대부분은 다음 조합으로 설명됩니다.

  • 동시 요청 증가(가상스레드)
  • 커넥션 점유 시간 증가(큰 트랜잭션, 외부 호출 포함, N+1/슬로우쿼리, 락 대기)
  • 결과적으로 풀 고갈 → 대기 폭증 → 타임아웃

해결의 핵심은 “풀을 키우기”가 아니라:

  • 트랜잭션을 최소화하고
  • 외부 호출을 트랜잭션 밖으로 빼고
  • 요청당 DB 동시성을 제한하며
  • 누수/대기 메트릭을 관찰해 빠르게 감지하는 것

이 4가지만 지켜도, 가상스레드 전환 후 체감하는 ‘교착’ 문제의 대부분은 안정적으로 정리됩니다.