Published on

Spring Boot 대규모 트래픽 HikariCP 고갈 진단·튜닝

Authors

서버가 바빠지면 가장 먼저 무너지는 지점이 DB 커넥션 풀인 경우가 많습니다. 특히 Spring Boot + JPA/Hibernate 조합에서 HikariCP 풀 고갈이 발생하면, 애플리케이션은 “DB가 죽었다”처럼 보이지만 실제로는 커넥션을 빌려간 요청이 반환을 못 하거나(누수), 반환이 너무 늦거나(슬로우/락), 풀 크기와 동시성이 맞지 않아(설계 불일치) 기다리다 타임아웃이 나는 상황이 대부분입니다.

이 글은 “HikariPool-1 - Connection is not available, request timed out” 류의 장애를 빠르게 진단하고, 재현 가능한 지표 기반으로 튜닝하며, 재발 방지까지 가는 실전 가이드입니다. 운영 환경이 Kubernetes/EKS라면 장애 시점에 Pod가 재시작 루프(CrashLoopBackOff)로 번지는 경우도 흔하니, 인프라 레벨 증상은 함께 점검하는 것이 좋습니다. (관련: K8s CrashLoopBackOff 원인별 빠른 진단·복구)

1) HikariCP 풀 고갈의 전형적인 증상

애플리케이션 로그

  • HikariPool-1 - Connection is not available, request timed out after 30000ms
  • SQLTransientConnectionException / CannotGetJdbcConnectionException
  • 특정 API가 동시에 느려지거나 5xx가 급증

DB/인프라 지표

  • DB CPU가 꼭 100%가 아니어도 발생(락/IO/슬로우 쿼리면 CPU가 낮을 수 있음)
  • DB의 max_connections에 도달하지 않았는데도 앱은 타임아웃(앱 풀 내부에서 대기)
  • 스레드 덤프에서 HikariPool.getConnection() 대기 스레드 다수

핵심은 “DB 커넥션을 못 얻어서 대기하는가(풀 고갈)”와 “커넥션은 얻었는데 쿼리가 느린가”를 분리하는 것입니다.

2) 가장 흔한 원인 6가지 (우선순위 순)

2.1 슬로우 쿼리/인덱스 미스

커넥션은 요청 처리 동안 점유됩니다. 쿼리 1개가 50ms → 2s로 늘어나면, 같은 TPS에서도 필요한 동시 커넥션 수가 폭증합니다.

  • 증상: DB slow query 로그 증가, 특정 SQL이 상위
  • 대응: 실행계획/인덱스/쿼리 리라이트, 배치성 조회 페이지네이션 개선

2.2 DB 락(행 락/테이블 락)으로 인한 대기

락 대기는 커넥션을 붙잡은 채 기다립니다. 결과적으로 풀 고갈이 쉽게 발생합니다.

  • 증상: 트랜잭션이 오래 지속, 업데이트/정산 API에서 집중
  • 대응: 트랜잭션 범위 축소, 락 순서 통일, 격리수준 점검, 핫 레코드 분산

2.3 트랜잭션/세션 범위가 과도함 (@Transactional 남용)

@Transactional이 서비스 상단에 넓게 걸리고 그 안에서 외부 API 호출/파일 IO/복잡한 연산을 하면 커넥션이 불필요하게 오래 점유됩니다.

  • 대응: DB 작업 부분만 트랜잭션으로 감싸기, 외부 호출은 트랜잭션 밖으로 이동

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

JDBC를 직접 쓰거나, 스트림/ResultSet을 닫지 않거나, 예외 흐름에서 close가 누락되면 누수가 납니다. JPA에서도 비정상적인 리소스 사용(특히 커스텀 JDBC 혼용)에서 발생할 수 있습니다.

  • 대응: 누수 탐지 활성화, try-with-resources, 프레임워크 템플릿 사용

2.5 애플리케이션 동시성(스레드/요청)과 풀 크기 불일치

Tomcat(또는 Netty) 스레드는 많고 풀은 작으면, DB를 쓰는 요청이 몰릴 때 대기열이 길어집니다. 반대로 풀을 너무 키우면 DB가 동시 쿼리 폭주로 더 느려져 악화될 수도 있습니다.

2.6 커넥션 검증/네트워크 이슈로 인한 재시도 폭증

DB 앞단 프록시, NAT, 방화벽 idle timeout 때문에 커넥션이 죽어있는데 검증이 부실하면, 요청당 실패→재시도로 커넥션이 더 오래 잡히는 패턴이 나옵니다.

3) 진단의 출발점: “풀 고갈”을 숫자로 확인하기

3.1 HikariCP 메트릭 노출 (Micrometer + Actuator)

Spring Boot Actuator를 켜면 Hikari 메트릭이 자동으로 잡힙니다.

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

spring:
  datasource:
    hikari:
      pool-name: app-hikari

Prometheus/Actuator에서 주로 볼 것:

  • hikaricp_connections_active (현재 사용 중)
  • hikaricp_connections_idle (유휴)
  • hikaricp_connections_pending (대기 중)
  • hikaricp_connections_timeout_total (타임아웃 누적)

판단 포인트

  • active == maximumPoolSize에 자주 붙고 pending이 증가하면 풀 고갈
  • timeout_total이 증가하면 이미 사용자 영향 발생

3.2 로그로 커넥션 대기 시간 체감하기

Hikari 자체는 “대기시간 분포”를 상세히 주지 않으므로, 애플리케이션 레벨에서 getConnection 대기가 길어지는지 관측하는 것이 유용합니다.

JDBC를 직접 얻는 코드가 있다면 측정 래퍼를 두세요.

import javax.sql.DataSource;
import java.sql.Connection;

public final class TimingDataSource {
  private final DataSource delegate;

  public TimingDataSource(DataSource delegate) {
    this.delegate = delegate;
  }

  public Connection getConnection() throws Exception {
    long start = System.nanoTime();
    try {
      return delegate.getConnection();
    } finally {
      long ms = (System.nanoTime() - start) / 1_000_000;
      if (ms > 200) {
        // 200ms 이상 대기면 경고(환경에 맞게 조정)
        System.err.println("[WARN] getConnection wait=" + ms + "ms");
      }
    }
  }
}

JPA만 쓰는 경우엔 직접 getConnection을 호출하지 않으니, 아래의 “누수 탐지/슬로우 쿼리/스레드 덤프”로 접근하는 편이 현실적입니다.

4) 10분 내 빠른 트리아지 체크리스트

4.1 Hikari 스레드 덤프에서 ‘대기 줄’ 확인

장애 시점에 스레드 덤프를 뜨면, 다음 패턴이 보입니다.

  • 많은 스레드가 com.zaxxer.hikari.pool.HikariPool.getConnection 또는 java.util.concurrent.SynchronousQueue 대기
  • 반대로 커넥션은 얻었는데 socketRead0, DB 드라이버 내부에서 대기(쿼리 지연/락)

결론: “풀에서 못 빌림” vs “빌렸는데 DB에서 오래 걸림”을 분리합니다.

4.2 DB에서 ‘현재 오래 열린 트랜잭션’과 ‘락 대기’ 확인

DB별로 다르지만 공통적으로 확인할 것:

  • 오래 실행 중인 쿼리 Top N
  • 락 대기/블로킹 트랜잭션
  • 커넥션 수/세션 수 추이

이 단계에서 문제 SQL/테이블이 특정되면, 풀 튜닝보다 쿼리/락 해결이 우선입니다.

4.3 애플리케이션 타임아웃 체인 확인

타임아웃은 “가장 바깥(요청) → 내부(DB)” 순으로 정리되어야 합니다.

  • HTTP 서버(예: Tomcat) 요청 타임아웃
  • Hikari connectionTimeout
  • JDBC 드라이버 socketTimeout
  • DB statement timeout

바깥이 너무 길면 스레드가 오래 묶이고, 너무 짧으면 재시도가 폭증합니다.

5) HikariCP 핵심 설정과 ‘안전한’ 튜닝 방향

5.1 maximumPoolSize: 크게 하면 해결? 대부분은 악화

maximumPoolSizeDB에 동시에 때릴 수 있는 쿼리 수의 상한입니다. 이를 무작정 키우면:

  • DB CPU/IO 경합 증가
  • 락 경합 증가
  • 평균 쿼리 시간이 늘어 커넥션 점유 시간이 더 길어짐
  • 결과적으로 더 큰 풀도 다시 고갈

권장 접근

  1. 먼저 슬로우/락/트랜잭션 범위부터 줄여 “커넥션 점유 시간”을 낮춘다.
  2. 그 다음에 필요한 동시 커넥션 수를 계산해 풀을 맞춘다.

간이 산식(개념):

  • 필요한 커넥션 ≈ (DB를 사용하는 동시 요청 수) × (요청당 DB 점유 비율)
  • 동시 요청 수는 TPS × 평균 응답시간(리틀의 법칙)로 추정 가능

5.2 connectionTimeout: 너무 길면 장애 확산

spring:
  datasource:
    hikari:
      connection-timeout: 3000 # 3s (예시)
  • 너무 길면(예: 30s) 요청 스레드가 30초씩 대기하며 서버가 빠르게 포화
  • 너무 짧으면 일시적 스파이크에도 즉시 실패

대규모 트래픽에서는 짧게 실패하고 빠르게 폴백/서킷브레이크가 더 안전한 경우가 많습니다(서비스 성격에 따라 1~5초 범위에서 실험).

5.3 leakDetectionThreshold: 누수 의심 시 임시로 켜기

spring:
  datasource:
    hikari:
      leak-detection-threshold: 2000 # 2s 초과 점유 시 스택트레이스 로그
  • 운영 상시 ON은 로그 폭탄이 될 수 있음(특히 정상적으로 2~3초 걸리는 쿼리가 있으면 오탐)
  • 장애 재현/의심 구간에서 짧은 기간 켜서 누수 지점을 찾는 용도로 사용

5.4 maxLifetime / idleTimeout: 네트워크/프록시 idle timeout과 맞추기

클라우드 환경에서 L4/NAT/프록시가 커넥션을 조용히 끊는 경우가 있습니다. 이때는 커넥션이 죽어있어도 풀에 남아있다가 사용 시 실패합니다.

spring:
  datasource:
    hikari:
      max-lifetime: 1740000  # 29m (예: 30m보다 약간 작게)
      idle-timeout: 600000   # 10m
      keepalive-time: 300000 # 5m (Hikari 3.4+)
  • maxLifetime은 인프라 idle timeout보다 조금 짧게
  • keepaliveTime은 idle 커넥션을 주기적으로 살려 “조용한 끊김”을 줄임

5.5 minimumIdle: 무조건 크게? 트래픽 패턴 따라 다름

  • 트래픽이 항상 높다면 minimumIdle을 적절히 유지해 워밍업 비용 감소
  • 트래픽이 출렁이면 너무 큰 minimumIdle은 DB에 상시 부담

대부분은 minimumIdle == maximumPoolSize로 고정하기보다, 필요 최소로 두고 관측하며 조정합니다.

6) 스레드풀/서버 설정과 함께 봐야 하는 이유

6.1 Tomcat(서블릿) 스레드가 너무 많으면 대기 폭증

요청 스레드가 많고 DB 풀은 작으면, 스레드가 커넥션을 기다리며 적체됩니다.

server:
  tomcat:
    threads:
      max: 200
      min-spare: 20
  • DB 의존 API가 대부분이면, Tomcat max를 무작정 키우는 것은 대기만 늘릴 수 있습니다.
  • “동시 요청 수”를 늘리기 전에 “요청당 DB 점유 시간”을 줄이세요.

6.2 비동기/배치 작업이 같은 풀을 쓰는지 확인

스케줄러, 배치, 메시지 컨슈머가 같은 DataSource를 쓰면 피크 타임에 API 트래픽과 경쟁합니다.

  • 가능하면 읽기/쓰기 분리, 또는 배치 전용 풀 분리

7) JPA/Hibernate에서 커넥션을 오래 잡는 패턴들

7.1 Open Session In View(OSIV)로 인해 커넥션 점유가 길어지는가?

Spring Boot 기본값은 버전에 따라 다를 수 있으나, 웹 요청 전 구간에서 영속성 컨텍스트를 유지하면(특히 잘못된 접근) 커넥션 점유가 늘어날 수 있습니다.

spring:
  jpa:
    open-in-view: false

OSIV를 끄면 Lazy 로딩을 컨트롤러/뷰에서 못 하므로 설계(조회 DTO, fetch join 등)를 정리해야 하지만, 대규모 트래픽에서는 커넥션 점유 시간 관리에 유리한 경우가 많습니다.

7.2 N+1, 과도한 fetch join, 대량 IN 쿼리

  • N+1은 쿼리 수 폭증으로 커넥션 점유 시간 증가
  • fetch join 과다로 결과셋이 커져 네트워크/메모리 병목

해결은 “쿼리 수 최소화”와 “전송량 최소화”의 균형입니다.

8) 장애 재현: 부하 테스트로 ‘고갈 곡선’을 만든다

운영에서만 튜닝하면 감으로 끝납니다. 최소한 다음을 재현하세요.

  • 일정 TPS에서 active가 최대치에 붙는 시점
  • pending이 증가하기 시작하는 시점
  • timeout_total이 증가하는 시점

예: k6로 단순 API 부하

import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  stages: [
    { duration: '2m', target: 50 },
    { duration: '2m', target: 100 },
    { duration: '2m', target: 200 },
  ],
};

export default function () {
  http.get('https://your.api.example.com/orders/123');
  sleep(1);
}

부하를 올리면서 Hikari 메트릭과 DB 슬로우/락을 같이 보면, “풀 크기 문제인지, 쿼리 문제인지”가 명확해집니다.

9) 운영에서 자주 쓰는 ‘안전장치’ 패턴

9.1 DB 의존 구간에 서킷 브레이커/벌크헤드

풀 고갈은 연쇄 장애로 번지기 쉽습니다. DB가 느려질 때 빠르게 실패/격리하면 전체 시스템이 살아남습니다.

  • Resilience4j Bulkhead로 동시 실행 제한
  • CircuitBreaker로 오류율/지연 기반 차단

9.2 중요한 API에 우선순위 부여

모든 요청이 같은 풀을 경쟁하면, 덜 중요한 기능이 핵심 기능까지 끌어내립니다.

  • 기능별 풀 분리(읽기 전용, 배치 전용)
  • 또는 레이트 리밋/큐잉

9.3 Kubernetes 환경: 리소스 제한과 재시작 루프 주의

풀 고갈 → 응답 지연 → liveness/readiness 실패 → 재시작 → 콜드 스타트로 더 악화 패턴이 있습니다. 장애 시엔 Probe 임계값을 현실화하고, 원인(슬로우/락/누수)을 먼저 잡아야 합니다.

관련해서 클러스터 네트워크/보안그룹 이슈로 DB 연결이 간헐적으로 실패하면 풀 고갈처럼 보일 수 있으니(재시도/대기 증가), 인프라 레벨 점검도 병행하세요. (예: EKS에서 NodePort만 안 열릴 때 CNI·SG 점검)

10) 추천 기본값 템플릿 (출발점)

아래는 ‘정답’이 아니라, 관측/실험을 위한 출발점입니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      minimum-idle: 10
      connection-timeout: 3000
      validation-timeout: 1000
      max-lifetime: 1740000
      idle-timeout: 600000
      keepalive-time: 300000

  jpa:
    open-in-view: false

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

조정 순서(중요):

  1. 슬로우 쿼리/락/트랜잭션 범위 축소
  2. connectionTimeout으로 장애 확산 제어
  3. maximumPoolSize는 DB가 감당 가능한 동시성 내에서만 조정
  4. 네트워크 idle timeout에 맞춰 maxLifetime/keepaliveTime 정렬
  5. 누수 의심 시 leakDetectionThreshold로 범인 특정

11) 최종 점검 체크리스트

  • hikaricp_connections_pending이 0 근처인가? 스파이크 시 얼마나 증가하는가?
  • hikaricp_connections_timeout_total이 증가하는가? 증가 시점의 배포/트래픽/DB 이벤트는?
  • 슬로우 쿼리 Top 5가 무엇이며, 실행계획이 바뀐 적이 있는가?
  • 락 대기/블로킹 트랜잭션이 있는가? 핫 테이블/핫 로우는?
  • @Transactional 범위 안에 외부 호출/대기/대량 연산이 섞여 있는가?
  • OSIV가 커넥션 점유를 늘리는 구조인가?
  • 배치/컨슈머가 같은 풀을 경쟁하지 않는가?
  • maxLifetime이 인프라 idle timeout보다 짧은가?

풀 고갈은 “풀을 키우면 된다”로 끝나는 문제가 아니라, 요청-스레드-트랜잭션-쿼리-락-DB동시성이 맞물린 시스템 문제입니다. 지표로 고갈을 확인하고, 점유 시간을 줄이며, 마지막에 풀 크기를 맞추는 순서를 지키면 재발률이 눈에 띄게 떨어집니다.