- Published on
Spring Boot 3 503? HikariCP 풀 고갈 원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Spring Boot 3 서비스에서 갑자기 503 Service Unavailable가 증가하고, 애플리케이션 로그에는 HikariCP 관련 타임아웃이 보이는 패턴은 매우 흔합니다. 특히 트래픽이 순간적으로 치솟거나, 특정 API 호출이 느려지는 순간에 503이 몰리면 “서버가 죽은 게 아니라 DB 커넥션을 못 빌려서 요청을 처리하지 못한” 상황일 가능성이 큽니다.
이 글에서는 Spring Boot 3(기본 HikariCP)에서 커넥션 풀 고갈(pool exhaustion) 이 왜 발생하는지, 무엇을 먼저 확인해야 하는지, 그리고 설정 튜닝만으로는 해결되지 않는 구조적 원인(트랜잭션 경계, 누수, 느린 쿼리, 외부 I/O 혼합)을 어떻게 잡는지까지 단계적으로 정리합니다.
503과 HikariCP 풀 고갈의 전형적인 증상
다음 중 하나라도 보이면 “DB 커넥션을 제때 반납하지 못하거나, 너무 오래 잡고 있거나, 풀 크기에 비해 동시 트랜잭션이 과도한” 상황을 의심하세요.
HikariPool-1 - Connection is not available, request timed out after ...ms로그- 응답 지연이 점점 커지다가 특정 시점에 503이 연쇄적으로 발생(큐잉)
- DB CPU/IO가 치솟지 않았는데도 애플리케이션이 멈춘 듯 보임(애플리케이션 측 병목)
- 특정 엔드포인트에서만 유독 503이 발생
- 트래픽이 낮아져도 한동안 회복이 느림(커넥션이 장시간 점유)
503 자체는 로드밸런서/리버스프록시(Nginx/Ingress)가 애플리케이션의 타임아웃을 503/502로 변환하면서 보일 수도 있습니다. 하지만 애플리케이션 로그에 Hikari 타임아웃이 함께 있다면 우선순위는 HikariCP 진단입니다.
먼저 결론: “풀 크기”만 키우면 안 되는 이유
풀을 크게 하면 일시적으로 503이 줄어들 수 있지만, 다음 위험이 큽니다.
- DB가 감당 못하는 동시 접속 수로 인해 오히려 DB가 느려짐
- 느린 쿼리/락 경합이 숨겨져 병이 더 커짐
- 커넥션 누수(반납 누락)가 있으면 결국 다시 고갈
따라서 원인 분류(누수 vs 장기 점유 vs 동시성 과다 vs DB 병목) 를 먼저 하고, 그 다음에 풀/타임아웃/쿼리/코드 구조를 함께 손보는 것이 정석입니다.
원인 분류 체크리스트 (가장 많이 터지는 순)
1) 트랜잭션이 외부 I/O를 끌어안고 있다
가장 흔한 실수는 @Transactional 안에서 다음을 해버리는 경우입니다.
- 외부 HTTP 호출
- S3 업로드/다운로드
- 메시지 브로커 publish가 느리게 블로킹
- 대용량 파일 처리
이 경우 DB 커넥션은 “실제로 쿼리를 안 치는 시간”에도 계속 점유됩니다. 트래픽이 조금만 늘어도 풀은 금방 고갈됩니다.
나쁜 예시
@Service
public class OrderService {
@Transactional
public void placeOrder(Long userId) {
// DB 커넥션 점유 시작
Order order = orderRepository.save(new Order(userId));
// 외부 호출이 2초~10초 걸리면 그 동안 커넥션도 같이 묶임
paymentClient.requestPayment(order.getId());
order.markPaid();
}
}
개선 패턴
- 트랜잭션은 “DB 작업만” 짧게
- 외부 호출은 트랜잭션 밖으로
- 필요하면 Outbox 패턴/이벤트 기반으로 결합도 낮추기
@Service
public class OrderService {
public void placeOrder(Long userId) {
Long orderId = createOrder(userId); // 짧은 트랜잭션
// 트랜잭션 밖에서 외부 호출
paymentClient.requestPayment(orderId);
markPaid(orderId); // 짧은 트랜잭션
}
@Transactional
public Long createOrder(Long userId) {
Order saved = orderRepository.save(new Order(userId));
return saved.getId();
}
@Transactional
public void markPaid(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.markPaid();
}
}
2) 커넥션 누수(반납 누락)
JPA를 쓴다면 “진짜 커넥션 누수”는 생각보다 드물지만, 다음 상황에서는 발생할 수 있습니다.
JdbcTemplate/순수 JDBC 사용 시ResultSet/Statement/Connection정리 누락- 커스텀 DataSource 래핑/프록시에서 close 누락
- 트랜잭션 동기화가 깨지는 비정상적인 스레드 전환
누수 탐지: leakDetectionThreshold
운영에서 상시 켜면 노이즈가 있을 수 있으니, 장애 재현 구간에 제한적으로 켜는 것을 권장합니다.
spring:
datasource:
hikari:
leak-detection-threshold: 2000 # 2초 이상 점유 시 스택트레이스 로깅
로그에 “어떤 코드 경로에서 커넥션을 잡고 오래 안 놓는지” 스택이 찍히면, 그 지점을 최우선으로 고치면 됩니다.
3) 풀 크기 대비 동시 요청/스레드가 과다
Tomcat(또는 Jetty/Undertow)의 요청 스레드 수가 많고, 각 요청이 DB를 찍는 구조라면 풀 크기는 쉽게 바닥납니다.
server.tomcat.threads.max는 크고maximumPoolSize는 작고- 각 요청이 트랜잭션을 잡는 시간이 길면
결과적으로 “요청 스레드가 커넥션을 기다리며 정체”하고, 상위 프록시 타임아웃으로 503이 납니다.
핵심은 DB 커넥션은 비싼 자원이고, 요청 스레드 수와 1:1로 늘리면 안 된다는 점입니다.
4) 느린 쿼리/락 경합으로 커넥션이 오래 점유
풀 고갈은 애플리케이션 문제가 아니라 DB 문제가 트리거인 경우도 많습니다.
- 인덱스 누락으로 풀스캔
N+1로 쿼리 폭증- 업데이트 락 경합(특히 핫 로우)
- 트랜잭션 격리수준/긴 트랜잭션으로 인한 대기
이 경우 풀을 늘리면 DB는 더 느려지고, 고갈은 더 자주 옵니다.
5) DNS/네트워크 지연으로 DB 연결/쿼리 지연
클라우드 환경(EKS 등)에서는 간헐적인 DNS/네트워크 지연이 커넥션 획득이나 쿼리 왕복을 늘려 풀 고갈로 번질 수 있습니다. 특히 장애가 “간헐적”이고, 애플리케이션/DB 모두 정상처럼 보일 때 의심해야 합니다.
관련해서 네트워크 계층의 간헐 장애를 의심한다면 EKS에서 CoreDNS 정상인데 DNS가 간헐 실패할 때도 함께 점검 포인트가 겹칩니다.
진단: 로그와 메트릭으로 “정말 풀 고갈”인지 확인
HikariCP 핵심 설정/지표
maximumPoolSize: 최대 커넥션 수minimumIdle: 유휴 커넥션 유지 수connectionTimeout: 커넥션 빌릴 때 기다리는 최대 시간(기본 30초)maxLifetime: 커넥션 최대 수명(기본 30분)idleTimeout: 유휴 커넥션 정리 시간
Spring Boot Actuator + Micrometer를 쓰면 다음 지표가 중요합니다.
hikaricp.connections.activehikaricp.connections.idlehikaricp.connections.pendinghikaricp.connections.max
pending 이 증가하면서 active 가 max 에 붙어 있으면 고갈입니다.
Actuator/Prometheus 설정 예시
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
probes:
enabled: true
Prometheus에서 pending 이 튀는 순간의 요청량(RPS), p95 지연, DB slow query 로그를 같은 타임라인으로 겹쳐보면 원인이 빨리 좁혀집니다.
해결 1: 트랜잭션 경계 재설계(가장 효과 큼)
원칙
- 트랜잭션은 짧게, DB 작업만
- 외부 호출은 트랜잭션 밖
- 대용량 처리/배치는 chunk 단위로 커밋
배치에서 흔한 실수: 한 번에 다 처리
@Transactional
public void migrateAll(List<Long> ids) {
for (Long id : ids) {
migrateOne(id);
}
}
이러면 트랜잭션이 길어지고, 영속성 컨텍스트도 커지고, 커넥션 점유도 길어집니다.
대신 chunk 커밋 또는 REQUIRES_NEW 로 나누거나, Spring Batch를 쓰는 편이 안전합니다.
해결 2: HikariCP 튜닝(증상 완화 + 안정화)
기본 튜닝 예시(출발점)
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 2000
validation-timeout: 1000
max-lifetime: 1740000 # 29분(네트워크/LB idle timeout보다 약간 짧게)
idle-timeout: 600000
설명:
connection-timeout을 너무 길게 두면 요청 스레드가 오래 대기하다가 상위에서 503/504로 끊겨 “느린 실패”가 됩니다. 짧게 두고 빠르게 실패시키는 것이 장애 전파를 줄일 때가 많습니다.max-lifetime은 DB/프록시의 커넥션 idle timeout보다 조금 짧게 두는 게 안전합니다(중간 장비가 커넥션을 끊어버리는 문제 방지).
풀 크기 산정의 현실적인 기준
정답 공식은 없지만, 다음 순서로 잡으면 시행착오가 줄어듭니다.
- DB가 허용 가능한 총 커넥션 수 확인(인스턴스/파라미터/리소스)
- 서비스 인스턴스(파드) 수로 나눠서 인스턴스당 상한 설정
- 평균 쿼리 시간과 목표 RPS를 바탕으로 동시 트랜잭션 수 추정
풀을 늘리기 전에 쿼리 시간을 줄이는 게 대부분 더 큰 효과를 냅니다.
해결 3: JPA 성능 이슈(N+1, fetch 전략)로 쿼리 폭증 줄이기
풀 고갈이 “커넥션을 오래 잡아서”가 아니라 “너무 많은 쿼리를 짧은 시간에” 날려서 생기는 경우도 있습니다.
N+1문제로 요청 1건당 쿼리 수가 폭증- 불필요한 EAGER 로딩
예를 들어 주문과 아이템을 조회할 때:
@Query("select o from Order o join fetch o.items where o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);
이런 식으로 fetch join으로 왕복 횟수를 줄이면, 커넥션 점유 시간과 DB 부하가 함께 내려가 풀 고갈이 완화됩니다.
해결 4: 타임아웃을 “계층별로” 정렬해 503 연쇄를 막기
타임아웃은 한 군데만 조정하면 안 되고, 계층별로 일관성이 있어야 합니다.
- 클라이언트(또는 API Gateway)
- Ingress/Nginx/ALB
- 애플리케이션 요청 처리 시간
- DB 커넥션 획득 타임아웃(
connectionTimeout) - 쿼리 타임아웃
권장 방향은 보통 다음과 같습니다.
- DB 커넥션 획득 타임아웃은 비교적 짧게(예: 1~3초)
- 애플리케이션 전체 요청 타임아웃은 그보다 길게
- Ingress/ALB 타임아웃은 애플리케이션보다 약간 길게
그리고 외부 의존성(S3/외부 API) 타임아웃도 명시적으로 설정해 “무한 대기”를 없애야 합니다. EKS에서 외부로 나가는 경로가 불안정하면 504/503이 섞여 보이기도 하니, 네트워크 진단이 필요하면 EKS Pod→S3 504 타임아웃 - VPC 엔드포인트·NAT·DNS 진단처럼 경로를 함께 점검하세요.
해결 5: 격리(벌크헤드)와 백프레셔로 “고갈 전”에 막기
풀 고갈은 결국 공유 자원(DB)에 대한 동시 접근이 폭주하는 문제이므로, 애플리케이션 레벨에서 동시성을 제한하면 장애 전파를 크게 줄일 수 있습니다.
- 엔드포인트별 동시 처리 제한
- 요청 큐잉 대신 빠른 실패(사용자 경험/재시도 전략과 함께)
- Resilience4j Bulkhead/RateLimiter 적용
예시(세마포어 기반 벌크헤드):
@Bean
public BulkheadConfig bulkheadConfig() {
return BulkheadConfig.custom()
.maxConcurrentCalls(50)
.maxWaitDuration(java.time.Duration.ofMillis(0))
.build();
}
DB를 반드시 쓰는 API에만 선택적으로 적용하면, 전체 서비스가 같이 무너지는 것을 막을 수 있습니다.
운영 팁: 장애 재현이 어렵다면 “짧게 켜고” 증거 수집
leakDetectionThreshold를 짧게 켜서 스택 확보- 슬로우 쿼리 로그/락 대기 로그 활성화(일시적으로)
- Hikari 메트릭 대시보드에서
pending스파이크 시점의 상관관계 확인
간헐 장애는 원인이 네트워크/DNS/외부 의존성일 때도 많습니다. 이런 유형의 간헐성 문제를 다루는 관점은 EKS에서 CoreDNS 정상인데 DNS가 간헐 실패할 때에서 소개한 “정상처럼 보이는데 실패하는” 패턴과 유사합니다.
빠른 체크용: 실전 대응 순서
- 로그에서
Connection is not available확인(발생 빈도/시점) - 메트릭에서
active == max와pending증가 확인 - leak 탐지(임시)로 “오래 잡는 코드 경로” 스택 확보
- 느린 쿼리/락 경합 확인(슬로우 로그, APM 트레이스)
- 트랜잭션 경계에서 외부 I/O 제거
- 풀/타임아웃을 계층별로 정렬하고, 필요 시 풀 크기는 DB 한도 내에서만 조정
- 벌크헤드/레이트리밋으로 고갈 전 차단
마무리
Spring Boot 3에서 503이 터질 때 HikariCP 풀 고갈은 “결과”이고, 원인은 대개 트랜잭션 설계(외부 I/O 포함), 느린 쿼리/락, 또는 동시성 과다에 있습니다. 풀 크기 증설은 마지막 카드에 가깝고, 먼저 커넥션 점유 시간을 줄이는 구조 개선과 메트릭 기반 원인 분류가 선행되어야 재발을 막을 수 있습니다.
장애가 간헐적이고 인프라 레이어(네트워크/DNS) 가능성이 보이면 위에서 링크한 EKS 관련 글처럼 경로 진단도 병행해, “애플리케이션만” 붙잡고 시간을 쓰지 않도록 하세요.