Published on

Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기

Authors

서버에 Spring Boot 3 가상 스레드(virtual threads)를 적용하면, 기존 플랫폼 스레드 기반의 요청 처리보다 훨씬 많은 동시 요청을 “싸게” 처리할 수 있습니다. 문제는 스레드는 늘었는데 DB 커넥션은 그대로라는 점입니다. 결과적으로 “애플리케이션은 더 많은 요청을 동시에 처리하려고 시도하지만, DB 커넥션 풀이 먼저 바닥나면서 지연/타임아웃이 발생”하는 형태의 장애가 쉽게 만들어집니다.

이 글에서는 가상 스레드를 켠 뒤 흔히 겪는 **DB 커넥션 고갈(connection pool exhaustion)**을 어떻게 진단하고, 어떤 레버(풀 사이즈, 타임아웃, 트랜잭션 경계, 동시성 제한, 쿼리/인덱스, 아키텍처)를 어떤 순서로 조정해야 안전한지 정리합니다.

> 참고로 네트워크 레벨에서도 “동시 연결”이 급증하면 커널/노드 리소스가 먼저 터질 수 있습니다. EKS 환경이라면 conntrack 포화로 연결 드랍이 발생하는 케이스도 함께 점검하세요: EKS conntrack 테이블 포화로 연결 끊김 해결법

1) 왜 가상 스레드가 DB 커넥션 고갈을 더 잘 드러내나

가상 스레드의 핵심: 블로킹을 “감당”할 수 있게 됨

가상 스레드는 블로킹 I/O에서 플랫폼 스레드를 점유하지 않도록 설계되어, 요청당 스레드를 많이 만들어도 비용이 낮습니다. 그래서 기존엔 톰캣 스레드 풀(예: 200)이 자연스럽게 동시성을 제한하던 상황에서, 가상 스레드를 켜면 동시 요청 수가 훨씬 커질 수 있습니다.

하지만 DB는 다릅니다.

  • DB 커넥션은 비싸고, 서버/DB가 수용 가능한 동시 세션 수가 제한적입니다.
  • HikariCP 같은 풀은 기본적으로 10~수십 개 수준으로 시작합니다.

즉, 가상 스레드 도입 후에는 다음의 불균형이 생깁니다.

  • (이전) 요청 동시성서버 스레드 수로 제한됨 → DB 풀 고갈이 덜 드러남
  • (이후) 요청 동시성 ↑↑ (가상 스레드) → DB 풀은 그대로 → 풀 고갈이 바로 병목

장애 양상

대표적인 로그/증상은 다음과 같습니다.

  • HikariPool-1 - Connection is not available, request timed out after ...ms
  • API p95/p99 지연 급증, 스루풋은 오히려 떨어짐(큐잉)
  • DB CPU가 낮은데도 커넥션 대기만 길어짐(락/슬로우쿼리/트랜잭션 장기화)

2) 먼저 확인할 것: “풀을 키우면 해결”이 아닌 이유

풀 사이즈를 키우는 건 필요할 수 있지만, 무작정 늘리면 다음 문제가 생깁니다.

  • DB의 동시 처리 한계(코어 수, 락 경합, 버퍼 캐시, I/O)가 먼저 병목이 됨
  • 커넥션 수 증가로 컨텍스트 스위치/락 경합/메모리 사용이 증가
  • 쿼리가 느린 상태에서 커넥션만 늘리면 “느린 쿼리 × 더 많은 동시 실행”로 DB를 더 압박

따라서 순서는 보통 이렇게 가는 게 안전합니다.

  1. 커넥션을 오래 잡는 원인 제거(트랜잭션/쿼리/락)
  2. 애플리케이션 동시성 제한(가상 스레드 시대의 백프레셔)
  3. 그 다음에 풀/DB 설정을 필요한 만큼 조정

3) 진단 체크리스트 (장애 재현 없이도 확인 가능)

3.1 HikariCP 메트릭으로 “대기”인지 “실제 사용”인지 분리

Actuator + Micrometer를 켜면 다음 메트릭이 유용합니다.

  • hikaricp.connections.active
  • hikaricp.connections.idle
  • hikaricp.connections.pending (대기 중인 스레드)
  • hikaricp.connections.max

pending이 치솟는데 DB CPU는 낮다면, 대개는:

  • 트랜잭션이 길어 커넥션 반환이 늦거나
  • 락 경합/슬로우쿼리로 커넥션이 오래 점유되거나
  • 애플리케이션이 DB로 과도한 동시 요청을 던지고 있는 상황입니다.

3.2 스레드 덤프 대신 “요청-DB 경계”를 본다

가상 스레드는 수가 많아 스레드 덤프만으로는 결론이 잘 안 납니다. 대신:

  • 엔드포인트별 DB 호출 횟수(N+1)
  • 트랜잭션 범위(외부 API 호출을 트랜잭션 안에서 하는지)
  • 커넥션 점유 시간(쿼리 시간 + 애플리케이션 처리)

을 추적하는 편이 빠릅니다.

3.3 네트워크/커널 리소스도 동시성 증가에 영향

가상 스레드로 동시 요청이 늘면 DB 커넥션뿐 아니라 노드의 커널 리소스도 압박받습니다. EKS라면 conntrack 포화로 DB 연결이 끊기는 증상도 나올 수 있으니 함께 점검해보세요: EKS conntrack 테이블 포화로 연결 끊김 해결법

4) Spring Boot 3에서 가상 스레드 켜는 방법과 주의점

4.1 설정

Spring Boot 3.2+ 기준으로 가상 스레드를 켜는 가장 간단한 방법은 다음입니다.

spring:
  threads:
    virtual:
      enabled: true

이 설정은 주로 서블릿 컨테이너(톰캣/제티) 요청 처리 스레드에 가상 스레드를 사용하도록 돕습니다.

주의할 점:

  • 가상 스레드는 “동시성 상한”을 사실상 풀어버립니다. 따라서 DB/외부 API/내부 락 같은 공유 자원에 대한 백프레셔를 반드시 설계해야 합니다.

5) 해결 전략 1: 커넥션 점유 시간을 줄여라 (가장 효과적)

5.1 트랜잭션 범위를 최소화

가장 흔한 실수는 트랜잭션 안에서 외부 API 호출, 파일 I/O, 메시지 발행 등을 수행해 커넥션을 불필요하게 오래 잡는 것입니다.

나쁜 예(트랜잭션이 외부 호출을 포함):

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

  @Transactional
  public void placeOrder(OrderRequest req) {
    // DB 커넥션 획득
    Order order = orderRepository.save(new Order(req));

    // 외부 네트워크 호출(느릴 수 있음) -> 커넥션을 잡은 채로 대기
    paymentClient.pay(order.getId(), req.amount());

    order.markPaid();
  }
}

개선 예(외부 호출을 트랜잭션 밖으로 분리 + 상태 전이만 트랜잭션):

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

  public void placeOrder(OrderRequest req) {
    Long orderId = createOrder(req);     // 짧은 트랜잭션
    paymentClient.pay(orderId, req.amount());
    markPaid(orderId);                  // 짧은 트랜잭션
  }

  @Transactional
  public Long createOrder(OrderRequest req) {
    Order saved = orderRepository.save(new Order(req));
    return saved.getId();
  }

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

외부 호출 실패 시 보상/재처리까지 고려해야 하는 서비스라면, 사가/보상 트랜잭션 패턴으로 확장하는 게 안전합니다. 관련 설계는 MSA Saga 보상 트랜잭션 실패 재처리 설계도 참고할 만합니다.

5.2 슬로우 쿼리/N+1 제거

가상 스레드를 켠 뒤 커넥션 풀이 고갈되는 서비스는 대개 “한 요청이 DB를 너무 오래/너무 자주” 만집니다.

  • N+1: 요청당 쿼리가 폭증 → 커넥션 점유 시간이 늘고 DB도 과부하
  • 인덱스 미스/풀스캔: 쿼리 1개가 오래 걸리면 커넥션 1개가 오래 묶임

우선순위:

  1. 상위 트래픽 API의 쿼리 플랜 확인
  2. 슬로우 쿼리 로그/pg_stat_statements 등으로 상위 지연 쿼리 제거
  3. N+1은 fetch join, batch size, DTO projection 등으로 감소

6) 해결 전략 2: “동시 DB 접근”에 백프레셔를 걸어라

가상 스레드 환경에서 핵심은 DB에 들어가는 동시성을 제어하는 것입니다. 예전에는 톰캣 스레드 풀이 그 역할을 했지만, 이제는 애플리케이션 레벨에서 명시적으로 걸어야 합니다.

6.1 세마포어로 DB 구간 동시성 제한 (간단하고 강력)

DB 호출을 감싸는 방식으로 “동시에 DB를 쓰는 요청 수”를 제한합니다.

@Component
public class DbConcurrencyLimiter {
  // 풀의 max(예: 30)보다 약간 작은 값으로 시작
  private final Semaphore semaphore = new Semaphore(25);

  public <T> T execute(Callable<T> action) {
    boolean acquired = false;
    try {
      acquired = semaphore.tryAcquire(200, java.util.concurrent.TimeUnit.MILLISECONDS);
      if (!acquired) {
        throw new IllegalStateException("DB is busy; apply backpressure");
      }
      return action.call();
    } catch (Exception e) {
      throw new RuntimeException(e);
    } finally {
      if (acquired) semaphore.release();
    }
  }
}

사용 예:

@Service
public class ProductService {
  private final DbConcurrencyLimiter limiter;
  private final ProductRepository repo;

  public ProductService(DbConcurrencyLimiter limiter, ProductRepository repo) {
    this.limiter = limiter;
    this.repo = repo;
  }

  public Product get(Long id) {
    return limiter.execute(() -> repo.findById(id).orElseThrow());
  }
}

이 패턴의 장점:

  • DB가 바쁠 때 빠르게 실패/대기를 선택할 수 있어 폭주를 막음
  • 풀 고갈로 인한 긴 타임아웃(수 초~수십 초) 대신, 짧은 대기 후 429/503 등으로 응답 가능

실제 운영에서는 예외를 잡아 503으로 변환하고 Retry-After를 주거나, 내부 호출이라면 재시도 정책을 붙입니다.

6.2 Bulkhead(격벽)로 엔드포인트별 격리

한 API가 DB를 과도하게 쓰면 다른 API까지 같이 죽습니다. Resilience4j Bulkhead(세마포어 기반)를 쓰면 엔드포인트/유스케이스별로 동시성을 분리할 수 있습니다.

@Bean
public io.github.resilience4j.bulkhead.Bulkhead catalogBulkhead() {
  var config = io.github.resilience4j.bulkhead.BulkheadConfig.custom()
      .maxConcurrentCalls(20)
      .maxWaitDuration(java.time.Duration.ofMillis(100))
      .build();
  return io.github.resilience4j.bulkhead.Bulkhead.of("catalog", config);
}

7) 해결 전략 3: HikariCP 튜닝은 “증상 완화”가 아니라 “정합”을 맞추는 작업

가상 스레드 적용 후에는 HikariCP 설정을 기본값으로 두기 어렵습니다. 다만 목표는 “크게 키우기”가 아니라 애플리케이션의 동시성/DB 용량과 일치시키기입니다.

7.1 권장 시작점(예시)

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 1000   # 풀 고갈 시 오래 기다리지 않게
      validation-timeout: 500
      idle-timeout: 600000
      max-lifetime: 1800000

가이드:

  • connection-timeout을 너무 길게 두면(기본 30초) 장애 시 요청이 서버에 쌓여 더 큰 장애로 번집니다.
  • maximum-pool-size는 DB의 max connections, 인스턴스 크기, 쿼리 특성, 그리고 애플리케이션 인스턴스 수를 함께 고려해야 합니다.
    • 예: DB max connections 300, 앱 6대면 이론상 50이 상한이지만, 실제론 여유를 두고 더 낮게 잡는 편이 안전합니다.

7.2 풀을 키우기 전에 “요청당 커넥션 점유 시간”부터 줄여라

풀을 10→30으로 늘렸는데도 고갈된다면, 대부분은 풀 사이즈 문제가 아니라:

  • 트랜잭션이 길거나
  • 락/슬로우쿼리로 점유가 길거나
  • 동시성 제한이 없어 DB로 폭주하고 있거나

중 하나입니다.

8) 운영에서 자주 놓치는 포인트

8.1 커넥션 누수(leak) 감지

커넥션을 반납하지 않는 코드(스트리밍 결과셋, 예외 처리 누락 등)가 있으면 가상 스레드에서 더 빨리 터집니다.

  • Hikari의 leak detection을 임시로 켜서 스택 트레이스를 확보합니다.
spring:
  datasource:
    hikari:
      leak-detection-threshold: 2000

(상시 활성화는 오버헤드가 있을 수 있어, 문제 재현/조사 시에만 권장)

8.2 DB 락 경합이 “풀 고갈”로 보이는 경우

동시 업데이트가 많은 테이블에서 락 대기가 길어지면, 쿼리는 실행 중이지만 실제로는 락을 기다리며 커넥션을 점유합니다. 이때는:

  • 트랜잭션 격리 수준/락 범위 조정
  • 업데이트 핫스팟 분산(샤딩 키, 파티셔닝, 카운터 테이블 재설계)
  • 낙관적 락/재시도

같은 접근이 필요합니다.

8.3 스레드는 늘었는데 DB 외에도 “다른 병목”이 먼저일 수 있음

가상 스레드로 동시성이 늘면, DB 외에도 DNS, 로드밸런서, NAT, conntrack 같은 병목이 먼저 나타날 수 있습니다. 특히 컨테이너/쿠버네티스 환경에서는 네트워크 계층 이슈가 DB 타임아웃처럼 보일 때가 많습니다.

9) 적용 순서 요약(실전 체크리스트)

  1. 가상 스레드 활성화 후, Hikari 메트릭(active/pending)과 DB 슬로우쿼리/락을 함께 본다.
  2. 트랜잭션 범위를 줄이고(외부 호출 분리), N+1/슬로우쿼리를 제거해 커넥션 점유 시간을 단축한다.
  3. 세마포어/Bulkhead로 DB 동시 접근을 제한해 백프레셔를 만든다.
  4. 그 다음에 HikariCP 풀 사이즈와 타임아웃을 DB 용량과 인스턴스 수에 맞게 조정한다.
  5. 마지막으로 네트워크/커널(conntrack 등) 병목 가능성까지 점검한다.

10) 결론

Spring Boot 3의 가상 스레드는 “스레드 부족” 문제를 크게 완화하지만, 그 대가로 기존에 스레드 풀이 해주던 자연스러운 동시성 제한이 사라집니다. 그래서 DB 커넥션 풀 고갈은 가상 스레드 도입 후 가장 흔하게 마주치는 새로운 병목입니다.

해결의 핵심은 풀을 무작정 키우는 것이 아니라:

  • 커넥션을 오래 잡는 원인을 제거하고(트랜잭션/쿼리/락),
  • DB로 들어가는 동시성을 명시적으로 제한하며(백프레셔),
  • 그 위에서 HikariCP를 정합 있게 맞추는 것

입니다. 이 순서대로 접근하면 가상 스레드의 장점(높은 동시성, 낮은 스레드 비용)을 살리면서도 DB 안정성을 확보할 수 있습니다.