Published on

Spring Boot 3 가상스레드 적용 후 DB 커넥션 고갈

Authors

서버 스레드가 부족해서 느렸던 서비스를 Spring Boot 3 + Java 21의 **가상 스레드(Virtual Thread)**로 전환하면, 종종 예상과 다르게 장애가 납니다. 대표적인 패턴이 **"DB 커넥션 고갈"**입니다.

가상 스레드는 요청당 스레드를 싸게 만들어 주지만, DB 커넥션은 여전히 비싼 유한 자원입니다. 즉, 이전에는 톰캣/서블릿 스레드 수가 자연스럽게 동시성을 제한해줬는데, 가상 스레드로 그 제한이 풀리면서 순식간에 커넥션 풀을 끝까지 써버리는 상황이 발생합니다.

이 글에서는 “왜” 고갈이 더 쉽게 일어나는지, “어떻게” 재현·진단하는지, 그리고 “무엇을” 고쳐야 하는지를 가상 스레드 관점에서 정리합니다. (HikariCP 자체의 빠른 진단은 Spring Boot HikariCP 커넥션 고갈 3분 진단도 함께 참고하면 좋습니다.)

1) 증상: 가상 스레드 이후 더 자주 보이는 에러

대표 로그는 아래 형태입니다.

com.zaxxer.hikari.pool.HikariPool$PoolInitializationException: Failed to initialize pool
...
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.

또는 애플리케이션은 살아있지만 API 지연이 급증하고, 스레드 덤프에는 “DB 커넥션 대기”가 다수 보입니다.

가상 스레드 환경에서는 이 상태가 더 위험합니다.

  • 블로킹 대기(커넥션 획득 대기)가 가상 스레드로 무한히 쌓일 수 있음
  • 결과적으로 요청이 빠르게 누적되고, 타임아웃/재시도까지 겹치면 폭발적으로 악화

2) 원인: 가상 스레드가 ‘동시성 제한’을 제거한다

2.1 기존(플랫폼 스레드)의 자연스러운 백프레셔

전통적인 서블릿 톰캣 모델에서 동시 요청은 대략 다음으로 제한됩니다.

  • server.tomcat.threads.max (예: 200)
  • 커넥션 풀 maximumPoolSize (예: 20~50)

톰캣 스레드가 200이라면, 최악의 경우에도 동시에 DB에 접근하는 스레드 수가 어느 정도 상한을 갖습니다(물론 비동기/내부 스레드 풀을 쓰면 달라짐).

2.2 가상 스레드의 특성: “요청당 스레드”를 다시 가능하게 함

Spring Boot 3.2+에서 가상 스레드를 켜면(서블릿 스택 기준) 요청 처리가 가상 스레드에서 실행될 수 있습니다.

spring:
  threads:
    virtual:
      enabled: true

가상 스레드는 생성/컨텍스트 스위칭 비용이 낮아 동시에 훨씬 많은 요청이 실행 상태로 진입합니다. 그런데 DB 커넥션 풀은 그대로라면?

  • 동시 요청 N이 커짐
  • 각 요청이 트랜잭션 시작과 함께 커넥션을 잡음
  • 커넥션 풀은 maximumPoolSize에서 포화
  • 이후 요청은 커넥션 획득 대기 → 타임아웃 → 재시도 → 더 많은 부하

즉, 가상 스레드는 “DB를 더 빠르게 만든다”가 아니라 “DB에 더 빨리 몰려가게 만든다”에 가깝습니다.

3) 가장 흔한 트리거 5가지

3.1 트랜잭션 범위가 과도하게 큼

@Transactional이 서비스 상단에 넓게 잡혀 있고, 그 안에서 외부 API 호출/파일 I/O/대기 시간이 길면 커넥션을 오래 점유합니다.

@Service
public class OrderService {

  @Transactional
  public OrderResult placeOrder(OrderRequest req) {
    // DB 커넥션을 잡은 뒤
    var user = userRepository.findById(req.userId()).orElseThrow();

    // 외부 호출이 길어지면 커넥션 점유 시간이 급증
    paymentClient.charge(req.payment());

    // 이후 저장
    orderRepository.save(new Order(...));
    return new OrderResult(...);
  }
}

가상 스레드에서는 동시에 더 많은 placeOrder()가 실행되므로, 커넥션 점유 시간이 조금만 길어도 풀 고갈이 빨라집니다.

3.2 N+1 / 과도한 쿼리로 트랜잭션 시간이 늘어남

가상 스레드는 쿼리 수 자체를 줄여주지 않습니다. 오히려 동시성이 늘어 N+1이 더 치명적일 수 있습니다.

N+1을 빠르게 잡는 방법은 Spring Boot 3+ JPA N+1 즉시 잡는 7가지를 참고하세요.

3.3 커넥션 누수(leak)

예외 경로에서 ResultSet/Statement 미정리, 직접 JDBC 사용 시 close 누락, 트랜잭션 동기화 밖에서 커넥션을 잡는 코드 등은 가상 스레드 전환 후 더 빨리 드러납니다.

3.4 재시도/서킷브레이커 설정이 풀 고갈을 가속

DB 타임아웃이 발생하면 클라이언트/서버에서 재시도하는 경우가 많습니다.

  • 요청 1개가 타임아웃
  • 재시도 2~3회
  • 동시 요청이 3배로 증폭
  • 풀 고갈이 더 심화

3.5 DB 자체 병목(락/슬로우쿼리)

가상 스레드로 애플리케이션이 더 많은 동시성을 “낼 수 있게” 되면, DB의 락 경합/인덱스 부재/IO 병목이 더 빨리 임계점에 도달합니다.

4) 진단: “커넥션 고갈”이 아니라 “점유 시간이 길다”를 찾아라

핵심은 두 가지 지표입니다.

  • 커넥션 획득 대기 시간(pool wait)
  • 커넥션 점유 시간(usage / hold time)

4.1 HikariCP 메트릭 확인

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

  • hikaricp.connections.active
  • hikaricp.connections.pending
  • hikaricp.connections.idle
  • hikaricp.connections.timeout

pending이 치솟고 activemaximumPoolSize에 붙어있다면 “풀 크기 부족”이 아니라, 대부분은 커넥션을 오래 쥐고 있는 트랜잭션/쿼리가 원인입니다.

4.2 leakDetectionThreshold로 누수/장기 점유 추적

운영에 상시로 크게 켜는 건 부담이지만, 장애 재현 환경에서는 매우 유용합니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      leak-detection-threshold: 2000  # 2초 이상 점유 시 스택트레이스 출력

로그에 “Connection leak detection triggered”가 뜨면, 해당 스택이 커넥션을 잡은 코드 위치입니다.

4.3 스레드 덤프에서 “커넥션 대기” 확인

가상 스레드에서는 플랫폼 스레드 덤프와 보는 방식이 조금 다르지만, 핵심은 동일합니다.

  • HikariPool.getConnection 근처에서 대기
  • JDBC 드라이버 read/write에서 장시간 대기

5) 해결 전략: ‘풀을 키우기’ 전에 해야 할 것들

5.1 트랜잭션 범위를 줄이고, DB 밖 작업을 분리

가장 효과가 큰 처방입니다.

@Service
public class OrderService {

  public OrderResult placeOrder(OrderRequest req) {
    // 1) DB 읽기 (짧게)
    User user = loadUser(req.userId());

    // 2) 외부 호출은 트랜잭션 밖에서
    paymentClient.charge(req.payment());

    // 3) DB 쓰기도 필요한 구간만 트랜잭션으로 짧게
    return persistOrder(user, req);
  }

  @Transactional(readOnly = true)
  protected User loadUser(Long userId) {
    return userRepository.findById(userId).orElseThrow();
  }

  @Transactional
  protected OrderResult persistOrder(User user, OrderRequest req) {
    orderRepository.save(new Order(...));
    return new OrderResult(...);
  }
}

요점은 “커넥션을 잡고 있는 시간”을 줄이는 것입니다. 가상 스레드로 동시성이 늘수록 이 효과는 기하급수적으로 중요해집니다.

5.2 동시성 상한을 ‘명시적으로’ 둔다 (세마포어/벌크헤드)

가상 스레드는 동시성을 쉽게 늘리므로, DB 접근 구간에는 의도적인 제한이 필요할 수 있습니다.

@Component
public class DbBulkhead {
  private final Semaphore permits = new Semaphore(30); // DB 동시 작업 상한

  public <T> T call(Callable<T> task) throws Exception {
    permits.acquire();
    try {
      return task.call();
    } finally {
      permits.release();
    }
  }
}

@Service
public class ReportService {
  private final DbBulkhead bulkhead;
  private final ReportRepository repo;

  public ReportService(DbBulkhead bulkhead, ReportRepository repo) {
    this.bulkhead = bulkhead;
    this.repo = repo;
  }

  public Report getReport(Long id) throws Exception {
    return bulkhead.call(() -> repo.findById(id).orElseThrow());
  }
}
  • 풀 크기를 무한정 키우는 대신
  • 애플리케이션에서 DB로 들어가는 동시성을 제어해
  • 지연을 “대기열”로 흡수하고, DB를 보호합니다.

Resilience4j의 Bulkhead(세마포어/스레드풀)로도 동일한 패턴을 구현할 수 있습니다.

5.3 풀 사이즈는 “DB가 감당 가능한 범위”에서만 조정

가상 스레드 이후 커넥션 풀이 고갈되었다고 해서 maximumPoolSize를 크게 올리는 건 위험합니다.

  • DB의 max_connections 및 실제 CPU/IO 한계
  • 쿼리당 평균 실행 시간
  • DB 락 경합

을 고려하지 않으면, 풀을 키운 순간 DB가 먼저 죽고 장애 범위가 커집니다.

권장 접근:

  1. 먼저 점유 시간을 줄임(트랜잭션/쿼리 최적화)
  2. 다음으로 동시성 제한 도입(벌크헤드)
  3. 마지막으로 풀 크기를 소폭 조정

5.4 타임아웃을 “짧고 일관되게” 정렬

커넥션 획득/쿼리/트랜잭션/HTTP 타임아웃이 서로 어긋나면, 대기가 꼬리를 물며 리소스를 계속 점유합니다.

예시(개념):

  • connectionTimeout(풀 대기) < API 전체 타임아웃
  • JDBC 쿼리 타임아웃 < 트랜잭션 타임아웃
spring:
  datasource:
    hikari:
      connection-timeout: 1500
      maximum-pool-size: 30

그리고 재시도는 “무조건”이 아니라, DB 타임아웃/락 경합 상황에서는 재시도가 오히려 독이 될 수 있으니 조건부로 제한합니다.

5.5 가상 스레드 적용 범위를 재검토 (특히 JDBC)

중요한 현실: JDBC는 블로킹 I/O이고, 가상 스레드는 블로킹을 “싸게” 만들지만 DB 커넥션이라는 병목을 제거하지는 못합니다.

가상 스레드를 껐을 때 문제가 사라진다면, 그동안 플랫폼 스레드가 해주던 백프레셔를 다른 방식으로 복원해야 합니다.

  • DB 접근 경로에 벌크헤드
  • 엔드포인트별 Rate Limit
  • 큐 기반 비동기 처리(주문/정산 등)

6) 운영에서 자주 묻는 질문

6.1 “가상 스레드면 풀도 크게 늘려야 하나요?”

대부분은 아니요입니다. 풀을 늘리기 전에 “커넥션 점유 시간이 왜 길어졌는지”를 먼저 해결해야 합니다. 풀 확장은 마지막 단계이며, DB가 감당 가능한 범위에서만 해야 합니다.

6.2 “가상 스레드가 DB 커넥션 누수를 더 잘 만들까요?”

누수를 만들지는 않지만, 동시성이 증가해 누수가 더 빨리 임계점에 도달하게 합니다. 그래서 전환 직후 누수가 발견되는 경우가 많습니다.

6.3 “장애가 나면 쿠버네티스에서 Pod가 재시작되기도 하던데요?”

커넥션 고갈이 애플리케이션 응답 불능으로 이어지면 liveness/readiness 실패 → 재시작으로 번질 수 있습니다. 이때는 DB 고갈이 근본 원인인데도 증상은 재시작으로 보이기도 합니다. 재시작 루프 관점의 진단은 Kubernetes CrashLoopBackOff 원인 12가지와 진단도 함께 보면 연결 고리를 찾기 쉽습니다.

7) 체크리스트: 전환 후 바로 점검할 것

  • Hikari 메트릭에서 pending/timeout 증가 확인
  • leakDetectionThreshold로 장기 점유 위치 찾기
  • @Transactional 범위가 외부 호출/대기 시간을 포함하는지 점검
  • N+1/슬로우 쿼리로 트랜잭션이 길어지는지 점검
  • 재시도 정책이 타임아웃 시 부하를 증폭시키지 않는지 점검
  • DB 접근 구간에 벌크헤드(세마포어)로 동시성 상한 도입

결론

Spring Boot 3에서 가상 스레드를 적용한 뒤 DB 커넥션 고갈이 발생하는 이유는, 가상 스레드가 “스레드 병목”을 해소하는 대신 DB라는 유한 자원에 더 많은 동시 요청을 밀어 넣기 때문입니다. 해결의 핵심은 풀을 키우는 것이 아니라,

  • 커넥션 점유 시간을 줄이고(트랜잭션/쿼리 최적화)
  • DB로 들어가는 동시성을 제한하며(벌크헤드)
  • 타임아웃/재시도를 정렬해(폭주 방지)

가상 스레드 시대에 맞는 명시적 백프레셔를 설계하는 것입니다.