Published on

Spring Boot HikariCP 커넥션 고갈 원인과 해결 가이드

Authors

서론

운영 중인 Spring Boot 서비스에서 갑자기 API 응답이 느려지거나, 일정 시점부터 전부 500으로 터지면서 로그에 아래와 같은 메시지가 쌓인 경험이 있을 겁니다.

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

이건 대부분 HikariCP 커넥션 풀이 고갈(Exhaustion) 됐다는 신호입니다. “DB가 느려서”로 뭉뚱그리기 쉽지만, 실제 원인은 애플리케이션 코드(트랜잭션/락/누수), 풀 설정, DB의 동시성/락, 네트워크까지 다양합니다.

이 글에서는 원인을 빠르게 분류하는 방법 → 재현/관측 포인트 → 해결책(코드/설정/DB) 순으로 정리합니다. 특히 “풀 사이즈만 키우면 해결”이 왜 위험한지, 어떤 경우에만 유효한지도 함께 다룹니다.


HikariCP 고갈이 의미하는 것

HikariCP 풀은 대략 아래 상태를 가집니다.

  • active: 현재 빌려간(사용 중인) 커넥션 수
  • idle: 풀에 남아 있는 유휴 커넥션 수
  • pending: 커넥션을 기다리는 스레드 수

고갈은 보통 다음 중 하나입니다.

  1. active가 maxPoolSize에 붙은 채로 오래 유지된다(커넥션이 반환되지 않거나, 반환이 늦다)
  2. DB 쿼리가 느려 active가 오래 점유된다(락/인덱스/IO)
  3. 트래픽 스파이크로 동시 요청이 maxPoolSize를 초과한다(설계 상 동시성 초과)

핵심은 “커넥션이 충분히 빨리 반환되지 않는다”입니다.


가장 흔한 원인 7가지 (증상별 체크리스트)

1) 트랜잭션 범위가 너무 넓다 (@Transactional 남발)

웹 요청 전체를 트랜잭션으로 감싸거나, 외부 API 호출/파일 IO/대기 로직이 트랜잭션 안에 들어가면 커넥션을 잡은 채로 오래 버팁니다.

  • 증상: 특정 API가 느려질 때 active가 같이 증가, pending 급증
  • 힌트: @Transactional이 Controller/Facade/Service 상단에 넓게 걸려 있음

해결: 트랜잭션은 “DB 작업 구간”으로 최소화합니다.

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

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

    // 2) DB 반영만 짧게 트랜잭션
    saveOrder(req, payment);
  }

  @Transactional
  protected void saveOrder(OrderRequest req, PaymentResult payment) {
    orderRepository.save(Order.of(req, payment));
  }
}

> 주의: 같은 클래스 내부 호출은 프록시가 안 타서 @Transactional이 적용되지 않을 수 있습니다. 필요하면 분리하거나 TransactionTemplate을 사용하세요.


2) 커넥션 누수(반환 누락) 또는 비정상 자원 점유

JPA를 쓰면 보통 커넥션은 프레임워크가 관리하지만, 아래 케이스에서 누수가 생기거나 반환이 지연될 수 있습니다.

  • JdbcTemplate/DataSource를 직접 쓰면서 close 누락
  • 스트리밍 조회(커서) 후 ResultSet을 오래 들고 있음
  • 비동기 작업에서 트랜잭션/세션 경계가 꼬임

해결:

  • try-with-resources 준수
  • HikariCP의 leak detection을 임시로 활성화해 누수 위치를 추적
spring:
  datasource:
    hikari:
      leak-detection-threshold: 3000 # 3초 이상 반환 안되면 스택 트레이스 로깅

누수는 “풀 사이즈 확장”로 일시적으로 가려질 뿐, 결국 다시 터집니다.


3) 느린 쿼리/인덱스 부재로 active가 오래 유지

커넥션을 ‘정상 반환’하더라도, 쿼리 자체가 느리면 커넥션 점유 시간이 길어져 고갈됩니다.

  • 증상: DB CPU/IO 상승, 특정 쿼리에서 latency 급증
  • 해결: 슬로우 쿼리 로그, 실행계획, 인덱스 점검

MySQL이라면 락/데드락이 얽혀 쿼리가 대기 상태로 늘어지면서 풀을 잡아먹는 경우가 많습니다. 데드락/락 튜닝 관점은 아래 글도 함께 참고하면 진단에 도움이 됩니다.


4) 락 경합(특히 SELECT ... FOR UPDATE, 갱신 핫스팟)

“DB가 느리다”가 아니라 “락을 기다리느라 느리다”인 케이스입니다.

  • 재고 차감, 쿠폰 사용, 시퀀스성 테이블 업데이트
  • 같은 row/인덱스 범위를 여러 트랜잭션이 동시에 건드림

해결 방향:

  • 트랜잭션을 짧게
  • 핫스팟 row를 분산(샤딩 키/버킷)
  • 낙관적 락(@Version) 또는 재시도 전략
@Entity
public class Stock {
  @Id Long id;
  @Version Long version;
  long quantity;

  public void decrease(long n) {
    if (quantity < n) throw new IllegalStateException("not enough");
    quantity -= n;
  }
}

낙관적 락은 경합이 심할수록 재시도가 늘 수 있으니, “핫스팟 분산”과 함께 봐야 합니다.


5) 스레드풀/서버 동시성이 커넥션 풀보다 훨씬 크다

Tomcat(또는 Undertow/Netty) 스레드가 200인데 Hikari maxPoolSize가 10이면, DB를 조금이라도 쓰는 엔드포인트에서 쉽게 pending이 쌓입니다.

  • 증상: 트래픽 증가 시 pending이 급증, 응답이 계단식으로 느려짐

해결:

  • “애플리케이션 스레드 동시성”과 “DB 커넥션 동시성”을 같이 설계
  • 무작정 풀을 키우기 전에 DB가 감당 가능한지 확인
server:
  tomcat:
    threads:
      max: 100

spring:
  datasource:
    hikari:
      maximum-pool-size: 30
      connection-timeout: 30000

권장 접근은 다음 순서입니다.

  1. 먼저 쿼리/락/트랜잭션으로 점유 시간을 줄이고
  2. 그 다음에 필요한 만큼만 풀을 늘립니다.

6) 커넥션 검증/네트워크 이슈로 커넥션 생성이 지연

DB와의 네트워크가 불안정하거나, maxLifetime/keepaliveTime/DB idle timeout이 충돌하면 커넥션이 자주 끊기고 재생성 비용이 올라갑니다.

  • 증상: idle이 충분한데도 커넥션 획득이 느림, 간헐적 타임아웃

해결 체크:

  • DB의 idle timeout(예: MySQL wait_timeout) 확인
  • Hikari maxLifetime을 DB idle timeout보다 짧게
spring:
  datasource:
    hikari:
      max-lifetime: 1700000   # 예: 28분
      keepalive-time: 300000  # 5분(상황에 따라)
      validation-timeout: 3000

Kubernetes/EKS 환경이라면 노드/보안그룹/NAT 등 인프라 이슈로 외부 의존성이 느려져 트랜잭션이 길어지는 경우도 있습니다. (특히 외부 호출이 트랜잭션 안에 있을 때 치명적)


7) 애플리케이션 레벨의 “긴 작업(Long Task)”이 DB 점유를 유발

대용량 처리/동기 이벤트/직렬화 지연 등으로 요청 처리가 길어지면, 그 구간이 트랜잭션과 겹칠 때 커넥션이 장시간 점유됩니다.

프론트 성능 글이지만, “Long Task를 찾아 쪼개는 사고방식”은 서버에서도 유효합니다. 느린 구간을 분해해 “DB 점유 구간”을 줄이는 방향으로 접근하세요.


진단: 로그/메트릭으로 ‘고갈의 타입’을 먼저 분류하기

1) Actuator + Micrometer로 Hikari 메트릭 보기

Spring Boot는 Hikari 메트릭을 쉽게 노출할 수 있습니다.

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

Prometheus 기준으로 자주 보는 지표:

  • hikaricp_connections_active
  • hikaricp_connections_idle
  • hikaricp_connections_pending
  • hikaricp_connections_timeout_total

패턴 해석:

  • active가 max에 붙고 pending이 증가 → 점유 시간이 길거나(쿼리/락/트랜잭션), 누수
  • idle이 0 근처인데 active는 낮음 → 커넥션 생성/검증/네트워크 이슈 가능

2) 스레드 덤프로 “어디서 커넥션을 잡고 있는지” 확인

고갈 순간에 스레드 덤프를 뜨면, DB 호출에서 막혀 있는지(락/IO) 또는 애플리케이션 로직에서 트랜잭션을 붙잡고 있는지(외부 호출/대기)를 분리할 수 있습니다.

  • jstack <pid>
  • Kubernetes면 jcmd/jstack가 포함된 디버그 이미지 또는 ephemeral container 활용

3) Hikari leak detection으로 스택 트레이스 확보

앞서 설정한 leak-detection-threshold는 운영에서 상시 켜기보단, 문제 재현 구간에 짧게 켜서 스택을 확보하는 용도입니다.


해결: 코드/설정/DB 관점의 처방전

A. 코드 레벨: “커넥션 점유 시간”을 줄이는 것이 1순위

1) 외부 호출을 트랜잭션 밖으로

결제/메일/푸시/HTTP 호출을 트랜잭션 안에서 하면, 네트워크 지연이 곧 커넥션 점유로 전이됩니다.

  • 트랜잭션 밖으로 이동
  • 이벤트 발행 후 비동기 처리(단, 정확히 한 번 처리/중복 처리 고려)

2) 배치성 작업은 페이징 + 짧은 트랜잭션

대량 업데이트/정산을 한 트랜잭션으로 돌리면 풀을 장시간 점유합니다.

@Transactional
public void migrateOnce(int pageSize) {
  int page = 0;
  while (true) {
    List<User> users = userRepository.findPage(PageRequest.of(page, pageSize));
    if (users.isEmpty()) break;

    for (User u : users) {
      u.normalize();
    }

    userRepository.flush();
    entityManager.clear();
    page++;
  }
}

상황에 따라 페이지 단위로 트랜잭션을 끊는 것이 더 안전합니다.

3) 읽기 트랜잭션 최적화

읽기만 하는데도 트랜잭션이 불필요하게 길어지는 경우가 있습니다.

  • @Transactional(readOnly = true)로 힌트 제공
  • 필요한 컬럼만 조회(프로젝션)
  • N+1 제거(fetch join, batch size)

B. Hikari 설정: “풀을 키우기” 전에 지켜야 할 기준

1) maximumPoolSize 산정의 현실적인 기준

다음 질문에 답해야 합니다.

  • DB 인스턴스가 동시에 처리 가능한 쿼리 수는?
  • 평균 쿼리 시간(P95/P99)은?
  • 애플리케이션 인스턴스 수(파드 수)는?

예를 들어 DB가 동시 200을 넘기면 급격히 느려지는 구조인데, 파드 10개가 각자 50개 풀을 가지면(총 500) 오히려 전체가 망가질 수 있습니다.

2) connectionTimeout을 무작정 늘리지 말기

connectionTimeout을 늘리면 “더 오래 기다리다 죽는” 상태가 됩니다. 보통은 빠르게 실패시키고, 상위에서 재시도/서킷브레이커/큐잉을 고려하는 편이 장애 전파를 줄입니다.

spring:
  datasource:
    hikari:
      connection-timeout: 10000 # 10초 등으로 현실화

3) minimumIdle은 과도하게 높이지 않기

minimumIdle을 높이면 유휴 커넥션을 많이 유지해 초기 성능은 좋아질 수 있지만, DB 자원을 상시 점유합니다. 컨테이너 오토스케일 환경에서는 특히 비용/리스크가 커질 수 있습니다.


C. DB 관점: 락/인덱스/트랜잭션 격리 수준 점검

  • 핫스팟 업데이트가 있는 테이블의 인덱스/쿼리 패턴 재검토
  • 불필요한 SELECT ... FOR UPDATE 제거
  • 격리 수준이 과도하게 높아(예: SERIALIZABLE) 락이 늘어나지 않는지 확인

MySQL이라면 performance_schema, information_schema.innodb_trx, innodb_lock_waits 등을 통해 “누가 누구를 막는지”를 확인할 수 있습니다.


운영에서 자주 쓰는 “응급 처치”와 그 한계

1) 풀 사이즈 증가

  • 효과: 일시적으로 타임아웃 감소
  • 한계: DB가 병목이면 더 많은 동시 쿼리로 DB를 눌러 전체 지연이 악화될 수 있음

2) 문제 엔드포인트 차단/레이트 리밋

  • 효과: 고갈 전파 차단
  • 한계: 근본 원인(락/느린 쿼리/트랜잭션 범위)은 그대로

3) 파드 스케일 아웃

  • 효과: CPU/스레드 병목이면 개선
  • 한계: DB 커넥션 총량이 늘어 DB가 더 불안정해질 수 있음

결론: “커넥션 수”가 아니라 “점유 시간”을 줄여라

HikariCP 커넥션 고갈은 대개 풀 자체의 문제가 아니라, 커넥션이 반환되지 않거나(누수), 반환이 늦어지는(트랜잭션/락/느린 쿼리) 구조에서 시작합니다.

정리하면 우선순위는 다음이 안전합니다.

  1. 메트릭으로 active/pending/timeout 패턴을 확인
  2. leak detection + 스레드 덤프로 “점유 지점”을 특정
  3. 트랜잭션 범위 축소, 외부 호출 분리, 느린 쿼리/락 해결
  4. 그 다음에만 풀/스레드/타임아웃을 합리적으로 조정

이 순서를 지키면 “풀만 키우다 더 큰 장애”로 가는 길을 피할 수 있습니다.