Published on

Spring Boot 3 HikariCP 풀 고갈 원인과 해결

Authors

서버가 갑자기 느려지고, API 응답이 타임아웃으로 떨어지며, 로그에 HikariPool-1 - Connection is not available, request timed out after ...ms가 반복된다면 대부분은 “DB가 느려서”가 아니라 “커넥션을 제때 반환하지 못해서” 발생합니다. Spring Boot 3의 기본 커넥션 풀인 HikariCP는 빠르고 안정적이지만, 작은 코드/설정 실수 하나로도 풀 고갈(Exhaustion)이 쉽게 터집니다.

이 글에서는 Spring Boot 3 환경에서 HikariCP 풀 고갈이 나는 전형적인 원인들을 증상별로 분류하고, 진단 순서해결 체크리스트를 코드와 설정 예제로 정리합니다.

관련해서 DB 자체가 커넥션은 잘 받는데 내부적으로 부하가 누적되는 케이스(테이블 bloat, VACUUM 문제 등)도 풀 고갈을 악화시킬 수 있으니, PostgreSQL을 쓴다면 필요 시 아래 글도 함께 확인해보세요.

HikariCP 풀 고갈이 “진짜로” 의미하는 것

HikariCP 풀 고갈은 단순히 커넥션 개수가 부족하다는 뜻이 아닙니다. 더 정확히는 다음 중 하나입니다.

  1. 애플리케이션이 커넥션을 빌린 뒤 반환하지 못한다(누수)
  2. 커넥션을 빌린 뒤 너무 오래 쥐고 있다(긴 트랜잭션, 느린 쿼리, 락 대기)
  3. 풀 사이즈와 트래픽/쿼리 특성이 맞지 않는다(설정 미스)
  4. DB가 커넥션은 제공하지만, DB 내부 병목으로 인해 쿼리가 느려져 점유 시간이 길어진다

즉, “풀을 키우면 해결”은 임시 처방일 수 있고, 원인에 따라서는 오히려 장애를 키웁니다(예: DB에 동시 쿼리 폭증).

가장 먼저 확인할 로그와 지표

1) 대표 에러 로그

다음 메시지가 핵심 신호입니다.

  • Connection is not available, request timed out after ...ms
  • Timeout after ...ms of waiting for a connection

이 로그는 커넥션을 빌리는 단계에서 막혔다는 뜻입니다. 즉, 이미 풀의 커넥션들이 다른 요청에 의해 점유 중입니다.

2) HikariCP 메트릭(운영에서 가장 유용)

Spring Boot Actuator + Micrometer를 쓰면 다음 지표를 바로 볼 수 있습니다.

  • hikaricp.connections.active (현재 사용 중)
  • hikaricp.connections.idle (대기 중)
  • hikaricp.connections.pending (커넥션을 기다리는 스레드 수)
  • hikaricp.connections.max (풀 최대)

pending가 증가하고 activemax에 붙어 있으면 전형적인 풀 고갈 패턴입니다.

원인 1: 커넥션 누수(반환 누락)

전형적인 패턴

  • JDBC를 직접 쓸 때 Connection, PreparedStatement, ResultSet을 닫지 않음
  • 예외가 발생했을 때 close 경로가 실행되지 않음
  • 외부 라이브러리/레거시 코드가 커넥션을 잡고 반환하지 않음

해결: try-with-resources로 강제 반환

Spring JDBC 또는 순수 JDBC를 쓴다면 반드시 try-with-resources로 감싸 반환을 보장하세요.

import javax.sql.DataSource;
import java.sql.*;

public class UserRepository {
  private final DataSource dataSource;

  public UserRepository(DataSource dataSource) {
    this.dataSource = dataSource;
  }

  public String findName(long id) throws SQLException {
    String sql = "select name from users where id = ?";

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

      ps.setLong(1, id);

      try (ResultSet rs = ps.executeQuery()) {
        if (!rs.next()) return null;
        return rs.getString(1);
      }
    }
  }
}

누수 진단: leakDetectionThreshold 활성화

HikariCP는 “반환이 늦는 커넥션”을 추적할 수 있습니다. 운영에서 상시로 켜기보다는, 장애 재현/의심 구간에서 일시적으로 켜는 것을 권장합니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 2000
  • 단위는 ms입니다.
  • 설정한 시간보다 오래 커넥션을 쥐고 있으면 스택트레이스를 로그로 남깁니다.
  • 너무 낮게 잡으면 정상 트랜잭션도 “누수처럼” 찍혀 노이즈가 커집니다.

원인 2: 긴 트랜잭션(커넥션 점유 시간 과다)

풀 고갈의 가장 흔한 원인은 누수보다 긴 점유 시간입니다.

자주 터지는 케이스

  1. @Transactional 범위가 넓어서, DB 작업 이후에도 커넥션을 계속 잡고 있음
  2. 트랜잭션 안에서 외부 API 호출, 파일 IO, 대기(sleep) 등을 수행
  3. 대량 처리(배치)에서 한 트랜잭션으로 너무 많은 작업을 묶음

해결 1: 트랜잭션 범위 최소화

트랜잭션 안에서 외부 호출을 하지 않도록 구조를 바꿉니다.

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

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

  public void placeOrder(OrderCommand cmd) {
    // 1) 외부 호출은 트랜잭션 밖에서
    PaymentResult payment = paymentClient.pay(cmd);

    // 2) DB 반영만 트랜잭션으로 짧게
    saveOrderTransactional(cmd, payment);
  }

  @Transactional
  protected void saveOrderTransactional(OrderCommand cmd, PaymentResult payment) {
    orderRepository.save(cmd.toEntity(payment));
  }
}

해결 2: 읽기 전용 트랜잭션과 fetch 전략 점검

읽기 API에서 불필요한 영속성 컨텍스트 유지, 지연 로딩으로 인한 추가 쿼리가 트랜잭션 길이를 늘릴 수 있습니다.

  • 조회 전용이면 @Transactional(readOnly = true)
  • N+1로 쿼리 폭증이 나면 커넥션 점유 시간이 증가
  • 필요한 관계는 fetch join 또는 적절한 배치 사이즈로 제어

원인 3: 커넥션 풀 사이징/타임아웃 설정 미스

흔한 실수 1: max pool size를 “무작정 크게”

풀을 키우면 애플리케이션은 일시적으로 빨라질 수 있지만, DB는 동시 쿼리 증가로 더 느려지고 락 경합이 커져 결과적으로 커넥션 점유 시간이 늘어납니다.

운영에서는 다음을 같이 봐야 합니다.

  • DB의 max_connections
  • 애플리케이션 인스턴스 수(스케일 아웃 시 총 커넥션 수)
  • DB CPU, IOPS, 락 대기

흔한 실수 2: connectionTimeout을 너무 크게

connectionTimeout이 크면, 풀 고갈 상황에서 요청이 오래 대기하면서 서버 스레드가 쌓이고 장애가 확대됩니다.

권장 접근: “빨리 실패” + 백프레셔

  • connectionTimeout은 너무 길게 두지 말고(예: 1초~3초 수준) 빠르게 실패
  • 애플리케이션 레벨에서 동시성 제한(세마포어), 큐잉, 레이트 리밋을 적용

예시 설정입니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 2000
      validation-timeout: 1000
      idle-timeout: 600000
      max-lifetime: 1800000

설정 의미를 요약하면 다음과 같습니다.

  • maximum-pool-size: 동시에 빌려줄 수 있는 최대 커넥션
  • connection-timeout: 커넥션을 빌리기 위해 기다리는 최대 시간
  • max-lifetime: DB/LB가 커넥션을 강제로 끊기 전에 애플리케이션이 먼저 교체하도록 유도

주의: max-lifetime을 DB나 프록시의 커넥션 종료 타이밍보다 약간 짧게 두는 것이 일반적으로 안전합니다.

원인 4: DB 락/느린 쿼리로 인한 점유 시간 증가

풀 고갈인데도 DB 커넥션 수 자체는 충분해 보이는 경우, 실제로는 쿼리가 락에 막혀 대기하거나, 인덱스 부재/통계 문제로 느려진 경우가 많습니다.

진단 포인트

  • APM에서 특정 쿼리 지연이 급증했는지
  • DB에서 락 대기/블로킹 세션이 있는지
  • 특정 테이블에 쓰기 경합이 몰리는지

PostgreSQL이라면 bloat가 커지면서 쿼리 비용이 증가하는 케이스도 있으니, 앞서 링크한 VACUUM/bloat 진단을 같이 보면 좋습니다.

원인 5: 스레드 풀과 커넥션 풀의 불균형

Tomcat 요청 스레드가 과도하게 많으면, DB 커넥션을 얻지 못한 스레드가 대기열로 쌓입니다. 이때 CPU는 문맥전환으로 낭비되고, 타임아웃이 연쇄적으로 발생합니다.

해결: 서버 스레드 수를 DB 처리량에 맞추기

예를 들어 DB 커넥션이 최대 20개인데, 웹 스레드가 200개면 180개는 대기할 수 있습니다. 다음처럼 상한을 낮추어 “동시 요청”을 DB 처리량에 맞추는 것이 실전에서 안정적입니다.

server:
  tomcat:
    threads:
      max: 50
      min-spare: 10

정답은 시스템마다 다르지만, 핵심은 다음입니다.

  • 웹 스레드 최대치가 커넥션 풀보다 지나치게 크면 대기열이 커짐
  • 대기열이 커지면 타임아웃과 재시도가 겹치며 풀 고갈이 가속

장애 대응 체크리스트(운영 실전)

1) 즉시 완화(Mitigation)

  • connectionTimeout을 길게 두었다면 줄여서 빠르게 실패하도록 조정
  • 클라이언트 재시도 정책(특히 무제한 재시도)을 점검해 폭주를 막기
  • 트래픽 급증이면 레이트 리밋/서킷 브레이커로 DB 보호

2) 원인 추적(재발 방지)

  • hikaricp.connections.pending가 튀는 시점의 요청 패턴 확인
  • leak detection을 짧은 시간 켜서 “오래 점유한 코드 경로” 스택 확보
  • 느린 쿼리/락 대기 분석
  • 트랜잭션 범위 재설계(외부 호출 분리, 배치 커밋 단위 조정)

3) 설정 재검증

  • 인스턴스 수 곱하기 maximum-pool-size가 DB max_connections를 넘지 않는지
  • max-lifetime이 DB/프록시 idle timeout보다 충분히 짧은지
  • 웹 스레드와 풀 크기가 균형인지

관측 가능성(Observability) 강화 팁

  • Actuator로 Hikari 메트릭을 수집하고 대시보드/알람 구성
  • APM에서 DB span(쿼리 시간)과 요청 지연을 함께 상관 분석
  • 장애 시점의 스레드 덤프를 떠서 “커넥션 대기” 스레드가 얼마나 쌓였는지 확인

Spring Security나 인증 계층에서 특정 상황에 재시도/캐시 미스로 트래픽이 튀는 경우도 장애를 증폭시킬 수 있습니다. JWT 키 로테이션이나 JWKS 캐시 이슈로 인증 실패가 반복되면 요청이 재시도되며 DB까지 연쇄 부하로 이어질 수 있어, 아래 글이 간접적으로 도움이 될 수 있습니다.

결론: 풀 고갈은 “커넥션 개수” 문제가 아니라 “점유 시간” 문제

HikariCP 풀 고갈을 안정적으로 해결하려면 우선순위를 이렇게 잡는 것이 좋습니다.

  1. 누수 여부를 leak detection과 코드 점검으로 제거
  2. 트랜잭션 범위를 줄여 커넥션 점유 시간을 단축
  3. 느린 쿼리/락/DB 병목을 해결해 점유 시간을 더 줄임
  4. 그 다음에야 풀 사이즈를 조정(필요 시)하고, 웹 스레드/재시도/백프레셔로 폭주를 제어

풀 크기를 늘리는 것은 마지막 단계의 선택지로 두고, “왜 커넥션을 오래 잡고 있는가”를 먼저 잡으면 재발률이 크게 떨어집니다.