- Published on
Spring Boot HikariCP 커넥션 고갈 원인과 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
운영 중인 Spring Boot 서비스에서 갑자기 API 응답이 느려지거나, 일정 시점부터 전부 500으로 터지면서 로그에 아래와 같은 메시지가 쌓인 경험이 있을 겁니다.
HikariPool-1 - Connection is not available, request timed out after 30000msSQLTransientConnectionException: HikariPool-1 - Connection is not available...
이건 대부분 HikariCP 커넥션 풀이 고갈(Exhaustion) 됐다는 신호입니다. “DB가 느려서”로 뭉뚱그리기 쉽지만, 실제 원인은 애플리케이션 코드(트랜잭션/락/누수), 풀 설정, DB의 동시성/락, 네트워크까지 다양합니다.
이 글에서는 원인을 빠르게 분류하는 방법 → 재현/관측 포인트 → 해결책(코드/설정/DB) 순으로 정리합니다. 특히 “풀 사이즈만 키우면 해결”이 왜 위험한지, 어떤 경우에만 유효한지도 함께 다룹니다.
HikariCP 고갈이 의미하는 것
HikariCP 풀은 대략 아래 상태를 가집니다.
- active: 현재 빌려간(사용 중인) 커넥션 수
- idle: 풀에 남아 있는 유휴 커넥션 수
- pending: 커넥션을 기다리는 스레드 수
고갈은 보통 다음 중 하나입니다.
- active가 maxPoolSize에 붙은 채로 오래 유지된다(커넥션이 반환되지 않거나, 반환이 늦다)
- DB 쿼리가 느려 active가 오래 점유된다(락/인덱스/IO)
- 트래픽 스파이크로 동시 요청이 maxPoolSize를 초과한다(설계 상 동시성 초과)
핵심은 “커넥션이 충분히 빨리 반환되지 않는다”입니다.
가장 흔한 원인 7가지 (증상별 체크리스트)
1) 트랜잭션 범위가 너무 넓다 (@Transactional 남발)
웹 요청 전체를 트랜잭션으로 감싸거나, 외부 API 호출/파일 IO/대기 로직이 트랜잭션 안에 들어가면 커넥션을 잡은 채로 오래 버팁니다.
- 증상: 특정 API가 느려질 때 active가 같이 증가, pending 급증
- 힌트:
@Transactional이 Controller/Facade/Service 상단에 넓게 걸려 있음
해결: 트랜잭션은 “DB 작업 구간”으로 최소화합니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public void placeOrder(OrderRequest req) {
// 1) 외부 호출은 트랜잭션 밖에서
PaymentResult payment = paymentClient.pay(req);
// 2) DB 반영만 짧게 트랜잭션
saveOrder(req, payment);
}
@Transactional
protected void saveOrder(OrderRequest req, PaymentResult payment) {
orderRepository.save(Order.of(req, payment));
}
}
> 주의: 같은 클래스 내부 호출은 프록시가 안 타서 @Transactional이 적용되지 않을 수 있습니다. 필요하면 분리하거나 TransactionTemplate을 사용하세요.
2) 커넥션 누수(반환 누락) 또는 비정상 자원 점유
JPA를 쓰면 보통 커넥션은 프레임워크가 관리하지만, 아래 케이스에서 누수가 생기거나 반환이 지연될 수 있습니다.
JdbcTemplate/DataSource를 직접 쓰면서 close 누락- 스트리밍 조회(커서) 후 ResultSet을 오래 들고 있음
- 비동기 작업에서 트랜잭션/세션 경계가 꼬임
해결:
- try-with-resources 준수
- HikariCP의 leak detection을 임시로 활성화해 누수 위치를 추적
spring:
datasource:
hikari:
leak-detection-threshold: 3000 # 3초 이상 반환 안되면 스택 트레이스 로깅
누수는 “풀 사이즈 확장”로 일시적으로 가려질 뿐, 결국 다시 터집니다.
3) 느린 쿼리/인덱스 부재로 active가 오래 유지
커넥션을 ‘정상 반환’하더라도, 쿼리 자체가 느리면 커넥션 점유 시간이 길어져 고갈됩니다.
- 증상: DB CPU/IO 상승, 특정 쿼리에서 latency 급증
- 해결: 슬로우 쿼리 로그, 실행계획, 인덱스 점검
MySQL이라면 락/데드락이 얽혀 쿼리가 대기 상태로 늘어지면서 풀을 잡아먹는 경우가 많습니다. 데드락/락 튜닝 관점은 아래 글도 함께 참고하면 진단에 도움이 됩니다.
4) 락 경합(특히 SELECT ... FOR UPDATE, 갱신 핫스팟)
“DB가 느리다”가 아니라 “락을 기다리느라 느리다”인 케이스입니다.
- 재고 차감, 쿠폰 사용, 시퀀스성 테이블 업데이트
- 같은 row/인덱스 범위를 여러 트랜잭션이 동시에 건드림
해결 방향:
- 트랜잭션을 짧게
- 핫스팟 row를 분산(샤딩 키/버킷)
- 낙관적 락(@Version) 또는 재시도 전략
@Entity
public class Stock {
@Id Long id;
@Version Long version;
long quantity;
public void decrease(long n) {
if (quantity < n) throw new IllegalStateException("not enough");
quantity -= n;
}
}
낙관적 락은 경합이 심할수록 재시도가 늘 수 있으니, “핫스팟 분산”과 함께 봐야 합니다.
5) 스레드풀/서버 동시성이 커넥션 풀보다 훨씬 크다
Tomcat(또는 Undertow/Netty) 스레드가 200인데 Hikari maxPoolSize가 10이면, DB를 조금이라도 쓰는 엔드포인트에서 쉽게 pending이 쌓입니다.
- 증상: 트래픽 증가 시 pending이 급증, 응답이 계단식으로 느려짐
해결:
- “애플리케이션 스레드 동시성”과 “DB 커넥션 동시성”을 같이 설계
- 무작정 풀을 키우기 전에 DB가 감당 가능한지 확인
server:
tomcat:
threads:
max: 100
spring:
datasource:
hikari:
maximum-pool-size: 30
connection-timeout: 30000
권장 접근은 다음 순서입니다.
- 먼저 쿼리/락/트랜잭션으로 점유 시간을 줄이고
- 그 다음에 필요한 만큼만 풀을 늘립니다.
6) 커넥션 검증/네트워크 이슈로 커넥션 생성이 지연
DB와의 네트워크가 불안정하거나, maxLifetime/keepaliveTime/DB idle timeout이 충돌하면 커넥션이 자주 끊기고 재생성 비용이 올라갑니다.
- 증상: idle이 충분한데도 커넥션 획득이 느림, 간헐적 타임아웃
해결 체크:
- DB의 idle timeout(예: MySQL
wait_timeout) 확인 - Hikari
maxLifetime을 DB idle timeout보다 짧게
spring:
datasource:
hikari:
max-lifetime: 1700000 # 예: 28분
keepalive-time: 300000 # 5분(상황에 따라)
validation-timeout: 3000
Kubernetes/EKS 환경이라면 노드/보안그룹/NAT 등 인프라 이슈로 외부 의존성이 느려져 트랜잭션이 길어지는 경우도 있습니다. (특히 외부 호출이 트랜잭션 안에 있을 때 치명적)
7) 애플리케이션 레벨의 “긴 작업(Long Task)”이 DB 점유를 유발
대용량 처리/동기 이벤트/직렬화 지연 등으로 요청 처리가 길어지면, 그 구간이 트랜잭션과 겹칠 때 커넥션이 장시간 점유됩니다.
프론트 성능 글이지만, “Long Task를 찾아 쪼개는 사고방식”은 서버에서도 유효합니다. 느린 구간을 분해해 “DB 점유 구간”을 줄이는 방향으로 접근하세요.
진단: 로그/메트릭으로 ‘고갈의 타입’을 먼저 분류하기
1) Actuator + Micrometer로 Hikari 메트릭 보기
Spring Boot는 Hikari 메트릭을 쉽게 노출할 수 있습니다.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
Prometheus 기준으로 자주 보는 지표:
hikaricp_connections_activehikaricp_connections_idlehikaricp_connections_pendinghikaricp_connections_timeout_total
패턴 해석:
active가 max에 붙고pending이 증가 → 점유 시간이 길거나(쿼리/락/트랜잭션), 누수idle이 0 근처인데active는 낮음 → 커넥션 생성/검증/네트워크 이슈 가능
2) 스레드 덤프로 “어디서 커넥션을 잡고 있는지” 확인
고갈 순간에 스레드 덤프를 뜨면, DB 호출에서 막혀 있는지(락/IO) 또는 애플리케이션 로직에서 트랜잭션을 붙잡고 있는지(외부 호출/대기)를 분리할 수 있습니다.
jstack <pid>- Kubernetes면
jcmd/jstack가 포함된 디버그 이미지 또는 ephemeral container 활용
3) Hikari leak detection으로 스택 트레이스 확보
앞서 설정한 leak-detection-threshold는 운영에서 상시 켜기보단, 문제 재현 구간에 짧게 켜서 스택을 확보하는 용도입니다.
해결: 코드/설정/DB 관점의 처방전
A. 코드 레벨: “커넥션 점유 시간”을 줄이는 것이 1순위
1) 외부 호출을 트랜잭션 밖으로
결제/메일/푸시/HTTP 호출을 트랜잭션 안에서 하면, 네트워크 지연이 곧 커넥션 점유로 전이됩니다.
- 트랜잭션 밖으로 이동
- 이벤트 발행 후 비동기 처리(단, 정확히 한 번 처리/중복 처리 고려)
2) 배치성 작업은 페이징 + 짧은 트랜잭션
대량 업데이트/정산을 한 트랜잭션으로 돌리면 풀을 장시간 점유합니다.
@Transactional
public void migrateOnce(int pageSize) {
int page = 0;
while (true) {
List<User> users = userRepository.findPage(PageRequest.of(page, pageSize));
if (users.isEmpty()) break;
for (User u : users) {
u.normalize();
}
userRepository.flush();
entityManager.clear();
page++;
}
}
상황에 따라 페이지 단위로 트랜잭션을 끊는 것이 더 안전합니다.
3) 읽기 트랜잭션 최적화
읽기만 하는데도 트랜잭션이 불필요하게 길어지는 경우가 있습니다.
@Transactional(readOnly = true)로 힌트 제공- 필요한 컬럼만 조회(프로젝션)
- N+1 제거(fetch join, batch size)
B. Hikari 설정: “풀을 키우기” 전에 지켜야 할 기준
1) maximumPoolSize 산정의 현실적인 기준
다음 질문에 답해야 합니다.
- DB 인스턴스가 동시에 처리 가능한 쿼리 수는?
- 평균 쿼리 시간(P95/P99)은?
- 애플리케이션 인스턴스 수(파드 수)는?
예를 들어 DB가 동시 200을 넘기면 급격히 느려지는 구조인데, 파드 10개가 각자 50개 풀을 가지면(총 500) 오히려 전체가 망가질 수 있습니다.
2) connectionTimeout을 무작정 늘리지 말기
connectionTimeout을 늘리면 “더 오래 기다리다 죽는” 상태가 됩니다. 보통은 빠르게 실패시키고, 상위에서 재시도/서킷브레이커/큐잉을 고려하는 편이 장애 전파를 줄입니다.
spring:
datasource:
hikari:
connection-timeout: 10000 # 10초 등으로 현실화
3) minimumIdle은 과도하게 높이지 않기
minimumIdle을 높이면 유휴 커넥션을 많이 유지해 초기 성능은 좋아질 수 있지만, DB 자원을 상시 점유합니다. 컨테이너 오토스케일 환경에서는 특히 비용/리스크가 커질 수 있습니다.
C. DB 관점: 락/인덱스/트랜잭션 격리 수준 점검
- 핫스팟 업데이트가 있는 테이블의 인덱스/쿼리 패턴 재검토
- 불필요한
SELECT ... FOR UPDATE제거 - 격리 수준이 과도하게 높아(예: SERIALIZABLE) 락이 늘어나지 않는지 확인
MySQL이라면 performance_schema, information_schema.innodb_trx, innodb_lock_waits 등을 통해 “누가 누구를 막는지”를 확인할 수 있습니다.
운영에서 자주 쓰는 “응급 처치”와 그 한계
1) 풀 사이즈 증가
- 효과: 일시적으로 타임아웃 감소
- 한계: DB가 병목이면 더 많은 동시 쿼리로 DB를 눌러 전체 지연이 악화될 수 있음
2) 문제 엔드포인트 차단/레이트 리밋
- 효과: 고갈 전파 차단
- 한계: 근본 원인(락/느린 쿼리/트랜잭션 범위)은 그대로
3) 파드 스케일 아웃
- 효과: CPU/스레드 병목이면 개선
- 한계: DB 커넥션 총량이 늘어 DB가 더 불안정해질 수 있음
결론: “커넥션 수”가 아니라 “점유 시간”을 줄여라
HikariCP 커넥션 고갈은 대개 풀 자체의 문제가 아니라, 커넥션이 반환되지 않거나(누수), 반환이 늦어지는(트랜잭션/락/느린 쿼리) 구조에서 시작합니다.
정리하면 우선순위는 다음이 안전합니다.
- 메트릭으로
active/pending/timeout패턴을 확인 - leak detection + 스레드 덤프로 “점유 지점”을 특정
- 트랜잭션 범위 축소, 외부 호출 분리, 느린 쿼리/락 해결
- 그 다음에만 풀/스레드/타임아웃을 합리적으로 조정
이 순서를 지키면 “풀만 키우다 더 큰 장애”로 가는 길을 피할 수 있습니다.