Published on

Spring Boot 대용량 트래픽 HikariCP 고갈 진단·튜닝

Authors

서버가 갑자기 느려지더니 HikariPool-1 - Connection is not available, request timed out after ...ms 로그가 쏟아지고, API 지연이 폭증하는 상황은 대용량 트래픽에서 자주 마주칩니다. 문제는 단순히 maximumPoolSize 를 키우는 것으로 끝나지 않는다는 점입니다. 커넥션 풀 고갈은 보통 애플리케이션 스레드가 커넥션을 오래 쥐고 있거나, DB가 느려져 커넥션 반환이 지연되거나, 트래픽 패턴 대비 풀과 스레드/DB 자원이 불균형할 때 발생합니다.

이 글은 Spring Boot 환경에서 HikariCP 고갈을 재현 가능한 관찰 지표로 진단하고, 안전한 튜닝 순서실전 설정 예시를 정리합니다.

1) HikariCP 고갈이 의미하는 것

HikariCP 풀은 “동시에 DB 작업을 수행할 수 있는 티켓 수”에 가깝습니다. 풀 고갈은 다음 중 하나를 뜻합니다.

  • 커넥션을 빌린 스레드가 너무 오래 반환하지 않음
  • DB 쿼리 또는 트랜잭션이 너무 오래 걸림
  • 애플리케이션의 동시 요청 수 대비 풀 크기가 구조적으로 부족
  • 커넥션이 죽었는데 검증/재연결이 지연되어 유효 커넥션 수가 감소

즉, 풀은 증상이고 원인은 대개 쿼리, 트랜잭션 경계, 락, I/O, 스레드 모델, DB max connections 쪽에 있습니다.

2) 먼저 확인할 관찰 포인트: 로그, 메트릭, 스레드 덤프

2.1 HikariCP 핵심 메트릭

Micrometer를 쓴다면 HikariCP는 보통 다음 메트릭을 제공합니다.

  • hikaricp.connections.active: 현재 사용 중
  • hikaricp.connections.idle: 유휴
  • hikaricp.connections.pending: 커넥션 대기 중인 요청 수
  • hikaricp.connections.max: 최대 풀 크기
  • hikaricp.connections.timeout: 타임아웃 발생 횟수

관찰 팁:

  • activemax 에 붙고 pending 이 증가하면 고갈 확정
  • active 가 낮은데도 타임아웃이 나면 커넥션 검증/네트워크/DB 장애 가능성
  • pending 이 순간적으로 튀는지, 지속적으로 증가하는지로 “스파이크”와 “지속 병목”을 구분

2.2 타임아웃 로그를 “정상적 경보”로 만들기

Hikari 타임아웃은 보통 애플리케이션 레벨에서는 500으로 보이지만, 운영 관점에서는 SLO 위반의 선행 지표입니다. 타임아웃을 단순히 늘리면 문제를 숨기는 효과가 큽니다.

  • connectionTimeout 을 늘리기 전에 pendingactive 패턴을 먼저 확인
  • 타임아웃이 발생한 시점의 슬로우 쿼리, 락 대기, GC, CPU 스파이크를 함께 상관 분석

2.3 스레드 덤프로 “커넥션을 쥔 채 무엇을 하는지” 확인

풀 고갈의 가장 빠른 진단은 스레드 덤프입니다.

  • 웹 스레드가 getConnection 에서 대기 중인지
  • 커넥션을 얻은 뒤 JDBC 호출에서 막혔는지
  • 트랜잭션 안에서 외부 API 호출, 파일 I/O, 락 대기 등이 있는지

덤프 예시 명령은 환경에 따라 다르지만, 컨테이너라면 jcmdjstack 를 활용합니다.

# PID 확인 후 스레드 덤프
jcmd `pidof java` Thread.print > /tmp/threaddump.txt

덤프에서 com.zaxxer.hikari.pool.HikariPool.getConnection 근처에 WAITING 스레드가 많으면 풀 대기가 실제 병목입니다.

3) 대표 원인 7가지와 확인 방법

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

가장 흔한 패턴은 @Transactional 내부에서 DB 작업 후 외부 호출을 하거나, 큰 루프를 돌며 오래 잡고 있는 경우입니다.

  • 트랜잭션은 “DB 일”만 감싸고, 외부 I/O는 밖으로 빼기
  • 배치성 루프는 페이지/청크로 나누고 커밋 단위를 줄이기
@Service
public class OrderService {

  private final PaymentClient paymentClient;
  private final OrderRepository orderRepository;

  public OrderService(PaymentClient paymentClient, OrderRepository orderRepository) {
    this.paymentClient = paymentClient;
    this.orderRepository = orderRepository;
  }

  // 나쁜 예: 트랜잭션 안에서 외부 API 호출
  @Transactional
  public void payBad(Long orderId) {
    var order = orderRepository.findById(orderId).orElseThrow();
    paymentClient.requestPayment(order); // 여기서 지연되면 커넥션을 오래 점유
    order.markPaid();
  }

  // 개선 예: 외부 호출은 트랜잭션 밖, DB 변경만 트랜잭션으로
  public void payGood(Long orderId) {
    var order = orderRepository.findById(orderId).orElseThrow();
    paymentClient.requestPayment(order);
    markPaid(orderId);
  }

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

3.2 슬로우 쿼리, 인덱스 부재, N+1

쿼리가 느리면 커넥션 반환이 늦어져 풀 고갈로 이어집니다. 특히 JPA 환경에서는 N+1이 트래픽 구간에서 “폭발”하며 커넥션을 잠식합니다.

  • DB 슬로우 쿼리 로그 활성화
  • APM에서 쿼리 수와 총 쿼리 시간을 함께 보기
  • N+1 의심 시 fetch join, batch size, DTO projection 검토

관련해서 JPA N+1 튜닝은 별도 글로 정리했습니다: Spring Boot 3 JPA N+1 폭발, fetch join 튜닝 실전

3.3 락 경합과 트랜잭션 격리 수준

특정 테이블 업데이트가 몰리면 락 대기가 발생하고, 대기 중인 트랜잭션이 커넥션을 계속 점유합니다.

  • DB에서 락 대기 뷰 확인
  • 업데이트 핫스팟 키를 분산하거나, 큐잉/샤딩/비동기 처리 고려
  • 불필요하게 높은 격리 수준 사용 여부 점검

3.4 커넥션 누수

코드에서 커넥션을 닫지 않는 누수는 Hikari가 leakDetectionThreshold 로 탐지할 수 있습니다. 다만 이 값이 너무 낮으면 정상 쿼리도 누수처럼 보일 수 있어 운영에서는 신중하게 사용합니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 20000 # 20초 이상 점유 시 스택트레이스 로깅

누수가 의심되면:

  • JDBC 템플릿/ORM 외에 직접 DataSource.getConnection 을 쓰는 구간 점검
  • 스트리밍 결과를 열어둔 채로 오래 처리하는 코드 점검

3.5 풀 크기와 웹 스레드/동시성 모델 불일치

Tomcat 기본 스레드 수가 크고, 각 요청이 DB를 사용한다면 순간적으로 풀 대기가 폭발할 수 있습니다.

  • 웹 스레드 수를 무작정 키우면 DB에 동시에 더 많은 부하를 걸어 악화 가능
  • “동시 DB 작업 수”는 풀 크기로 제한되므로, 애플리케이션의 동시 처리 전략을 일치시키는 것이 중요

실무적으로는 다음을 함께 봅니다.

  • server.tomcat.threads.max
  • spring.datasource.hikari.maximum-pool-size
  • 요청당 평균 DB 점유 시간

3.6 DB max_connections 및 RDS 프록시/네트워크 이슈

애플리케이션에서 풀을 늘리기 전에 DB가 허용하는 총 커넥션 수를 확인해야 합니다. 여러 인스턴스가 있으면 “인스턴스 수 x 풀 크기”가 DB 한도를 초과할 수 있습니다.

  • DB max_connections 대비 여유 확인
  • 커넥션 생성 폭증이 있다면 minimumIdlemaxLifetime 조합 점검
  • 네트워크 단절이 잦다면 keepalive, 타임아웃, maxLifetime 을 DB 측 idle timeout 보다 짧게 설정

3.7 장애 시 재시도 폭주로 인한 2차 고갈

DB나 외부 의존성이 느려지면 재시도가 폭주하고, 더 많은 스레드가 더 오래 커넥션을 기다리며 악순환이 생깁니다. 이때는 백오프, 큐잉, 벌크헤드가 필요합니다.

재시도/큐잉 패턴은 다음 글도 참고할 만합니다: OpenAI 429/Rate Limit 재시도·큐잉 패턴 7가지

4) 튜닝의 올바른 순서: 풀을 키우기 전에 할 일

4.1 1단계: 쿼리와 트랜잭션 시간을 줄인다

풀 고갈은 “점유 시간” 문제인 경우가 많습니다.

  • 슬로우 쿼리 제거, 인덱스 추가
  • N+1 제거
  • 트랜잭션 범위 축소
  • 락 경합 완화

점유 시간이 절반이 되면, 사실상 같은 풀 크기로 처리량이 2배가 됩니다.

4.2 2단계: 동시성 제한과 타임아웃을 설계한다

  • API 레벨에서 동시 요청을 제한하거나
  • 특정 기능을 큐로 넘기거나
  • DB 접근을 벌크헤드로 격리

예를 들어 Resilience4j 벌크헤드로 DB 의존 구간의 동시성을 제한할 수 있습니다.

BulkheadConfig config = BulkheadConfig.custom()
  .maxConcurrentCalls(50)
  .maxWaitDuration(Duration.ofMillis(200))
  .build();

Bulkhead bulkhead = Bulkhead.of("dbBulkhead", config);

Supplier<String> supplier = Bulkhead.decorateSupplier(bulkhead, () -> {
  // DB 호출
  return "ok";
});

핵심은 “풀 타임아웃이 터지기 전에” 애플리케이션 레벨에서 빠르게 실패하거나 대기열로 흡수하는 것입니다.

4.3 3단계: 그 다음에 HikariCP 파라미터를 조정한다

풀 크기 증설은 DB와 애플리케이션 전체에 영향을 주므로 마지막에 합니다.

5) HikariCP 주요 설정값과 실전 권장 접근

아래는 자주 쓰는 옵션과 해석입니다.

5.1 maximumPoolSize

  • 동시에 DB 작업을 수행할 수 있는 최대 커넥션 수
  • 너무 작으면 대기가 늘고, 너무 크면 DB CPU/락 경합/컨텍스트 스위칭이 증가

권장 접근:

  • 먼저 “요청당 평균 DB 점유 시간”을 측정
  • 목표 TPS와 지연을 기준으로 필요한 동시 DB 작업 수를 계산
  • 인스턴스 수를 고려해 DB 총 커넥션 한도 내에서 배분

5.2 minimumIdle

  • 유휴 커넥션을 최소로 유지
  • 트래픽 스파이크가 잦다면 너무 낮으면 순간적인 커넥션 생성 비용이 커질 수 있음

다만 Hikari는 기본적으로 효율적이라, 운영에서는 minimumIdlemaximumPoolSize 와 동일하게 두기보다는 상황에 맞게 조정합니다.

5.3 connectionTimeout

  • 커넥션을 기다리는 최대 시간
  • 늘리면 “대기열”이 길어져 지연이 늘고 스레드가 묶입니다

권장:

  • 애플리케이션의 API 타임아웃, 로드밸런서 타임아웃과 정합성 있게 설정
  • 보통은 수 초 단위로 두고, 장기 대기는 피합니다

5.4 maxLifetimeidleTimeout

  • maxLifetime: 커넥션을 강제로 교체하는 최대 수명
  • idleTimeout: 유휴 커넥션을 정리하는 시간

주의:

  • DB나 프록시가 커넥션을 특정 시간 이후 끊는다면, maxLifetime 을 그보다 약간 짧게 두는 편이 안전
  • 너무 짧으면 커넥션 교체가 잦아져 오버헤드 증가

5.5 validationTimeout 및 커넥션 테스트

대부분의 경우 Hikari는 JDBC4 isValid 로 충분히 검증합니다. 별도의 테스트 쿼리를 넣는 것은 DB에 불필요한 부하가 될 수 있습니다.

6) Spring Boot 설정 예시: 운영 기준 템플릿

아래 예시는 “무난한 출발점”입니다. 실제 값은 트래픽, 쿼리 시간, DB 한도에 따라 조정해야 합니다.

spring:
  datasource:
    url: jdbc:postgresql://db:5432/app
    username: app
    password: secret
    hikari:
      pool-name: app-hikari
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 2000
      validation-timeout: 1000
      idle-timeout: 600000
      max-lifetime: 1740000

server:
  tomcat:
    threads:
      max: 200

해석 포인트:

  • connection-timeout 을 짧게 두면, 풀 고갈 시 빠르게 실패하며 장애 전파를 줄일 수 있습니다
  • Tomcat 스레드는 풀보다 훨씬 클 수 있지만, 그 경우 pending 이 늘 때 웹 스레드가 대기하며 지연이 커질 수 있으니 “빠른 실패” 또는 “상위 레벨 큐잉”이 필요합니다

7) 고갈 재현과 부하 테스트 체크리스트

튜닝은 반드시 부하 테스트로 검증해야 합니다.

  • 목표 TPS에서 activemax 에 지속적으로 붙는가
  • pending 이 선형으로 증가하는가
  • p95, p99 지연이 어디서 꺾이는가
  • 슬로우 쿼리/락/CPU/GC 중 무엇이 먼저 한계에 도달하는가

부하 테스트 중에는 다음을 함께 수집하면 원인 분리가 쉬워집니다.

  • 애플리케이션: Hikari 메트릭, GC 로그, 스레드 덤프
  • DB: 슬로우 로그, CPU/IOPS, 락 대기, 커넥션 수
  • 인프라: 노드 CPU throttling, 네트워크 지연

컨테이너 환경에서 리소스 문제로 앱이 불안정해지면 커넥션 풀 문제처럼 보일 수 있습니다. 장애 루프 진단은 다음 글도 도움이 됩니다: Kubernetes CrashLoopBackOff 원인 7가지와 재현·해결

8) 운영에서 자주 하는 실수와 안전장치

8.1 maximumPoolSize 만 크게 올리기

풀을 키우면 단기적으로 타임아웃은 줄 수 있지만, DB가 감당하지 못하면 쿼리가 더 느려져 결국 다시 고갈됩니다. 특히 락 경합이 있는 워크로드는 커넥션 수 증가가 오히려 지연을 증폭시킬 수 있습니다.

8.2 타임아웃을 과도하게 늘리기

connectionTimeout 을 30초, 60초로 늘리면 장애 시 스레드가 장시간 대기하면서 애플리케이션이 “서서히 죽는” 형태가 됩니다.

  • 상위 레벨 타임아웃과 일관된 짧은 타임아웃
  • 재시도는 백오프와 상한을 두기
  • 빠른 실패 후 큐잉 또는 폴백

8.3 커넥션 누수 탐지를 상시 켜두기

leakDetectionThreshold 는 유용하지만, 스택 트레이스 로깅 비용이 있고 노이즈를 만들 수 있습니다.

  • 장애 구간에 한시적으로 활성화
  • 임계값은 “정상 쿼리 p99”보다 충분히 크게

9) 결론: 풀 고갈은 “DB 동시성 설계” 문제다

HikariCP 커넥션 풀 고갈은 단순 설정 문제가 아니라, 애플리케이션과 DB 사이의 동시성 계약이 깨졌다는 신호입니다. 해결의 우선순위는 다음 순서가 안전합니다.

  1. 쿼리/트랜잭션 시간 단축
  2. 락 경합 제거, N+1 제거
  3. 재시도 폭주 방지, 동시성 제한(벌크헤드) 도입
  4. DB max_connections 를 고려해 풀 크기 조정
  5. 마지막으로 Hikari 파라미터 미세 조정

이 순서대로 접근하면 “풀을 키워서 잠깐 버티는” 방식이 아니라, 대용량 트래픽에서도 예측 가능한 지연과 안정성을 확보할 수 있습니다.