Published on

Spring Boot 3.x HikariCP 커넥션 고갈 원인과 튜닝

Authors

서버가 멀쩡히 떠 있는데도 어느 순간부터 API 응답이 느려지다가 Connection is not available, request timed out after ...ms 같은 오류가 터지면, 대개 HikariCP 커넥션 풀이 고갈(exhaustion)된 상태입니다. 문제는 단순히 maximumPoolSize를 키우는 것으로 끝나지 않는다는 점입니다. 커넥션은 DB의 제한과 직결되고, 애플리케이션 스레드/트랜잭션/쿼리 패턴의 결과로 고갈되기 때문에 원인 진단 → 지표 확인 → 튜닝 순서가 중요합니다.

이 글은 Spring Boot 3.x(기본 HikariCP) 기준으로 커넥션 고갈의 대표 원인과, 운영에서 안전하게 적용할 수 있는 튜닝 체크리스트를 다룹니다.

관련해서 운영 장애를 단계적으로 좁혀가는 접근은 다른 분야에서도 동일합니다. 예를 들어 EKS Pod ImagePullBackOff - ECR 인증 7단계처럼 “증상 → 관측 포인트 → 원인 분기”를 만들어두면 재현이 어려운 문제도 빨리 잡을 수 있습니다.

1) HikariCP 고갈이란 무엇인가

HikariCP는 JDBC 커넥션을 풀로 관리합니다.

  • 요청이 들어오면 스레드는 풀에서 커넥션을 borrow합니다.
  • 사용이 끝나면 반드시 close()로 반환됩니다(실제 close가 아니라 풀 반납).
  • 동시에 빌릴 수 있는 커넥션 수는 maximumPoolSize로 제한됩니다.

고갈은 다음 상황에서 발생합니다.

  • 반납이 지연되거나 누락되어 풀에 남은 커넥션이 0에 수렴
  • 대기열이 증가하면서 connectionTimeout 동안 기다리다가 타임아웃

운영에서는 “DB CPU는 낮은데 앱이 멈춘다” 같은 형태로 나타나기도 합니다. 이유는 DB가 바쁜 게 아니라, 앱이 커넥션을 잡고 놓지 않거나(트랜잭션/락), 풀 크기와 동시성 설계가 맞지 않기 때문입니다.

2) 대표 원인 7가지 (우선순위대로)

2.1 커넥션 누수(가장 흔함)

  • 예외 경로에서 ResultSet/Statement/Connection이 닫히지 않음
  • JdbcTemplate를 쓰지 않고 로우 JDBC를 직접 다루며 try/finally를 빼먹음
  • 스트리밍 처리 중 커서를 오래 잡고 있음

로우 JDBC를 직접 쓴다면 최소한 아래 패턴을 강제하세요.

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

  while (rs.next()) {
    // ...
  }
}

try-with-resources는 누수 방지의 1차 방어선입니다.

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

@Transactional이 붙은 서비스 메서드에서

  • 외부 API 호출
  • 파일 IO
  • 대용량 JSON 직렬화
  • 오래 걸리는 루프

같은 작업을 함께 수행하면, 그 시간 동안 커넥션을 반납하지 못합니다. 결과적으로 동시 요청이 늘 때 풀이 고갈됩니다.

권장 패턴은 “DB 구간만 트랜잭션”으로 쪼개는 것입니다.

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

  public OrderResult placeOrder(PlaceOrderCommand cmd) {
    // 1) 외부 호출은 트랜잭션 밖에서
    PaymentAuth auth = paymentClient.authorize(cmd.payment());

    // 2) DB 작업만 짧게 트랜잭션으로
    Long orderId = persistOrder(cmd, auth);

    return new OrderResult(orderId);
  }

  @Transactional
  protected Long persistOrder(PlaceOrderCommand cmd, PaymentAuth auth) {
    Order order = new Order(cmd, auth);
    orderRepository.save(order);
    return order.getId();
  }
}

2.3 느린 쿼리, 인덱스 미스, 락 대기

커넥션을 “잡고 있는 시간”이 길어지는 가장 전형적인 원인입니다.

  • 풀 고갈은 앱 증상으로 보이지만, 원인은 DB 쿼리 플랜일 수 있음
  • 특히 업데이트/삭제에서 락 경합이 심하면 커넥션 점유 시간이 폭증

대응 순서:

  1. DB에서 슬로우 쿼리 로그 확인
  2. EXPLAIN으로 인덱스/풀스캔 여부 확인
  3. 락 대기(예: InnoDB lock wait) 모니터링

2.4 스레드 풀과 커넥션 풀의 불균형

Tomcat(또는 Undertow/Netty) 요청 스레드 수가 커넥션 풀보다 훨씬 크면, 동시 요청이 몰릴 때 많은 스레드가 커넥션을 기다리며 쌓입니다.

  • Tomcat 기본 max-threads가 큰 편
  • maximumPoolSize가 작으면 대기열이 급증

이때 흔한 오해는 “그럼 풀만 키우면 되지 않나”인데, DB가 감당 못 하면 더 큰 장애로 이어집니다.

핵심은 요청 동시성(스레드)과 DB 동시성(커넥션)을 함께 설계하는 것입니다.

2.5 잘못된 비동기/병렬 처리로 커넥션 폭발

예를 들어 요청 하나에서 병렬로 여러 DB 작업을 CompletableFuture로 날리면, 단일 요청이 여러 커넥션을 동시에 소비합니다.

  • 병렬 작업 수 n이 커지면 풀을 순식간에 소진
  • 특히 @Async@Transactional 조합은 의도치 않은 점유를 만들기 쉬움

DB 작업은 “병렬화가 항상 이득”이 아닙니다. DB는 공유 자원이고 락/IO 병목이 있기 때문에, 병렬화는 풀 고갈을 악화시킬 수 있습니다.

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

  • DB 쪽에서 idle 커넥션을 끊는 정책이 있는데 앱은 이를 모르고 사용
  • 네트워크 장비/방화벽이 일정 시간 이후 세션을 종료

이 경우 풀은 살아 있는 것처럼 보이지만, 빌린 커넥션이 실패하고 재시도가 늘어 체감 고갈이 발생합니다.

2.7 잘못된 풀 설정(과도한 minimumIdle, 짧은 타임아웃)

  • minimumIdle을 크게 잡아 불필요한 상시 커넥션 점유
  • connectionTimeout이 너무 짧아 순간 스파이크에 취약

다만 이것들은 대개 “근본 원인”이라기보다 “증상 악화 요인”입니다.

3) 진단: 로그와 지표로 ‘고갈의 형태’를 먼저 분류

3.1 HikariCP 주요 지표

Micrometer/Actuator를 켰다면 다음 지표가 핵심입니다.

  • hikaricp.connections.active: 사용 중 커넥션 수
  • hikaricp.connections.idle: 유휴 커넥션 수
  • hikaricp.connections.pending: 커넥션 대기 중 스레드 수
  • hikaricp.connections.max: 최대 풀 크기

패턴 해석:

  • activemax에 붙고 pending이 증가: 풀 고갈 진행
  • active가 높고 DB 슬로우 쿼리/락 대기가 동반: DB 원인 가능성 큼
  • active가 높고 애플리케이션에서 특정 요청만 느림: 누수/트랜잭션 범위 의심

3.2 누수 의심 시 leak detection 활성화

운영에서 항상 켜두기보다는, 의심 구간에서 임시로 켜는 것을 권장합니다(스택 트레이스 로깅 비용이 있음).

application.yml 예시:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 30000
      leak-detection-threshold: 20000
  • leak-detection-threshold는 밀리초
  • 예: 20초 이상 반환되지 않으면 “누수 의심” 로그가 남음

로그에 찍힌 스택 트레이스는 “어디서 커넥션을 빌렸는지”를 가리키므로, 해당 코드 경로의 트랜잭션 범위/예외 처리를 집중 점검하면 됩니다.

3.3 스레드 덤프와 함께 보면 더 빨리 좁혀짐

고갈 시점에 스레드 덤프를 떠서

  • HikariPool에서 대기 중인 스레드가 많은지
  • 특정 서비스 메서드/리포지토리 호출에서 멈춰 있는지

를 보면, “커넥션을 못 빌리는 문제”인지 “빌렸는데 DB에서 막힌 문제”인지가 분리됩니다.

문제 원인을 캐시/상태 꼬임처럼 ‘겉보기 증상’만으로 오판하는 사례는 프론트엔드에서도 자주 있습니다. 예를 들어 Next.js 14 RSC에서 fetch 캐시 꼬임 해결법처럼, 관측 지점을 정해 실제 병목을 분리하는 게 중요합니다.

4) 튜닝: 숫자부터 올리기 전에 해야 할 것

4.1 maximumPoolSize 산정의 출발점

정답 공식은 없지만, 다음 제약을 먼저 확인해야 합니다.

  • DB의 최대 커넥션 제한(max_connections 등)
  • 애플리케이션 인스턴스 수(오토스케일 포함)
  • 배치/리포팅/관리 툴 등 다른 클라이언트가 쓰는 커넥션

예시로 DB가 300 커넥션까지 허용이고, 앱이 최대 10개 파드까지 늘 수 있다면 앱에 할당 가능한 커넥션 예산은 대략 300 - 여유분을 10으로 나눈 값이 상한선입니다.

즉, 풀을 무작정 100으로 올리는 건 “내 앱 하나만” 볼 때는 좋아 보여도, 스케일아웃 순간 DB를 터뜨릴 수 있습니다.

4.2 connectionTimeout은 “장애 감지 시간”이다

connectionTimeout을 길게 잡으면 요청이 오래 기다리다 실패하고, 짧게 잡으면 순간 스파이크에도 바로 실패합니다.

  • 운영 API는 보통 10초~30초 범위에서 시작
  • 중요한 건 타임아웃이 났을 때 즉시 알람이 오고 원인 분석이 가능해야 함

4.3 minimumIdle은 보통 기본값(자동)으로 두기

HikariCP는 minimumIdle을 명시하지 않으면 내부적으로 maximumPoolSize에 맞춰 동작합니다. 많은 경우 이 편이 안전합니다.

상시 트래픽이 크고 “첫 요청 지연”이 문제인 경우에만 minimumIdle을 올리는 것을 검토하세요.

4.4 maxLifetime과 DB idle timeout 정합성

DB나 네트워크가 일정 시간 후 커넥션을 끊는다면, HikariCP의 maxLifetime을 그보다 짧게 잡아 “죽기 전에 교체”하도록 맞추는 게 안정적입니다.

spring:
  datasource:
    hikari:
      max-lifetime: 1700000
      keepalive-time: 0
  • 값은 환경에 따라 달라서, DB/네트워크의 idle 정책을 먼저 확인해야 합니다.
  • keepalive-time은 네트워크 장비가 idle을 끊는 환경에서 도움이 되지만, 불필요한 핑이 늘 수 있어 신중히 사용합니다.

4.5 풀을 키우기 전에 쿼리를 줄여라

고갈을 해결하는 가장 비용 효율적인 방법은 “커넥션 점유 시간을 줄이는 것”입니다.

  • N+1 제거
  • 불필요한 트랜잭션 제거
  • 슬로우 쿼리 인덱싱
  • 락 경합 줄이기(업데이트 범위 축소, 정렬된 접근, 짧은 트랜잭션)

특히 JPA를 쓰면 N+1이 커넥션 고갈의 간접 원인이 되기 쉽습니다. 쿼리 수가 늘면 각 쿼리의 총합 시간이 늘고, 그만큼 커넥션 점유 시간이 증가합니다.

5) Spring Boot 3.x에서 자주 하는 실수와 안전한 설정 예시

5.1 “풀만 키웠더니 DB가 느려졌다”

풀을 키우면 앱은 더 많은 동시 쿼리를 DB로 밀어 넣습니다. DB의 CPU/IO가 한계에 도달하면 전체 쿼리가 느려지고, 결국 커넥션 점유 시간이 더 길어져 다시 고갈되는 악순환이 생깁니다.

즉, 풀 증설은 DB 처리량이 남아 있을 때만 효과가 있습니다.

5.2 안전한 기본 프로파일 예시

아래는 “진단 가능성”과 “과도한 점유 방지”를 우선한 예시입니다.

spring:
  datasource:
    hikari:
      pool-name: main
      maximum-pool-size: 20
      connection-timeout: 30000
      validation-timeout: 5000
      idle-timeout: 600000
      max-lifetime: 1800000

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  • pool-name은 로그/지표에서 풀을 구분할 때 유용합니다.
  • metrics/prometheus 노출은 운영에서 필수에 가깝습니다.

6) 재발 방지 체크리스트

6.1 코드 레벨

  • 로우 JDBC는 try-with-resources 강제
  • @Transactional 범위를 DB 작업으로 최소화
  • 외부 호출/대용량 작업을 트랜잭션 밖으로 이동
  • 병렬 처리로 DB를 동시에 두드리는 구조 점검

6.2 운영 레벨

  • HikariCP 지표 대시보드 구축(active, idle, pending)
  • 슬로우 쿼리/락 모니터링과 함께 상관 분석
  • 고갈 알람 조건 예시
    • pending이 0보다 큰 상태가 1분 이상 지속
    • active / max가 0.9 이상이 일정 시간 지속

6.3 장애 대응 런북

  • 타임아웃 로그 발생 시점의
    • Hikari 지표 스냅샷
    • DB 슬로우 쿼리/락 현황
    • 스레드 덤프

이 3가지를 묶어두면, “풀을 키울지/쿼리를 고칠지/누수를 잡을지”가 빠르게 결정됩니다.

운영에서 조건 분기가 무너져 원인 추적이 어려워지는 사례는 CI/CD에서도 흔합니다. 예를 들어 Jenkins Declarative Pipeline when이 무시될 때처럼, 기대한 조건이 실제로는 다르게 평가되어 문제가 커지는 패턴이 있습니다. 커넥션 고갈도 마찬가지로 “내가 믿는 가정(반납된다, 짧다, 병렬이 이득이다)”이 깨지는 순간 발생합니다.

7) 결론: 고갈은 ‘풀 크기 문제’가 아니라 ‘점유 시간 문제’인 경우가 많다

Spring Boot 3.x에서 HikariCP 커넥션 고갈을 만나면, 먼저 maximumPoolSize를 올리기보다 다음을 순서대로 확인하는 것이 가장 빠릅니다.

  1. pending 증가 여부로 “진짜 풀 고갈”인지 확인
  2. 누수 의심이면 leak-detection-threshold로 스택 트레이스 확보
  3. 슬로우 쿼리/락 대기로 커넥션 점유 시간이 늘었는지 확인
  4. 트랜잭션 범위와 병렬 처리 구조를 줄여 “점유 시간을 단축”
  5. DB 커넥션 예산(인스턴스 수 포함)을 계산한 뒤 풀 크기를 조정

이 과정을 거치면, 풀 고갈을 단발성 장애가 아니라 재발 방지 가능한 구조적 문제로 바꿀 수 있습니다.