Published on

Spring Boot HikariCP 커넥션 누수 감지·해결 가이드

Authors

서버가 갑자기 느려지고, DB 커넥션 풀이 고갈되며, 결국 타임아웃이 연쇄적으로 터지는 장애의 상당수는 “커넥션을 빌려가서 제때 반납하지 않는” 문제에서 시작합니다. Spring Boot 기본 풀인 HikariCP는 성능이 좋지만, 누수가 발생하면 매우 빠르게 풀을 소진시켜 장애로 번집니다.

이 글에서는 HikariCP의 누수(Leak) 감지 기능을 제대로 켜는 법, 로그를 읽고 원인을 특정하는 법, Spring/JPA/JdbcTemplate/트랜잭션 경계에서 자주 발생하는 누수 패턴과 해결책, 그리고 운영 환경에서 재발을 막는 점검 포인트를 정리합니다.

HikariCP 커넥션 누수란 무엇인가

커넥션 누수는 “커넥션 풀에서 커넥션을 획득한 뒤, 예외/조기 리턴/비동기 처리/잘못된 트랜잭션 경계 등으로 인해 close가 호출되지 않아 풀로 반환되지 않는 상태”를 의미합니다.

증상은 비교적 전형적입니다.

  • 애플리케이션 로그에 Connection is not available, request timed out after ...ms 증가
  • 응답 지연이 급증하고, 스레드가 DB 커넥션 대기에서 막힘
  • DB 서버는 오히려 CPU가 낮은데 애플리케이션이 멈춘 것처럼 보임
  • 재시작하면 잠깐 정상화되다가 다시 악화(누수 누적)

HikariCP는 “누수 의심”을 감지해 스택 트레이스를 로그로 남길 수 있습니다. 다만 이 기능을 어떻게 켜고, 어떤 기준으로 해석할지가 핵심입니다.

leakDetectionThreshold로 누수 의심 지점 찾기

HikariCP의 누수 감지는 leakDetectionThreshold(밀리초)로 동작합니다. 커넥션을 빌려간 뒤 해당 시간 안에 반환되지 않으면 “누수 의심”으로 로그를 남깁니다.

Spring Boot 설정 예시

application.yml에서 다음처럼 설정합니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      leak-detection-threshold: 5000
      pool-name: main-pool
  • leak-detection-threshold: 5000은 5초 이상 반환되지 않는 커넥션을 의심합니다.
  • 너무 낮게 잡으면 정상적으로 오래 걸리는 쿼리도 누수처럼 찍힙니다.
  • 너무 높게 잡으면 장애가 터진 뒤에야 단서가 나옵니다.

운영에서 흔히 쓰는 접근은 다음과 같습니다.

  • 평상시: 비활성 또는 높게(예: 30000)
  • 이상 징후 감지 시: 일시적으로 3000~10000 사이로 낮춰 스택 트레이스 수집

누수 감지 로그 형태 이해하기

누수 감지가 트리거되면 보통 아래와 비슷한 로그가 나옵니다.

Connection leak detection triggered for com.zaxxer.hikari.pool.ProxyConnection@... on thread ...
Stack trace follows:
...

여기서 중요한 건 “어디서 커넥션을 빌려갔는지”입니다. 스택 트레이스 상단에 DataSource.getConnection, JdbcTemplate, EntityManager, Repository 호출 지점이 보일 텐데, 그 라인이 1차 용의자입니다.

주의할 점도 있습니다.

  • 이 로그는 “확정 누수”가 아니라 “임계시간 내 미반환”입니다.
  • 느린 쿼리, 락 대기, 외부 API 호출을 트랜잭션 안에 넣은 경우도 동일하게 찍힙니다.

따라서 누수 로그를 봤다면, 반드시 “커넥션을 잡고 있는 시간 동안 무슨 일을 했는지”를 함께 확인해야 합니다.

풀 고갈을 유발하는 대표 원인 7가지와 해결책

여기서는 Spring Boot에서 특히 자주 나오는 패턴을 원인별로 정리합니다.

1) try-with-resources 누락(JdbcTemplate 아닌 순수 JDBC)

순수 JDBC로 Connection/PreparedStatement/ResultSet을 직접 다룰 때 close 누락이 가장 흔합니다.

나쁜 예:

Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("select ...");
ResultSet rs = ps.executeQuery();
// 예외가 나면 close까지 못 감

좋은 예(반드시 try-with-resources):

try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement("select ...");
     ResultSet rs = ps.executeQuery()) {

    while (rs.next()) {
        // ...
    }
}

핵심은 Connection뿐 아니라 Statement, ResultSet까지 모두 자원 해제 대상이라는 점입니다.

2) @Transactional 경계가 잘못되어 커넥션을 오래 잡음

엄밀히 말하면 “누수”가 아니라 “과도한 홀딩(holding)”이지만, HikariCP 관점에서는 동일하게 풀 고갈을 만듭니다.

  • 트랜잭션 안에서 외부 API 호출
  • 트랜잭션 안에서 대용량 파일 I/O
  • 트랜잭션 안에서 메시지 발행 후 대기

해결 원칙은 단순합니다.

  • DB 작업만 트랜잭션에 넣고, 외부 호출은 트랜잭션 밖으로 분리
  • 필요한 경우 읽기 트랜잭션은 readOnly로 명시
@Service
public class OrderService {

    @Transactional
    public void createOrder(CreateOrderCommand cmd) {
        // 1) DB에 주문 생성
        orderRepository.save(cmd.toEntity());

        // 2) 외부 결제 승인 호출을 여기서 하면 커넥션 홀딩이 길어짐
        // paymentClient.approve(...);
    }

    public void createOrderWithPayment(CreateOrderCommand cmd) {
        createOrder(cmd);
        paymentClient.approve(cmd.payment());
    }
}

트랜잭션 이슈는 원인이 다양합니다. 관련해서는 내부 글인 Spring Boot 3에서 @Transactional 먹통 원인 7가지도 함께 보면 “트랜잭션이 기대대로 동작하지 않아 커넥션이 예상보다 오래 잡히는” 케이스를 점검하는 데 도움이 됩니다.

3) 스트리밍 쿼리/커서 기반 처리에서 커넥션이 오래 열림

JPA에서 스트리밍으로 결과를 처리하거나, JDBC에서 fetch size를 크게 잡고 장시간 소비하면 커넥션이 그 시간 동안 점유됩니다.

예: JPA 스트림을 열고 닫지 않는 경우

@Transactional(readOnly = true)
public void export() {
    Stream<User> stream = userRepository.streamAll();
    stream.forEach(this::write);
    // stream close가 보장되지 않으면 커넥션이 오래 점유될 수 있음
}

해결:

@Transactional(readOnly = true)
public void export() {
    try (Stream<User> stream = userRepository.streamAll()) {
        stream.forEach(this::write);
    }
}

또는 애초에 배치 단위 페이징으로 바꾸는 것이 운영 안정성 측면에서 더 낫습니다.

4) 비동기/멀티스레드에서 트랜잭션 컨텍스트를 착각

@Async, CompletableFuture, 스레드풀에서 DB 접근을 섞으면 “트랜잭션이 이어질 것”이라는 착각이 누수/홀딩으로 이어집니다.

  • 부모 스레드에서 커넥션을 잡은 상태로 자식 작업을 기다림
  • 자식 스레드에서 별도 트랜잭션이 열리며 풀을 추가로 소모

원칙:

  • 비동기 작업은 트랜잭션을 명시적으로 분리
  • DB 작업은 가능한 한 짧게, 동기적으로 끝내고 비동기 작업에는 DB 커넥션이 필요 없는 데이터만 전달

5) 예외 처리/조기 return으로 close 누락(수동 자원 관리 코드)

특히 레거시 코드에서 try { ... } catch { ... return; } finally { close } 형태가 깨져 있는 경우가 많습니다.

  • finally 블록이 없거나
  • finally 안에서 또 예외가 나 close가 스킵되거나
  • close 순서가 잘못되어 다른 자원 해제가 누락

해결은 try-with-resources로 구조를 바꾸는 것이 최선입니다.

6) 커넥션 풀 사이즈/타임아웃을 문제의 원인으로 착각

풀을 키우면 일시적으로 장애가 늦게 나타납니다. 하지만 누수가 있으면 결국 고갈됩니다.

점검 순서 권장:

  • 누수/홀딩 확인(로그, 스택 트레이스)
  • 슬로우 쿼리/락 대기 확인(DB 관측)
  • 그 다음에 풀 사이즈 조정

특히 connection-timeout을 너무 길게 잡으면 장애 시 요청이 오래 대기하며 스레드가 쌓여 2차 장애로 번질 수 있습니다.

7) DB 락/인덱스 미사용으로 쿼리가 오래 걸려 “누수처럼” 보임

HikariCP 누수 감지는 “반납이 늦다”를 잡기 때문에, 실제로는 쿼리가 느려서 커넥션이 늦게 돌아오는 상황도 동일하게 찍힙니다.

이 경우 해결은 애플리케이션 코드가 아니라 DB 쿼리/인덱스/락입니다. PostgreSQL을 쓴다면 인덱스 미사용 이슈를 점검해보는 것이 좋습니다.

운영에서 바로 쓰는 진단 체크리스트

누수 로그가 보이거나 풀 고갈이 의심되면 아래 순서로 좁혀가면 빠릅니다.

1) HikariCP 상태 지표 확인(Actuator + Micrometer)

Spring Boot Actuator를 켜면 HikariCP 메트릭을 수집할 수 있습니다.

build.gradle 예시:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
  implementation 'io.micrometer:micrometer-registry-prometheus'
}

application.yml 예시:

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

대표적으로 봐야 할 값:

  • active: 현재 사용 중 커넥션
  • idle: 유휴 커넥션
  • pending: 커넥션을 기다리는 스레드 수

pending이 증가하는데 activemaximum-pool-size 근처에서 유지되면 “커넥션이 반환되지 않거나(누수) / 너무 오래 잡히거나(슬로우/락)” 둘 중 하나입니다.

2) leakDetectionThreshold를 임시로 낮춰 스택 트레이스 확보

장애가 재현되는 시간대에만 임계값을 낮춰 단서를 모읍니다.

  • 너무 많은 로그가 쏟아지면 오히려 분석이 어려우니 기간을 짧게
  • 스택 트레이스의 공통 호출 지점을 묶어서 1~3개 후보로 압축

3) 스레드 덤프에서 커넥션 대기 확인

서버가 멈춘 것처럼 보일 때는 대개 스레드가 DB 커넥션 대기에서 막혀 있습니다.

  • HikariPool 관련 대기
  • JDBC 드라이버 호출에서 블록

이 단계에서 “요청 스레드가 대기”인지 “커넥션을 잡고 뭔가 오래 하는 중”인지 구분이 됩니다.

4) DB에서 락/슬로우 쿼리 확인

애플리케이션이 커넥션을 오래 잡는 이유가 DB 락이라면, 코드 수정만으로는 해결이 안 됩니다.

  • 슬로우 쿼리 로그
  • 락 대기 세션
  • 인덱스/실행 계획

재발 방지: 안전한 코딩 규칙과 설정 가이드

1) DB 자원은 프레임워크에 맡기고, 수동 JDBC는 최소화

Spring에서는 JdbcTemplate/NamedParameterJdbcTemplate을 쓰면 자원 해제는 프레임워크가 관리합니다. 수동 JDBC가 필요하다면 try-with-resources를 규칙화하세요.

2) 트랜잭션 안에 “DB 외 작업”을 넣지 않기

외부 API, 파일 처리, 긴 CPU 작업을 트랜잭션에 넣으면 커넥션 홀딩이 길어집니다. 결과적으로 누수 감지 로그가 찍히고 풀 고갈이 빨라집니다.

3) 풀 사이즈는 “DB가 감당 가능한 동시성”에 맞추기

무작정 maximum-pool-size를 늘리면 DB에 동시 부하를 더 주고, 락 경합을 키워 오히려 응답 시간이 늘 수 있습니다.

4) 운영 환경에서의 권장값(출발점)

서비스 특성에 따라 다르지만, 출발점으로는 아래 조합이 무난합니다.

  • connection-timeout: 1000~30000 (너무 길게 잡지 않기)
  • leak-detection-threshold: 평시 비활성 또는 30000, 이슈 시 3000~10000
  • maximum-pool-size: DB CPU/IO, 쿼리 특성, 락 경합 고려해 산정

마무리

HikariCP 커넥션 누수는 “close를 안 했다” 같은 단순 실수부터 “트랜잭션 경계 설계”나 “DB 락/슬로우 쿼리”처럼 구조적 문제까지 스펙트럼이 넓습니다.

가장 빠른 해결 루트는 다음 3단계입니다.

  1. leakDetectionThreshold로 스택 트레이스를 확보해 “빌린 지점”을 특정
  2. 그 지점이 진짜 누수인지(자원 해제 누락) vs 홀딩인지(트랜잭션/슬로우/락) 구분
  3. 코드(try-with-resources, 트랜잭션 분리)와 DB(인덱스/락) 양쪽에서 원인을 제거

이 과정을 한 번 체계화해두면, 다음 장애에서는 “풀 고갈”을 단서로 훨씬 빠르게 원인에 도달할 수 있습니다.