- Published on
Spring Boot 대용량 트래픽 - HikariCP 풀 고갈 진단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 평소엔 멀쩡한데 특정 시간대(프로모션, 배치 시작, 트래픽 스파이크)에만 갑자기 SQLTransientConnectionException: HikariPool-1 - Connection is not available가 터지면, 대부분은 “풀 사이즈가 작아서”가 아니라 커넥션을 오래 잡고 놓지 못하는 상황이 숨어 있습니다. HikariCP는 매우 빠르고 보수적으로 동작하지만, 그만큼 풀 고갈이 발생하면 애플리케이션의 병목이 빠르게 표면화됩니다.
이 글은 Spring Boot 환경에서 대용량 트래픽 중 HikariCP 커넥션 풀 고갈을 실전적으로 진단하는 순서를 제공합니다. 목표는 단순히 maximumPoolSize를 올리는 것이 아니라, 어떤 요청/코드/쿼리/락이 커넥션을 점유하고 있는지를 재현 가능하게 찾아내는 것입니다.
증상 정의: “풀 고갈”은 왜 생기나
HikariCP 풀 고갈은 결과적으로 다음 중 하나(또는 복합)입니다.
- 커넥션 대여 시간(hold time)이 길다
- 느린 쿼리, 인덱스 미스, 풀스캔, VACUUM/ANALYZE 지연 등
- DB 락 대기(Deadlock/Lock wait)로 쿼리가 멈춤
- 커넥션이 반환되지 않는다(Leak)
- JDBC 직접 사용 시 close 누락
- 잘못된 트랜잭션 경계로 커밋/롤백 지연
- 동시성 대비 풀/DB capacity가 부족하다
- 애플리케이션 인스턴스 수 × 풀 사이즈가 DB
max_connections를 초과 - DB CPU/IO가 이미 포화라 커넥션을 늘려도 처리량이 늘지 않음
- 애플리케이션 인스턴스 수 × 풀 사이즈가 DB
- 요청 스레드가 DB 커넥션을 기다리며 누적된다
- 톰캣/Undertow 스레드 풀과 Hikari 풀의 비율 불일치
여기서 중요한 점: **풀 고갈은 “원인”이 아니라 “결과”**입니다. 따라서 진단은 “커넥션이 어디서 오래 잡히는가”를 좁히는 방식으로 진행해야 합니다.
1단계: 로그에서 신호 읽기 (timeout, pool state)
가장 먼저 확인할 것은 타임아웃 로그의 형태입니다.
- 대표 예외
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms
- 이 메시지는 “DB가 죽었다”가 아니라, 지정한 시간 내에 풀에서 커넥션을 못 빌렸다는 뜻입니다.
Spring Boot에서 Hikari 설정은 보통 다음과 같습니다.
spring:
datasource:
hikari:
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
여기서 connection-timeout은 “DB connect timeout”이 아니라 풀 대여 대기 시간입니다.
추가로, Hikari의 디버깅 로그를 잠깐 켜면 풀 상태를 더 잘 볼 수 있습니다(운영에선 짧게만).
logging.level.com.zaxxer.hikari=DEBUG
다만 로그만으로는 “어디서 오래 잡는지”가 명확하지 않으니, 다음 단계로 메트릭을 봅니다.
2단계: Actuator + Micrometer로 풀 메트릭 확인
Spring Boot Actuator를 쓰면 Hikari 메트릭을 거의 공짜로 얻습니다.
의존성 예시:
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
Prometheus로 긁을 때 핵심 지표는 다음입니다.
hikaricp_connections_active: 현재 사용 중(대여 중) 커넥션 수hikaricp_connections_idle: 유휴 커넥션 수hikaricp_connections_pending: 커넥션을 기다리는 스레드 수hikaricp_connections_timeout_total: 풀 대여 타임아웃 누적
진단 포인트:
- active가 max에 고정 + pending 증가: 커넥션 hold time이 길거나 DB가 느림
- idle이 0인데 active가 max 미만: 풀 생성/DB 연결 문제, 혹은 네트워크/인증 지연
- timeout_total이 계단식으로 증가: 특정 배치/트래픽 패턴에서 병목이 반복
이 단계에서 “언제 pending이 치솟는지”를 시간축으로 잡아두면, 다음 단계(스레드 덤프/슬로우 쿼리)와 연결하기 쉽습니다.
3단계: 스레드 덤프로 ‘커넥션을 잡고 있는 코드’ 찾기
풀 고갈의 핵심은 “커넥션이 어디서 오래 잡히는가”입니다. 이건 스레드 덤프가 가장 빠릅니다.
스레드 덤프 채집
- 컨테이너/VM에서 PID 확인 후:
jcmd <PID> Thread.print > threaddump.txt
또는:
kill -3 <PID>
덤프에서 찾을 패턴
- 많은 스레드가 다음과 같은 스택에서 멈춰 있으면:
com.zaxxer.hikari.pool.HikariPool.getConnectionjava.util.concurrent.SynchronousQueue/LockSupport.park
이는 “커넥션을 기다리는 스레드”입니다.
반대로 “커넥션을 잡고 있는 스레드”는 보통 아래 쪽에 있습니다.
- JDBC 드라이버 호출
- MySQL:
com.mysql.cj.jdbc.ClientPreparedStatement.execute - PostgreSQL:
org.postgresql.core.v3.QueryExecutorImpl.execute
- MySQL:
- JPA/Hibernate
org.hibernate.loader/org.hibernate.engine.jdbc
팁: 스레드 덤프를 10초 간격으로 3번 떠서 비교하면, “계속 같은 스택에 머무는” 요청이 범인일 확률이 큽니다.
4단계: Leak(반환 누락) 의심 시 Hikari leakDetectionThreshold 사용
커넥션 반환 누락이 의심되면 Hikari의 leak detection을 잠깐 켭니다.
spring:
datasource:
hikari:
leak-detection-threshold: 3000
- 단위: ms
- 의미: 커넥션을 3초 이상 쥐고 있으면 로그로 스택 트레이스를 남김
주의점:
- 운영에서 너무 낮게 잡으면(예: 500ms) 정상 쿼리도 누수처럼 찍혀 노이즈가 커집니다.
- leak detection은 “진짜 누수”뿐 아니라 “오래 잡는” 케이스도 잡아내므로, 원인 후보를 좁히는 데 유용합니다.
JDBC를 직접 쓴다면 try-with-resources가 기본입니다.
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement("select ...");
ResultSet rs = ps.executeQuery()) {
// ...
}
JPA를 쓰더라도, 트랜잭션 범위가 과도하게 넓으면(예: 외부 API 호출까지 트랜잭션에 포함) 커넥션을 오래 점유할 수 있습니다.
5단계: DB에서 ‘느린 쿼리’와 ‘락 대기’를 함께 본다
풀 고갈은 애플리케이션 문제처럼 보여도, 실제로는 DB 락/슬로우 쿼리가 촉발하는 경우가 많습니다.
락/데드락이 원인인 경우
- 증상: 특정 테이블/로우 업데이트가 몰릴 때만 active가 꽉 차고 pending 폭증
- 해결: 인덱스/쿼리 개선, 트랜잭션 순서 통일, 업데이트 범위 축소
MySQL이라면 데드락 로그에서 원인 SQL을 뽑아내는 것이 빠릅니다. 관련 글: MySQL InnoDB Deadlock 로그로 원인 SQL 찾기
PostgreSQL이라면 autovacuum/lock 경합/장기 트랜잭션이 진짜 원인일 수 있습니다. VACUUM이 끝나지 않는 상황도 커넥션 대기와 연쇄적으로 이어질 수 있어요: PostgreSQL VACUUM 안 끝날 때 원인과 해결법
슬로우 쿼리 확인 체크리스트
- 같은 API에서만 고갈이 발생하는가?
- 특정 파라미터(기간 범위, 정렬 조건)에서만 느려지는가?
- 실행 계획이 바뀌었는가(통계, 인덱스 손상/미스)?
애플리케이션 레벨에서 최소한의 상관관계를 만들려면, 쿼리에 코멘트를 넣는 것도 방법입니다(과용 금지).
@Query(value = "/* api=orderList */ select o from Order o where o.userId = :userId")
List<Order> findOrders(@Param("userId") Long userId);
6단계: 풀 사이즈/스레드 풀/DB max_connections의 ‘곱셈 사고’
대용량 트래픽에서 흔한 실수는 인스턴스를 늘리면서도 풀 사이즈를 그대로 두거나, 반대로 풀을 무작정 키워 DB를 터뜨리는 것입니다.
계산 예시
- 앱 인스턴스 20개
- 인스턴스당
maximumPoolSize=30
=> 이론상 최대 600 커넥션을 DB가 받아야 합니다.
DB max_connections가 300이면, 일부 인스턴스는 커넥션 생성 자체가 실패하거나 대기하게 됩니다. 또한 DB CPU/IO가 감당 못하면 커넥션이 많을수록 컨텍스트 스위칭/락 경합이 늘어 처리량이 오히려 감소할 수 있습니다.
톰캣 스레드와의 비율
요청 스레드가 200인데 DB 커넥션이 30이면, 피크에 170개는 대기합니다. 대기가 길어지면 타임아웃/재시도/서킷브레이커 부재로 “대기열이 더 커지는” 악순환이 생깁니다.
외부 의존성 호출(결제/추천/LLM 등)도 마찬가지로 재시도 폭탄이 풀 고갈을 가속합니다. 재시도/폴백/서킷브레이커 관점은 이 글의 패턴이 그대로 적용됩니다: OpenAI Responses API 500·503 대응 재시도 폴백 서킷브레이커
7단계: “설정 튜닝”은 마지막에, 그러나 꼭 필요한 것들
원인을 좁힌 뒤에야 Hikari 설정이 의미가 있습니다. 자주 쓰는 실전 포인트만 정리합니다.
connectionTimeout
- 너무 길면: 요청 스레드가 오래 묶여 장애 전파가 커짐
- 너무 짧으면: 순간 스파이크에도 쉽게 실패
일반적으로 API SLA와 톰캣 타임아웃을 고려해 15초(내부 API) 또는 1030초(배치/관리성) 범위에서 결정합니다.
maxLifetime / idleTimeout
- DB/LB가 커넥션을 임의로 끊는 환경에서는
maxLifetime을 DB의 idle timeout보다 짧게 잡아 “죽은 커넥션”을 줄입니다. - 다만 너무 짧으면 재연결 비용이 커집니다.
minimumIdle
- 트래픽이 급격히 치솟는 서비스는
minimumIdle을 적절히 올려 워밍업을 돕습니다. - 하지만 인스턴스가 많다면 DB 커넥션을 상시 점유하게 되므로 총량을 고려해야 합니다.
8단계: 재현과 검증(부하 테스트 + 관측 지표)
진단이 끝났다면 “고쳤다”를 증명해야 합니다. 최소한 아래 조합으로 검증하세요.
- 부하 테스트: 특정 API에 동시 요청을 점진적으로 증가
- 관측:
hikaricp_connections_pending이 0에 가깝게 유지되는지- p95/p99 latency가 급격히 꺾이는 지점이 사라졌는지
- DB CPU/IO/락 대기가 개선되었는지
부하 테스트 중에도 스레드 덤프를 2~3회 떠서, 병목이 다른 곳(예: 외부 API, GC, 락)으로 이동했는지 확인하면 “풍선 효과”를 줄일 수 있습니다.
실전 체크리스트 요약
- Hikari timeout 로그의 의미를 정확히 해석했는가(풀 대여 대기)?
-
active/idle/pending/timeout_total메트릭을 시간대별로 확인했는가? - 스레드 덤프 3회로 ‘커넥션 대기 스레드’와 ‘커넥션 점유 스레드’를 분리했는가?
- leakDetectionThreshold로 “오래 잡는 지점”의 스택을 확보했는가?
- DB에서 슬로우 쿼리 + 락/데드락을 함께 봤는가?
- 인스턴스 수 × 풀 사이즈가 DB capacity와 맞는가?
- 수정 후 부하 테스트로 pending/latency 개선을 검증했는가?
마무리
HikariCP 풀 고갈은 대체로 “풀을 키우면 해결”이 아니라, 커넥션을 오래 쥐는 코드/쿼리/락을 찾아내야 끝납니다. 로그 → 메트릭 → 스레드 덤프 → leak detection → DB 락/슬로우 쿼리 순으로 좁혀가면, 원인을 감으로 추측하는 대신 증거 기반으로 해결할 수 있습니다.
다음 단계로는 (1) 특정 API/쿼리 단위의 분산 추적(Trace) 도입, (2) 트랜잭션 경계 재설계, (3) 캐시/비동기화/벌크 처리로 DB 의존도를 낮추는 전략까지 확장하면 대용량 트래픽에서도 안정적인 커넥션 사용 패턴을 만들 수 있습니다.