Published on

Spring Boot HikariCP 풀 고갈·DB 타임아웃 10분 진단

Authors

서버가 멀쩡해 보이는데도 갑자기 API 지연이 폭증하고, 이어서 SQLTransientConnectionException(커넥션 획득 타임아웃)이나 DB 타임아웃이 연쇄적으로 터지는 경우가 있습니다. 대부분의 시작점은 HikariCP 풀 고갈(pool exhaustion) 입니다.

풀 고갈은 “커넥션이 부족하다”로 끝나지 않습니다. (1) 애플리케이션이 커넥션을 오래 쥐고 있거나, (2) DB가 새 커넥션을 못 받아주거나, (3) 네트워크/인프라가 연결을 지연시키거나, (4) 트래픽/스레드 구조가 풀보다 공격적일 때 발생합니다.

이 글은 원인 추측 대신, 10분 안에 원인을 좁히는 순서로 정리합니다.

0) 증상 패턴 빠르게 확인(1분)

다음 로그/메트릭이 보이면 “풀 고갈” 가능성이 큽니다.

  • 애플리케이션 로그
    • HikariPool-1 - Connection is not available, request timed out after 30000ms
    • java.sql.SQLTransientConnectionException
    • org.hibernate.exception.JDBCConnectionException
  • 지연 패턴
    • 특정 엔드포인트만 느린 게 아니라 DB 붙는 요청 전체가 계단식으로 느려짐
    • 타임아웃 직전 스레드 대기 증가(Tomcat/Netty worker가 블로킹)

핵심 질문은 하나입니다.

> “커넥션이 부족한가(풀 크기 문제)?”가 아니라 “커넥션이 왜 반환되지 않거나, 왜 새로 못 만드는가?”

1) HikariCP 핵심 설정 5개부터 덤프(2분)

먼저 현재 설정을 확정해야 합니다. 환경별로 값이 달라서 ‘기억’은 의미가 없습니다.

application.yml 예시(기준점 만들기)

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20
      connection-timeout: 30000      # 커넥션 획득 대기
      idle-timeout: 600000
      max-lifetime: 1800000          # RDS/NLB idle timeout보다 짧게
      keepalive-time: 0
      leak-detection-threshold: 0
      validation-timeout: 5000
      pool-name: app-hikari

지금 당장 확인할 것

  • maximumPoolSize : 풀 상한
  • connectionTimeout : 고갈 시 “몇 초 후 예외”로 드러나는지
  • maxLifetime : 네트워크 장비/DB가 커넥션을 끊기 전에 애플리케이션이 먼저 교체하는지
  • leakDetectionThreshold : 커넥션 누수/장기 점유 탐지
  • minimumIdle : 피크 대비 idle 확보 전략

운영에서 흔한 함정:

  • minimumIdlemaximumPoolSize와 같게 두면, 트래픽이 적어도 커넥션을 계속 유지합니다. DB 커넥션 수 제한이 빡빡한 환경(RDS 소형 등)에서는 다른 서비스까지 밀어내며 장애를 키울 수 있습니다.

DB 자체 커넥션 제한 문제로 번지는 케이스는 아래 글도 함께 보면 좋습니다.

2) “풀 고갈”인지 “DB가 느린 것”인지 1분에 구분

풀 고갈은 보통 두 부류로 나뉩니다.

  1. 커넥션은 있는데(풀 크기 충분) 쿼리가 느려서 반환이 늦음 → active가 꽉 차고 대기열 증가
  2. 아예 커넥션을 못 만듦/못 가져옴 → DB/네트워크/인증/최대 커넥션 제한

Actuator + Micrometer로 바로 보기

Spring Boot Actuator를 켜면 Hikari 메트릭을 즉시 볼 수 있습니다.

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

아래 지표를 확인합니다.

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

판단 기준(대략):

  • active == maximumPoolSize 이면서 pending > 0 급증 → 풀 고갈 확정
  • timeout이 증가 → 획득 실패가 실제로 발생
  • idle이 있는데도 pending이 높다 → 스레드/락/커넥션 검증 문제 가능(드묾)

3) 커넥션 누수 vs 장기 점유를 3분 안에 잡는 법

3-1) Leak Detection을 “짧게” 켜서 증거 확보

운영에서 영구적으로 켜면 노이즈가 생길 수 있으니, 장애 재현 구간에만 임시로 켭니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 5000  # 5초 이상 점유 시 스택트레이스 로그

로그에 “커넥션을 빌린 위치” 스택이 찍히면, 그 경로에서 다음을 의심합니다.

  • @Transactional 범위가 과도하게 큼(외부 API 호출/파일 IO 포함)
  • 스트리밍 응답/대용량 처리 중 ResultSet을 오래 들고 있음
  • 예외 경로에서 close가 누락(직접 JDBC 사용 시)

3-2) 직접 JDBC를 쓴다면 try-with-resources로 강제

try (Connection con = dataSource.getConnection();
     PreparedStatement ps = con.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {

    while (rs.next()) {
        // ...
    }
} // 여기서 무조건 반환

JPA/Hibernate를 써도, 아래 패턴은 커넥션을 오래 잡는 트리거가 됩니다.

  • 트랜잭션 안에서 외부 HTTP 호출
  • 트랜잭션 안에서 대량 루프 + flush/clear 없이 누적

3-3) 트랜잭션 범위 축소 예시

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

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

  // 1) DB 작업만 트랜잭션
  @Transactional
  public Order createOrder(CreateOrderCommand cmd) {
    Order order = repo.save(new Order(cmd.userId(), cmd.items()));
    return order;
  }

  // 2) 외부 호출은 트랜잭션 밖에서
  public void pay(Long orderId) {
    Order order = repo.findById(orderId).orElseThrow();
    paymentClient.requestPayment(orderId, order.getAmount());
  }
}

4) 풀 크기만 늘리면 왜 더 망가질 수 있나(중요)

장애 때 가장 흔한 처방이 maximumPoolSize를 올리는 겁니다. 하지만 다음 조건이면 악화됩니다.

  • DB가 이미 CPU/IO 한계 → 커넥션 늘리면 동시 쿼리만 늘어 락/스왑/IO wait 증가
  • DB max_connections가 낮음 → 커넥션 생성/인증 실패가 늘어 타임아웃 가속
  • 애플리케이션 스레드가 더 공격적(요청 스레드 수가 훨씬 많음) → 대기열만 커져 응답 지연 길어짐

풀 크기 조정은 “원인 제거”가 아니라 “완충”일 때만 유효합니다.

5) DB/네트워크 타임아웃과 Hikari maxLifetime 정렬(2분)

풀 고갈처럼 보이지만 실제로는 중간 장비가 커넥션을 조용히 끊어 커넥션 재사용 시점에 지연/예외가 발생하는 케이스가 있습니다.

정렬 원칙

  • maxLifetimeDB/프록시/NLB의 idle timeout보다 30~60초 짧게
  • connectionTimeout요청 SLA보다 짧게(예: API 타임아웃 3초면 커넥션 획득을 30초로 두면 늦게 터짐)

예시(상황에 맞게 조정):

spring:
  datasource:
    hikari:
      connection-timeout: 3000
      max-lifetime: 840000   # 14분 (예: 중간 idle timeout 15분이면 더 짧게)
      validation-timeout: 1000

인프라 레벨에서 타임아웃/버퍼 이슈가 장애를 증폭시키는 경우도 많습니다. 특히 Ingress/Nginx 타임아웃/버퍼가 요청 재시도를 유발하면 DB 부하가 2차로 증가합니다.

6) 10분 트리아지 체크리스트(현장용)

아래 순서대로 보면 “어디를 파야 하는지”가 빠르게 결정됩니다.

1) Hikari 메트릭

  • active == max?
  • pending 급증?
  • timeout 증가?

→ 예: active가 max에 붙고 pending이 증가하면 풀 고갈 확정.

2) DB 커넥션 수/대기 이벤트

  • DB에서 현재 커넥션 수가 상한에 근접?
  • 대기 이벤트(락, IO, CPU) 급증?

→ DB가 병목이면 풀을 늘리기보다 쿼리/인덱스/락부터.

3) 슬로우 쿼리/락

  • 슬로우 쿼리 로그, pg_stat_activity, information_schema 등으로 상위 쿼리 확인
  • 장기 트랜잭션/락 홀더 확인

4) 애플리케이션 스레드 덤프

  • 요청 스레드가 getConnection()에서 대기?
  • 특정 서비스 메서드에서 외부 호출/대기?

5) 누수 탐지(임시)

  • leakDetectionThreshold=5000로 5~10분만 켜서 스택 확보

7) 재발 방지: “풀”이 아니라 “부하 형태”를 제어

풀 고갈은 결국 동시성 제어 실패인 경우가 많습니다.

7-1) 엔드포인트별 동시성 제한(간단한 세마포어)

DB를 강하게 치는 API가 있다면, 애플리케이션에서 먼저 제한을 걸어 “대기열 폭발”을 막을 수 있습니다.

@Component
public class DbHeavyEndpointLimiter {
  private final Semaphore semaphore = new Semaphore(30); // 상황에 맞게

  public <T> T run(Callable<T> action) throws Exception {
    if (!semaphore.tryAcquire(50, TimeUnit.MILLISECONDS)) {
      throw new IllegalStateException("Too many concurrent requests");
    }
    try {
      return action.call();
    } finally {
      semaphore.release();
    }
  }
}

7-2) 타임아웃을 “짧게” + 재시도는 “신중하게”

  • 커넥션 획득 타임아웃이 길면 장애 감지가 늦고 큐가 쌓입니다.
  • 무분별한 재시도는 DB를 더 때립니다(특히 5xx 재시도).

마무리

HikariCP 풀 고갈은 증상이 명확하지만, 원인은 다양합니다. 10분 안에 결론을 내려면 순서가 중요합니다.

  1. 메트릭으로 풀 고갈을 확정하고
  2. 누수/장기 트랜잭션/슬로우 쿼리/DB 커넥션 상한 중 어디가 병목인지 갈라낸 뒤
  3. 설정은 maxLifetime/timeout 정렬로 안정성을 올리고
  4. 재발 방지는 동시성/트랜잭션 범위/재시도 정책에서 잡아야 합니다.

특히 DB 커넥션 상한이 장애의 뇌관이 되는 경우가 많으니, RDS를 쓴다면 아래 글의 점검 항목(최대 커넥션, 풀/서비스별 분배, pgbouncer 등)도 함께 확인해 두면 좋습니다.