- Published on
Spring Boot HikariCP 커넥션 고갈 원인과 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡히 떠 있는데도 특정 순간부터 API가 타임아웃으로 쓰러지고, 로그에는 HikariPool-1 - Connection is not available, request timed out after ...ms 같은 메시지가 반복된다면 대부분은 “DB가 죽었다”가 아니라 커넥션 풀이 고갈된 상황입니다. HikariCP는 성능이 좋고 기본값도 괜찮지만, 풀 고갈은 설정만으로 해결되지 않고 코드의 트랜잭션 경계, 쿼리 패턴, 스레드 모델과 강하게 얽혀 있습니다.
이 글에서는 Spring Boot에서 HikariCP 커넥션 고갈이 발생하는 전형적인 원인을 유형별로 나누고, 증상-진단-해결을 한 번에 연결합니다.
커넥션 고갈이 의미하는 것
HikariCP 풀은 “동시 DB 작업을 수행할 수 있는 슬롯”입니다. 풀의 maximumPoolSize가 20이면, 동시에 20개의 DB 커넥션만 대여할 수 있습니다.
고갈은 다음 중 하나로 발생합니다.
- 대여한 커넥션이 빨리 반환되지 않는다: 트랜잭션이 길거나, 애플리케이션 레벨에서 커넥션이 새는 경우
- 반환은 되지만 DB 작업 자체가 너무 느리다: 슬로우 쿼리, 락 대기, 인덱스 미스 등으로 커넥션이 오래 점유됨
- 요청 동시성이 풀 크기를 초과한다: 스레드/비동기 작업이 풀과 불균형
증상은 보통 다음 3가지로 나타납니다.
- API 응답 지연이 급증하다가 타임아웃
- DB CPU가 높지 않은데도 애플리케이션이 멈춘 듯 보임(대기 스레드 증가)
- 풀 관련 경고 로그 증가
1) 가장 흔한 원인: 트랜잭션 경계가 너무 넓다
Spring에서 @Transactional은 편리하지만, 범위를 넓게 잡으면 “DB를 쓰지 않는 시간”까지 커넥션을 잡고 있게 됩니다.
나쁜 패턴 예시: 외부 호출을 트랜잭션 안에서 수행
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
@Transactional
public void placeOrder(Long userId) {
// 1) DB 조회
Order order = orderRepository.findDraftByUserId(userId)
.orElseThrow();
// 2) 외부 API 호출(느리거나 재시도 발생 가능)
paymentClient.reserve(order.getId());
// 3) DB 업데이트
order.confirm();
}
}
이 경우 외부 호출이 1초만 걸려도, 그 1초 동안 커넥션이 점유됩니다. 동시 요청이 늘면 풀은 빠르게 고갈됩니다.
개선: 트랜잭션을 “DB 작업 최소 구간”으로 축소
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient;
public void placeOrder(Long userId) {
Long orderId = loadDraftOrderId(userId);
// 트랜잭션 밖에서 외부 호출
paymentClient.reserve(orderId);
confirmOrder(orderId);
}
@Transactional(readOnly = true)
protected Long loadDraftOrderId(Long userId) {
return orderRepository.findDraftIdByUserId(userId)
.orElseThrow();
}
@Transactional
protected void confirmOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.confirm();
}
}
핵심은 “외부 I/O, 파일 작업, 대용량 연산”을 트랜잭션 밖으로 빼서 커넥션 점유 시간을 줄이는 것입니다.
2) 커넥션 누수: close 누락, 스트리밍 결과 미종료
HikariCP는 기본적으로 커넥션을 잘 회수하지만, 아래 케이스에서는 누수가 생길 수 있습니다.
- JDBC를 직접 쓰면서
ResultSet/Statement/Connection을 닫지 않음 - JPA에서 스트리밍 조회를 열어놓고 소비를 끝내지 않음
- 예외 경로에서 자원 반환이 누락됨
JDBC 직접 사용 시: 반드시 try-with-resources
public List<User> findUsers(DataSource dataSource) throws SQLException {
String sql = "select id, name from users where active = 1";
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
List<User> out = new ArrayList<>();
while (rs.next()) {
out.add(new User(rs.getLong("id"), rs.getString("name")));
}
return out;
}
}
누수 탐지 설정(진단용)
운영에서 상시 켜두기보다는, 이슈 재현/조사 중에 임시로 켜는 것을 권장합니다.
spring:
datasource:
hikari:
leak-detection-threshold: 2000 # ms
maximum-pool-size: 20
connection-timeout: 30000
leak-detection-threshold를 넘기면 “커넥션을 빌린 스택 트레이스”가 로그로 남아 누수 위치를 찾는 데 도움이 됩니다.
3) 느린 쿼리/락 대기: 커넥션은 반환되지 않는다
풀 고갈의 본질은 “커넥션 점유 시간이 길어지는 것”입니다. 그 원인이 누수가 아니라면, 대부분은 DB에서 시간이 소모됩니다.
대표 원인:
- 인덱스 미스, 테이블 풀스캔
IN절 남발, 비효율적 쿼리 플랜- 대량 정렬/임시 테이블 증가
- 락 경합(특히 갱신 쿼리)
쿼리가 느려지면 동시 요청이 같은 풀을 더 오래 점유하므로, 풀 크기를 늘려도 “조금 늦게 죽는” 정도로 끝날 수 있습니다.
IN 조건이 커지고 플랜이 불리해져 풀을 잡아먹는 케이스는 꽤 흔합니다. 필요하면 다음 글도 같이 보세요.
또한 JPA에서는 N+1이 발생하면 “한 요청이 여러 쿼리”를 만들고, 결과적으로 커넥션 점유 시간이 늘어납니다.
실전 진단 루틴
- 애플리케이션: 풀 메트릭에서
active가maximumPoolSize에 붙어 있는지 확인 - DB: 슬로우 쿼리 로그,
performance_schema,pg_stat_activity등으로 오래 도는 쿼리/락 대기 확인 - 공통: 특정 엔드포인트/배치에서만 발생하는지 상관관계 확인
4) 스레드 수와 풀 크기의 불균형
다음 조합은 풀 고갈을 쉽게 만듭니다.
- Tomcat
max-threads가 크고(예: 200) - Hikari
maximum-pool-size는 작고(예: 10) - 대부분 요청이 DB를 사용
이때 트래픽이 몰리면 200개 스레드가 DB 커넥션 10개를 기다리며 적체되고, 큐잉 지연이 폭발합니다.
권장 접근
- 풀을 무작정 키우기 전에 “요청당 커넥션 점유 시간”을 줄인다
- DB를 쓰는 엔드포인트 비율과 평균 쿼리 시간을 기준으로 풀 크기를 산정한다
- 애플리케이션 스레드 상한도 함께 조정한다
간단한 감으로는 아래가 출발점이 될 수 있습니다.
- DB가 단일 인스턴스이고 CPU가 크지 않다면
maximumPoolSize를 과도하게 키우지 않는다(락/컨텍스트 스위칭 증가) - 대신 슬로우 쿼리/트랜잭션 범위부터 줄인다
5) open-in-view로 인한 “웹 요청 전체” 커넥션 점유
Spring Boot의 JPA 기본값은 환경에 따라 다를 수 있지만, open-in-view가 켜져 있으면 웹 요청이 끝날 때까지 영속성 컨텍스트가 열려 Lazy 로딩이 가능해집니다. 문제는 이 과정에서 트랜잭션/세션이 길어지거나, 뷰 렌더링/직렬화 과정에서 추가 쿼리가 나가면서 커넥션 점유가 늘 수 있다는 점입니다.
가능하면 API 서버에서는 꺼두고, 필요한 데이터는 서비스 계층에서 명시적으로 조회하는 편이 안전합니다.
spring:
jpa:
open-in-view: false
6) 풀 설정 튜닝: “진짜 원인”을 드러내는 방향으로
설정은 해결책이기도 하지만, 동시에 문제를 숨길 수도 있습니다. 다음은 실전에서 자주 쓰는 기준입니다.
필수에 가까운 기본값 점검
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 30000
validation-timeout: 5000
max-lifetime: 1800000
idle-timeout: 600000
connection-timeout: 풀 고갈 시 얼마나 기다릴지. 너무 길면 장애 전파가 늦고 스레드가 쌓입니다.max-lifetime: DB/프록시의 커넥션 종료 정책보다 약간 짧게 잡아 예기치 않은 끊김을 줄입니다.
minimum-idle은 신중히
minimum-idle을 maximum-pool-size와 같게 두면 항상 풀을 꽉 채워두는데, DB가 작은 환경에서는 오히려 부담이 될 수 있습니다. 트래픽 패턴에 따라 조정하세요.
문제 조사 시 유용한 로그
logging:
level:
com.zaxxer.hikari: DEBUG
조사 기간에만 켜고, 평소에는 INFO 이하로 두는 것을 권장합니다.
7) 메트릭으로 재발 방지: Actuator + Micrometer
풀 고갈은 “발생했을 때만” 보면 늦습니다. 풀 상태를 상시 관측해야 합니다.
Spring Boot Actuator를 쓰면 Hikari 메트릭이 노출됩니다.
hikaricp.connections.activehikaricp.connections.idlehikaricp.connections.pendinghikaricp.connections.max
pending이 증가하거나, active가 max에 붙는 시간이 늘어나면 이미 위험 신호입니다. 이때는 풀을 키우기 전에 아래를 먼저 의심하세요.
- 특정 배포 이후 트랜잭션이 길어졌는지
- 특정 쿼리 플랜이 바뀌었는지
- 락 대기가 늘었는지
8) 자주 놓치는 케이스 체크리스트
아래 항목은 “원인 찾기” 단계에서 빠르게 확인하면 시간을 줄일 수 있습니다.
- 배치/스케줄러가 동시에 여러 개 돌며 DB를 잠식하는가
@Async작업이 DB를 사용하면서 동시성이 폭증하는가- 한 요청에서 대량 데이터를 읽고 애플리케이션에서 필터링하는가(쿼리로 줄일 수 있는가)
- 페이징 없이 전체 조회를 하는가
- 직렬화 과정에서 Lazy 로딩이 발생하는가(
open-in-view와 함께 자주 발생)
결론: 풀을 키우기 전에 “점유 시간을 줄여라”
HikariCP 커넥션 고갈은 대개 풀 자체 문제가 아니라, 커넥션을 오래 잡는 코드/쿼리에서 시작됩니다. 해결 우선순위를 정리하면 다음이 가장 효율적입니다.
- 트랜잭션 범위를 줄이고 외부 I/O를 트랜잭션 밖으로 이동
- 누수 탐지로 “반환되지 않는 커넥션”이 있는지 확인
- 슬로우 쿼리/락 대기를 찾아 쿼리와 인덱스를 개선
- 스레드/풀 사이징을 함께 조정하고, 메트릭으로 상시 감시
이 순서대로 접근하면 “일단 풀부터 2배” 같은 임시방편을 넘어, 재발하지 않는 구조적 해결에 가까워집니다.