- Published on
Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 중인 Spring Boot 서비스에서 갑자기 응답 지연이 늘고, 로그에 HikariPool-1 - Connection is not available, request timed out after ...ms 같은 메시지가 반복되면 거의 항상 “DB 커넥션 고갈” 상황입니다. 증상은 단순하지만 원인은 다양합니다. 풀 사이즈가 작아서가 아니라, 트랜잭션이 오래 잡히거나 커넥션이 반환되지 않거나, DB 자체가 느려져서 풀을 소진하는 경우가 많습니다.
이 글은 HikariCP를 “크게 잡으면 해결” 같은 방식이 아니라, 병목을 분리하고 관측 가능한 근거로 튜닝하는 방법을 다룹니다.
커넥션 고갈의 전형적인 증상과 로그
다음 중 하나라도 보이면 풀 고갈을 의심하세요.
- API 지연이 계단식으로 증가하고, 일부 요청이 타임아웃
- 애플리케이션 스레드는 살아있지만 DB 호출이 멈춘 듯 대기
- 에러 로그에
Connection is not available또는Timeout after ...반복 - DB CPU 또는 IOPS가 치솟고, 락 대기가 증가
HikariCP의 대표 메시지는 다음과 같습니다.
HikariPool-1 - Connection is not available, request timed out after 30000ms
이 메시지는 “풀에 남는 커넥션이 없음”을 의미하지만, 왜 반환이 안 되는지는 별개 문제입니다.
먼저 결론: 풀 사이즈만 늘리면 왜 위험한가
maximumPoolSize를 무작정 늘리면 일시적으로 타임아웃은 줄어들 수 있습니다. 하지만 다음 부작용이 큽니다.
- DB 동시 쿼리 수가 증가해 DB가 더 느려짐(락 경합, CPU, I/O 증가)
- 느린 쿼리가 더 오래 쌓이며 커넥션 점유 시간이 증가
- 결과적으로 더 큰 풀도 결국 고갈(“더 큰 실패”)로 이어짐
즉, 풀은 “버퍼”가 아니라 “DB로 들어가는 동시성 제한 장치”로 봐야 합니다.
커넥션 고갈의 4가지 근본 원인
1) 트랜잭션이 너무 오래 열린다
- 외부 API 호출을 트랜잭션 안에서 수행
- 대량 처리 로직이 한 트랜잭션으로 묶임
@Transactional이 넓게 걸려 불필요한 구간까지 커넥션 점유
커넥션은 트랜잭션 동안 점유되는 경우가 많습니다. 특히 JPA는 flush 타이밍과 연관되어 더 길어질 수 있습니다.
2) 커넥션 누수(반환 누락)
- JDBC 직접 사용 시
close()누락 - 예외 경로에서 반환 누락
- 커넥션을 필드에 저장하거나 비동기 스레드로 넘김
Spring의 JdbcTemplate나 JPA를 정상 사용하면 누수는 드물지만, 레거시 JDBC 코드나 커스텀 데이터 접근 계층에서 자주 발생합니다.
3) DB가 느려져서 점유 시간이 늘어난다
- 인덱스 누락, 풀스캔
- 락 대기(동일 row 업데이트 경쟁)
- IOPS 부족, 스토리지 지연
이 경우 애플리케이션 풀 튜닝보다 쿼리/인덱스/락이 우선입니다.
4) 애플리케이션 동시성이 풀/DB 용량을 초과한다
- Tomcat 스레드가 너무 많아 동시에 DB를 때림
- 배치/스케줄러가 피크에 겹침
- 메시지 컨슈머 동시성이 과도함
“요청 스레드 수”와 “DB 커넥션 수”의 비율이 맞지 않으면 풀 고갈이 구조적으로 발생합니다.
HikariCP 핵심 파라미터: 무엇을, 어떤 근거로 조정할까
Spring Boot 기본 풀은 HikariCP이며, 설정은 application.yml에서 합니다.
기본 예시: 운영에서 자주 쓰는 안전한 뼈대
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 30000
validation-timeout: 5000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 0
이 값이 정답은 아닙니다. 아래에서 각 항목을 “언제, 왜” 바꾸는지 정리합니다.
maximumPoolSize: 풀의 상한(가장 중요한 값)
- 의미: 동시에 DB에 붙을 수 있는 최대 커넥션 수
- 목표: DB가 감당 가능한 동시 쿼리 수를 넘기지 않도록 제한
산정 접근
- DB의 최대 커넥션 한도 확인
- 예: PostgreSQL
max_connections - 예: MySQL
max_connections
- “서비스 인스턴스 수”로 나누기
- 예: DB
max_connections=300 - 운영 애플리케이션 6개 파드
- 단순 분배 상한은
300 / 6 = 50
- 여유분과 다른 클라이언트 고려
- 배치, 어드민, 리포팅, 마이그레이션 툴 등이 DB 커넥션을 사용
- 최종적으로는 DB CPU/락/쿼리 시간을 보며 조정
풀을 늘렸는데 DB latency가 악화되면, 풀을 늘리는 방향이 아니라 동시성 제한(스레드/컨슈머 조절) 또는 쿼리 개선이 필요합니다.
minimumIdle: 유휴 커넥션 유지 수
- 의미: 놀고 있어도 유지할 커넥션 수
- 운영에서 흔한 선택:
minimumIdle을maximumPoolSize와 같게 두어 고정 풀처럼 운영
트래픽이 급격히 튀는 서비스라면, 커넥션 생성 비용과 핸드셰이크 비용을 줄이기 위해 minimumIdle = maximumPoolSize가 유리할 때가 많습니다. 다만 DB가 커넥션 수에 민감하거나 다중 서비스가 공유하는 DB라면 과도한 선점이 될 수 있습니다.
connectionTimeout: 풀에서 커넥션을 기다리는 최대 시간
- 의미: 커넥션을 못 구하면 이 시간 후 타임아웃
- 운영 권장: 무작정 늘리지 말기
connectionTimeout을 크게 늘리면 에러는 줄어 보이지만, 실제로는 요청이 더 오래 매달리며 스레드가 잠식됩니다. 결과적으로 장애 전파가 쉬워집니다. gRPC나 MSA 환경에서는 타임아웃 전파 설계가 중요하므로, 커넥션 대기 시간을 무한정 늘리는 방식은 피하는 게 좋습니다. (관련 주제로는 gRPC MSA에서 DEADLINE_EXCEEDED 연쇄 장애 차단도 함께 참고할 만합니다.)
maxLifetime와 idleTimeout: 커넥션 재생성 정책
maxLifetime: 커넥션의 최대 수명idleTimeout: 유휴 커넥션을 정리하는 시간
운영에서 중요한 포인트는 DB 또는 프록시(LB)가 커넥션을 끊기 전에 애플리케이션이 먼저 교체하도록 하는 것입니다.
- 일반적으로
maxLifetime은 30분(1800000) 근처가 많이 쓰입니다. - 만약 네트워크 장비나 프록시가 10분에 커넥션을 끊는다면,
maxLifetime을 그보다 짧게(예: 9분) 설정합니다.
다만 너무 짧게 잡으면 커넥션 재생성이 잦아져 오히려 부하가 늘 수 있습니다.
leakDetectionThreshold: 누수 탐지(강력하지만 주의)
- 의미: 커넥션을 빌린 뒤 지정 시간 내 반환하지 않으면 스택트레이스를 로그로 남김
예를 들어 5초 이상 반환이 없으면 의심 로그를 남기고 싶다면:
spring:
datasource:
hikari:
leak-detection-threshold: 5000
주의할 점:
- “진짜 누수”뿐 아니라 “정상적으로 오래 걸리는 쿼리/트랜잭션”도 누수처럼 찍힙니다.
- 장애 상황에서 로그가 폭증할 수 있습니다.
권장 운영 방식:
- 평상시
0(비활성) - 커넥션 고갈 재현 또는 의심 구간에서 일시적으로 켜서 원인 스택을 확보
Spring Boot에서 자주 터지는 실수 패턴과 수정
트랜잭션 안에서 외부 호출
잘못된 예:
@Transactional
public void placeOrder(OrderRequest req) {
inventoryClient.reserve(req); // 외부 HTTP 호출
orderRepository.save(...);
}
이 패턴은 외부 호출이 느려지면 DB 커넥션이 그 시간만큼 점유될 수 있습니다.
개선 예(트랜잭션 범위 축소):
public void placeOrder(OrderRequest req) {
inventoryClient.reserve(req); // 트랜잭션 밖
saveOrder(req);
}
@Transactional
void saveOrder(OrderRequest req) {
orderRepository.save(...);
}
JDBC 직접 사용 시 try-with-resources 누락
잘못된 예:
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select 1");
ResultSet rs = ps.executeQuery();
// close 누락
개선 예:
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select 1");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// ...
}
}
Spring에서는 가능하면 JdbcTemplate를 쓰면 반환 누락 위험이 크게 줄어듭니다.
풀 튜닝은 “애플리케이션 동시성”과 같이 해야 한다
HikariCP만 조정해도 어느 정도 완화되지만, 구조적으로는 다음 관계를 맞춰야 합니다.
- 웹 서버 워커 스레드(예: Tomcat
max-threads) - 비동기 실행 스레드 풀(예:
@Asyncexecutor) - 메시지 컨슈머 동시성
- Hikari
maximumPoolSize
예를 들어 Tomcat이 동시에 200개 요청을 처리하게 열려 있는데 풀은 20이면, DB를 쓰는 요청이 몰릴 때 180개는 커넥션을 기다리게 됩니다. 반대로 풀을 200으로 올리면 DB가 터질 수 있습니다. 보통은 “DB를 사용하는 요청의 동시성”을 제한하는 쪽이 안전합니다.
Tomcat 설정 예:
server:
tomcat:
threads:
max: 80
여기서도 정답은 없고, 서비스의 DB 의존도(요청당 DB 호출 횟수, 쿼리 시간)와 DB 용량을 기반으로 실측 조정해야 합니다.
관측과 진단: 무엇을 보면 원인이 빨리 드러나는가
1) HikariCP 풀 메트릭 확인(Micrometer)
Spring Boot Actuator와 Micrometer를 쓰면 풀 상태를 수치로 볼 수 있습니다.
의존성 예:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
}
주요 지표(이름은 환경에 따라 다를 수 있음):
- 사용 중(active) 커넥션 수
- 유휴(idle) 커넥션 수
- 대기(pending) 스레드 수
패턴 해석:
- active가 항상
maximumPoolSize에 붙고 pending이 증가: 풀 고갈 - active는 낮은데 pending이 증가: DB 호출 경로가 막혔거나, 커넥션 획득 이전 단계에서 병목(스레드 풀 포화 등)
2) DB에서 “오래 열린 트랜잭션/락” 확인
애플리케이션에서 커넥션이 오래 점유되는 이유가 DB 락이라면, 풀을 손대기 전에 락 원인을 제거해야 합니다.
PostgreSQL 예시(개념):
- 오래 실행 중인 쿼리
- 락 대기 중인 세션
MySQL 예시(개념):
SHOW PROCESSLIST- InnoDB 락/트랜잭션 상태
SQL은 DB별로 다르므로, 운영 DB에 맞는 “장기 트랜잭션/락 대기” 쿼리를 준비해 두는 것이 좋습니다.
3) OS 레벨 리소스도 같이 점검
커넥션 고갈처럼 보이지만 실제로는 파일 디스크립터가 부족해 소켓 생성이 실패하는 경우도 있습니다. 특히 트래픽 급증 시 Too many open files가 함께 나타나면 풀 튜닝이 아니라 OS 한도 조정이 먼저입니다.
또한 컨테이너 환경에서 프로세스가 재시작을 반복한다면, 커넥션 고갈로 인한 타임아웃이 상위 레벨 헬스체크 실패로 이어졌을 수 있습니다.
실전 튜닝 체크리스트(재발 방지용)
1) “고갈”을 재현 가능한 지표로 바꾸기
- 풀 active, idle, pending을 대시보드에 올림
- API p95, p99 latency와 함께 비교
- DB 쿼리 시간, 락 대기, CPU/IO와 함께 비교
2) 누수와 장기 점유를 구분하기
leakDetectionThreshold를 짧게 켜서 스택 확보- 동일 스택이 반복되면 누수 또는 트랜잭션 범위 과다 가능성 높음
- 스택이 다양하고 DB가 느리면 쿼리/락/IO 문제 가능성 높음
3) 풀 사이즈는 “DB 한도 / 인스턴스 수”에서 시작
- DB
max_connections확인 - 서비스 인스턴스 수로 나눔
- 다른 클라이언트 여유분 확보
4) 동시성 제한을 같이 조정
- Tomcat 스레드 과다 여부 확인
- 메시지 컨슈머 동시성 조정
- 배치 작업 시간 분산
5) 타임아웃 계층 정리
- HTTP 타임아웃, DB 타임아웃, 커넥션 대기 타임아웃이 서로 모순되지 않게
connectionTimeout을 늘려 “기다리게” 하기보다, 병목을 줄이거나 빠르게 실패시키고 재시도 정책을 설계
권장 예시 설정(출발점)
아래는 “대부분의 웹 API 서비스”에서 출발점으로 무난한 조합입니다. 실제 값은 반드시 부하 테스트와 운영 지표로 조정하세요.
spring:
datasource:
hikari:
# DB 용량과 인스턴스 수로 산정 후 조정
maximum-pool-size: 30
# 트래픽 급증에 대비해 고정 풀처럼 운영(상황에 따라 조정)
minimum-idle: 30
# 커넥션 대기: 너무 길게 두지 말기
connection-timeout: 20000
# 커넥션 검증
validation-timeout: 5000
# 유휴/수명: 네트워크 장비 타임아웃보다 짧게
idle-timeout: 600000
max-lifetime: 1800000
# 장애 분석 시에만 일시적으로 활성화 권장
leak-detection-threshold: 0
마무리: HikariCP 튜닝의 핵심은 “원인 분리”
DB 커넥션 고갈은 HikariCP 설정 몇 개로 끝나는 문제가 아니라, 다음 중 어디가 병목인지 먼저 분리해야 합니다.
- 애플리케이션이 커넥션을 오래 잡는가(트랜잭션 범위)
- 커넥션을 반환하지 않는가(누수)
- DB가 느린가(쿼리, 인덱스, 락, I/O)
- 동시성이 과도한가(스레드, 컨슈머, 배치)
풀은 마지막 방어선이자 동시성 제한 장치입니다. 메트릭으로 “active가 꽉 찼는지”, “pending이 얼마나 쌓이는지”, “DB가 실제로 느린지”를 확인하고, 그에 맞춰 풀 사이즈와 타임아웃, 그리고 애플리케이션 동시성을 함께 조정하면 재발률이 크게 줄어듭니다.