- Published on
Spring Boot HikariCP 커넥션 고갈 원인·해결 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Spring Boot API가 갑자기 느려지고, 결국 503 또는 타임아웃으로 무너지는 패턴을 보면 상당수가 DB 커넥션 풀 고갈에서 시작합니다. HikariCP는 빠르고 안정적인 풀 구현이지만, 풀 자체가 만능은 아닙니다. 애플리케이션이 커넥션을 오래 쥐고 있거나, 트랜잭션 경계가 잘못되었거나, 풀 사이징이 트래픽과 DB 용량에 맞지 않으면 HikariPool-1 - Connection is not available, request timed out after ... 같은 로그와 함께 장애로 이어집니다.
이 글에서는 Spring Boot 환경에서 HikariCP 커넥션 고갈이 발생하는 원인 9가지를 실제로 자주 만나는 형태로 분류하고, 각 항목별로 진단 포인트와 해결책(설정, 코드, 운영)을 함께 정리합니다.
문제의 성격상 애플리케이션만 보는 것보다 인프라 관점도 함께 보는 것이 좋습니다. 예를 들어 쿠버네티스에서 503이 발생한다면 앱 내부의 풀 고갈이 원인일 수도 있으니, 외부 증상과 함께 교차 점검을 권합니다: EKS에서 503 Service Unavailable 원인 10분 진단
커넥션 고갈을 먼저 확인하는 3가지 신호
1) 전형적인 HikariCP 로그
아래 메시지가 반복된다면 거의 확정입니다.
Connection is not available, request timed out after ...Pool stats (total=..., active=..., idle=..., waiting=...)
2) 지표에서 보는 패턴
active가maximumPoolSize에 붙어서 내려오지 않음waiting이 증가- API p95, p99가 계단식으로 상승
- DB 쿼리 시간보다 애플리케이션 대기 시간이 더 큼
3) 스레드 덤프/프로파일에서 보는 패턴
- 많은 스레드가 커넥션 획득 대기
- 또는 트랜잭션 안에서 외부 API 호출, 파일 IO 등으로 블로킹
기본 방어 설정(필수)
원인 분석 전에, 운영에서 최소한으로 켜두면 좋은 HikariCP 안전장치입니다.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 3000
validation-timeout: 1000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 2000
connection-timeout을 너무 길게 두면 장애 전파가 느려집니다. 빠르게 실패하고 상위 레이어에서 폴백/서킷브레이커로 처리하는 편이 낫습니다.leak-detection-threshold는 상시 켜두면 오탐이 생길 수 있지만, 고갈 이슈가 있을 때는 매우 유용합니다.
또한 Actuator와 Micrometer를 사용하면 풀 상태를 지표로 쉽게 관찰할 수 있습니다.
implementation "org.springframework.boot:spring-boot-starter-actuator"
runtimeOnly "io.micrometer:micrometer-registry-prometheus"
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
원인·해결 9가지
1) 커넥션 릭(반납 누락)
가장 고전적이고, 여전히 자주 발생합니다. JDBC를 직접 쓰거나, 프레임워크 경계를 우회할 때 특히 빈번합니다.
증상
- 트래픽이 낮아도 시간이 지날수록
active가 서서히 증가 - 재시작하면 잠깐 정상, 다시 고갈
진단
leak-detection-threshold로그에서 스택트레이스 확인
해결
try-with-resources로Connection,PreparedStatement,ResultSet을 반드시 닫기
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// ...
}
}
- Spring JDBC 또는 JPA 사용 시에는 트랜잭션 경계 밖에서 엔티티매니저를 직접 들고 다니지 않기
2) 트랜잭션 범위가 과도하게 큼
@Transactional이 “DB 작업 구간”이 아니라 “요청 전체”를 감싸는 순간, 커넥션을 필요 이상으로 오래 점유합니다.
흔한 실수
- 트랜잭션 안에서 외부 API 호출
- 트랜잭션 안에서 대용량 파일 업로드/다운로드
- 트랜잭션 안에서 메시지 큐 publish 후 응답 대기
해결
- DB 작업만 트랜잭션으로 묶고, 외부 호출은 트랜잭션 밖으로 분리
public void placeOrder(PlaceOrderCommand cmd) {
Long orderId = txCreateOrder(cmd); // 트랜잭션 구간
notifyExternal(orderId); // 트랜잭션 밖
}
@Transactional
public Long txCreateOrder(PlaceOrderCommand cmd) {
// DB 작업만 수행
return orderRepository.save(...).getId();
}
- 읽기 전용은
@Transactional(readOnly = true)로 힌트 제공(특히 JPA flush 동작 억제에 도움)
3) N+1 쿼리 또는 비효율 쿼리로 커넥션 점유 시간이 증가
커넥션 고갈은 “커넥션 수” 문제이기도 하지만, 더 본질적으로는 “커넥션 점유 시간” 문제입니다. 쿼리가 느리면 동일 트래픽에서도 훨씬 빨리 풀이 찹니다.
진단
- 슬로우 쿼리 로그
- APM에서 DB span이 길게 늘어짐
- 특정 API에서만 고갈
해결
- JPA라면 fetch join, batch size, DTO projection 등으로 N+1 제거
- 인덱스 추가, 쿼리 리라이트
- 페이지네이션 시 count 쿼리 비용 점검
4) 풀 사이징을 무작정 키움(앱은 버티는데 DB가 죽음)
maximumPoolSize를 크게 올리면 일시적으로 타임아웃은 줄어드는 것처럼 보일 수 있습니다. 하지만 DB의 동시 처리 한계를 넘으면 락/컨텍스트 스위칭/IO 경합으로 DB가 더 느려지고, 결국 전체가 더 나빠집니다.
권장 접근
- “앱 인스턴스 수” 곱하기 “최대 풀 사이즈”가 DB가 감당할 수 있는 동시 커넥션/쿼리 처리량을 넘지 않게
- DB
max_connections와 실제 워크로드(OLTP, OLAP)를 함께 고려
실전 가이드
- 먼저 쿼리 최적화 및 트랜잭션 축소로 점유 시간을 줄이고
- 그 다음에 필요한 만큼만 풀 사이즈를 키우기
5) maxLifetime 미조정으로 네트워크 장비/DB에 의해 커넥션이 끊김
클라우드 환경에서는 NAT, 로드밸런서, 방화벽, DB 프록시 등이 유휴 커넥션을 중간에서 끊는 일이 흔합니다. 이때 HikariCP가 “죽은 커넥션”을 잡고 있다가 재사용 시점에 오류가 나고, 재시도 폭풍으로 풀 고갈처럼 보이기도 합니다.
해결
maxLifetime을 인프라 idle timeout보다 짧게(보통 30분보다 조금 낮게 시작)keepaliveTime(필요 시)로 유휴 커넥션 유지
spring:
datasource:
hikari:
max-lifetime: 1500000
keepalive-time: 300000
6) 커넥션 획득 대기 시간이 너무 길어 장애를 키움
connectionTimeout이 길면, 스레드가 오래 대기하면서 톰캣/넷티 워커 스레드까지 묶입니다. 결과적으로 앱 전체가 멈춘 것처럼 보입니다.
해결
connection-timeout을 짧게(예: 1초~3초) 설정- 상위 레이어에서 빠른 실패 처리
- 재시도는 반드시 제한하고 지터를 적용(무한 재시도는 풀을 더 빨리 고갈시킴)
7) 읽기 트래픽과 쓰기 트래픽이 같은 풀을 공유
읽기 API가 급증하면 쓰기 트랜잭션까지 커넥션을 못 잡아 장애가 커집니다. 특히 읽기 쿼리가 무겁거나, 리포팅성 조회가 섞여 있으면 더 위험합니다.
해결
- 읽기 전용 데이터소스(리드 레플리카) 분리
- 풀도 분리해서 서로 영향 차단
spring:
datasource:
write:
hikari:
maximum-pool-size: 15
read:
hikari:
maximum-pool-size: 30
- 라우팅 데이터소스 또는 CQRS 패턴 적용
8) 동기 블로킹 모델에서 동시성 제한이 없음(요청 폭주 시 풀 선점)
톰캣 기반의 전형적인 동기 MVC는 요청 수가 늘수록 스레드가 늘고, 스레드가 늘수록 커넥션을 동시에 잡으려 듭니다. 결국 풀 고갈이 “트래픽 리미터”처럼 동작합니다.
해결
- 서버 레벨 동시성 제한(예: 톰캣 스레드/큐 조정)
- API 레벨 벌크헤드(세마포어), 레이트 리미팅
- DB를 반드시 쓰지 않아도 되는 요청은 캐시로 우회
server:
tomcat:
threads:
max: 200
accept-count: 100
스레드 수를 무작정 키우는 것이 아니라, DB 처리량과 균형을 맞추는 것이 핵심입니다.
9) 커넥션 풀 고갈의 “진짜 원인”이 DB 락/대기(Blocking)인 경우
애플리케이션에서 보기에는 커넥션이 부족해 보이지만, 실제로는 DB에서 락 대기나 장시간 트랜잭션 때문에 쿼리가 끝나지 않아 커넥션이 반환되지 않는 경우가 많습니다.
진단
- DB에서 lock wait, deadlock, long transaction 확인
- 특정 테이블/인덱스에 경합 집중
해결
- 트랜잭션을 더 짧게
- 락 범위를 줄이는 쿼리/인덱스 설계
- 격리 수준 점검
- 배치/정산 작업을 OLTP 피크 시간에 돌리지 않기
재현·관찰을 위한 최소 코드(부하로 고갈 확인)
아래는 커넥션을 오래 점유하는 요청을 만들어 풀 고갈을 관찰하는 예시입니다. 운영에 넣지 말고 로컬/스테이징에서만 사용하세요.
@RestController
@RequiredArgsConstructor
public class HoldConnectionController {
private final JdbcTemplate jdbcTemplate;
@GetMapping("/test/hold")
@Transactional
public String hold(@RequestParam(defaultValue = "3000") long sleepMs) throws Exception {
jdbcTemplate.queryForObject("select 1", Integer.class);
Thread.sleep(sleepMs); // 트랜잭션 안에서 커넥션을 쥐고 대기
return "ok";
}
}
- 위 엔드포인트에 동시 요청을 걸면
active가 빠르게maximumPoolSize에 도달합니다. - 같은 패턴이 운영 코드 어디엔가 숨어 있다면(외부 호출, 대기, 대용량 처리), 그것이 고갈의 출발점이 됩니다.
운영 체크리스트(빠른 결론)
leak-detection-threshold로 릭 여부부터 확인- 트랜잭션 안의 블로킹 작업 제거(외부 호출/대기/파일 IO)
- 슬로우 쿼리 및 N+1 제거로 점유 시간 단축
connection-timeout은 짧게, 재시도는 제한적으로- 풀 사이즈는 DB 한계와 앱 인스턴스 수를 함께 고려
- 읽기/쓰기 풀 분리로 장애 전파 차단
- DB 락/장기 트랜잭션이 원인인지 반드시 확인
인증/인가 문제로 401이 늘며 재시도가 폭증하는 상황도 결과적으로 DB 부하와 풀 고갈을 촉발할 수 있습니다. 보안 계층에서 반복 실패가 발생한다면 아래 글처럼 원인을 먼저 제거하는 것도 도움이 됩니다: Spring Security JWT 401 원인 - 시계오차·키롤오버
커넥션 고갈은 “풀 설정”만으로 해결되는 경우가 드뭅니다. 결국 커넥션을 오래 잡는 코드 경로를 찾아 줄이고, DB가 동시에 처리할 수 있는 양에 맞춰 애플리케이션의 동시성을 설계하는 것이 정답입니다.