HikariCP 커넥션 풀 고갈, 왜 생기고 어떻게 진단하나

운영에서 가장 당황스러운 장애 중 하나가 애플리케이션은 살아 있는데 모든 요청이 30초 뒤 실패하는 상황입니다. 로그에는 HikariPool-1 - Connection is not available 한 줄만 남습니다. 커넥션 풀이 고갈된 것인데, 원인은 거의 항상 “풀이 작아서”가 아니라 커넥션을 너무 오래 붙들고 있어서입니다. 이 글에서는 고갈이 생기는 네 가지 경로와 진단 방법, 그리고 풀 크기를 정하는 기준을 정리합니다.

먼저, 이 로그가 뜻하는 것

HikariCP가 커넥션을 내주지 못하면 다음 예외가 발생합니다.

java.sql.SQLTransientConnectionException:
  HikariPool-1 - Connection is not available,
  request timed out after 30000ms.

여기서 30000ms는 connectionTimeout 기본값입니다. 커넥션을 30초 동안 기다렸지만 아무도 반납하지 않았다는 뜻이지, DB가 죽었다는 뜻이 아닙니다. 실제로 이 로그가 뜰 때 DB는 대체로 멀쩡합니다.

중요한 것은 이 예외가 원인이 아니라 결과라는 점입니다. 누군가 커넥션을 오래 붙들고 있고, 그 뒤에 줄을 선 요청들이 차례로 타임아웃을 맞는 구조입니다. 그래서 풀 크기를 늘리는 대응은 대부분 시간을 조금 벌 뿐 같은 장애를 다시 만납니다.

고갈로 가는 네 가지 경로

1. 트랜잭션 안에서 외부 API를 호출한다

가장 흔하고 가장 위험합니다. 트랜잭션이 열려 있는 동안 커넥션은 그 스레드에 묶여 있는데, 그 안에서 외부 호출을 하면 외부 응답 시간만큼 커넥션이 잠깁니다.

@Transactional
public void placeOrder(OrderRequest req) {
    Order order = orderRepository.save(req.toEntity());
    paymentClient.approve(order);   // ← 외부 결제 API. 여기서 3초가 걸리면
                                    //    커넥션도 3초 동안 잠긴다
    order.markPaid();
}

평소엔 문제가 없다가 외부 API가 느려지는 순간 한꺼번에 터집니다. 외부가 3초로 느려지고 풀이 10개면, 초당 4건만 들어와도 몇 초 안에 풀이 비워집니다. 이 구조에서 타임아웃까지 없으면 그대로 전면 장애가 됩니다. 타임아웃이 왜 필수인지는 RestTemplate 타임아웃 누락, 전 서버가 멈춘 날에서 다뤘습니다.

해법은 단순합니다. 외부 호출을 트랜잭션 밖으로 빼는 것입니다. 저장과 외부 호출을 분리하고, 결과 반영은 별도 트랜잭션으로 처리합니다.

2. OSIV가 켜져 있다

Spring Boot는 spring.jpa.open-in-view가 기본 true입니다. 이 설정은 영속성 컨텍스트를 뷰 렌더링이 끝날 때까지 열어 두는데, 그동안 커넥션도 함께 붙들고 있습니다.

요청 시작 ──[커넥션 획득]── 서비스 ── 컨트롤러 ── 뷰 렌더링 ──[커넥션 반납]── 응답
           └──────────────── 이 구간 전체를 점유 ────────────────┘

서비스 로직이 50ms에 끝나도 응답 직렬화와 네트워크 전송이 느리면 그만큼 커넥션이 더 잡혀 있습니다. 트래픽이 늘면 DB가 아니라 커넥션 풀이 먼저 한계에 닿는 이유가 이것입니다.

spring:
  jpa:
    open-in-view: false

끄는 쪽이 맞지만, 끄면 서비스 계층 밖에서의 지연 로딩이 즉시 LazyInitializationException이 됩니다. 조회 시점에 필요한 데이터를 다 채워 두는 것이 전제입니다. 그 방법은 JPA N+1 해결에서 정리했습니다.

3. 트랜잭션이 그냥 길다

대량 배치를 트랜잭션 하나로 묶거나, 조회와 계산과 저장을 한 메서드에 몰아넣으면 트랜잭션이 길어집니다. 특히 루프 안에서 저장하는 코드가 흔한 원인입니다.

@Transactional
public void settleAll() {
    for (Member m : memberRepository.findAll()) {   // 10만 건
        settle(m);                                  // 건마다 계산 + 저장
    }
}   // ← 전부 끝날 때까지 커넥션 1개를 계속 점유

배치는 청크 단위로 잘라 짧은 트랜잭션 여러 개로 나누는 것이 원칙입니다. 트랜잭션 경계를 어디에 둘지는 @Transactional 자주 틀리는 5가지 함정에서 더 자세히 다뤘습니다.

4. 커넥션이 새고 있다

앞의 셋은 “오래 쓰는” 문제지만, 이건 아예 반납하지 않는 문제입니다. JdbcTemplate이나 프레임워크를 통하면 보통 반납이 보장되지만, DataSource.getConnection()을 직접 호출하고 try-with-resources를 쓰지 않으면 그대로 샙니다.

// ✗ 예외가 나면 반납되지 않는다
Connection conn = dataSource.getConnection();
doSomething(conn);
conn.close();

// ✓ 예외와 무관하게 반납된다
try (Connection conn = dataSource.getConnection()) {
    doSomething(conn);
}

누수는 시간이 지날수록 가용 커넥션이 단조롭게 줄어드는 패턴을 보입니다. 부하와 무관하게 줄어들면 누수를 의심해야 합니다.

진단 — 무엇을 먼저 볼 것인가

누수 감지 켜기

HikariCP에는 커넥션을 오래 붙들고 있는 코드를 잡아 주는 설정이 있습니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 20000   # 20초 이상 반납 안 되면 경고 + 스택트레이스

임계를 넘으면 그 커넥션을 가져간 지점의 스택트레이스가 로그에 찍힙니다. 범인을 직접 지목해 주므로 가장 먼저 켜 볼 설정입니다. 운영에서는 정상 처리 시간보다 넉넉하게 잡아 오탐을 줄입니다.

풀 상태를 지표로 보기

Actuator를 열면 풀의 실시간 상태가 메트릭으로 나옵니다.

management:
  endpoints:
    web:
      exposure:
        include: health,metrics
메트릭 의미 이상 신호
hikaricp.connections.active 사용 중 계속 최댓값에 붙어 있음
hikaricp.connections.idle 대기 중 0에서 회복되지 않음
hikaricp.connections.pending 커넥션을 기다리는 스레드 0보다 크면 이미 병목
hikaricp.connections.usage 커넥션 점유 시간 평균이 수백 ms 이상

핵심 지표는 pending입니다. 이 값이 0이 아니라는 것은 “지금 커넥션을 못 받아 대기 중인 스레드가 있다”는 뜻이고, 장애 발생 전에 먼저 올라갑니다. 알림을 건다면 여기에 걸어야 합니다. 지표를 지속적으로 보는 방법은 Pinpoint APM 구축 가이드에서 정리했습니다.

이미 터졌다면 스레드 덤프

장애가 진행 중이라면 jstack으로 스레드 덤프를 뜨는 것이 가장 빠릅니다.

jstack <pid> > dump.txt

# 커넥션을 기다리는 스레드
grep -A5 "HikariPool" dump.txt

# 외부 호출에 묶인 스레드 (원인일 가능성이 큼)
grep -B2 -A5 "socketRead0" dump.txt

워커 스레드 다수가 같은 자리에 멈춰 있다면 그 지점이 원인입니다. 커넥션 대기(getConnection)에 멈춰 있으면 결과이고, 외부 호출이나 특정 쿼리에 멈춰 있으면 그게 원인입니다.

풀 크기는 얼마가 적당한가

흔한 오해가 “풀을 크게 잡을수록 좋다”입니다. 반대에 가깝습니다. 커넥션은 DB 쪽에서도 프로세스·메모리를 차지하고, 동시 실행이 늘수록 DB 내부 경합이 심해져 전체 처리량이 오히려 떨어집니다.

HikariCP 문서가 제시하는 출발점은 다음 식입니다.

풀 크기 = (코어 수 × 2) + 유효 디스크 수

# 예: 4코어, SSD 1개 → (4 × 2) + 1 = 9

생각보다 작은 숫자라 의아할 수 있지만, 이 크기로 충분하지 않다면 대개 풀이 아니라 쿼리나 트랜잭션 길이가 문제입니다. 기본값 10으로 두고 pending과 usage를 관찰하면서 조정하는 편이 안전합니다.

함께 볼 설정은 두 가지입니다.

설정 기본값 정하는 기준
maximum-pool-size 10 위 공식에서 시작. DB의 max_connections를 인스턴스 수로 나눈 값을 넘기지 않는다
connection-timeout 30000ms 너무 길면 장애를 늦게 감지한다. 3~5초로 줄여 빠르게 실패시키는 편이 낫다
max-lifetime 1800000ms DB나 앞단 프록시의 유휴 종료 시간보다 짧게 잡는다

max-lifetime은 놓치기 쉬운 설정입니다. DB가 먼저 커넥션을 끊어 버리면 애플리케이션은 이미 죽은 커넥션을 들고 있다가 다음 쿼리에서 실패합니다. 항상 DB 쪽 설정보다 짧게 두어야 합니다.

자주 묻는 질문

Q1. 풀 크기를 100으로 늘렸더니 해결됐습니다. 그대로 둬도 되나요?

증상이 가려진 것일 가능성이 큽니다. 커넥션을 오래 붙드는 코드가 그대로라면 트래픽이 조금 더 늘었을 때 같은 장애가 재현됩니다. 더 큰 문제는 DB 쪽 부담입니다. 애플리케이션 인스턴스가 5대면 커넥션 500개를 요구하는 셈이라, DB의 max_connections를 넘기면 이번엔 DB가 접속을 거부합니다. 풀을 늘리기 전에 usage 지표부터 확인하십시오.

Q2. OSIV를 끄면 무엇이 깨지나요?

서비스 계층을 벗어난 뒤의 지연 로딩이 전부 예외가 됩니다. 컨트롤러에서 order.getMember().getName() 같은 코드를 쓰고 있었다면 그 지점이 터집니다. 그래서 끄기 전에 조회 시점에 필요한 연관을 다 채우도록 먼저 정리해야 합니다. Fetch Join, @EntityGraph, 배치 사이즈, 또는 DTO 직접 조회를 쓰면 됩니다. 순서를 반대로 하면 운영에서 예외를 만나게 됩니다.

Q3. 읽기 전용 트랜잭션도 커넥션을 잡나요?

잡습니다. @Transactional(readOnly = true)는 쓰기를 막고 플러시를 생략해 부하를 줄이지만, 커넥션 점유 자체는 동일합니다. 다만 읽기 전용으로 표시하면 읽기 복제본(Replica)으로 라우팅할 수 있어, 쓰기 풀과 읽기 풀을 분리하는 구조로 갈 수 있습니다. 조회가 많은 서비스라면 검토할 만합니다.

Q4. 커넥션 풀과 톰캣 스레드 풀은 어떤 관계인가요?

서로 다른 자원이고 둘 다 고갈될 수 있습니다. 톰캣 스레드가 200개인데 커넥션이 10개면, 동시에 DB를 쓰는 요청은 10개까지만 진행되고 나머지 190개는 커넥션을 기다립니다. 반대로 커넥션이 남아도 톰캣 스레드가 외부 호출에 묶여 있으면 요청 자체를 못 받습니다. 어느 쪽이 먼저 마르는지를 지표로 구분하는 것이 진단의 시작입니다.

마무리

커넥션 풀 고갈은 원인이 다양해 보이지만 결국 한 문장으로 정리됩니다. 커넥션을 필요한 시간보다 오래 붙들고 있다. 외부 호출을 트랜잭션 안에 두었거나, OSIV로 요청 끝까지 잡고 있거나, 트랜잭션이 길거나, 반납을 빠뜨렸거나입니다.

그래서 대응 순서도 정해져 있습니다. 먼저 leak-detection-threshold를 켜서 범인을 찾고, pending 지표에 알림을 걸어 장애 전에 감지하고, 그다음에야 풀 크기를 조정합니다. 풀 크기부터 손대는 것은 가장 마지막에 할 일입니다.

함께 보면 좋은 글

“HikariCP 커넥션 풀 고갈, 왜 생기고 어떻게 진단하나”에 대한 1개의 생각

댓글은 닫혔습니다.