- Published on
Spring Boot HikariCP 풀 고갈 원인·튜닝 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡해 보이는데도 API가 갑자기 느려지거나 타임아웃이 쏟아지는 순간이 있습니다. 로그에는 대개 다음과 같은 문구가 찍힙니다.
HikariPool-1 - Connection is not available, request timed out after ...msSQLTransientConnectionException: HikariPool...
이건 단순히 “DB가 느리다”로 끝나지 않습니다. 애플리케이션의 동시성, 트랜잭션 경계, 쿼리 패턴, 커넥션 반환 누락, 그리고 풀 설정이 서로 맞물리며 발생합니다. 이 글에서는 풀 고갈의 구조적 원인을 분해하고, 관측 가능한 신호로 좁혀가며, 안전한 튜닝을 하는 실전 흐름을 정리합니다.
참고로 가상 스레드 도입 이후 동시성이 급격히 늘어 DB 풀 고갈이 더 자주 보인다면, 아래 글도 함께 보시면 맥락이 바로 연결됩니다.
1) HikariCP 풀 고갈이 의미하는 것
HikariCP는 애플리케이션이 DB에 접속할 때 매번 새 커넥션을 만들지 않고, 미리 만들어 둔 커넥션을 빌려주고 돌려받는 풀입니다. 고갈이란 다음 중 하나(또는 조합)입니다.
- 동시에 필요한 커넥션 수가
maximumPoolSize를 넘었다 - 커넥션이 반환되지 않거나(누수), 반환이 지연된다
- DB 혹은 네트워크 문제로 커넥션이 죽었는데 풀은 그것을 정상으로 오인하거나, 재생성 비용이 급증한다
- 애플리케이션이 불필요하게 커넥션을 오래 잡고 있는 구조다
이걸 튜닝으로만 해결하려고 maximumPoolSize만 올리면, 다음 부작용이 잘 옵니다.
- DB
max_connections초과로 DB 자체 장애 - 컨텍스트 스위칭 증가, 락 경합 증가로 성능 악화
- 커넥션 수는 늘었는데 쿼리 지연의 근본 원인(락/슬로우쿼리) 은 그대로
결론적으로 먼저 해야 할 일은 “풀 크기”가 아니라 커넥션이 어디서 얼마나 오래 점유되는지를 관측하는 것입니다.
2) 가장 흔한 원인 7가지 (체크리스트)
2.1 느린 쿼리, 락, 트랜잭션 대기
가장 흔한 1순위입니다. 쿼리 자체가 느리거나, 인덱스가 없거나, 업데이트/DDL로 락이 잡혀 대기하면 커넥션이 반환되지 않습니다.
특징적인 신호:
- DB CPU/IO가 치솟음
- 애플리케이션은 커넥션 대기(
connectionTimeout)로 타임아웃 - DB에서
idle in transaction세션이 증가
PostgreSQL을 쓴다면 autovacuum 지연으로 인한 팽창과 슬로우가 커넥션 점유를 폭발시키는 경우가 많습니다.
2.2 트랜잭션 범위가 너무 큼 (웹 요청 전체를 감싸는 패턴)
@Transactional이 서비스 메서드가 아니라 상위 레이어(컨트롤러/필터)까지 확장되면, DB 작업이 끝난 뒤에도 커넥션을 오래 잡습니다.
대표 패턴:
- 트랜잭션 안에서 외부 API 호출
- 트랜잭션 안에서 대용량 파일 처리
- 트랜잭션 안에서 메시지 발행/재시도 로직
2.3 커넥션 누수 (반환 누락)
JPA를 쓰더라도, JDBC를 직접 쓰거나 멀티 데이터소스/수동 트랜잭션을 섞을 때 누수가 발생할 수 있습니다.
신호:
- 트래픽이 낮아도 시간이 지날수록 풀 사용량이 계속 증가
- 재기동하면 잠깐 정상
2.4 N+1, 과도한 쿼리 수로 커넥션 점유시간 증가
각 쿼리가 빠르더라도 요청당 쿼리 수가 많으면 커넥션 점유시간이 길어집니다. 특히 페이지네이션 없이 대량 로딩, 지연 로딩 남발이 흔합니다.
2.5 애플리케이션 동시성 폭증 (스레드/가상스레드/비동기)
스레드 풀, 웹서버 워커 수, @Async, 배치 동시 실행, 스케줄러가 합쳐져 “동시에 DB를 때리는 수”가 풀 크기를 초과합니다.
여기서 중요한 점은 풀 크기는 DB 동시 처리량의 상한인데, 애플리케이션 동시성은 쉽게 무한대로 커질 수 있다는 것입니다.
2.6 커넥션 검증/네트워크 이슈로 재생성 비용 증가
DB와의 네트워크가 불안정하거나, NAT/방화벽/로드밸런서가 idle 커넥션을 끊으면 풀에서 죽은 커넥션을 빌려주려다 실패하고 재시도 비용이 늘어납니다.
2.7 DB max_connections 및 RDS/Proxy 제약
애플리케이션에서 풀을 늘려도 DB가 받지 못하면 병목이 DB에서 터집니다. 특히 오토스케일로 인스턴스 수가 늘면 “인스턴스 수 × 풀 크기”가 DB 한도를 초과하기 쉽습니다.
3) 진단 1단계: Hikari 로그와 기본 지표로 방향 잡기
3.1 타임아웃 로그의 의미
Hikari의 connectionTimeout은 “커넥션을 빌리기 위해 기다린 시간”입니다. 즉 타임아웃이 났다는 건,
- 풀에 놀고 있는 커넥션이 없었고
connectionTimeout동안 반환도 안 됐다는 뜻
따라서 이 시점에서 봐야 할 것은 “왜 반환이 안 됐는가”입니다.
3.2 Micrometer/Actuator로 풀 상태 보기
Spring Boot Actuator와 Micrometer를 쓰면 Hikari 지표를 쉽게 볼 수 있습니다.
application.yml 예시:
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
metrics:
tags:
application: my-service
관심 지표(이름은 환경에 따라 약간 다를 수 있습니다):
hikaricp.connections.active: 사용 중 커넥션hikaricp.connections.idle: 유휴 커넥션hikaricp.connections.pending: 커넥션 대기 중 스레드 수hikaricp.connections.timeout: 타임아웃 횟수
해석 팁:
active가maximumPoolSize에 붙어 있고pending이 증가하면: 수요가 공급 초과active는 높지 않은데timeout이 증가하면: 죽은 커넥션/검증 문제 또는 풀 설정 불일치 가능성
4) 진단 2단계: 커넥션 누수 의심 시 leakDetectionThreshold
Hikari의 누수 감지는 “커넥션을 빌린 뒤 일정 시간 안에 반환하지 않으면 스택트레이스를 찍는 기능”입니다.
설정 예시:
spring:
datasource:
hikari:
leak-detection-threshold: 20000 # 20초
주의:
- 너무 낮게 잡으면 정상적으로 오래 걸리는 쿼리도 누수처럼 찍혀 노이즈가 커집니다.
- 운영에서는 일시적으로 켜고(예: 20~60초), 원인 잡으면 끄는 것을 권장합니다.
로그에 “어디서 커넥션을 빌렸는지” 스택이 찍히면, 그 경로에서 트랜잭션 범위/외부 호출/대기(락)를 집중 점검합니다.
5) 튜닝의 핵심: 풀 크기보다 “점유 시간”을 줄여라
풀 고갈을 수식으로 보면 간단합니다.
- 동시 요청 수가
C - 요청당 평균 커넥션 점유 시간이
T - 초당 처리량이
R
대략적으로 active는 R × T에 비례합니다. 즉 T를 줄이면 풀을 크게 늘리지 않아도 됩니다.
5.1 트랜잭션을 DB 작업 구간으로만 좁히기
나쁜 예(트랜잭션 안에서 외부 호출):
@Transactional
public OrderResult placeOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(cmd.toEntity());
// 외부 API 호출이 느려지면 커넥션을 잡고 기다리게 됨
paymentClient.requestPayment(order.getId());
return new OrderResult(order.getId());
}
개선 예(트랜잭션 분리):
public OrderResult placeOrder(PlaceOrderCommand cmd) {
Long orderId = createOrder(cmd); // DB 구간
paymentClient.requestPayment(orderId); // 트랜잭션 밖
return new OrderResult(orderId);
}
@Transactional
public Long createOrder(PlaceOrderCommand cmd) {
Order order = orderRepository.save(cmd.toEntity());
return order.getId();
}
5.2 요청당 쿼리 수 줄이기 (N+1 제거)
- fetch join, entity graph, batch size, DTO projection 등으로 “쿼리 수”를 줄이면 점유 시간이 짧아집니다.
- 단, fetch join 남발로 한 번에 너무 큰 결과를 가져오면 메모리/네트워크가 병목이 될 수 있어 균형이 필요합니다.
5.3 DB 락/대기 원인 제거
- 업데이트 경합이 큰 테이블은 인덱스/쿼리 조건을 점검
- 긴 트랜잭션(특히 배치)이 OLTP 요청과 같은 테이블을 잠그지 않도록 분리
6) HikariCP 주요 파라미터 실전 가이드
아래는 “무작정 올리기”가 아니라, 자주 쓰는 기준과 함께 정리한 것입니다.
6.1 maximumPoolSize
- 애플리케이션 인스턴스 1개가 동시에 사용할 수 있는 최대 커넥션 수
- 정답은 “DB가 감당 가능한 동시 쿼리 수”와 “인스턴스 수”를 함께 봐야 합니다.
주의할 점:
- 오토스케일 시
인스턴스 수 × maximumPoolSize가 DBmax_connections를 넘지 않게 설계 - DB가 100 커넥션까지 여유가 있고 앱이 5개면, 이론상 인스턴스당 20이 상한이 됩니다(여기에 운영툴/배치/관리 커넥션도 고려)
설정 예시:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
minimumIdle을 maximumPoolSize와 같게 두면 “항상 풀을 꽉 채움”이라 지연이 줄 수 있지만, DB 자원/비용이 증가합니다. 트래픽 변동이 큰 서비스라면 minimumIdle을 낮게 두고 관찰하는 것도 방법입니다.
6.2 connectionTimeout
- 커넥션을 빌리기 위해 기다리는 최대 시간
너무 길면:
- 장애 시 요청이 오래 매달려 스레드가 쌓이고, 연쇄 장애가 커질 수 있습니다.
너무 짧으면:
- 순간 스파이크에도 타임아웃이 빨리 발생합니다.
예시:
spring:
datasource:
hikari:
connection-timeout: 1000 # 1초
실무에서는 500ms~3s 사이를 자주 봅니다. 중요한 건 숫자 자체보다, “타임아웃이 날 때 빨리 실패하고 상위에서 재시도/폴백을 할지”의 정책입니다.
6.3 maxLifetime와 idleTimeout
maxLifetime: 커넥션의 최대 수명. 이 시간이 지나면 풀에서 교체 대상이 됩니다.idleTimeout: 유휴 커넥션을 얼마나 유지할지
네트워크 장비나 DB가 idle 커넥션을 끊는 환경에서는 maxLifetime를 그보다 짧게 둬서 “Hikari가 먼저 교체”하게 만드는 전략이 유효합니다.
예시:
spring:
datasource:
hikari:
max-lifetime: 1740000 # 29분
idle-timeout: 600000 # 10분
6.4 validationTimeout 및 커넥션 테스트
Hikari는 기본적으로 JDBC4 isValid() 등을 활용해 커넥션 유효성을 확인합니다. DB 드라이버나 네트워크가 불안정하면 검증 비용이 커지거나 실패가 늘 수 있습니다.
커넥션 테스트 쿼리를 별도로 두는 방식은 드라이버/DB에 따라 득실이 있으니, 먼저 maxLifetime 조정과 네트워크 안정화부터 확인하는 편이 안전합니다.
7) “풀을 늘려야 하는 경우”의 안전한 접근
다음 조건이 충족되면 풀 증설이 의미가 있습니다.
- 쿼리/락/트랜잭션 점유시간 최적화가 어느 정도 됐고
- DB CPU/IO가 아직 여유가 있으며
pending이 꾸준히 발생하고- DB
max_connections및 인스턴스 수를 고려해도 안전할 때
권장 절차:
maximumPoolSize를 10~20%씩 점진적으로 증가- 증가 전후로
p95응답시간, DB CPU/IO, 락 대기,pending,timeout변화 비교 - 악화되면 즉시 롤백
“풀만 키웠더니 DB가 더 느려졌다”는 흔한 결말은, DB가 동시 처리량 한계에 가까웠는데 앱이 더 밀어 넣은 케이스입니다.
8) 운영에서 자주 쓰는 재현/검증 방법
8.1 작은 풀로 일부러 고갈을 재현해 보기
스테이징에서 풀을 작게 잡고 부하를 걸면 병목이 빨리 드러납니다.
spring:
datasource:
hikari:
maximum-pool-size: 3
connection-timeout: 500
leak-detection-threshold: 20000
이 상태에서 특정 API만 유독 pending을 만들면, 그 API의 트랜잭션 범위/쿼리 패턴이 원인일 확률이 큽니다.
8.2 슬로우 쿼리 로그/APM 트레이스와 함께 보기
- APM에서 “DB time”이 긴 트랜잭션을 우선순위로 잡기
- DB 슬로우 로그에서 동일 시간대 상위 쿼리 확인
- 둘을 매칭하면 “커넥션 점유의 실체”가 보입니다
9) 최종 체크리스트 (현장용)
-
hikaricp.connections.pending가 증가하는가,active가maximumPoolSize에 붙는가 - 요청당 쿼리 수(N+1) 또는 대량 로딩이 있는가
-
@Transactional안에 외부 호출/파일 처리/재시도가 있는가 - DB 락 대기,
idle in transaction이 늘었는가 -
leakDetectionThreshold로 “반환 지연/누수” 스택을 확보했는가 - 오토스케일을 고려한
인스턴스 수 × 풀 크기가 DB 한도 내인가 - 네트워크/장비 idle 종료 정책을 고려해
maxLifetime를 조정했는가
마무리
HikariCP 풀 고갈은 “커넥션이 부족하다”가 아니라, 대부분 커넥션이 오래 잡혀 있는 이유가 있다는 신호입니다. 먼저 지표(active, pending, timeout)로 상황을 분류하고, 누수 감지와 트랜잭션/쿼리 구조 점검으로 점유 시간을 줄이세요. 그 다음에야 maximumPoolSize를 안전하게 늘릴지 판단할 수 있습니다.
가상 스레드나 비동기 확대로 동시성이 급증한 환경이라면, 풀과 동시성의 밸런스를 다시 잡는 접근이 특히 중요합니다.