Published on

Spring Boot HikariCP 커넥션 누수 진단·해결

Authors

운영 중인 Spring Boot 서비스에서 응답 지연이 점점 커지다가 어느 순간부터 HikariPool-1 - Connection is not available, request timed out 같은 메시지가 나오면, 가장 먼저 의심해야 할 것 중 하나가 커넥션 누수(connection leak) 입니다. HikariCP는 기본적으로 매우 빠르고 안정적이지만, 애플리케이션 코드가 커넥션을 제대로 반환하지 않으면 풀은 결국 고갈되고 DB는 멀쩡해도 서비스는 타임아웃/에러를 내기 시작합니다.

이 글에서는 Spring Boot + HikariCP 환경에서 누수를 빠르게 재현/진단하고, 원인별로 확실히 해결하는 방법을 코드와 설정 중심으로 정리합니다.

또한 커넥션 누수는 종종 파일 디스크립터 고갈과 함께 나타나므로, OS 레벨 증상까지 함께 보면 더 빨리 좁힐 수 있습니다. 관련해서는 리눅스 Too many open files 즉시 진단·해결도 함께 참고하면 좋습니다.

커넥션 누수의 전형적인 증상

다음 징후가 함께 보이면 누수 가능성이 큽니다.

  • 트래픽이 일정해도 응답 시간이 서서히 증가
  • DB CPU/락은 크게 문제 없는데 애플리케이션에서 DB 대기 시간이 증가
  • HikariCP에서 커넥션 획득 타임아웃 발생
  • active가 계속 높고 idle이 거의 0에 수렴
  • 특정 API 호출 이후부터 풀 고갈이 더 빨리 발생

HikariCP 로그에서 흔히 보는 메시지 예시는 다음과 같습니다.

  • Connection is not available, request timed out after ...ms
  • Apparent connection leak detected

1단계: HikariCP leakDetectionThreshold로 “누수 위치”부터 잡기

가장 효율적인 첫 조치는 누수 탐지 로그를 켜서 스택 트레이스를 확보하는 것입니다.

설정 예시(application.yml)

< > 문자가 본문에 노출되면 MDX 빌드 에러가 날 수 있으니, 아래처럼 코드 블록 안에서만 사용합니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 3000
      validation-timeout: 1000
      leak-detection-threshold: 2000   # 2초 이상 반환되지 않으면 의심
      pool-name: main-pool
  • leak-detection-threshold는 “진짜 누수”뿐 아니라 정상적으로 오래 걸리는 쿼리/트랜잭션도 잡아낼 수 있습니다.
  • 그래서 운영에서는 보통 일시적으로만 활성화하고, 원인 파악 후 끄는 방식을 추천합니다.

로그에서 무엇을 봐야 하나

누수 탐지가 켜지면 HikariCP가 “커넥션을 빌려간 코드 위치”를 스택 트레이스로 출력합니다. 여기서 중요한 건:

  • 커넥션을 획득한 메서드(또는 DAO/Repository 호출 체인)
  • 예외 처리 경로에서 close()가 누락되는지
  • 트랜잭션 경계가 과도하게 넓어 커넥션을 오래 점유하는지

2단계: Actuator 메트릭으로 풀 고갈 패턴 확인

로그만으로는 “어느 시점에 얼마나 고갈되는지”가 잘 안 보일 수 있습니다. Spring Boot Actuator + Micrometer로 HikariCP 메트릭을 보면 패턴이 명확해집니다.

의존성(Gradle)

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

엔드포인트 노출

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

확인할 핵심 지표

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

특히 pending이 증가하는데 activemax에 붙어 있고 내려오지 않으면 “반환이 안 되는 커넥션”이 있다는 뜻입니다.

클라우드 런타임에서 콜드스타트/스케일링과 함께 증상이 악화되는 경우도 있는데, 트래픽 급증과 인스턴스 증설 타이밍이 겹치면 풀/DB 한계가 더 빨리 드러납니다. 인프라 관점의 지연 요인은 GCP Cloud Run 503·콜드스타트 지연 해결법처럼 함께 점검하는 게 좋습니다.

3단계: 가장 흔한 누수 원인과 해결

원인 A: JDBC를 직접 쓰면서 close() 누락

가장 고전적인 누수입니다. Connection/PreparedStatement/ResultSet 중 하나라도 닫히지 않으면, 커넥션이 풀로 반환되지 않거나 관련 리소스가 쌓입니다.

나쁜 예

public User find(long id) throws SQLException {
    Connection con = dataSource.getConnection();
    PreparedStatement ps = con.prepareStatement("select id, name from users where id = ?");
    ps.setLong(1, id);
    ResultSet rs = ps.executeQuery();

    if (rs.next()) {
        return new User(rs.getLong("id"), rs.getString("name"));
    }
    return null;
    // rs/ps/con close 누락
}

좋은 예: try-with-resources

public User find(long id) throws SQLException {
    try (Connection con = dataSource.getConnection();
         PreparedStatement ps = con.prepareStatement("select id, name from users where id = ?")) {

        ps.setLong(1, id);
        try (ResultSet rs = ps.executeQuery()) {
            if (rs.next()) {
                return new User(rs.getLong("id"), rs.getString("name"));
            }
            return null;
        }
    }
}

핵심은 모든 JDBC 리소스를 try-with-resources로 감싸는 것입니다.

원인 B: 트랜잭션 범위가 과도하게 넓어 “누수처럼 보이는” 케이스

@Transactional이 붙은 서비스 메서드 안에서 다음을 함께 하면 커넥션을 오래 잡고 있게 됩니다.

  • 외부 API 호출
  • 대용량 파일 처리
  • 긴 CPU 작업
  • 락 경합이 큰 쿼리

이 경우는 엄밀히 말하면 “반환을 안 하는 누수”가 아니라 커넥션 홀드 시간이 길어 풀을 고갈시키는 문제입니다. 하지만 운영 증상은 누수와 거의 동일합니다.

개선 방향

  • 트랜잭션은 DB 작업 구간에만 최소화
  • 외부 호출/무거운 작업은 트랜잭션 밖으로 이동
  • 읽기 전용은 @Transactional(readOnly = true)로 힌트 제공

예시:

public void placeOrder(PlaceOrderCommand cmd) {
    // 1) 외부 호출은 트랜잭션 밖
    PaymentQuote quote = paymentClient.quote(cmd);

    // 2) DB 작업만 트랜잭션으로
    persistOrder(cmd, quote);
}

@Transactional
void persistOrder(PlaceOrderCommand cmd, PaymentQuote quote) {
    orderRepository.save(new Order(cmd, quote));
}

원인 C: 예외 처리/조기 반환 경로에서 리소스 반환 누락

특정 조건에서 return을 먼저 해버리거나, catch에서 예외를 삼키면서 close()가 호출되지 않는 패턴이 자주 나옵니다.

  • 해결: try-with-resources, 또는 Spring의 JdbcTemplate/JPA 사용으로 자원 관리를 프레임워크에 위임

원인 D: JPA에서 스트리밍/지연 로딩으로 커넥션을 오래 점유

JPA/Hibernate는 일반적으로 커넥션을 잘 반납하지만, 다음 패턴은 커넥션 점유 시간을 길게 만들 수 있습니다.

  • Stream으로 결과를 스트리밍하면서 트랜잭션을 길게 유지
  • Open Session In View(OSIV)로 인해 웹 요청 전체에서 영속성 컨텍스트가 열려 DB 접근이 늦게 발생

OSIV 점검

spring:
  jpa:
    open-in-view: false

OSIV를 끄면 컨트롤러/뷰 계층에서의 지연 로딩이 깨질 수 있으니, DTO로 필요한 데이터를 서비스 계층에서 미리 조회하는 방식으로 정리해야 합니다.

원인 E: 커넥션 풀 사이즈/타임아웃을 “증상 숨기기”로만 조정

풀 사이즈를 키우면 일시적으로 장애가 늦게 오지만, 누수 자체는 그대로라 결국 더 큰 장애로 돌아옵니다. 다만 진단 과정에서 다음 튜닝은 유효합니다.

  • connection-timeout을 짧게 해서 장애를 빨리 감지
  • maximum-pool-size는 DB의 max_connections와 서비스 인스턴스 수를 함께 고려

예시 가이드(정답은 아님):

  • 인스턴스 10개, 인스턴스당 풀 20이면 DB는 최대 200 커넥션을 감당해야 함
  • DB 설정/리소스가 이를 못 버티면 풀 고갈과 별개로 DB 자체가 불안정해짐

4단계: 누수 재현 테스트로 “고쳤는지” 검증

운영에서만 재현되는 것처럼 보여도, 많은 누수는 부하/동시성 조건을 만들면 스테이징에서도 잡힙니다.

간단한 동시성 테스트 예시

@Test
void connectionLeakSmokeTest() throws Exception {
    int threads = 50;
    var executor = java.util.concurrent.Executors.newFixedThreadPool(threads);

    var futures = new java.util.ArrayList<java.util.concurrent.Future<?>>();
    for (int i = 0; i < 1000; i++) {
        futures.add(executor.submit(() -> {
            // 누수가 있는 API/서비스 호출을 반복
            userService.findUser(1L);
        }));
    }

    for (var f : futures) {
        f.get();
    }

    executor.shutdown();
}

이 테스트 자체가 완전한 부하 테스트는 아니지만, “특정 코드 경로에서 반환이 누락되는지”를 빠르게 확인하는 데 도움이 됩니다.

5단계: 스레드 덤프와 DB 관점에서 교차 확인

스레드 덤프에서 볼 것

  • 다수의 스레드가 HikariPool.getConnection() 또는 JDBC 실행 지점에서 대기
  • 특정 락/동기화 지점에서 막혀 트랜잭션이 길어지는 패턴

DB에서 볼 것

  • 장시간 실행 쿼리
  • idle in transaction 상태의 세션 증가(특히 PostgreSQL)

애플리케이션 누수처럼 보이지만 실제로는 “트랜잭션이 열린 채로 멈춤”인 경우가 많습니다.

운영 체크리스트(빠르게 적용)

  • leak-detection-threshold를 짧게(예: 2초~5초) 설정해 스택 트레이스로 위치 확보
  • Actuator 메트릭으로 active/idle/pending 추이를 대시보드화
  • JDBC 직접 사용 코드는 try-with-resources로 전면 정리
  • 트랜잭션 경계를 DB 작업으로만 축소(외부 호출/무거운 작업 분리)
  • JPA 사용 시 OSIV 정책과 스트리밍 쿼리 사용 여부 점검
  • 풀 사이즈 증설은 최후의 수단, DB max_connections와 함께 계산

마무리: “누수”와 “장시간 점유”를 구분하라

HikariCP 장애는 겉으로 보면 모두 풀 고갈로 나타나지만, 실제 원인은 크게 두 갈래입니다.

  • 커넥션을 반환하지 않는 진짜 누수
  • 반환은 하지만 너무 오래 잡고 있는 장시간 점유(트랜잭션/쿼리/락/외부 호출)

leak-detection-threshold로 위치를 잡고, 메트릭으로 패턴을 확인한 뒤, 코드 레벨에서 자원 반환과 트랜잭션 경계를 정리하면 대부분의 문제는 재발 없이 해결됩니다. 이후에도 간헐적인 타임아웃이 남는다면, OS 파일 디스크립터/네트워크/인그레스 타임아웃 같은 주변 요인까지 확장해서 점검하는 것이 운영에서의 정답입니다.