- Published on
Spring Boot HikariCP 커넥션 고갈 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버는 살아 있는데 응답이 갑자기 느려지고, 얼마 지나지 않아 SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out 같은 메시지가 터지면 대부분 “커넥션 고갈”입니다. 문제는 커넥션 풀이 원인이 아니라 결과인 경우가 많다는 점입니다. 커넥션이 반환되지 않거나(누수), 반환은 되지만 너무 늦게 반환되거나(긴 트랜잭션/슬로우 쿼리), 애플리케이션 스레드가 커넥션을 쥔 채로 대기하거나(락/외부 I/O), DB가 새 커넥션/쿼리를 처리하지 못하는 상황이 겹치면 HikariCP는 결국 타임아웃을 낼 수밖에 없습니다.
이 글은 Spring Boot에서 HikariCP 커넥션 고갈을 “증상”이 아니라 “원인 트리”로 분해해, 어떤 데이터를 보고 어떤 설정과 코드로 해결할지 실전 기준으로 정리합니다.
1) 커넥션 고갈의 전형적인 증상 패턴
애플리케이션 로그
Connection is not available, request timed out after ...msPool stats (total=..., active=..., idle=..., waiting=...)형태의 경고- 트래픽이 증가하지 않았는데도 특정 시간대에만 급격히 발생
운영 지표(Actuator/Micrometer)
hikaricp.connections.active가maximumPoolSize근처에서 고정hikaricp.connections.pending가 증가(대기열)hikaricp.connections.idle이 0에 수렴hikaricp.connections.acquire시간이 급격히 증가
이때 핵심 질문은 하나입니다.
- “active 커넥션이 왜 오래 잡혀 있나?”
2) 원인 분류: 4가지로 먼저 나누면 빠릅니다
2.1 커넥션 누수(반환 누락)
- JDBC를 직접 쓰면서
close()누락 - 예외 경로에서 자원 반환이 빠짐
- 별도 스레드에서 커넥션을 잡아두고 장시간 유지
JPA/Hibernate를 쓰더라도, 다음 상황에서 누수/장기 점유가 발생할 수 있습니다.
@Transactional범위가 과도하게 넓음- 트랜잭션 안에서 외부 API 호출/파일 I/O/대기 로직 수행
2.2 긴 트랜잭션(커넥션 점유 시간 증가)
- 대량 배치 처리, 대용량
IN쿼리, 비효율 조인 - 락 경합으로 쿼리가 대기
SERIALIZABLE같은 높은 격리수준 남용
2.3 스레드/동시성 문제(커넥션을 잡고 기다림)
- 웹 요청 스레드가 커넥션을 확보한 뒤, 다른 락/세마포어/모니터를 기다림
- 가상 스레드/서블릿 스레드 모델 변경 후 동시성 폭증으로 풀 사이즈 부족
가상 스레드를 도입했다면 동시 요청 수가 늘어 커넥션 풀이 더 빨리 포화될 수 있습니다. 관련 트러블슈팅은 Spring Boot 3 가상스레드 적용 트러블슈팅도 함께 참고하세요.
2.4 DB 자체 지연(커넥션을 빨리 못 씀)
- DB CPU/IO 포화
- 커넥션 생성/인증이 느림(TLS, IAM, 프록시)
- 네트워크 지연 또는 간헐적 패킷 손실
이 경우 애플리케이션에서 풀만 키우면 “더 많은 동시 쿼리로 DB를 더 괴롭히는” 악순환이 됩니다.
3) 가장 먼저 해야 할 설정: 관측 가능하게 만들기
3.1 HikariCP leakDetectionThreshold 켜기
누수 또는 장기 점유를 가장 빨리 잡는 방법입니다.
application.yml
spring:
datasource:
hikari:
maximum-pool-size: 20
connection-timeout: 30000
leak-detection-threshold: 5000
- 단위는 ms입니다.
- 운영에서는 너무 낮게 잡으면 정상 트랜잭션도 “누수 의심” 로그가 쏟아질 수 있습니다. 보통 5초~30초 사이에서 시작해 조정합니다.
Hikari가 특정 커넥션이 임계시간 이상 반환되지 않으면 “어떤 스레드가 어디에서 빌렸는지” 스택트레이스를 남깁니다. 이 로그 한 줄로 원인의 60%는 끝납니다.
3.2 Actuator 메트릭 노출
build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
확인할 메트릭 예시(환경에 따라 이름은 약간 다를 수 있음):
hikaricp.connections.activehikaricp.connections.idlehikaricp.connections.pendinghikaricp.connections.acquire
3.3 SQL/트랜잭션 경계 로그
JPA를 쓴다면 “트랜잭션이 언제 시작/종료되는지”가 핵심입니다. 특히 롤백이 기대대로 안 되거나 예외 처리 경로가 꼬이면 트랜잭션 경계가 예상과 달라질 수 있습니다. 관련 이슈는 Spring Boot 3에서 @Transactional 롤백이 안될 때도 같이 보면 도움이 됩니다.
4) 원인별 재현 코드와 해결 패턴
4.1 트랜잭션 안에서 외부 호출을 하는 경우(가장 흔함)
문제 코드
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
this.orderRepository = orderRepository;
this.paymentClient = paymentClient;
}
@Transactional
public void placeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
// 외부 결제 API가 느려지면, 그 시간 동안 커넥션이 점유될 수 있음
paymentClient.requestPayment(order);
order.markPaid();
}
}
JPA는 트랜잭션 범위에서 커넥션을 잡고 있을 가능성이 큽니다(특히 flush/락/격리수준/DB 드라이버 설정에 따라). 외부 API가 느려지면 active 커넥션이 길게 유지되어 풀이 말라갑니다.
해결 패턴 A: 트랜잭션을 DB 작업에만 최소화
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
this.orderRepository = orderRepository;
this.paymentClient = paymentClient;
}
public void placeOrder(Long orderId) {
Order order = loadOrder(orderId);
// 트랜잭션 밖에서 외부 호출
paymentClient.requestPayment(order);
markPaid(orderId);
}
@Transactional(readOnly = true)
public Order loadOrder(Long orderId) {
return orderRepository.findById(orderId).orElseThrow();
}
@Transactional
public void markPaid(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
}
- 외부 호출이 실패해도 DB 트랜잭션을 불필요하게 오래 잡지 않습니다.
- 단, 중간 상태/정합성 요구가 높다면 아웃박스 패턴/사가 패턴 등으로 전환을 고려해야 합니다(분산 트랜잭션을 코드로 억지로 만들면 더 큰 장애로 돌아옵니다).
4.2 JDBC 직접 사용 시 close 누락
문제 코드
public List<User> findUsers(DataSource dataSource) throws SQLException {
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("select id, name from users");
ResultSet rs = ps.executeQuery();
List<User> out = new ArrayList<>();
while (rs.next()) {
out.add(new User(rs.getLong("id"), rs.getString("name")));
}
// close 누락: 커넥션이 풀로 반환되지 않음
return out;
}
해결: try-with-resources로 강제
public List<User> findUsers(DataSource dataSource) throws SQLException {
String sql = "select id, name from users";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
List<User> out = new ArrayList<>();
while (rs.next()) {
out.add(new User(rs.getLong("id"), rs.getString("name")));
}
return out;
}
}
이 패턴만 적용해도 “누수로 인한 고갈”은 대부분 사라집니다.
4.3 슬로우 쿼리/락 대기
커넥션 고갈은 종종 “DB에서 쿼리가 끝나지 않아서 반환이 늦는 현상”입니다. 이때 풀을 키우는 것은 임시방편이며, 먼저 쿼리 시간을 줄이거나 락 경합을 줄여야 합니다.
체크리스트
- 슬로우 쿼리 로그 활성화
- 인덱스 누락 확인
- 같은 row를 여러 요청이 업데이트하는 핫스팟 제거
- 트랜잭션 격리수준/락 범위 점검
실전 팁: 타임아웃을 “계층별”로 둬서 전파를 막기
- Hikari
connectionTimeout: 커넥션 대기 상한 - JDBC 드라이버 쿼리 타임아웃: 쿼리 실행 상한
- Spring 트랜잭션 타임아웃: 트랜잭션 전체 상한
예시:
spring:
transaction:
default-timeout: 10
datasource:
hikari:
connection-timeout: 3000
maximum-pool-size: 20
- 커넥션을 30초씩 기다리게 두면, 대기 요청이 쌓이며 스레드/메모리까지 같이 무너집니다.
- 반대로 너무 짧으면 일시적인 스파이크에도 실패가 늘어납니다. “장애 전파를 막는 값”으로 조정합니다.
4.4 풀 사이즈를 무작정 키우면 안 되는 이유
HikariCP의 maximumPoolSize 를 늘리면 당장은 타임아웃이 줄 수 있습니다. 하지만 다음을 함께 계산해야 합니다.
- DB가 감당 가능한 동시 쿼리 수
- 애플리케이션 인스턴스 수(수평 확장 시 총 커넥션 수는
poolSize x replica) - DB 커넥션 제한(max connections)
예를 들어 인스턴스 10개에 풀 50이면 총 500 커넥션입니다. DB가 200 커넥션에서 이미 CPU가 90%라면, 풀을 키운 순간 “고갈” 대신 “DB 다운”으로 바뀔 수 있습니다.
5) 운영에서 자주 놓치는 HikariCP 설정 포인트
5.1 maxLifetime / idleTimeout 조정
- 클라우드 환경에서 NAT/로드밸런서/프록시가 유휴 커넥션을 끊는 경우가 있습니다.
- 이때는 Hikari가 “죽은 커넥션”을 들고 있다가 재사용 시점에 장애가 나며, 재시도 폭증이 커넥션 고갈로 이어질 수 있습니다.
일반적으로는 기본값이 무난하지만, DB나 네트워크 장비의 idle timeout보다 약간 짧게 maxLifetime 을 잡는 전략이 있습니다.
application.yml
spring:
datasource:
hikari:
max-lifetime: 1740000
idle-timeout: 600000
5.2 minimumIdle 과 실제 트래픽
minimumIdle을 너무 높이면 트래픽이 적어도 커넥션을 많이 유지합니다.- 반대로 너무 낮으면 스파이크 시 생성 비용이 커질 수 있습니다.
기본적으로 Hikari는 “필요할 때만 생성”에 강하므로, 특별한 이유가 없다면 minimumIdle 을 과하게 올리지 않는 편이 안전합니다.
6) 장애 시 10분 안에 하는 진단 순서(현장용)
- 로그에서 에러 시점 확인:
Connection is not available... - 같은 시점의 풀 상태 확인: active/idle/pending
leakDetectionThreshold로그로 “누가 빌렸는지” 스택트레이스 확보- 슬로우 쿼리/락 대기 확인(DB 모니터링)
- 트랜잭션 범위 점검: 외부 호출/대기 로직이 트랜잭션 안에 있는지
- 타임아웃 계층 정리: 커넥션 대기, 쿼리, 트랜잭션
- 마지막에 풀 사이즈 조정(필요 시) 및 인스턴스 수와 함께 재계산
운영 장애는 종종 “한 가지 원인”이 아니라 “느려짐 + 대기열 + 재시도”가 결합해 커넥션을 말립니다. 특히 네트워크/리졸브 이슈로 외부 호출이 지연되면 트랜잭션이 길어지고, 그 결과가 커넥션 고갈로 보일 수 있습니다. 인프라 레벨의 간헐 장애 가능성도 염두에 두고, 비슷한 방식의 네트워크 진단 글인 EKS CoreDNS SERVFAIL·NXDOMAIN 간헐 해결 9가지도 참고해두면 좋습니다.
7) 결론: “풀을 키우기” 전에 커넥션 점유 시간을 줄여라
HikariCP 커넥션 고갈은 대개 다음 중 하나로 귀결됩니다.
- 반환되지 않는 커넥션(누수)
- 너무 오래 잡는 커넥션(긴 트랜잭션/슬로우 쿼리/락)
- 커넥션을 잡은 채로 기다리는 스레드(외부 I/O, 동시성 문제)
- DB가 이미 한계라서 처리량이 안 나오는 상태
가장 효과적인 접근은 관측(메트릭/누수 감지)으로 “누가 얼마나 오래 커넥션을 잡는지”를 먼저 고정밀로 확인한 뒤, 트랜잭션 경계를 줄이고, 쿼리를 빠르게 만들고, 타임아웃을 계층적으로 설정해 장애 전파를 끊는 것입니다. 풀 사이즈 증가는 그 다음입니다.