Published on

Spring Boot 3 HikariCP 커넥션 고갈 원인·해결

Authors

운영 중 갑자기 API 지연이 길어지고, 결국 HikariPool-1 - Connection is not available, request timed out가 터지면 대부분 “DB가 느리다”로 결론 내리기 쉽습니다. 하지만 Spring Boot 3 환경에서 HikariCP 커넥션 고갈은 DB 성능 이슈 + 애플리케이션 코드/트랜잭션 경계 문제 + 풀 설정 불일치가 겹쳐서 발생하는 경우가 많습니다.

이 글은 증상부터 원인 분류, 재현/관측 방법, 그리고 실제로 효과가 큰 해결책을 순서대로 정리합니다. 목표는 단순히 풀 사이즈를 키우는 임시처방이 아니라, 커넥션을 오래 쥐고 있는 지점을 찾아 줄이고, 동시성 상한을 설계해 고갈을 구조적으로 막는 것입니다.

1) 커넥션 고갈의 전형적인 증상

다음 중 2개 이상이 동시에 보이면 “풀 고갈” 가능성이 큽니다.

  • 애플리케이션 로그에 Connection is not available, request timed out after ... 반복
  • 응답 시간이 계단식으로 증가하다가 타임아웃 폭발
  • DB CPU는 100%가 아닌데도 애플리케이션이 멈춘 듯 느려짐
  • 스레드 덤프에서 com.zaxxer.hikari.pool.HikariPool.getConnection 대기 스레드가 다수
  • Actuator 메트릭에서 activemax에 붙고 pending이 증가

풀 고갈은 “커넥션이 부족”이라기보다, 반납이 늦거나(혹은 안 되거나), 동시에 너무 많이 빌리려는 상황입니다.

2) 원인 분류: 무엇이 커넥션을 붙잡고 있나

2.1 트랜잭션 범위가 과도하게 넓음

@Transactional이 서비스 메서드 전체를 감싸고 있고, 그 안에서 외부 API 호출, 파일 I/O, 메시지 발행, 대용량 직렬화 같은 작업을 하면 DB 커넥션을 쥔 채로 오래 머무릅니다.

특히 Spring MVC에서 다음 패턴이 위험합니다.

  • 트랜잭션 안에서 다른 마이크로서비스 호출
  • 트랜잭션 안에서 대량 데이터 가공 후 응답 생성
  • 트랜잭션 안에서 이벤트 발행 후 동기 처리

해결은 간단합니다. DB 작업만 트랜잭션으로 묶고, 나머지는 트랜잭션 밖으로 빼거나 비동기/이벤트로 분리합니다.

2.2 커넥션 누수(반납되지 않음)

JPA/Hibernate를 쓰면 “내가 커넥션을 닫지 않았는데?”라는 착각이 생깁니다. 하지만 다음 케이스는 누수로 이어질 수 있습니다.

  • JdbcTemplate/DataSource를 직접 쓰면서 try-with-resources 누락
  • 예외 처리 흐름에서 ResultSet/Statement가 닫히지 않음
  • 커스텀 코드에서 Connection을 보관(캐싱)하거나 다른 스레드로 넘김

HikariCP는 누수 추적 기능이 있으므로, 먼저 누수 여부를 계측으로 확정하는 게 좋습니다.

2.3 느린 쿼리/락 대기/인덱스 부재

풀은 “동시 요청 수”만큼 커넥션을 빌려주는데, 쿼리가 느리면 커넥션 점유 시간이 늘어납니다. 결과적으로 동일 QPS에서도 active가 올라가고 고갈이 빨라집니다.

특히 PostgreSQL이라면 VACUUM 지연, 테이블/인덱스 팽창, 락 경합이 느린 쿼리를 유발하기도 합니다. DB 쪽 점검이 필요하면 이 글도 함께 보세요.

2.4 동시성 폭주: 스레드 풀과 커넥션 풀 불일치

Tomcat(또는 Undertow/Netty) 스레드가 200개인데, Hikari maximumPoolSize가 10이면 순간적으로 10개를 넘어서는 DB 접근 요청이 모두 대기열로 쌓입니다.

반대로 커넥션 풀을 무작정 크게 잡으면 DB가 감당 못하고 전체가 느려져 “느림으로 인한 고갈”이 더 심해질 수 있습니다.

핵심은 웹 스레드, 비동기 실행기, 배치/스케줄러 동시성, 커넥션 풀을 한 세트로 맞추는 것입니다.

2.5 타임아웃 설정의 부조화

  • connectionTimeout이 너무 길면 대기 스레드가 오래 쌓여 장애가 확대됩니다.
  • DB/네트워크 문제로 커넥션이 죽었는데 maxLifetime/keepaliveTime이 부적절하면 “죽은 커넥션을 오래 들고” 문제가 커집니다.

또한 ALB, Nginx, 앱, DB의 타임아웃이 서로 다르면 “상위는 끊었는데 하위는 계속 실행” 같은 유령 작업이 생겨 커넥션 점유가 늘어날 수 있습니다.

3) 먼저 관측부터: 로그·메트릭으로 고갈을 확정하기

3.1 Actuator + Micrometer로 풀 상태 보기

Spring Boot 3에서 Actuator를 켜면 Hikari 메트릭이 노출됩니다.

application.yml 예시입니다.

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

spring:
  datasource:
    hikari:
      pool-name: main

Prometheus를 쓴다면 다음 지표를 봅니다.

  • hikaricp_connections_active
  • hikaricp_connections_idle
  • hikaricp_connections_pending
  • hikaricp_connections_max

패턴 해석:

  • activemax에 붙고 pending이 증가한다면 고갈
  • pending이 0인데도 느리다면 DB가 아니라 앱 내부 병목일 수 있음

3.2 누수 의심이면 leakDetectionThreshold를 임시로 켜기

운영에 상시 적용은 부담이 될 수 있으니, 장애 재현 구간에 제한적으로 켭니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 5000
      connection-timeout: 3000
  • leak-detection-threshold는 “커넥션을 빌린 뒤 설정 시간 내 반납이 안 되면 스택트레이스 출력”입니다.
  • 너무 낮게 잡으면 정상적으로 오래 걸리는 트랜잭션도 누수처럼 보일 수 있으니, 먼저 5초~10초로 시작합니다.

3.3 스레드 덤프로 대기 원인 확인

커넥션을 기다리는 스레드가 많다면 스레드 덤프에서 다음을 찾습니다.

  • HikariPool.getConnection에서 BLOCKED 또는 WAITING
  • 그 위 호출 스택이 어떤 서비스/리포지토리로 이어지는지

이때 중요한 건 “누가 커넥션을 못 빌리나”가 아니라, **“누가 커넥션을 오래 쥐고 있나”**를 찾는 것입니다.

4) 코드 레벨 해결: 커넥션 점유 시간을 줄이는 패턴

4.1 트랜잭션 안에서 외부 호출 금지

나쁜 예시는 다음과 같습니다.

@Transactional
public OrderResult placeOrder(OrderCommand cmd) {
    Order order = orderRepository.save(new Order(cmd));

    // 외부 호출이 느리면 커넥션을 쥔 채로 대기
    paymentClient.pay(order.getId(), cmd.amount());

    order.markPaid();
    return OrderResult.from(order);
}

개선 예시: DB 트랜잭션을 최소화하고, 외부 호출은 트랜잭션 밖에서 수행합니다.

public OrderResult placeOrder(OrderCommand cmd) {
    Long orderId = createOrder(cmd); // 짧은 트랜잭션

    paymentClient.pay(orderId, cmd.amount()); // 트랜잭션 밖

    markPaid(orderId); // 짧은 트랜잭션
    return new OrderResult(orderId);
}

@Transactional
public Long createOrder(OrderCommand cmd) {
    Order order = orderRepository.save(new Order(cmd));
    return order.getId();
}

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

일관성 요구가 더 높다면 아웃박스 패턴, 사가 패턴 등으로 확장하는 게 일반적입니다.

4.2 스트리밍 조회는 반드시 닫기

JPA 스트림 조회는 커넥션을 오래 잡을 수 있습니다. 다음처럼 try-with-resources로 닫아야 합니다.

@Transactional(readOnly = true)
public void exportUsers(Writer writer) throws IOException {
    try (var stream = userRepository.streamAll()) {
        stream.forEach(u -> {
            try {
                writer.write(u.getId() + "," + u.getEmail() + "\n");
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        });
    }
}

또한 “export” 같은 작업을 웹 요청 트랜잭션으로 처리하면 고갈이 쉬우니, 배치/비동기 작업으로 분리하는 편이 안전합니다.

4.3 JdbcTemplate/JDBC 직접 사용 시 자원 해제 강제

다음처럼 커넥션을 직접 열면 누수 위험이 큽니다.

public void bad(DataSource ds) throws SQLException {
    Connection c = ds.getConnection();
    PreparedStatement ps = c.prepareStatement("select 1");
    ps.execute();
    // close 누락 가능
}

반드시 try-with-resources를 씁니다.

public void good(DataSource ds) throws SQLException {
    try (Connection c = ds.getConnection();
         PreparedStatement ps = c.prepareStatement("select 1")) {
        ps.execute();
    }
}

5) 설정 레벨 해결: HikariCP를 “현실적인 상한”으로 맞추기

5.1 기본 권장: 풀을 키우기 전에 쿼리/트랜잭션부터 줄이기

풀 사이즈 증설은 마지막 단계가 좋습니다. 이유는 간단합니다.

  • 느린 쿼리로 점유 시간이 긴 상태에서 풀만 키우면 DB 동시 실행이 늘어 DB가 더 느려질 수 있음
  • 결과적으로 “더 큰 풀”도 결국 고갈

5.2 실전 설정 예시(출발점)

아래는 흔히 쓰는 출발점입니다. 서비스 특성에 맞춰 조정해야 합니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 3000
      validation-timeout: 1000
      idle-timeout: 600000
      max-lifetime: 1800000
      keepalive-time: 300000

조정 가이드:

  • maximum-pool-size: DB가 감당 가능한 동시 쿼리 수를 기준으로 잡습니다. “웹 스레드 수”가 아니라 “DB 동시 처리 가능량”이 기준입니다.
  • connection-timeout: 장애 확산을 막기 위해 보통 짧게(예: 1초~3초) 둡니다.
  • max-lifetime: DB나 프록시가 커넥션을 끊는 시간보다 약간 짧게 둡니다.
  • keepalive-time: NAT/방화벽 환경에서 유휴 커넥션이 끊기는 문제가 있다면 도움이 됩니다.

5.3 웹 스레드와의 균형

Tomcat을 쓴다면 다음 조합을 같이 봅니다.

server:
  tomcat:
    threads:
      max: 200
  • DB 접근이 많은 API라면 tomcat.threads.max를 무작정 높이지 말고, 오히려 낮추거나 엔드포인트별 레이트 리밋을 적용해 “DB로 가는 동시성”을 제어하는 편이 낫습니다.
  • 비동기 실행기(TaskExecutor)를 쓰는 경우에도 그 스레드 수가 곧 DB 동시성으로 이어질 수 있습니다.

6) DB/쿼리 측면 해결: 커넥션을 빨리 돌려주게 만들기

6.1 슬로우 쿼리와 인덱스

풀 고갈 상황에서 가장 효과가 큰 처방은 상위 N개 느린 쿼리를 줄이는 것인 경우가 많습니다.

  • 슬로우 쿼리 로그 활성화
  • 실행 계획 확인
  • 적절한 인덱스 추가
  • 불필요한 select * 제거
  • 페이징/커서 기반 처리

6.2 락 경합 줄이기

  • 동일 row를 여러 트랜잭션이 갱신하는 구조라면 “업데이트 폭”을 줄이거나, 큐/샤딩/낙관적 락으로 변경
  • 긴 트랜잭션에서 갱신을 늦게 하는 패턴 제거

7) 재발 방지 체크리스트

  • hikaricp_connections_pending 알람: 0이 아닌 상태가 일정 시간 유지되면 경보
  • 누수 추적 로그는 상시가 아니라 “문제 구간”에 한해 임시 적용
  • 트랜잭션 안에서 외부 호출/대용량 처리 금지 규칙화
  • 배치/스케줄러/비동기 작업의 동시성 상한을 커넥션 풀과 함께 설계
  • 타임아웃을 계층별로 정렬: 클라이언트, 게이트웨이, 앱, DB 순으로 일관성 있게

8) 결론: 풀을 키우기 전에 “점유 시간”을 줄여라

Spring Boot 3에서 HikariCP 커넥션 고갈은 대개 다음 순서로 해결됩니다.

  1. 메트릭으로 active/max/pending 패턴을 확인해 고갈을 확정
  2. 누수인지(반납 안 됨) vs 점유 시간이 긴지(반납이 늦음) 분리
  3. 트랜잭션 경계 축소, 외부 호출 분리, 스트리밍 자원 닫기 등으로 점유 시간 단축
  4. 느린 쿼리/락 경합을 줄여 커넥션이 빨리 돌아오게 만들기
  5. 마지막으로 풀/스레드/타임아웃을 현실적인 상한으로 정렬

이 과정을 거치면 “가끔 터지는 장애”에서 “예측 가능한 용량 관리” 단계로 넘어갈 수 있습니다.