- Published on
Spring Boot 3 HikariCP 커넥션 고갈 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중 갑자기 API 지연이 길어지고, 결국 HikariPool-1 - Connection is not available, request timed out가 터지면 대부분 “DB가 느리다”로 결론 내리기 쉽습니다. 하지만 Spring Boot 3 환경에서 HikariCP 커넥션 고갈은 DB 성능 이슈 + 애플리케이션 코드/트랜잭션 경계 문제 + 풀 설정 불일치가 겹쳐서 발생하는 경우가 많습니다.
이 글은 증상부터 원인 분류, 재현/관측 방법, 그리고 실제로 효과가 큰 해결책을 순서대로 정리합니다. 목표는 단순히 풀 사이즈를 키우는 임시처방이 아니라, 커넥션을 오래 쥐고 있는 지점을 찾아 줄이고, 동시성 상한을 설계해 고갈을 구조적으로 막는 것입니다.
1) 커넥션 고갈의 전형적인 증상
다음 중 2개 이상이 동시에 보이면 “풀 고갈” 가능성이 큽니다.
- 애플리케이션 로그에
Connection is not available, request timed out after ...반복 - 응답 시간이 계단식으로 증가하다가 타임아웃 폭발
- DB CPU는 100%가 아닌데도 애플리케이션이 멈춘 듯 느려짐
- 스레드 덤프에서
com.zaxxer.hikari.pool.HikariPool.getConnection대기 스레드가 다수 - Actuator 메트릭에서
active가max에 붙고pending이 증가
풀 고갈은 “커넥션이 부족”이라기보다, 반납이 늦거나(혹은 안 되거나), 동시에 너무 많이 빌리려는 상황입니다.
2) 원인 분류: 무엇이 커넥션을 붙잡고 있나
2.1 트랜잭션 범위가 과도하게 넓음
@Transactional이 서비스 메서드 전체를 감싸고 있고, 그 안에서 외부 API 호출, 파일 I/O, 메시지 발행, 대용량 직렬화 같은 작업을 하면 DB 커넥션을 쥔 채로 오래 머무릅니다.
특히 Spring MVC에서 다음 패턴이 위험합니다.
- 트랜잭션 안에서 다른 마이크로서비스 호출
- 트랜잭션 안에서 대량 데이터 가공 후 응답 생성
- 트랜잭션 안에서 이벤트 발행 후 동기 처리
해결은 간단합니다. DB 작업만 트랜잭션으로 묶고, 나머지는 트랜잭션 밖으로 빼거나 비동기/이벤트로 분리합니다.
2.2 커넥션 누수(반납되지 않음)
JPA/Hibernate를 쓰면 “내가 커넥션을 닫지 않았는데?”라는 착각이 생깁니다. 하지만 다음 케이스는 누수로 이어질 수 있습니다.
JdbcTemplate/DataSource를 직접 쓰면서try-with-resources누락- 예외 처리 흐름에서
ResultSet/Statement가 닫히지 않음 - 커스텀 코드에서
Connection을 보관(캐싱)하거나 다른 스레드로 넘김
HikariCP는 누수 추적 기능이 있으므로, 먼저 누수 여부를 계측으로 확정하는 게 좋습니다.
2.3 느린 쿼리/락 대기/인덱스 부재
풀은 “동시 요청 수”만큼 커넥션을 빌려주는데, 쿼리가 느리면 커넥션 점유 시간이 늘어납니다. 결과적으로 동일 QPS에서도 active가 올라가고 고갈이 빨라집니다.
특히 PostgreSQL이라면 VACUUM 지연, 테이블/인덱스 팽창, 락 경합이 느린 쿼리를 유발하기도 합니다. DB 쪽 점검이 필요하면 이 글도 함께 보세요.
2.4 동시성 폭주: 스레드 풀과 커넥션 풀 불일치
Tomcat(또는 Undertow/Netty) 스레드가 200개인데, Hikari maximumPoolSize가 10이면 순간적으로 10개를 넘어서는 DB 접근 요청이 모두 대기열로 쌓입니다.
반대로 커넥션 풀을 무작정 크게 잡으면 DB가 감당 못하고 전체가 느려져 “느림으로 인한 고갈”이 더 심해질 수 있습니다.
핵심은 웹 스레드, 비동기 실행기, 배치/스케줄러 동시성, 커넥션 풀을 한 세트로 맞추는 것입니다.
2.5 타임아웃 설정의 부조화
connectionTimeout이 너무 길면 대기 스레드가 오래 쌓여 장애가 확대됩니다.- DB/네트워크 문제로 커넥션이 죽었는데
maxLifetime/keepaliveTime이 부적절하면 “죽은 커넥션을 오래 들고” 문제가 커집니다.
또한 ALB, Nginx, 앱, DB의 타임아웃이 서로 다르면 “상위는 끊었는데 하위는 계속 실행” 같은 유령 작업이 생겨 커넥션 점유가 늘어날 수 있습니다.
3) 먼저 관측부터: 로그·메트릭으로 고갈을 확정하기
3.1 Actuator + Micrometer로 풀 상태 보기
Spring Boot 3에서 Actuator를 켜면 Hikari 메트릭이 노출됩니다.
application.yml 예시입니다.
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
spring:
datasource:
hikari:
pool-name: main
Prometheus를 쓴다면 다음 지표를 봅니다.
hikaricp_connections_activehikaricp_connections_idlehikaricp_connections_pendinghikaricp_connections_max
패턴 해석:
active가max에 붙고pending이 증가한다면 고갈pending이 0인데도 느리다면 DB가 아니라 앱 내부 병목일 수 있음
3.2 누수 의심이면 leakDetectionThreshold를 임시로 켜기
운영에 상시 적용은 부담이 될 수 있으니, 장애 재현 구간에 제한적으로 켭니다.
spring:
datasource:
hikari:
leak-detection-threshold: 5000
connection-timeout: 3000
leak-detection-threshold는 “커넥션을 빌린 뒤 설정 시간 내 반납이 안 되면 스택트레이스 출력”입니다.- 너무 낮게 잡으면 정상적으로 오래 걸리는 트랜잭션도 누수처럼 보일 수 있으니, 먼저 5초~10초로 시작합니다.
3.3 스레드 덤프로 대기 원인 확인
커넥션을 기다리는 스레드가 많다면 스레드 덤프에서 다음을 찾습니다.
HikariPool.getConnection에서 BLOCKED 또는 WAITING- 그 위 호출 스택이 어떤 서비스/리포지토리로 이어지는지
이때 중요한 건 “누가 커넥션을 못 빌리나”가 아니라, **“누가 커넥션을 오래 쥐고 있나”**를 찾는 것입니다.
4) 코드 레벨 해결: 커넥션 점유 시간을 줄이는 패턴
4.1 트랜잭션 안에서 외부 호출 금지
나쁜 예시는 다음과 같습니다.
@Transactional
public OrderResult placeOrder(OrderCommand cmd) {
Order order = orderRepository.save(new Order(cmd));
// 외부 호출이 느리면 커넥션을 쥔 채로 대기
paymentClient.pay(order.getId(), cmd.amount());
order.markPaid();
return OrderResult.from(order);
}
개선 예시: DB 트랜잭션을 최소화하고, 외부 호출은 트랜잭션 밖에서 수행합니다.
public OrderResult placeOrder(OrderCommand cmd) {
Long orderId = createOrder(cmd); // 짧은 트랜잭션
paymentClient.pay(orderId, cmd.amount()); // 트랜잭션 밖
markPaid(orderId); // 짧은 트랜잭션
return new OrderResult(orderId);
}
@Transactional
public Long createOrder(OrderCommand cmd) {
Order order = orderRepository.save(new Order(cmd));
return order.getId();
}
@Transactional
public void markPaid(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
일관성 요구가 더 높다면 아웃박스 패턴, 사가 패턴 등으로 확장하는 게 일반적입니다.
4.2 스트리밍 조회는 반드시 닫기
JPA 스트림 조회는 커넥션을 오래 잡을 수 있습니다. 다음처럼 try-with-resources로 닫아야 합니다.
@Transactional(readOnly = true)
public void exportUsers(Writer writer) throws IOException {
try (var stream = userRepository.streamAll()) {
stream.forEach(u -> {
try {
writer.write(u.getId() + "," + u.getEmail() + "\n");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
또한 “export” 같은 작업을 웹 요청 트랜잭션으로 처리하면 고갈이 쉬우니, 배치/비동기 작업으로 분리하는 편이 안전합니다.
4.3 JdbcTemplate/JDBC 직접 사용 시 자원 해제 강제
다음처럼 커넥션을 직접 열면 누수 위험이 큽니다.
public void bad(DataSource ds) throws SQLException {
Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement("select 1");
ps.execute();
// close 누락 가능
}
반드시 try-with-resources를 씁니다.
public void good(DataSource ds) throws SQLException {
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement("select 1")) {
ps.execute();
}
}
5) 설정 레벨 해결: HikariCP를 “현실적인 상한”으로 맞추기
5.1 기본 권장: 풀을 키우기 전에 쿼리/트랜잭션부터 줄이기
풀 사이즈 증설은 마지막 단계가 좋습니다. 이유는 간단합니다.
- 느린 쿼리로 점유 시간이 긴 상태에서 풀만 키우면 DB 동시 실행이 늘어 DB가 더 느려질 수 있음
- 결과적으로 “더 큰 풀”도 결국 고갈
5.2 실전 설정 예시(출발점)
아래는 흔히 쓰는 출발점입니다. 서비스 특성에 맞춰 조정해야 합니다.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 3000
validation-timeout: 1000
idle-timeout: 600000
max-lifetime: 1800000
keepalive-time: 300000
조정 가이드:
maximum-pool-size: DB가 감당 가능한 동시 쿼리 수를 기준으로 잡습니다. “웹 스레드 수”가 아니라 “DB 동시 처리 가능량”이 기준입니다.connection-timeout: 장애 확산을 막기 위해 보통 짧게(예: 1초~3초) 둡니다.max-lifetime: DB나 프록시가 커넥션을 끊는 시간보다 약간 짧게 둡니다.keepalive-time: NAT/방화벽 환경에서 유휴 커넥션이 끊기는 문제가 있다면 도움이 됩니다.
5.3 웹 스레드와의 균형
Tomcat을 쓴다면 다음 조합을 같이 봅니다.
server:
tomcat:
threads:
max: 200
- DB 접근이 많은 API라면
tomcat.threads.max를 무작정 높이지 말고, 오히려 낮추거나 엔드포인트별 레이트 리밋을 적용해 “DB로 가는 동시성”을 제어하는 편이 낫습니다. - 비동기 실행기(
TaskExecutor)를 쓰는 경우에도 그 스레드 수가 곧 DB 동시성으로 이어질 수 있습니다.
6) DB/쿼리 측면 해결: 커넥션을 빨리 돌려주게 만들기
6.1 슬로우 쿼리와 인덱스
풀 고갈 상황에서 가장 효과가 큰 처방은 상위 N개 느린 쿼리를 줄이는 것인 경우가 많습니다.
- 슬로우 쿼리 로그 활성화
- 실행 계획 확인
- 적절한 인덱스 추가
- 불필요한
select *제거 - 페이징/커서 기반 처리
6.2 락 경합 줄이기
- 동일 row를 여러 트랜잭션이 갱신하는 구조라면 “업데이트 폭”을 줄이거나, 큐/샤딩/낙관적 락으로 변경
- 긴 트랜잭션에서 갱신을 늦게 하는 패턴 제거
7) 재발 방지 체크리스트
hikaricp_connections_pending알람: 0이 아닌 상태가 일정 시간 유지되면 경보- 누수 추적 로그는 상시가 아니라 “문제 구간”에 한해 임시 적용
- 트랜잭션 안에서 외부 호출/대용량 처리 금지 규칙화
- 배치/스케줄러/비동기 작업의 동시성 상한을 커넥션 풀과 함께 설계
- 타임아웃을 계층별로 정렬: 클라이언트, 게이트웨이, 앱, DB 순으로 일관성 있게
8) 결론: 풀을 키우기 전에 “점유 시간”을 줄여라
Spring Boot 3에서 HikariCP 커넥션 고갈은 대개 다음 순서로 해결됩니다.
- 메트릭으로
active/max/pending패턴을 확인해 고갈을 확정 - 누수인지(반납 안 됨) vs 점유 시간이 긴지(반납이 늦음) 분리
- 트랜잭션 경계 축소, 외부 호출 분리, 스트리밍 자원 닫기 등으로 점유 시간 단축
- 느린 쿼리/락 경합을 줄여 커넥션이 빨리 돌아오게 만들기
- 마지막으로 풀/스레드/타임아웃을 현실적인 상한으로 정렬
이 과정을 거치면 “가끔 터지는 장애”에서 “예측 가능한 용량 관리” 단계로 넘어갈 수 있습니다.