- Published on
Spring Boot DB 커넥션 누수? HikariCP 원인 9가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 잘 돌다가 어느 순간부터 HikariPool-1 - Connection is not available, request timed out가 터지고, DB 세션 수가 치솟으며, 애플리케이션은 점점 느려진다. 많은 팀이 이를 “DB 커넥션 누수”라고 부르지만, 실제로는 커넥션이 반환되지 않는 코드(진짜 누수) 뿐 아니라 트랜잭션/락/네트워크/풀 설정 문제로 커넥션이 오래 점유되는 현상까지 모두 같은 증상으로 나타난다.
이 글은 Spring Boot + HikariCP 환경에서 커넥션 풀이 고갈(고정 점유)되는 상황을 원인 9가지로 분해하고, 각 케이스별로 “관측 포인트 → 재현 힌트 → 해결책”을 제시한다.
> DB 락/교착과 엮이면 “누수처럼 보이는 장기 점유”가 자주 발생한다. PostgreSQL을 쓴다면 PostgreSQL RDS deadlock_detected(40P01) 원인·해결도 함께 보면 원인 분리가 훨씬 빨라진다.
먼저: ‘누수’인지 ‘장기 점유’인지 구분하기
HikariCP는 커넥션을 빌려간 스레드가 반환하지 않으면 풀에서 사라진 것처럼 보인다. 하지만 반환은 되고 있는데 DB 쿼리가 끝나지 않거나(락/네트워크), 트랜잭션이 길어져서 커넥션이 오래 묶이는 경우도 동일한 증상을 만든다.
필수 관측 지표(최소)
- HikariCP 풀 지표
active(사용 중),idle(대기),pending(대기 중 요청 수)max(maximumPoolSize)
- 애플리케이션
- 요청 지연/타임아웃 분포, 스레드 덤프(요청 스레드가 어디서 막히는지)
- DB
- 현재 세션/트랜잭션/락 대기(예: PostgreSQL
pg_stat_activity,pg_locks)
- 현재 세션/트랜잭션/락 대기(예: PostgreSQL
Spring Boot에서 Hikari 지표 노출
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
# Hikari는 Micrometer로 자동 노출됨
# metrics 예: hikaricp.connections.active, idle, pending
Prometheus를 쓰면 hikaricp_connections_active 같은 형태로 확인할 수 있다.
원인 1) 커넥션/Statement/ResultSet 미반환(진짜 누수)
가장 정석적인 누수는 JDBC 자원을 닫지 않는 코드에서 발생한다. Spring/JPA를 쓰면 직접 JDBC를 다룰 일이 적지만, 아래 상황에서 빈번하다.
JdbcTemplate대신 rawDataSource.getConnection()을 직접 사용- 배치/유틸 코드에서
try-with-resources미사용 - 예외 경로에서
close()누락
문제 코드 예시
Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select * from orders where id=?");
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
// ...
// close 누락
해결 코드(try-with-resources)
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select * from orders where id=?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
// ...
}
}
}
진단 팁
Hikari의 leak detection을 “일시적으로” 켜서 빌린 커넥션의 스택트레이스를 확인한다.
spring:
datasource:
hikari:
leak-detection-threshold: 2000 # 2s (운영에선 너무 낮게 오래 켜지 말 것)
> leakDetectionThreshold는 정말 누수뿐 아니라 2초 이상 점유되는 정상 트랜잭션도 경고를 낸다. 즉, “누수”가 아니라 “슬로우/락”을 잡는 용도로도 유용하지만, 경고를 그대로 믿으면 오진하기 쉽다.
원인 2) @Transactional 경계가 너무 넓어 커넥션 장기 점유
Spring에서 트랜잭션은 보통 메서드 시작 시 커넥션을 확보하고, 커밋/롤백 때 반환한다. 따라서 트랜잭션 경계가 넓으면(=비즈니스 로직이 길면) 커넥션 점유 시간이 늘어난다.
흔한 패턴
- 트랜잭션 안에서 외부 API 호출(HTTP), S3 업로드, 메시지 발행 대기
- 트랜잭션 안에서 대량 루프 처리
- 트랜잭션 안에서 불필요한
sleep/재시도
개선 방향
- 트랜잭션 내부는 DB 작업만 최대한 짧게
- 외부 호출은 트랜잭션 밖으로 이동하거나, Outbox/Saga 패턴 고려
- 분산 트랜잭션/보상 설계는 MSA Saga 보상 트랜잭션 설계 실수 7가지도 참고
코드 예시(개선 전/후)
// 개선 전: 외부 호출이 트랜잭션 안에 있음
@Transactional
public void placeOrder(OrderRequest req) {
Order o = orderRepository.save(req.toEntity());
paymentClient.approve(req.payment()); // 외부 API 대기
shippingClient.reserve(o.getId());
}
// 개선 후: DB 트랜잭션을 짧게, 외부 호출은 분리
public void placeOrder(OrderRequest req) {
Long orderId = createOrder(req); // 트랜잭션 메서드
paymentClient.approve(req.payment());
shippingClient.reserve(orderId);
}
@Transactional
public Long createOrder(OrderRequest req) {
return orderRepository.save(req.toEntity()).getId();
}
원인 3) 락 대기/교착/장기 트랜잭션으로 쿼리가 끝나지 않음
커넥션은 “반환되지 않는” 게 아니라 DB에서 쿼리가 끝나지 않아 커넥션이 계속 사용 중(active) 으로 남는다. 특히 아래에서 자주 발생한다.
SELECT ... FOR UPDATE남발- 인덱스 미비로 인해 갱신 쿼리가 많은 row를 잠금
- 교착(deadlock) 빈발
- 트랜잭션 격리 수준/락 범위가 과도
진단 체크
- 애플리케이션: 스레드 덤프에서 JDBC 호출(드라이버)에서 대기
- DB: 락 대기 세션 증가, 특정 쿼리/트랜잭션이 오래 지속
PostgreSQL이라면 deadlock/락은 증상이 비슷하니 함께 확인하자: PostgreSQL RDS deadlock_detected(40P01) 원인·해결
해결 방향
- 인덱스/쿼리 플랜 개선
- 트랜잭션 범위 축소
- 락 순서 통일(교착 예방)
- DB statement timeout 도입(“무한 대기” 차단)
원인 4) 커넥션 풀 크기(maximumPoolSize)와 워크로드 불일치
HikariCP는 “작은 풀로도 빠르게”가 기본 철학이지만, 다음 조건에서 풀 고갈이 쉽게 일어난다.
- 동시 요청이 많은데, 요청당 DB 작업 시간이 길다
- DB 작업이 직렬화(락/테이블 핫스팟)되어 처리량이 낮다
- 애플리케이션 인스턴스 수가 늘었는데 DB의
max_connections는 그대로
자주 하는 실수
maximumPoolSize를 무작정 크게 올림 → DBmax_connections초과/컨텍스트 스위칭 증가로 더 느려짐
권장 접근
- 먼저 쿼리/락/트랜잭션 시간을 줄여 “커넥션 점유 시간”을 낮춘다
- 그 다음에 풀 크기를 조정한다
- 인스턴스 수 × 풀 크기 ≤ DB가 감당 가능한 커넥션 수(여유 포함)
원인 5) connectionTimeout이 너무 길어 장애를 늦게 발견
connectionTimeout은 “풀에서 커넥션을 빌릴 때” 기다리는 시간이다. 너무 길면 장애가 늦게 터지고, 요청 스레드가 쌓여 2차 장애로 번진다.
spring:
datasource:
hikari:
connection-timeout: 3000 # 3s 정도로 시작(상황에 맞게)
- 짧게 실패시키고(빠른 실패) 상위 레이어에서 재시도/서킷브레이커로 제어하는 편이 안전한 경우가 많다.
원인 6) idleTimeout/maxLifetime 설정 부재로 ‘죽은 커넥션’ 재사용
클라우드 환경(NAT, LB, 방화벽, DB 프록시)에서는 유휴 커넥션이 중간 장비에 의해 끊기는 일이 흔하다. 이때 풀은 살아있다고 생각하고 커넥션을 빌려주지만, 실제 쿼리 시점에 예외가 나며 재시도 폭풍이 발생하고 결과적으로 풀이 고갈처럼 보일 수 있다.
권장 설정(일반 가이드)
maxLifetime은 DB/네트워크 idle timeout보다 조금 짧게keepaliveTime(Hikari 지원)로 주기적 keepalive
spring:
datasource:
hikari:
max-lifetime: 1740000 # 29분 (예시)
idle-timeout: 600000 # 10분 (예시)
keepalive-time: 300000 # 5분 (예시)
> 값은 환경마다 다르다. RDS Proxy, NAT Gateway, 사내 방화벽 등 “중간 장비”의 idle timeout을 확인하고 그보다 짧게 잡는 것이 핵심이다.
원인 7) 트랜잭션/커넥션을 다른 스레드로 넘기는 비동기 사용
다음 패턴은 커넥션 반환 타이밍을 예측 불가능하게 만들고, 심하면 반환 누락으로 이어진다.
@Async메서드에서 JPA 엔티티 지연 로딩 접근- 요청 스레드에서 열린 트랜잭션 컨텍스트를 다른 스레드에서 사용하려는 시도
- WebFlux(리액티브)에서 블로킹 JDBC를 섞음
대표 증상
- 특정 상황에서만 풀 고갈
- 로그에
LazyInitializationException또는 트랜잭션 경계 관련 경고
해결 방향
- 비동기 경계 밖에서 필요한 데이터를 DTO로 미리 로딩
- 리액티브 스택이면 R2DBC 같은 논블로킹 드라이버 사용(또는 전체를 MVC로)
원인 8) 스트리밍 조회/대용량 결과 처리로 커넥션을 오래 붙잡음
JPA/Querydsl에서 스트리밍 처리(Stream<T>)나 커서 기반 페치, 혹은 대용량 결과를 애플리케이션에서 오래 순회하면 그 시간만큼 커넥션이 점유된다.
위험한 예시
@Transactional(readOnly = true)
public void export() {
try (Stream<Order> s = orderRepository.streamAll()) {
s.forEach(this::writeCsv); // 오래 걸리면 커넥션 장기 점유
}
}
개선 방향
- 페이지네이션(batch size)으로 끊어서 처리
- DB에서 집계/필터를 최대한 수행
- 파일 생성/외부 전송은 트랜잭션 밖에서
원인 9) 커넥션 풀 고갈의 “진짜 범인”은 스레드 풀/백프레셔 부재
톰캣 스레드가 과도하게 많거나(혹은 요청이 폭주) 백프레셔가 없으면, DB는 처리량이 고정인데 애플리케이션은 계속 커넥션을 요구한다. 그 결과 pending이 늘고, 타임아웃이 나며, 전체가 느려진다.
관찰 포인트
hikaricp.connections.pending증가- 톰캣
maxThreads가 크고, 요청 대기열이 길어짐
대응
- 톰캣 스레드 수를 DB 처리량에 맞게 제한
- API 레벨 rate limit/큐잉/서킷브레이커
- 느린 의존성(외부 API)로 인해 요청이 쌓이는 경우도 함께 점검
운영에서 바로 쓰는 진단 체크리스트
1) Hikari 로그/지표로 “누수 vs 장기 점유” 가르기
- leak detection 스택트레이스가 특정 코드 경로에 반복적으로 찍히는가?
- 반복되면 진짜 누수 가능성 ↑
active가 계속max에 붙어 있고, DB에서 해당 세션들이idle in transaction또는 락 대기인가?- 장기 트랜잭션/락 가능성 ↑
2) DB에서 오래 붙잡는 세션을 찾기
- 오래 실행 중인 쿼리
idle in transaction(트랜잭션 열어두고 놀고 있음)- 락 대기 체인(누가 누구를 막는지)
3) 애플리케이션 스레드 덤프
- 많은 스레드가
SocketInputStream/JDBC 드라이버 내부에서 대기 - 특정 DAO/Repository 메서드에서 반복
권장 기본 설정 예시(application.yml)
아래는 “무작정 정답”이 아니라, 대부분의 서비스에서 출발점으로 삼기 좋은 형태다.
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 3000
validation-timeout: 1000
max-lifetime: 1740000
idle-timeout: 600000
keepalive-time: 300000
logging:
level:
com.zaxxer.hikari: INFO
- 풀 크기는 “DB가 감당 가능한 커넥션 수 / 애플리케이션 인스턴스 수” 관점에서 재산정해야 한다.
leak-detection-threshold는 장애 시 단기간만 켜고 원인 잡으면 끄는 편이 좋다.
마무리: ‘커넥션 누수’라는 단어를 의심하라
HikariCP 풀 고갈은 결과(증상)이고, 원인은 다양하다. 이 글의 9가지를 우선순위로 정리하면 다음과 같다.
- 진짜 누수(close 누락) 여부를 leak detection으로 빠르게 배제
- 트랜잭션 경계/락/슬로우쿼리로 인한 장기 점유를 DB에서 확인
- 풀 크기/타임아웃/수명 설정을 워크로드와 네트워크 환경에 맞게 조정
- 스레드/요청 폭주를 제어해 DB 처리량을 넘지 않게 백프레셔 적용
특히 PostgreSQL에서 락/교착이 섞이면 “누수처럼 보이는” 상황이 자주 나온다. 관련 증상이 있다면 PostgreSQL RDS deadlock_detected(40P01) 원인·해결를 같이 점검해 원인 분리를 확실히 해두는 것이 좋다.