- Published on
Spring Boot 대용량 트래픽 HikariCP 고갈 진단·튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 갑자기 느려지더니 HikariPool-1 - Connection is not available, request timed out after ...ms 로그가 쏟아지고, API 지연이 폭증하는 상황은 대용량 트래픽에서 자주 마주칩니다. 문제는 단순히 maximumPoolSize 를 키우는 것으로 끝나지 않는다는 점입니다. 커넥션 풀 고갈은 보통 애플리케이션 스레드가 커넥션을 오래 쥐고 있거나, DB가 느려져 커넥션 반환이 지연되거나, 트래픽 패턴 대비 풀과 스레드/DB 자원이 불균형할 때 발생합니다.
이 글은 Spring Boot 환경에서 HikariCP 고갈을 재현 가능한 관찰 지표로 진단하고, 안전한 튜닝 순서와 실전 설정 예시를 정리합니다.
1) HikariCP 고갈이 의미하는 것
HikariCP 풀은 “동시에 DB 작업을 수행할 수 있는 티켓 수”에 가깝습니다. 풀 고갈은 다음 중 하나를 뜻합니다.
- 커넥션을 빌린 스레드가 너무 오래 반환하지 않음
- DB 쿼리 또는 트랜잭션이 너무 오래 걸림
- 애플리케이션의 동시 요청 수 대비 풀 크기가 구조적으로 부족
- 커넥션이 죽었는데 검증/재연결이 지연되어 유효 커넥션 수가 감소
즉, 풀은 증상이고 원인은 대개 쿼리, 트랜잭션 경계, 락, I/O, 스레드 모델, DB max connections 쪽에 있습니다.
2) 먼저 확인할 관찰 포인트: 로그, 메트릭, 스레드 덤프
2.1 HikariCP 핵심 메트릭
Micrometer를 쓴다면 HikariCP는 보통 다음 메트릭을 제공합니다.
hikaricp.connections.active: 현재 사용 중hikaricp.connections.idle: 유휴hikaricp.connections.pending: 커넥션 대기 중인 요청 수hikaricp.connections.max: 최대 풀 크기hikaricp.connections.timeout: 타임아웃 발생 횟수
관찰 팁:
active가max에 붙고pending이 증가하면 고갈 확정active가 낮은데도 타임아웃이 나면 커넥션 검증/네트워크/DB 장애 가능성pending이 순간적으로 튀는지, 지속적으로 증가하는지로 “스파이크”와 “지속 병목”을 구분
2.2 타임아웃 로그를 “정상적 경보”로 만들기
Hikari 타임아웃은 보통 애플리케이션 레벨에서는 500으로 보이지만, 운영 관점에서는 SLO 위반의 선행 지표입니다. 타임아웃을 단순히 늘리면 문제를 숨기는 효과가 큽니다.
connectionTimeout을 늘리기 전에pending과active패턴을 먼저 확인- 타임아웃이 발생한 시점의 슬로우 쿼리, 락 대기, GC, CPU 스파이크를 함께 상관 분석
2.3 스레드 덤프로 “커넥션을 쥔 채 무엇을 하는지” 확인
풀 고갈의 가장 빠른 진단은 스레드 덤프입니다.
- 웹 스레드가
getConnection에서 대기 중인지 - 커넥션을 얻은 뒤 JDBC 호출에서 막혔는지
- 트랜잭션 안에서 외부 API 호출, 파일 I/O, 락 대기 등이 있는지
덤프 예시 명령은 환경에 따라 다르지만, 컨테이너라면 jcmd 나 jstack 를 활용합니다.
# PID 확인 후 스레드 덤프
jcmd `pidof java` Thread.print > /tmp/threaddump.txt
덤프에서 com.zaxxer.hikari.pool.HikariPool.getConnection 근처에 WAITING 스레드가 많으면 풀 대기가 실제 병목입니다.
3) 대표 원인 7가지와 확인 방법
3.1 트랜잭션 범위가 과도하게 큼
가장 흔한 패턴은 @Transactional 내부에서 DB 작업 후 외부 호출을 하거나, 큰 루프를 돌며 오래 잡고 있는 경우입니다.
- 트랜잭션은 “DB 일”만 감싸고, 외부 I/O는 밖으로 빼기
- 배치성 루프는 페이지/청크로 나누고 커밋 단위를 줄이기
@Service
public class OrderService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
public OrderService(PaymentClient paymentClient, OrderRepository orderRepository) {
this.paymentClient = paymentClient;
this.orderRepository = orderRepository;
}
// 나쁜 예: 트랜잭션 안에서 외부 API 호출
@Transactional
public void payBad(Long orderId) {
var order = orderRepository.findById(orderId).orElseThrow();
paymentClient.requestPayment(order); // 여기서 지연되면 커넥션을 오래 점유
order.markPaid();
}
// 개선 예: 외부 호출은 트랜잭션 밖, DB 변경만 트랜잭션으로
public void payGood(Long orderId) {
var order = orderRepository.findById(orderId).orElseThrow();
paymentClient.requestPayment(order);
markPaid(orderId);
}
@Transactional
protected void markPaid(Long orderId) {
var order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
}
3.2 슬로우 쿼리, 인덱스 부재, N+1
쿼리가 느리면 커넥션 반환이 늦어져 풀 고갈로 이어집니다. 특히 JPA 환경에서는 N+1이 트래픽 구간에서 “폭발”하며 커넥션을 잠식합니다.
- DB 슬로우 쿼리 로그 활성화
- APM에서 쿼리 수와 총 쿼리 시간을 함께 보기
- N+1 의심 시 fetch join, batch size, DTO projection 검토
관련해서 JPA N+1 튜닝은 별도 글로 정리했습니다: Spring Boot 3 JPA N+1 폭발, fetch join 튜닝 실전
3.3 락 경합과 트랜잭션 격리 수준
특정 테이블 업데이트가 몰리면 락 대기가 발생하고, 대기 중인 트랜잭션이 커넥션을 계속 점유합니다.
- DB에서 락 대기 뷰 확인
- 업데이트 핫스팟 키를 분산하거나, 큐잉/샤딩/비동기 처리 고려
- 불필요하게 높은 격리 수준 사용 여부 점검
3.4 커넥션 누수
코드에서 커넥션을 닫지 않는 누수는 Hikari가 leakDetectionThreshold 로 탐지할 수 있습니다. 다만 이 값이 너무 낮으면 정상 쿼리도 누수처럼 보일 수 있어 운영에서는 신중하게 사용합니다.
spring:
datasource:
hikari:
leak-detection-threshold: 20000 # 20초 이상 점유 시 스택트레이스 로깅
누수가 의심되면:
- JDBC 템플릿/ORM 외에 직접
DataSource.getConnection을 쓰는 구간 점검 - 스트리밍 결과를 열어둔 채로 오래 처리하는 코드 점검
3.5 풀 크기와 웹 스레드/동시성 모델 불일치
Tomcat 기본 스레드 수가 크고, 각 요청이 DB를 사용한다면 순간적으로 풀 대기가 폭발할 수 있습니다.
- 웹 스레드 수를 무작정 키우면 DB에 동시에 더 많은 부하를 걸어 악화 가능
- “동시 DB 작업 수”는 풀 크기로 제한되므로, 애플리케이션의 동시 처리 전략을 일치시키는 것이 중요
실무적으로는 다음을 함께 봅니다.
server.tomcat.threads.maxspring.datasource.hikari.maximum-pool-size- 요청당 평균 DB 점유 시간
3.6 DB max_connections 및 RDS 프록시/네트워크 이슈
애플리케이션에서 풀을 늘리기 전에 DB가 허용하는 총 커넥션 수를 확인해야 합니다. 여러 인스턴스가 있으면 “인스턴스 수 x 풀 크기”가 DB 한도를 초과할 수 있습니다.
- DB
max_connections대비 여유 확인 - 커넥션 생성 폭증이 있다면
minimumIdle과maxLifetime조합 점검 - 네트워크 단절이 잦다면 keepalive, 타임아웃,
maxLifetime을 DB 측 idle timeout 보다 짧게 설정
3.7 장애 시 재시도 폭주로 인한 2차 고갈
DB나 외부 의존성이 느려지면 재시도가 폭주하고, 더 많은 스레드가 더 오래 커넥션을 기다리며 악순환이 생깁니다. 이때는 백오프, 큐잉, 벌크헤드가 필요합니다.
재시도/큐잉 패턴은 다음 글도 참고할 만합니다: OpenAI 429/Rate Limit 재시도·큐잉 패턴 7가지
4) 튜닝의 올바른 순서: 풀을 키우기 전에 할 일
4.1 1단계: 쿼리와 트랜잭션 시간을 줄인다
풀 고갈은 “점유 시간” 문제인 경우가 많습니다.
- 슬로우 쿼리 제거, 인덱스 추가
- N+1 제거
- 트랜잭션 범위 축소
- 락 경합 완화
점유 시간이 절반이 되면, 사실상 같은 풀 크기로 처리량이 2배가 됩니다.
4.2 2단계: 동시성 제한과 타임아웃을 설계한다
- API 레벨에서 동시 요청을 제한하거나
- 특정 기능을 큐로 넘기거나
- DB 접근을 벌크헤드로 격리
예를 들어 Resilience4j 벌크헤드로 DB 의존 구간의 동시성을 제한할 수 있습니다.
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(50)
.maxWaitDuration(Duration.ofMillis(200))
.build();
Bulkhead bulkhead = Bulkhead.of("dbBulkhead", config);
Supplier<String> supplier = Bulkhead.decorateSupplier(bulkhead, () -> {
// DB 호출
return "ok";
});
핵심은 “풀 타임아웃이 터지기 전에” 애플리케이션 레벨에서 빠르게 실패하거나 대기열로 흡수하는 것입니다.
4.3 3단계: 그 다음에 HikariCP 파라미터를 조정한다
풀 크기 증설은 DB와 애플리케이션 전체에 영향을 주므로 마지막에 합니다.
5) HikariCP 주요 설정값과 실전 권장 접근
아래는 자주 쓰는 옵션과 해석입니다.
5.1 maximumPoolSize
- 동시에 DB 작업을 수행할 수 있는 최대 커넥션 수
- 너무 작으면 대기가 늘고, 너무 크면 DB CPU/락 경합/컨텍스트 스위칭이 증가
권장 접근:
- 먼저 “요청당 평균 DB 점유 시간”을 측정
- 목표 TPS와 지연을 기준으로 필요한 동시 DB 작업 수를 계산
- 인스턴스 수를 고려해 DB 총 커넥션 한도 내에서 배분
5.2 minimumIdle
- 유휴 커넥션을 최소로 유지
- 트래픽 스파이크가 잦다면 너무 낮으면 순간적인 커넥션 생성 비용이 커질 수 있음
다만 Hikari는 기본적으로 효율적이라, 운영에서는 minimumIdle 을 maximumPoolSize 와 동일하게 두기보다는 상황에 맞게 조정합니다.
5.3 connectionTimeout
- 커넥션을 기다리는 최대 시간
- 늘리면 “대기열”이 길어져 지연이 늘고 스레드가 묶입니다
권장:
- 애플리케이션의 API 타임아웃, 로드밸런서 타임아웃과 정합성 있게 설정
- 보통은 수 초 단위로 두고, 장기 대기는 피합니다
5.4 maxLifetime 와 idleTimeout
maxLifetime: 커넥션을 강제로 교체하는 최대 수명idleTimeout: 유휴 커넥션을 정리하는 시간
주의:
- DB나 프록시가 커넥션을 특정 시간 이후 끊는다면,
maxLifetime을 그보다 약간 짧게 두는 편이 안전 - 너무 짧으면 커넥션 교체가 잦아져 오버헤드 증가
5.5 validationTimeout 및 커넥션 테스트
대부분의 경우 Hikari는 JDBC4 isValid 로 충분히 검증합니다. 별도의 테스트 쿼리를 넣는 것은 DB에 불필요한 부하가 될 수 있습니다.
6) Spring Boot 설정 예시: 운영 기준 템플릿
아래 예시는 “무난한 출발점”입니다. 실제 값은 트래픽, 쿼리 시간, DB 한도에 따라 조정해야 합니다.
spring:
datasource:
url: jdbc:postgresql://db:5432/app
username: app
password: secret
hikari:
pool-name: app-hikari
maximum-pool-size: 30
minimum-idle: 10
connection-timeout: 2000
validation-timeout: 1000
idle-timeout: 600000
max-lifetime: 1740000
server:
tomcat:
threads:
max: 200
해석 포인트:
connection-timeout을 짧게 두면, 풀 고갈 시 빠르게 실패하며 장애 전파를 줄일 수 있습니다- Tomcat 스레드는 풀보다 훨씬 클 수 있지만, 그 경우
pending이 늘 때 웹 스레드가 대기하며 지연이 커질 수 있으니 “빠른 실패” 또는 “상위 레벨 큐잉”이 필요합니다
7) 고갈 재현과 부하 테스트 체크리스트
튜닝은 반드시 부하 테스트로 검증해야 합니다.
- 목표 TPS에서
active가max에 지속적으로 붙는가 pending이 선형으로 증가하는가- p95, p99 지연이 어디서 꺾이는가
- 슬로우 쿼리/락/CPU/GC 중 무엇이 먼저 한계에 도달하는가
부하 테스트 중에는 다음을 함께 수집하면 원인 분리가 쉬워집니다.
- 애플리케이션: Hikari 메트릭, GC 로그, 스레드 덤프
- DB: 슬로우 로그, CPU/IOPS, 락 대기, 커넥션 수
- 인프라: 노드 CPU throttling, 네트워크 지연
컨테이너 환경에서 리소스 문제로 앱이 불안정해지면 커넥션 풀 문제처럼 보일 수 있습니다. 장애 루프 진단은 다음 글도 도움이 됩니다: Kubernetes CrashLoopBackOff 원인 7가지와 재현·해결
8) 운영에서 자주 하는 실수와 안전장치
8.1 maximumPoolSize 만 크게 올리기
풀을 키우면 단기적으로 타임아웃은 줄 수 있지만, DB가 감당하지 못하면 쿼리가 더 느려져 결국 다시 고갈됩니다. 특히 락 경합이 있는 워크로드는 커넥션 수 증가가 오히려 지연을 증폭시킬 수 있습니다.
8.2 타임아웃을 과도하게 늘리기
connectionTimeout 을 30초, 60초로 늘리면 장애 시 스레드가 장시간 대기하면서 애플리케이션이 “서서히 죽는” 형태가 됩니다.
- 상위 레벨 타임아웃과 일관된 짧은 타임아웃
- 재시도는 백오프와 상한을 두기
- 빠른 실패 후 큐잉 또는 폴백
8.3 커넥션 누수 탐지를 상시 켜두기
leakDetectionThreshold 는 유용하지만, 스택 트레이스 로깅 비용이 있고 노이즈를 만들 수 있습니다.
- 장애 구간에 한시적으로 활성화
- 임계값은 “정상 쿼리 p99”보다 충분히 크게
9) 결론: 풀 고갈은 “DB 동시성 설계” 문제다
HikariCP 커넥션 풀 고갈은 단순 설정 문제가 아니라, 애플리케이션과 DB 사이의 동시성 계약이 깨졌다는 신호입니다. 해결의 우선순위는 다음 순서가 안전합니다.
- 쿼리/트랜잭션 시간 단축
- 락 경합 제거, N+1 제거
- 재시도 폭주 방지, 동시성 제한(벌크헤드) 도입
- DB
max_connections를 고려해 풀 크기 조정 - 마지막으로 Hikari 파라미터 미세 조정
이 순서대로 접근하면 “풀을 키워서 잠깐 버티는” 방식이 아니라, 대용량 트래픽에서도 예측 가능한 지연과 안정성을 확보할 수 있습니다.