- Published on
Spring Boot 3에서 HikariCP 커넥션 고갈 원인 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡히 떠 있는데도 어느 순간부터 API가 줄줄이 타임아웃 나고, 로그에는 HikariPool-1 - Connection is not available, request timed out after ...ms가 반복된다면 거의 항상 “커넥션을 빌려간 쪽이 제때 돌려주지 못했다”는 뜻입니다. 문제는 그 이유가 코드 한 줄(트랜잭션 범위)일 수도 있고, DB의 슬로우 쿼리/락, 네트워크 단절, OS 파일 디스크립터 고갈 같은 인프라 레벨일 수도 있다는 점입니다.
Spring Boot 3(=Spring Framework 6, Jakarta EE 10) 환경에서 HikariCP 커넥션 고갈을 유발하는 대표 원인 9가지를 증상 → 확인 방법 → 해결 방향 순서로 정리합니다.
먼저: “고갈”을 어떻게 정의할 것인가
HikariCP는 커넥션 풀에서 커넥션을 빌릴 때 connectionTimeout 동안 기다립니다. 그 시간 내에 반납된 커넥션이 없으면 아래 예외가 발생합니다.
com.zaxxer.hikari.pool.HikariPool$PoolInitializationException
... Connection is not available, request timed out after 30000ms
여기서 핵심 지표는 3가지입니다.
- Active: 현재 대여 중인 커넥션 수
- Idle: 풀에 남아 있는 유휴 커넥션 수
- Pending: 커넥션을 기다리는 스레드 수
운영에서 원인 파악을 빠르게 하려면 Actuator + Micrometer로 Hikari 지표를 반드시 노출하세요.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
spring:
datasource:
hikari:
pool-name: main
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
leak-detection-threshold: 20000
leakDetectionThreshold는 “반납이 늦는 커넥션”의 스택 트레이스를 찍어주므로, 원인 1~4를 잡는 데 특히 유용합니다(단, 너무 낮추면 노이즈가 커집니다).
원인 1) 트랜잭션 범위가 과도하게 넓다(@Transactional 남용)
가장 흔합니다. 트랜잭션이 시작되면(특히 JPA) 커넥션을 오래 잡고 있는 경우가 많습니다. 아래처럼 외부 API 호출/파일 IO/복잡한 변환 로직까지 트랜잭션 안에 들어가면 커넥션이 묶입니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
@Transactional
public void placeOrder(Long userId) {
// DB 커넥션을 잡은 상태로...
var order = orderRepository.save(new Order(userId));
// 외부 호출이 느리면 커넥션이 장시간 점유됨
paymentClient.requestPayment(order.getId());
order.markPaid();
}
}
확인 방법
- Hikari leak log에서 스택 트레이스가 서비스 메서드에 걸려 있는지 확인
- APM에서 해당 트랜잭션의 DB time은 짧은데 전체 time이 긴지 확인
해결 방향
- 트랜잭션은 DB에 필요한 최소 구간으로 축소
- 외부 호출은 트랜잭션 밖에서 수행하거나, Outbox/Saga 패턴 고려
원인 2) 커넥션 누수(반납 누락) — JDBC 직접 사용/스트리밍 결과
JPA를 주로 쓰더라도, 배치/레거시 연동에서 JdbcTemplate/DataSource를 직접 만지는 순간 누수가 생기기 쉽습니다.
// 나쁜 예: close 누락 가능
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select ...");
ResultSet rs = ps.executeQuery();
// 예외 발생 시 close 안 되고 누수
반드시 try-with-resources를 사용합니다.
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select ...");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// ...
}
}
확인 방법
leakDetectionThreshold로그로 누수 지점 추적- DB에서 세션이 계속 증가하고, 애플리케이션 재시작 전까지 줄지 않는지 확인
해결 방향
- JDBC 자원은 무조건 try-with-resources
- JPA 스트리밍/커서 기반 조회는 트랜잭션/세션 관리 규칙 준수
원인 3) 슬로우 쿼리/인덱스 부재로 커넥션 점유 시간이 길다
풀 사이즈가 20이어도, 요청당 쿼리가 2~3초씩 걸리면 동시 요청이 조금만 늘어도 금방 고갈됩니다. 특히 N+1, 잘못된 조인, 인덱스 미스가 주범입니다.
확인 방법
- DB 슬로우 쿼리 로그/pg_stat_statements 확인
- 애플리케이션에서 “쿼리 시간 분포(p95/p99)”가 튀는지 확인
해결 방향
- 실행 계획 기반으로 인덱스 보강
- N+1 제거(fetch join, batch size 등)
- PostgreSQL이라면 테이블 팽창/오토바큠 지연도 슬로우의 원인이 될 수 있습니다: PostgreSQL autovacuum 지연으로 팽창·슬로우쿼리 잡기
원인 4) 락 경합/데드락/긴 트랜잭션으로 대기열이 쌓인다
쿼리 자체는 빠르더라도, 업데이트 경합이 심하면 커넥션이 “DB 락 대기” 상태로 오래 묶입니다. 재고 차감, 포인트 차감 같은 핫 로우가 대표적입니다.
확인 방법
- DB에서 lock wait 이벤트/blocked session 확인
- 특정 API에서만 pending이 급증하는지 확인
해결 방향
- 트랜잭션을 더 짧게, 업데이트 범위 축소
- 낙관적 락/버전 컬럼, 큐 기반 직렬화, 샤딩/파티셔닝 등 고려
원인 5) 웹 스레드/가상 스레드(virtual threads)와 풀 크기 불일치
Spring Boot 3는 Java 21 환경에서 가상 스레드를 쉽게 도입할 수 있습니다. 문제는 **동시성(요청 처리 스레드 수)**은 크게 늘었는데, DB 풀은 그대로면 pending이 폭증합니다.
또한 Tomcat/Jetty 스레드풀을 과도하게 키워도 같은 문제가 발생합니다.
확인 방법
pending이 급증하면서active == maximumPoolSize로 고정되는지- 요청 동시성 증가 시점과 고갈 시점이 일치하는지
해결 방향
- 풀을 무작정 키우기보다, DB가 감당 가능한 동시 쿼리 수부터 산정
- API별로 동시성 제한(세마포어/레이트리밋) 또는 비동기 경로 분리
- 가상 스레드는 “대기 비용”을 줄일 뿐, DB 커넥션은 물리 리소스라는 점을 명확히
원인 6) connectionTimeout/validationTimeout 설정이 현실과 맞지 않다
connectionTimeout이 너무 짧으면 순간적인 스파이크에도 바로 실패합니다. 반대로 너무 길면 요청이 오래 대기하면서 쓰레드가 묶이고 장애가 증폭됩니다.
또한 네트워크가 불안정하거나 DB가 재시작되는 상황에서 validation이 길어지면 풀 전체가 흔들릴 수 있습니다.
확인 방법
- 타임아웃이 빈번하지만 실제 DB는 살아 있는지
- 장애 시점에 “대기 시간이 누적”되는지
해결 방향
connectionTimeout은 SLO/상위 타임아웃(게이트웨이, LB)과 정합성 있게- 헬스체크/재시도 정책을 DB 보호 관점에서 설계
원인 7) maxLifetime/idleTimeout 설정으로 커넥션이 동시에 교체(스톰)
HikariCP는 maxLifetime이 되면 커넥션을 교체합니다. 이 값이 DB/LB의 커넥션 종료 정책과 충돌하거나, 너무 짧게 설정되면 특정 시점에 커넥션이 한꺼번에 만료되어 재생성 스톰이 발생할 수 있습니다.
확인 방법
- 고갈 시점에 커넥션 생성 로그가 급증하는지
- DB/LB에서 idle 커넥션을 끊는 정책이 있는지
해결 방향
- 일반적으로
maxLifetime은 DB가 강제 종료하는 시간보다 조금 짧게 minimumIdle을 적절히 유지해 급격한 재생성 완화
원인 8) DNS/네트워크 이슈로 커넥션 생성/사용이 지연된다
DB가 멀쩡해도, 애플리케이션에서 DB로 가는 경로(DNS, NAT, 보안그룹, 라우팅, 프록시)가 흔들리면 커넥션 생성이 지연되고 풀 고갈처럼 보입니다.
확인 방법
- 애플리케이션 노드에서 DB까지 TCP connect 지연/실패 여부
- 같은 시간대에 다른 외부 통신도 불안정했는지
해결 방향
- DNS 캐시/TTL, 커넥션 라우팅 경로 점검
- Kubernetes라면 노드/네트워크 플러그인(CNI) 이벤트와 함께 확인
원인 9) OS 파일 디스크립터(Too many open files)로 신규 커넥션 생성 실패
커넥션은 소켓을 사용하므로 파일 디스크립터(FD)를 소모합니다. FD 한도가 낮거나, 다른 소켓/파일 사용량이 많으면 커넥션을 만들 수 없어 풀의 회복이 불가능해집니다.
확인 방법
- 로그에
Too many open files또는 소켓 생성 실패가 있는지 lsof -p <pid> | wc -l,ulimit -n확인
해결 방향
- 시스템/컨테이너의 FD limit 상향
- 누수(HTTP 클라이언트, 파일 핸들) 동반 여부 점검
- 관련 정리 글: 리눅스 Too many open files 해결 - ulimit·systemd·Nginx
실전 점검 순서(10분 루틴)
장애 상황에서 “풀만 키우자”로 끝내면 재발합니다. 아래 순서가 빠릅니다.
- Hikari 지표(active/idle/pending)와 타임아웃 로그 시점 매칭
leakDetectionThreshold로 반납 지연 스택 확보(원인 1~2)- APM/슬로우 로그로 상위 3개 느린 쿼리 확인(원인 3)
- DB 락/대기 세션 확인(원인 4)
- 동시성(웹 스레드/가상 스레드) 변화 확인(원인 5)
- 네트워크/DNS/FD 한도까지 확장 점검(원인 8~9)
마무리: “풀 크기”는 마지막에 조정하라
HikariCP 커넥션 고갈은 증상이고, 원인은 대부분 커넥션 점유 시간이 길어지는 구조(트랜잭션 범위, 슬로우/락, 외부 호출 결합)에서 시작합니다. 풀을 늘리면 일시적으로 완화될 수 있지만, DB가 감당할 수 있는 동시 쿼리 한계를 넘기면 더 큰 장애(락 폭증, CPU 100%, I/O 병목)로 번집니다.
운영에서 재발을 막으려면 다음 3가지를 기본값처럼 가져가세요.
- 트랜잭션 최소화(특히 외부 호출 분리)
- 슬로우/락 관측(쿼리 p95/p99, 대기 이벤트)
- Hikari 지표 + leak detection으로 “누가 오래 잡는지” 즉시 추적