Published on

Spring Boot HikariCP 풀 고갈 원인·튜닝 실전

Authors

서버가 멀쩡해 보이는데도 API가 갑자기 느려지거나 타임아웃이 쏟아지는 순간이 있습니다. 로그에는 대개 다음과 같은 문구가 찍힙니다.

  • HikariPool-1 - Connection is not available, request timed out after ...ms
  • SQLTransientConnectionException: HikariPool...

이건 단순히 “DB가 느리다”로 끝나지 않습니다. 애플리케이션의 동시성, 트랜잭션 경계, 쿼리 패턴, 커넥션 반환 누락, 그리고 풀 설정이 서로 맞물리며 발생합니다. 이 글에서는 풀 고갈의 구조적 원인을 분해하고, 관측 가능한 신호로 좁혀가며, 안전한 튜닝을 하는 실전 흐름을 정리합니다.

참고로 가상 스레드 도입 이후 동시성이 급격히 늘어 DB 풀 고갈이 더 자주 보인다면, 아래 글도 함께 보시면 맥락이 바로 연결됩니다.

1) HikariCP 풀 고갈이 의미하는 것

HikariCP는 애플리케이션이 DB에 접속할 때 매번 새 커넥션을 만들지 않고, 미리 만들어 둔 커넥션을 빌려주고 돌려받는 풀입니다. 고갈이란 다음 중 하나(또는 조합)입니다.

  1. 동시에 필요한 커넥션 수가 maximumPoolSize를 넘었다
  2. 커넥션이 반환되지 않거나(누수), 반환이 지연된다
  3. DB 혹은 네트워크 문제로 커넥션이 죽었는데 풀은 그것을 정상으로 오인하거나, 재생성 비용이 급증한다
  4. 애플리케이션이 불필요하게 커넥션을 오래 잡고 있는 구조

이걸 튜닝으로만 해결하려고 maximumPoolSize만 올리면, 다음 부작용이 잘 옵니다.

  • DB max_connections 초과로 DB 자체 장애
  • 컨텍스트 스위칭 증가, 락 경합 증가로 성능 악화
  • 커넥션 수는 늘었는데 쿼리 지연의 근본 원인(락/슬로우쿼리) 은 그대로

결론적으로 먼저 해야 할 일은 “풀 크기”가 아니라 커넥션이 어디서 얼마나 오래 점유되는지를 관측하는 것입니다.

2) 가장 흔한 원인 7가지 (체크리스트)

2.1 느린 쿼리, 락, 트랜잭션 대기

가장 흔한 1순위입니다. 쿼리 자체가 느리거나, 인덱스가 없거나, 업데이트/DDL로 락이 잡혀 대기하면 커넥션이 반환되지 않습니다.

특징적인 신호:

  • DB CPU/IO가 치솟음
  • 애플리케이션은 커넥션 대기(connectionTimeout)로 타임아웃
  • DB에서 idle in transaction 세션이 증가

PostgreSQL을 쓴다면 autovacuum 지연으로 인한 팽창과 슬로우가 커넥션 점유를 폭발시키는 경우가 많습니다.

2.2 트랜잭션 범위가 너무 큼 (웹 요청 전체를 감싸는 패턴)

@Transactional이 서비스 메서드가 아니라 상위 레이어(컨트롤러/필터)까지 확장되면, DB 작업이 끝난 뒤에도 커넥션을 오래 잡습니다.

대표 패턴:

  • 트랜잭션 안에서 외부 API 호출
  • 트랜잭션 안에서 대용량 파일 처리
  • 트랜잭션 안에서 메시지 발행/재시도 로직

2.3 커넥션 누수 (반환 누락)

JPA를 쓰더라도, JDBC를 직접 쓰거나 멀티 데이터소스/수동 트랜잭션을 섞을 때 누수가 발생할 수 있습니다.

신호:

  • 트래픽이 낮아도 시간이 지날수록 풀 사용량이 계속 증가
  • 재기동하면 잠깐 정상

2.4 N+1, 과도한 쿼리 수로 커넥션 점유시간 증가

각 쿼리가 빠르더라도 요청당 쿼리 수가 많으면 커넥션 점유시간이 길어집니다. 특히 페이지네이션 없이 대량 로딩, 지연 로딩 남발이 흔합니다.

2.5 애플리케이션 동시성 폭증 (스레드/가상스레드/비동기)

스레드 풀, 웹서버 워커 수, @Async, 배치 동시 실행, 스케줄러가 합쳐져 “동시에 DB를 때리는 수”가 풀 크기를 초과합니다.

여기서 중요한 점은 풀 크기는 DB 동시 처리량의 상한인데, 애플리케이션 동시성은 쉽게 무한대로 커질 수 있다는 것입니다.

2.6 커넥션 검증/네트워크 이슈로 재생성 비용 증가

DB와의 네트워크가 불안정하거나, NAT/방화벽/로드밸런서가 idle 커넥션을 끊으면 풀에서 죽은 커넥션을 빌려주려다 실패하고 재시도 비용이 늘어납니다.

2.7 DB max_connections 및 RDS/Proxy 제약

애플리케이션에서 풀을 늘려도 DB가 받지 못하면 병목이 DB에서 터집니다. 특히 오토스케일로 인스턴스 수가 늘면 “인스턴스 수 × 풀 크기”가 DB 한도를 초과하기 쉽습니다.

3) 진단 1단계: Hikari 로그와 기본 지표로 방향 잡기

3.1 타임아웃 로그의 의미

Hikari의 connectionTimeout은 “커넥션을 빌리기 위해 기다린 시간”입니다. 즉 타임아웃이 났다는 건,

  • 풀에 놀고 있는 커넥션이 없었고
  • connectionTimeout 동안 반환도 안 됐다는 뜻

따라서 이 시점에서 봐야 할 것은 “왜 반환이 안 됐는가”입니다.

3.2 Micrometer/Actuator로 풀 상태 보기

Spring Boot Actuator와 Micrometer를 쓰면 Hikari 지표를 쉽게 볼 수 있습니다.

application.yml 예시:

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  metrics:
    tags:
      application: my-service

관심 지표(이름은 환경에 따라 약간 다를 수 있습니다):

  • hikaricp.connections.active: 사용 중 커넥션
  • hikaricp.connections.idle: 유휴 커넥션
  • hikaricp.connections.pending: 커넥션 대기 중 스레드 수
  • hikaricp.connections.timeout: 타임아웃 횟수

해석 팁:

  • activemaximumPoolSize에 붙어 있고 pending이 증가하면: 수요가 공급 초과
  • active는 높지 않은데 timeout이 증가하면: 죽은 커넥션/검증 문제 또는 풀 설정 불일치 가능성

4) 진단 2단계: 커넥션 누수 의심 시 leakDetectionThreshold

Hikari의 누수 감지는 “커넥션을 빌린 뒤 일정 시간 안에 반환하지 않으면 스택트레이스를 찍는 기능”입니다.

설정 예시:

spring:
  datasource:
    hikari:
      leak-detection-threshold: 20000 # 20초

주의:

  • 너무 낮게 잡으면 정상적으로 오래 걸리는 쿼리도 누수처럼 찍혀 노이즈가 커집니다.
  • 운영에서는 일시적으로 켜고(예: 20~60초), 원인 잡으면 끄는 것을 권장합니다.

로그에 “어디서 커넥션을 빌렸는지” 스택이 찍히면, 그 경로에서 트랜잭션 범위/외부 호출/대기(락)를 집중 점검합니다.

5) 튜닝의 핵심: 풀 크기보다 “점유 시간”을 줄여라

풀 고갈을 수식으로 보면 간단합니다.

  • 동시 요청 수가 C
  • 요청당 평균 커넥션 점유 시간이 T
  • 초당 처리량이 R

대략적으로 activeR × T에 비례합니다. 즉 T를 줄이면 풀을 크게 늘리지 않아도 됩니다.

5.1 트랜잭션을 DB 작업 구간으로만 좁히기

나쁜 예(트랜잭션 안에서 외부 호출):

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

    // 외부 API 호출이 느려지면 커넥션을 잡고 기다리게 됨
    paymentClient.requestPayment(order.getId());

    return new OrderResult(order.getId());
}

개선 예(트랜잭션 분리):

public OrderResult placeOrder(PlaceOrderCommand cmd) {
    Long orderId = createOrder(cmd); // DB 구간
    paymentClient.requestPayment(orderId); // 트랜잭션 밖
    return new OrderResult(orderId);
}

@Transactional
public Long createOrder(PlaceOrderCommand cmd) {
    Order order = orderRepository.save(cmd.toEntity());
    return order.getId();
}

5.2 요청당 쿼리 수 줄이기 (N+1 제거)

  • fetch join, entity graph, batch size, DTO projection 등으로 “쿼리 수”를 줄이면 점유 시간이 짧아집니다.
  • 단, fetch join 남발로 한 번에 너무 큰 결과를 가져오면 메모리/네트워크가 병목이 될 수 있어 균형이 필요합니다.

5.3 DB 락/대기 원인 제거

  • 업데이트 경합이 큰 테이블은 인덱스/쿼리 조건을 점검
  • 긴 트랜잭션(특히 배치)이 OLTP 요청과 같은 테이블을 잠그지 않도록 분리

6) HikariCP 주요 파라미터 실전 가이드

아래는 “무작정 올리기”가 아니라, 자주 쓰는 기준과 함께 정리한 것입니다.

6.1 maximumPoolSize

  • 애플리케이션 인스턴스 1개가 동시에 사용할 수 있는 최대 커넥션 수
  • 정답은 “DB가 감당 가능한 동시 쿼리 수”와 “인스턴스 수”를 함께 봐야 합니다.

주의할 점:

  • 오토스케일 시 인스턴스 수 × maximumPoolSize가 DB max_connections를 넘지 않게 설계
  • DB가 100 커넥션까지 여유가 있고 앱이 5개면, 이론상 인스턴스당 20이 상한이 됩니다(여기에 운영툴/배치/관리 커넥션도 고려)

설정 예시:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20

minimumIdlemaximumPoolSize와 같게 두면 “항상 풀을 꽉 채움”이라 지연이 줄 수 있지만, DB 자원/비용이 증가합니다. 트래픽 변동이 큰 서비스라면 minimumIdle을 낮게 두고 관찰하는 것도 방법입니다.

6.2 connectionTimeout

  • 커넥션을 빌리기 위해 기다리는 최대 시간

너무 길면:

  • 장애 시 요청이 오래 매달려 스레드가 쌓이고, 연쇄 장애가 커질 수 있습니다.

너무 짧으면:

  • 순간 스파이크에도 타임아웃이 빨리 발생합니다.

예시:

spring:
  datasource:
    hikari:
      connection-timeout: 1000 # 1초

실무에서는 500ms~3s 사이를 자주 봅니다. 중요한 건 숫자 자체보다, “타임아웃이 날 때 빨리 실패하고 상위에서 재시도/폴백을 할지”의 정책입니다.

6.3 maxLifetimeidleTimeout

  • maxLifetime: 커넥션의 최대 수명. 이 시간이 지나면 풀에서 교체 대상이 됩니다.
  • idleTimeout: 유휴 커넥션을 얼마나 유지할지

네트워크 장비나 DB가 idle 커넥션을 끊는 환경에서는 maxLifetime를 그보다 짧게 둬서 “Hikari가 먼저 교체”하게 만드는 전략이 유효합니다.

예시:

spring:
  datasource:
    hikari:
      max-lifetime: 1740000  # 29분
      idle-timeout: 600000   # 10분

6.4 validationTimeout 및 커넥션 테스트

Hikari는 기본적으로 JDBC4 isValid() 등을 활용해 커넥션 유효성을 확인합니다. DB 드라이버나 네트워크가 불안정하면 검증 비용이 커지거나 실패가 늘 수 있습니다.

커넥션 테스트 쿼리를 별도로 두는 방식은 드라이버/DB에 따라 득실이 있으니, 먼저 maxLifetime 조정과 네트워크 안정화부터 확인하는 편이 안전합니다.

7) “풀을 늘려야 하는 경우”의 안전한 접근

다음 조건이 충족되면 풀 증설이 의미가 있습니다.

  • 쿼리/락/트랜잭션 점유시간 최적화가 어느 정도 됐고
  • DB CPU/IO가 아직 여유가 있으며
  • pending이 꾸준히 발생하고
  • DB max_connections 및 인스턴스 수를 고려해도 안전할 때

권장 절차:

  1. maximumPoolSize를 10~20%씩 점진적으로 증가
  2. 증가 전후로 p95 응답시간, DB CPU/IO, 락 대기, pending, timeout 변화 비교
  3. 악화되면 즉시 롤백

“풀만 키웠더니 DB가 더 느려졌다”는 흔한 결말은, DB가 동시 처리량 한계에 가까웠는데 앱이 더 밀어 넣은 케이스입니다.

8) 운영에서 자주 쓰는 재현/검증 방법

8.1 작은 풀로 일부러 고갈을 재현해 보기

스테이징에서 풀을 작게 잡고 부하를 걸면 병목이 빨리 드러납니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 3
      connection-timeout: 500
      leak-detection-threshold: 20000

이 상태에서 특정 API만 유독 pending을 만들면, 그 API의 트랜잭션 범위/쿼리 패턴이 원인일 확률이 큽니다.

8.2 슬로우 쿼리 로그/APM 트레이스와 함께 보기

  • APM에서 “DB time”이 긴 트랜잭션을 우선순위로 잡기
  • DB 슬로우 로그에서 동일 시간대 상위 쿼리 확인
  • 둘을 매칭하면 “커넥션 점유의 실체”가 보입니다

9) 최종 체크리스트 (현장용)

  • hikaricp.connections.pending가 증가하는가, activemaximumPoolSize에 붙는가
  • 요청당 쿼리 수(N+1) 또는 대량 로딩이 있는가
  • @Transactional 안에 외부 호출/파일 처리/재시도가 있는가
  • DB 락 대기, idle in transaction이 늘었는가
  • leakDetectionThreshold로 “반환 지연/누수” 스택을 확보했는가
  • 오토스케일을 고려한 인스턴스 수 × 풀 크기가 DB 한도 내인가
  • 네트워크/장비 idle 종료 정책을 고려해 maxLifetime를 조정했는가

마무리

HikariCP 풀 고갈은 “커넥션이 부족하다”가 아니라, 대부분 커넥션이 오래 잡혀 있는 이유가 있다는 신호입니다. 먼저 지표(active, pending, timeout)로 상황을 분류하고, 누수 감지와 트랜잭션/쿼리 구조 점검으로 점유 시간을 줄이세요. 그 다음에야 maximumPoolSize를 안전하게 늘릴지 판단할 수 있습니다.

가상 스레드나 비동기 확대로 동시성이 급증한 환경이라면, 풀과 동시성의 밸런스를 다시 잡는 접근이 특히 중요합니다.