- Published on
Spring Boot 3 HikariCP 풀 고갈 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 갑자기 느려지고, 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 풀 고갈은 단순히 커넥션 개수가 부족하다는 뜻이 아닙니다. 더 정확히는 다음 중 하나입니다.
- 애플리케이션이 커넥션을 빌린 뒤 반환하지 못한다(누수)
- 커넥션을 빌린 뒤 너무 오래 쥐고 있다(긴 트랜잭션, 느린 쿼리, 락 대기)
- 풀 사이즈와 트래픽/쿼리 특성이 맞지 않는다(설정 미스)
- DB가 커넥션은 제공하지만, DB 내부 병목으로 인해 쿼리가 느려져 점유 시간이 길어진다
즉, “풀을 키우면 해결”은 임시 처방일 수 있고, 원인에 따라서는 오히려 장애를 키웁니다(예: DB에 동시 쿼리 폭증).
가장 먼저 확인할 로그와 지표
1) 대표 에러 로그
다음 메시지가 핵심 신호입니다.
Connection is not available, request timed out after ...msTimeout 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가 증가하고 active가 max에 붙어 있으면 전형적인 풀 고갈 패턴입니다.
원인 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: 긴 트랜잭션(커넥션 점유 시간 과다)
풀 고갈의 가장 흔한 원인은 누수보다 긴 점유 시간입니다.
자주 터지는 케이스
@Transactional범위가 넓어서, DB 작업 이후에도 커넥션을 계속 잡고 있음- 트랜잭션 안에서 외부 API 호출, 파일 IO, 대기(sleep) 등을 수행
- 대량 처리(배치)에서 한 트랜잭션으로 너무 많은 작업을 묶음
해결 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가 DBmax_connections를 넘지 않는지 max-lifetime이 DB/프록시 idle timeout보다 충분히 짧은지- 웹 스레드와 풀 크기가 균형인지
관측 가능성(Observability) 강화 팁
- Actuator로 Hikari 메트릭을 수집하고 대시보드/알람 구성
- APM에서 DB span(쿼리 시간)과 요청 지연을 함께 상관 분석
- 장애 시점의 스레드 덤프를 떠서 “커넥션 대기” 스레드가 얼마나 쌓였는지 확인
Spring Security나 인증 계층에서 특정 상황에 재시도/캐시 미스로 트래픽이 튀는 경우도 장애를 증폭시킬 수 있습니다. JWT 키 로테이션이나 JWKS 캐시 이슈로 인증 실패가 반복되면 요청이 재시도되며 DB까지 연쇄 부하로 이어질 수 있어, 아래 글이 간접적으로 도움이 될 수 있습니다.
결론: 풀 고갈은 “커넥션 개수” 문제가 아니라 “점유 시간” 문제
HikariCP 풀 고갈을 안정적으로 해결하려면 우선순위를 이렇게 잡는 것이 좋습니다.
- 누수 여부를 leak detection과 코드 점검으로 제거
- 트랜잭션 범위를 줄여 커넥션 점유 시간을 단축
- 느린 쿼리/락/DB 병목을 해결해 점유 시간을 더 줄임
- 그 다음에야 풀 사이즈를 조정(필요 시)하고, 웹 스레드/재시도/백프레셔로 폭주를 제어
풀 크기를 늘리는 것은 마지막 단계의 선택지로 두고, “왜 커넥션을 오래 잡고 있는가”를 먼저 잡으면 재발률이 크게 떨어집니다.