Published on

Spring Boot 3.2 HikariCP 커넥션 누수 경고 추적법

Authors

서버가 느려지고 DB 커넥션 풀이 고갈되기 시작하면, 애플리케이션 로그에 아래와 같은 경고가 보이곤 합니다.

WARN  com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detection triggered for ...

Spring Boot 3.2 기본 풀인 HikariCPleakDetectionThreshold를 넘겨 반환되지 않은 커넥션을 감지하면 스택트레이스를 남겨줍니다. 하지만 “경고가 떴다”와 “원인을 정확히 찾아 고쳤다” 사이에는 큰 간극이 있습니다. 이 글은 누수 경고를 재현 → 신뢰할 수 있는 증거 수집 → 원인 패턴별로 좁혀가기 → 재발 방지까지의 추적 루틴을 실전 관점에서 정리합니다.

> 트랜잭션 경계가 꼬여 커넥션이 오래 잡히는 이슈는 누수처럼 보일 수 있습니다. 관련해서는 Spring Boot 3에서 @Transactional 무시되는 5가지도 함께 보면 “진짜 누수”와 “트랜잭션/프록시 문제”를 구분하는 데 도움이 됩니다.


1) HikariCP 누수 경고의 의미부터 정확히 이해하기

HikariCP의 leak detection은 “커넥션이 영원히 닫히지 않았다”를 100% 증명하는 기능이 아닙니다. 기본적으로는 다음 의미에 가깝습니다.

  • 어떤 스레드가 커넥션을 빌려갔다(getConnection)
  • 설정한 임계값(leakDetectionThreshold) 시간 동안
  • 커넥션이 풀로 반환되지 않았다(close가 호출되지 않았다)

여기서 중요한 포인트:

  • 장시간 쿼리/락 대기/네트워크 지연도 “반환이 늦어” 누수 경고를 유발할 수 있습니다.
  • 트랜잭션 범위가 과하게 넓어 커넥션을 오래 점유해도 경고가 뜹니다.
  • 반대로, 정말로 ResultSet/Statement/Connection이 닫히지 않는 코드가 있으면 “진짜 누수”입니다.

따라서 추적의 첫 단계는 임계값을 올바르게 잡고, 경고 스택이 가리키는 지점이 “반환 지연”인지 “미반환”인지 구분하는 것입니다.


2) Spring Boot 3.2에서 leakDetectionThreshold 설정하기

application.yml 예시

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/app
    username: app
    password: secret
    hikari:
      maximum-pool-size: 20
      leak-detection-threshold: 2000   # ms (2초)
      connection-timeout: 3000         # 풀 고갈 시 대기
      validation-timeout: 1000
      max-lifetime: 1800000
      idle-timeout: 600000

권장 접근:

  • 운영에서 무턱대고 2초로 두면 정상 트래픽에서도 경고가 폭발할 수 있습니다.
  • 먼저 평균/95p 쿼리 시간 + 락 대기 가능성을 고려해 3~10초로 시작하고, 문제 상황에서만 낮추는 방식이 안전합니다.

“경고가 보이게” 로그 레벨 맞추기

logging:
  level:
    com.zaxxer.hikari: INFO
    com.zaxxer.hikari.pool.ProxyLeakTask: WARN

3) 누수 경고 스택트레이스를 읽는 요령

HikariCP 경고는 보통 “커넥션을 빌린 시점”의 스택을 남깁니다. 예:

WARN  ProxyLeakTask - Connection leak detection triggered for HikariProxyConnection@...
java.lang.Exception: Apparent connection leak detected
  at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:...)
  at org.springframework.jdbc.datasource.DataSourceUtils.getConnection(...)
  at ...
  at com.example.order.OrderRepository.findSlow(OrderRepository.java:42)
  at com.example.order.OrderService.placeOrder(OrderService.java:88)

여기서 핵심은:

  • 가장 아래의 “우리 코드” 프레임이 “커넥션을 빌린 지점”입니다.
  • 이 지점이 항상 범인인 것은 아니고, “그 커넥션이 오래 반환되지 않은” 시작점입니다.

실전 팁:

  • 동일한 경고가 반복되면, 항상 등장하는 클래스/메서드를 우선순위로 둡니다.
  • 경고 발생 시각과 함께 동일 시각의 SQL 로그/슬로우 쿼리 로그/DB 락을 교차 검증합니다.

4) “진짜 누수” vs “오래 잡는 것” 빠르게 구분하기

4-1. 풀 고갈 징후 확인

다음과 같은 로그가 같이 나오면 “반환 지연”이 누적되어 풀 고갈로 이어지고 있을 가능성이 큽니다.

WARN  HikariPool-1 - Connection is not available, request timed out after 3000ms.

4-2. Actuator로 풀 상태 관측

Spring Boot Actuator + Micrometer를 켜면 Hikari 메트릭을 쉽게 확인할 수 있습니다.

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

Prometheus 또는 /actuator/metrics에서 주로 볼 지표:

  • hikaricp.connections.active
  • hikaricp.connections.idle
  • hikaricp.connections.pending

판단 기준 예:

  • active가 지속적으로 maximumPoolSize 근처에서 내려오지 않으면 반환이 늦거나 누수
  • pending가 치솟으면 대기열 증가(사실상 장애 전조)

5) 원인 패턴 1: JDBC 자원 미반환(가장 고전적인 누수)

Spring JDBC/JdbcTemplate를 쓰더라도, 로우레벨로 내려가면 자원 반환을 놓치기 쉽습니다.

잘못된 예(try-with-resources 누락)

public List<User> findAllBad() throws SQLException {
    Connection con = dataSource.getConnection();
    PreparedStatement ps = con.prepareStatement("select id, name from users");
    ResultSet rs = ps.executeQuery();

    List<User> out = new ArrayList<>();
    while (rs.next()) {
        out.add(new User(rs.getLong(1), rs.getString(2)));
    }

    // rs/ps/con close 누락 -> 누수
    return out;
}

올바른 예(try-with-resources)

public List<User> findAllGood() throws SQLException {
    String sql = "select id, name from users";

    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(1), rs.getString(2)));
        }
        return out;
    }
}

체크리스트:

  • InputStream/Reader를 통해 LOB을 읽는 경우도 close 누락이 발생합니다.
  • 예외 처리 경로(early return, catch)에서 close가 빠지는 경우가 많습니다.

6) 원인 패턴 2: @Transactional 경계 문제로 커넥션 장기 점유

JPA/Hibernate는 보통 트랜잭션 범위에서 커넥션을 빌리고 반납합니다. 따라서 다음 상황은 “누수 경고”처럼 보이기 쉽습니다.

  • 트랜잭션이 필요 이상으로 넓음(외부 API 호출, 파일 IO, 대기/슬립 포함)
  • @Transactional이 기대대로 적용되지 않아 커넥션 반환 시점이 꼬임
  • Open Session In View(OSIV)로 인해 요청 전체에서 영속성 컨텍스트가 유지

흔한 안티패턴: 트랜잭션 안에서 외부 호출

@Transactional
public void placeOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();

    // 외부 API가 느리면 트랜잭션이 길어지고 커넥션 점유가 길어짐
    paymentClient.requestPayment(order);

    order.markPaid();
}

개선: DB 작업과 외부 호출 분리

public void placeOrder(Long orderId) {
    OrderSnapshot snap = loadForPayment(orderId); // 짧은 트랜잭션
    paymentClient.requestPayment(snap);           // 트랜잭션 밖
    markPaid(orderId);                            // 짧은 트랜잭션
}

@Transactional(readOnly = true)
public OrderSnapshot loadForPayment(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    return OrderSnapshot.from(order);
}

@Transactional
public void markPaid(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.markPaid();
}

추적 포인트:

  • 누수 경고가 특정 API에서만 뜬다면, 그 API의 “트랜잭션 범위”가 과한지부터 의심합니다.
  • @Transactional이 무시되는 케이스(자기호출, final, 프록시 우회 등)는 실제 현장에서 매우 흔합니다. 자세한 패턴은 Spring Boot 3에서 @Transactional 무시되는 5가지를 참고하세요.

7) 원인 패턴 3: 비동기/스레드 전환으로 트랜잭션 컨텍스트 붕괴

Spring의 트랜잭션은 기본적으로 스레드 로컬(ThreadLocal) 기반입니다. 따라서 아래처럼 스레드가 바뀌면, 생각한 것과 다른 시점에 커넥션이 잡히거나 풀리지 않는 현상이 생길 수 있습니다.

  • @Async
  • CompletableFuture.supplyAsync
  • Reactor(WebFlux)에서 블로킹 JDBC를 섞는 경우

위험 예: 트랜잭션 안에서 비동기 실행

@Transactional
public void doAsyncWork() {
    CompletableFuture.runAsync(() -> {
        // 다른 스레드: 트랜잭션 컨텍스트 없음
        userRepository.updateLastLogin("a");
    });
}

이 경우 “누수”라기보단 예상치 못한 커넥션 획득/반납 타이밍으로 인해 active가 치솟는 문제가 생길 수 있습니다.

대응:

  • JDBC/JPA는 가능하면 요청 스레드에서 동기적으로 처리
  • 비동기가 필요하면 경계를 명확히: 비동기 작업 내부에서 별도 트랜잭션을 시작하거나, 메시지 큐로 넘겨 워커에서 처리

8) 운영에서 재현이 어렵다면: 로그 상관관계(TraceId)로 좁히기

경고 스택만으로는 “어떤 요청이 그 커넥션을 오래 잡았는지”가 애매할 때가 많습니다. 이때는 **요청 단위 식별자(traceId)**를 로그에 심어 상관관계를 만드는 게 효과적입니다.

Spring Boot 3.x에서 흔한 구성은 MDC + 로그 패턴입니다.

Logback 패턴 예시(traceId 출력)

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger - traceId=%X{traceId} %msg%n</pattern>

간단한 Filter로 traceId 주입

@Component
public class TraceIdFilter extends OncePerRequestFilter {
  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
      throws ServletException, IOException {
    String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
        .orElse(UUID.randomUUID().toString());
    MDC.put("traceId", traceId);
    try {
      filterChain.doFilter(request, response);
    } finally {
      MDC.remove("traceId");
    }
  }
}

이렇게 해두면 Hikari 누수 경고 시각의 직전/직후 로그에서 같은 traceId의 요청이 무엇을 했는지(외부 호출 지연, 특정 SQL 반복 등)를 훨씬 빨리 찾을 수 있습니다.


9) DB 관점에서의 역추적: “애플리케이션이 커넥션을 잡고 뭘 하느냐”

애플리케이션에서 커넥션을 오래 잡는 이유는 대개 DB에서 다음 중 하나입니다.

  • 슬로우 쿼리(인덱스 미스, 대량 정렬/조인)
  • 락 대기(동시 업데이트, 트랜잭션 격리 수준)
  • 커넥션은 잡았지만 실제로는 외부 IO로 대기(애플리케이션 설계)

PostgreSQL 기준으로는 pg_stat_activity에서 오래 실행 중인 쿼리/대기를 확인할 수 있고, MySQL이라면 SHOW PROCESSLIST/performance_schema를 봅니다. 누수 경고가 뜬 시각에 DB에서 active 세션이 무엇을 기다리는지를 보면 “진짜 누수”인지 “DB 대기”인지 빠르게 갈립니다.


10) 실전 디버깅 루틴(추천 순서)

  1. 임계값 조정: leakDetectionThreshold를 너무 낮게 두지 말고, 재현 가능한 수준으로 맞춘다.
  2. 스택트레이스에서 ‘빌린 지점’ 식별: 반복적으로 등장하는 우리 코드 프레임을 추린다.
  3. 같은 시각의 풀 메트릭 확인: active/pending가 실제로 치솟는지 본다.
  4. DB 대기/슬로우 쿼리 확인: 락/대기라면 애플리케이션 누수가 아닐 수 있다.
  5. 코드 패턴 점검
    • try-with-resources 누락
    • 트랜잭션 범위 과다
    • @Transactional 미적용/자기호출
    • 비동기/스레드 전환
  6. 상관관계 로그(TraceId)로 요청 단위 추적

이 루틴대로 보면, “경고는 뜨는데 도대체 어디서 새는지 모르겠다” 상태에서 벗어나 확률 높은 원인부터 체계적으로 제거할 수 있습니다.


11) 재발 방지 체크리스트

  • 트랜잭션 안에 외부 API 호출/대기 로직을 넣지 않는다.
  • Repository/DAO에서 로우레벨 JDBC를 쓸 경우 try-with-resources를 강제한다(코드리뷰 룰).
  • 풀 메트릭을 대시보드화하고, pending > 0이 일정 시간 지속되면 알람을 건다.
  • 장애가 “특정 인프라 이벤트(네트워크/DNS)”와 겹친다면, 애플리케이션 누수로 오인하기 쉽다. EKS 환경이라면 DNS 이슈가 지연을 만들기도 하니 EKS CoreDNS SERVFAIL·NXDOMAIN 간헐 해결 9가지 같은 인프라 점검 항목도 함께 두는 것이 좋다.

마무리

Spring Boot 3.2에서 HikariCP 커넥션 누수 경고를 잡는 핵심은 “경고를 없애는 것”이 아니라, 커넥션이 반환되지 않는 정확한 흐름(또는 반환이 늦어지는 병목)을 증거 기반으로 특정하는 것입니다. leakDetectionThreshold는 경보장치일 뿐이고, 실제 해결은 트랜잭션 경계·자원 반환·비동기 경계·DB 대기 분석을 함께 해야 합니다.

다음에 누수 경고가 뜨면, 먼저 스택트레이스의 ‘빌린 지점’을 잡고, 풀 메트릭과 DB 대기를 교차 검증해 “진짜 누수”인지 “장기 점유”인지부터 분리해보세요. 그 순간부터 해결 속도가 달라집니다.