Published on

Spring Boot 3 가상스레드에서 HikariCP 고갈 해결

Authors

서버 스레드가 부족해서 처리량이 안 나오던 시대에는 “스레드를 늘리면 된다”가 정답인 경우가 많았습니다. 하지만 Spring Boot 3에서 가상 스레드(virtual threads)를 적용하면, 애플리케이션은 더 많은 요청을 동시에 처리할 수 있게 되고 그 결과 DB 커넥션 풀이 병목으로 튀어나오면서 HikariCP 고갈(Connection pool exhausted) 문제가 더 자주, 더 크게 드러납니다.

이 글은 “가상 스레드가 문제다”가 아니라, 가상 스레드가 숨겨져 있던 병목을 드러내는 촉매라는 관점에서 접근합니다. 진단 포인트(증상/지표/스레드 덤프) → 설정 → 코드 패턴 → 운영 체크리스트 순으로 정리합니다.

1) 증상: 가상 스레드 적용 후 왜 더 자주 터질까?

대표 로그/예외는 다음과 같습니다.

  • SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms
  • API 응답 지연이 늘고, 특정 구간에서 타임아웃이 연쇄적으로 발생
  • DB CPU/락/IO가 먼저 치솟거나, 반대로 DB는 여유 있는데 풀만 고갈되기도 함

가상 스레드는 “스레드 생성 비용”을 낮춰 동시에 더 많은 작업이 DB로 몰리는 구조를 만들 수 있습니다. 즉,

  • 기존(플랫폼 스레드)에는 요청이 스레드 수에 의해 자연스럽게 제한(암묵적 백프레셔)
  • 가상 스레드에서는 그 제한이 약해져 DB 커넥션 풀이 사실상 유일한 게이트가 됨

이때 풀 고갈은 단순히 maximumPoolSize를 늘리면 해결되는 문제가 아니라, 커넥션을 오래 쥐고 있는 코드/트랜잭션 경계/락 경합/외부 호출 혼입 같은 구조적 문제가 있음을 의미합니다.

2) 먼저 확인할 것: “풀 크기”가 아니라 “커넥션 점유 시간”

HikariCP 고갈은 보통 두 가지 중 하나입니다.

  1. 동시 요청 수가 커넥션 수를 초과(정상적인 포화)
  2. 커넥션을 너무 오래 점유(비정상적인 장기 점유)

가상 스레드 환경에서는 2번이 특히 치명적입니다. 커넥션을 잡은 채로 오래 대기하는 작업이 있으면(예: 외부 API 호출, 파일 IO, 락 대기, 대용량 처리) 가상 스레드가 많아질수록 “그 오래 잡고 있는 커넥션”의 동시 개수가 늘어 풀을 빠르게 잠식합니다.

핵심 지표

  • Hikari metrics
    • hikaricp.connections.active
    • hikaricp.connections.pending
    • hikaricp.connections.timeout
  • DB 측 지표
    • 평균 쿼리 시간, 락 대기 시간, 커넥션 수, 슬로우 쿼리

운영 중 ALB 502/504가 함께 보이면(업스트림 타임아웃) 애플리케이션의 스레드/커넥션 대기가 원인일 수 있습니다. 필요하면 AWS ALB 502·504 난사 - 원인별 해결 체크리스트도 같이 점검하세요.

3) Spring Boot 3 가상 스레드 적용 방식과 함정

Spring Boot 3.2+에서는 다음처럼 간단히 켤 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은 주로 서블릿 요청 처리 스레드(Tomcat/Jetty/Undertow의 요청 처리) 쪽에 영향을 줍니다. 즉, “요청 처리 동시성”이 급격히 늘 수 있습니다.

하지만 DB 접근(JDBC)은 여전히 블로킹 IO이며, 커넥션 풀은 제한된 리소스입니다. 가상 스레드는 블로킹을 ‘싸게’ 만들 뿐, DB 커넥션이라는 희소 자원을 무한히 늘려주지 않습니다.

4) 해결 전략 1: 풀을 무작정 키우지 말고, 상한을 설계하라

(1) maximumPoolSize는 DB가 감당 가능한 범위로

풀을 키우면 순간적으로 타임아웃은 줄 수 있지만, DB 동시 실행이 늘어 락 경합/버퍼캐시 미스/IO 병목이 폭발할 수 있습니다.

권장 접근:

  • DB 인스턴스 스펙과 쿼리 성격에 맞춰 DB가 안정적으로 처리 가능한 동시 쿼리 수를 먼저 산정
  • 그 범위 내에서 maximumPoolSize를 설정
  • 애플리케이션은 그 위로 올라가지 않도록 백프레셔를 걸어야 함

예시 설정:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20
      connection-timeout: 3000
      validation-timeout: 1000
      max-lifetime: 1740000   # 29m (LB/DB idle timeout보다 짧게)
      idle-timeout: 600000
      leak-detection-threshold: 5000
  • connection-timeout을 너무 길게 두면(예: 30s) 장애가 길게 전파됩니다. 짧게 두고 빠르게 실패시키는 편이 회복에 유리합니다.
  • leak-detection-threshold는 임시로 켜서 “커넥션을 오래 잡는 코드”를 찾는 데 씁니다(상시 ON은 비용/노이즈 가능).

(2) 요청 동시성 자체를 제한(가상 스레드의 역설)

가상 스레드를 켰다면, DB가 병목인 서비스는 오히려 동시성 상한이 필요합니다.

  • API 레벨에서 동시 실행 제한(세마포어)
  • 특정 기능(리포트/배치성 API)만 별도 제한

간단한 세마포어 예시:

import java.util.concurrent.Semaphore;

@Component
public class DbConcurrencyLimiter {
    // DB가 감당 가능한 동시 트랜잭션 수로 조정
    private final Semaphore semaphore = new Semaphore(20);

    public <T> T execute(Callable<T> action) throws Exception {
        semaphore.acquire();
        try {
            return action.call();
        } finally {
            semaphore.release();
        }
    }
}

컨트롤러/서비스에서:

public OrderDto getOrder(long id) {
    try {
        return limiter.execute(() -> orderService.getOrder(id));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}

이 방식은 “가상 스레드로 무한 동시성”을 열어두지 않고, DB 처리량에 맞춰 애플리케이션을 정렬합니다.

5) 해결 전략 2: 트랜잭션 경계를 줄이고, 커넥션 점유 시간을 단축

Hikari 고갈의 본질은 “커넥션을 잡고 있는 시간”입니다. 다음 패턴을 특히 조심해야 합니다.

(1) 트랜잭션 내부에서 외부 API 호출

나쁜 예:

@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
    Order order = orderRepository.save(new Order(cmd));

    // 외부 결제 승인 호출(느릴 수 있음)을 트랜잭션 안에서 수행
    paymentClient.approve(order.getId());

    order.markPaid();
}
  • 결제 API가 500ms~2s만 느려져도 커넥션이 그 시간 동안 점유됩니다.
  • 가상 스레드로 동시 요청이 늘면 “느린 외부 호출”만큼 커넥션이 묶여 풀 고갈이 가속됩니다.

개선 예(트랜잭션 분리 + 이벤트/비동기):

public void placeOrder(PlaceOrderCommand cmd) {
    long orderId = createOrder(cmd);   // 짧은 트랜잭션
    requestPayment(orderId);           // 트랜잭션 밖
}

@Transactional
protected long createOrder(PlaceOrderCommand cmd) {
    Order order = orderRepository.save(new Order(cmd));
    return order.getId();
}

protected void requestPayment(long orderId) {
    paymentClient.approve(orderId);

    // 승인 결과 반영은 별도 트랜잭션
    markPaid(orderId);
}

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

분산 트랜잭션/보상 로직이 필요한 경우, 사가(Saga) 패턴에서 “중복 보상 실행”까지 고려해야 합니다. 관련해서는 Saga 패턴 보상 트랜잭션 중복 실행 방지법도 함께 참고하면 트랜잭션 분리 후의 안정성을 높일 수 있습니다.

(2) @Transactional 범위가 과도하게 넓음

  • 조회 API인데도 서비스 전체가 @Transactional로 묶여 있음
  • 불필요한 OpenEntityManagerInView로 뷰 렌더링까지 영속성 컨텍스트가 유지

점검:

spring:
  jpa:
    open-in-view: false

그리고 읽기 전용 트랜잭션을 명시해 불필요한 락/플러시를 줄입니다.

@Transactional(readOnly = true)
public OrderDto getOrder(long id) {
    return orderRepository.findDtoById(id);
}

(3) N+1 쿼리 / 대용량 페이징 실수

가상 스레드로 요청이 늘면 N+1 쿼리는 “DB를 더 빨리 태워먹는 장치”가 됩니다.

  • fetch join / entity graph / DTO projection으로 쿼리 수 자체를 줄이기
  • 페이징 시 count 쿼리 비용 점검

6) 해결 전략 3: 타임아웃 계층을 정렬(Timeout Budget)

풀 고갈은 “대기” 문제이므로 타임아웃 정렬이 중요합니다.

  • HTTP 요청 타임아웃(클라이언트/ALB/Ingress)
  • 서버 처리 타임아웃
  • DB 커넥션 획득 타임아웃(connectionTimeout)
  • 쿼리 타임아웃

권장 원칙:

  • DB 커넥션 획득 타임아웃 < HTTP 타임아웃
  • 쿼리 타임아웃을 명시해 “커넥션을 잡고 무한 대기”를 막기

JPA 쿼리 타임아웃 예:

import org.springframework.data.jpa.repository.QueryHints;
import jakarta.persistence.QueryHint;

@QueryHints(@QueryHint(name = "jakarta.persistence.query.timeout", value = "1000"))
Optional<Order> findById(Long id);

DB 락 대기(특히 MySQL/InnoDB, Postgres lock)로 커넥션이 오래 묶이면 풀 고갈은 쉽게 재현됩니다.

7) 해결 전략 4: 관측 가능성(Observability)로 “누가 커넥션을 잡고 있나”를 찾기

(1) Micrometer + Hikari 지표 수집

Spring Boot Actuator를 사용하면 Hikari 지표를 쉽게 볼 수 있습니다.

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
  implementation 'io.micrometer:micrometer-registry-prometheus'
}
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

대시보드에서 최소한 다음을 봅니다.

  • active가 max에 붙어 있는가?
  • pending이 증가하는가?
  • timeout 카운트가 증가하는가?

(2) Leak detection 로그로 “장기 점유” 위치 찾기

leakDetectionThreshold를 3~10초로 잠시 켠 뒤, 어떤 코드 경로에서 커넥션이 오래 잡히는지 확인합니다.

주의: leak detection은 “진짜 누수”뿐 아니라 “오래 사용”도 잡습니다. 목적은 병목 위치를 찾는 것입니다.

(3) 스레드 덤프/프로파일링

가상 스레드 환경에서는 스레드 수가 많아도 정상일 수 있으므로, “몇 개가 있냐”보다 “무엇을 기다리냐”가 중요합니다.

  • DB 커넥션 획득 대기
  • DB 락 대기
  • 외부 HTTP 호출 대기

이 중 하나가 커넥션 점유와 결합되어 있으면 풀 고갈로 이어집니다.

8) 자주 나오는 오해와 정리

오해 1) “가상 스레드면 DB도 더 빨라진다”

아닙니다. JDBC는 블로킹이고, DB는 제한된 동시 처리량을 가집니다. 가상 스레드는 애플리케이션의 스레드 병목을 줄여줄 뿐이며, DB 병목은 더 잘 드러납니다.

오해 2) “maximumPoolSize를 크게 올리면 끝”

일시적으로 타임아웃은 줄 수 있지만, DB가 감당 못하면 전체 지연이 더 커지고 락 경합이 심해져 장애가 커질 수 있습니다. 풀 크기는 “DB가 처리 가능한 동시성”에 맞춰야 합니다.

오해 3) “WebFlux로 바꾸면 해결”

DB가 JDBC(블로킹)인 한, 반쪽짜리입니다. R2DBC로 끝까지 논블로킹으로 가거나, 최소한 트랜잭션 경계/쿼리 최적화/백프레셔가 필요합니다.

9) 실전 체크리스트(적용 순서 추천)

  1. hikaricp.connections.active/pending/timeout 지표 확보
  2. leakDetectionThreshold로 장기 점유 코드 경로 찾기(임시)
  3. 트랜잭션 내부 외부 호출 제거, 트랜잭션 범위 축소
  4. N+1/슬로우 쿼리 제거, 쿼리 타임아웃 설정
  5. connectionTimeout을 짧게, 타임아웃 계층 정렬
  6. 풀 크기는 DB 한계 내에서 조정
  7. DB 병목이면 애플리케이션에 동시성 상한(세마포어/레이트 리밋) 도입

가상 스레드는 “더 많은 요청을 동시에 처리할 기회”를 주지만, 그 기회를 실제 성능으로 바꾸려면 **DB 커넥션이라는 희소 자원에 대한 설계(점유 시간 단축 + 백프레셔 + 관측)**가 필수입니다. HikariCP 고갈은 그 설계가 필요하다는 가장 명확한 신호입니다.