Published on

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

Authors

운영 중인 Spring Boot 서비스에서 갑자기 응답이 느려지거나, 특정 시점부터 요청이 줄줄이 타임아웃 나는 상황을 겪었다면 HikariCP 커넥션 풀이 고갈(pool exhaustion)된 경우가 많습니다. 로그에는 대개 Connection is not available, request timed out after ...ms 같은 문구가 찍히고, 애플리케이션 스레드는 DB 커넥션을 기다리느라 멈춰 서게 됩니다.

이 글에서는 “왜 커넥션이 고갈되는지”를 원인별로 분해하고, 진단 방법과 해결책을 코드·설정·운영 관점에서 정리합니다. 특히 단순히 maximumPoolSize를 올리는 것으로 끝내면 재발하기 쉬우므로, 병목의 근본을 찾는 흐름으로 설명합니다.

관련해서 트랜잭션 경계가 의도대로 동작하지 않아 커넥션이 오래 잡히는 케이스도 많습니다. 필요하면 Spring Boot 3에서 @Transactional 무시 원인 7가지도 함께 확인해 보세요.

HikariCP 커넥션 고갈이란

HikariCP는 일정 개수의 DB 커넥션을 풀로 유지하고, 요청이 오면 풀에서 커넥션을 빌려주고 반납받습니다. 고갈은 다음 중 하나로 발생합니다.

  • 풀의 모든 커넥션이 사용 중이라서 더 이상 빌려줄 수 없음
  • 커넥션이 반납되지 않는 누수(leak) 가 존재
  • 커넥션은 반납되지만, DB 쿼리/락/네트워크로 인해 사용 시간이 너무 길어 풀 회전이 안 됨

고갈이 지속되면 애플리케이션은 DB 커넥션을 기다리는 대기열에서 막히고, 결국 요청 타임아웃, 스레드 고갈, 장애 전파로 이어집니다.

증상 체크리스트 (로그·메트릭·스레드덤프)

1) 대표 로그

  • HikariPool-1 - Connection is not available, request timed out after ...ms
  • DB 쪽 로그에서는 느린 쿼리, 락 대기, 커넥션 수 급증 등이 동반될 수 있습니다.

2) Actuator + Micrometer 메트릭

Spring Boot Actuator를 켜면 다음 메트릭으로 즉시 감이 옵니다.

  • hikaricp.connections.active: 현재 사용 중 커넥션 수
  • hikaricp.connections.idle: 대기 중 커넥션 수
  • hikaricp.connections.pending: 커넥션을 기다리는 스레드 수
  • hikaricp.connections.max: 풀 최대치

active == max 이고 pending 이 증가한다면 전형적인 고갈 상황입니다.

application.yml 예시:

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

3) 스레드 덤프에서 확인할 포인트

스레드 덤프에서 다수의 스레드가 다음 형태로 대기한다면 커넥션 대기 병목입니다.

  • com.zaxxer.hikari.pool.HikariPool.getConnection
  • java.sql.DriverManager.getConnection
  • JPA 사용 시 org.hibernate.engine.jdbc.connections.internal.DatasourceConnectionProviderImpl.getConnection

반대로, 커넥션은 확보했는데 쿼리 실행에서 오래 걸리면 DB 락/슬로우쿼리/네트워크 이슈 가능성이 큽니다.

원인 1: 커넥션 누수 (반납 누락)

가장 위험하고 흔한 원인입니다. 코드가 커넥션을 빌린 뒤 예외 경로에서 닫지 않거나, 스트리밍 처리로 ResultSet을 오래 잡고 있으면 풀은 영원히 회수하지 못합니다.

흔한 누수 패턴

(1) JDBC에서 close() 누락

나쁜 예(누수):

public User findUser(long id) throws SQLException {
    Connection conn = dataSource.getConnection();
    PreparedStatement ps = conn.prepareStatement("select * from users where id = ?");
    ps.setLong(1, id);
    ResultSet rs = ps.executeQuery();
    // 예외 발생하거나 return 경로가 꼬이면 close()가 누락될 수 있음
    return rs.next() ? map(rs) : null;
}

좋은 예(무조건 반납):

public User findUser(long id) throws SQLException {
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement("select * from users where id = ?")) {

        ps.setLong(1, id);
        try (ResultSet rs = ps.executeQuery()) {
            return rs.next() ? map(rs) : null;
        }
    }
}

(2) JPA/Hibernate에서 스트리밍 조회 남용

Stream 기반 조회는 트랜잭션/영속성 컨텍스트가 열려 있는 동안 커넥션을 붙잡을 수 있습니다. 특히 @Transactional(readOnly = true) 없이 스트림을 외부로 반환하면 커넥션이 길게 점유됩니다.

권장 방식은 트랜잭션 안에서 스트림을 소비하고 닫기입니다.

@Transactional(readOnly = true)
public void exportUsers(Writer out) {
    try (Stream<User> stream = userRepository.streamAll()) {
        stream.forEach(u -> writeLine(out, u));
    }
}

누수 진단: leak detection

HikariCP의 누수 감지를 켜면 “빌린 뒤 일정 시간 이상 반납되지 않은 커넥션”의 스택트레이스를 찍어줍니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 20000 # 20초 (운영에서는 상황에 맞게)

주의: 너무 짧게 잡으면 정상적으로 오래 걸리는 쿼리도 누수로 오탐할 수 있습니다. “평소 쿼리 p95”보다 여유 있게 두고, 장애 상황에서 일시적으로 낮추는 전략이 실무적으로 안전합니다.

원인 2: 트랜잭션이 너무 길다 (커넥션 장기 점유)

커넥션은 대개 트랜잭션 경계에서 오래 잡힙니다. 다음이 대표적인 장기 점유 패턴입니다.

  • 트랜잭션 안에서 외부 API 호출
  • 트랜잭션 안에서 대용량 파일 업로드/다운로드
  • 트랜잭션 안에서 대량 루프 처리(배치성 로직)
  • @Transactional이 의도와 다르게 적용되어 범위가 커짐

해결 원칙

  • 트랜잭션은 “DB에 필요한 최소 구간”으로 좁힙니다.
  • 외부 I/O(HTTP, S3, 메시지 브로커)는 트랜잭션 밖으로 이동합니다.
  • 긴 작업은 큐/비동기/배치로 분리하고, DB 작업은 짧게 쪼갭니다.

예시: 외부 호출을 트랜잭션 밖으로 분리

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

    // 2) DB 반영만 트랜잭션
    orderTxService.persistOrder(cmd, quote);
}

@Service
class OrderTxService {
    @Transactional
    public void persistOrder(PlaceOrderCommand cmd, PaymentQuote quote) {
        // DB insert/update만 수행
    }
}

트랜잭션이 무시되거나 프록시가 깨지는 케이스는 커넥션 점유 시간에 직접 영향을 줍니다. 관련 패턴은 위에서 언급한 내부 글(Spring Boot 3에서 @Transactional 무시 원인 7가지)이 도움이 됩니다.

원인 3: 슬로우 쿼리·락 경합·인덱스 부재

애플리케이션 코드가 커넥션을 “잘” 반납해도, 쿼리 실행 자체가 느리면 커넥션은 오래 점유됩니다. 그 결과 풀 회전률이 떨어져 고갈이 발생합니다.

진단 순서

  1. 애플리케이션에서 슬로우 쿼리 로그를 켭니다(JPA면 SQL 로그 + 실행 시간, 또는 APM).
  2. DB에서 slow query log 또는 pg_stat_statements 같은 통계를 봅니다.
  3. 락 대기/데드락을 확인합니다(예: InnoDB lock waits, PostgreSQL pg_locks).

해결책

  • 인덱스 추가/수정, 실행 계획 확인
  • N+1 제거(fetch join, batch size)
  • 대량 업데이트는 배치로 쪼개기
  • 락을 유발하는 트랜잭션 순서 정리(동일한 테이블/행을 잡는 순서 통일)

JPA N+1은 커넥션을 오래 잡는 원인이 되기도 합니다. 한 요청에서 수십~수백 쿼리가 발생하면, 그 시간 동안 커넥션이 점유되어 풀 회전이 급격히 나빠집니다.

원인 4: 풀 사이즈/타임아웃 설정이 워크로드와 불일치

maximumPoolSize를 무작정 올리면 “일시적으로” 좋아 보이지만, DB의 동시 처리 한계를 넘기면 오히려 락/컨텍스트 스위칭/IO 대기로 전체가 느려질 수 있습니다. 풀 튜닝은 DB가 감당 가능한 동시성애플리케이션 스레드 모델을 함께 봐야 합니다.

핵심 설정과 의미

  • maximumPoolSize: 동시에 빌려줄 수 있는 최대 커넥션
  • minimumIdle: 유휴 커넥션 유지 개수
  • connectionTimeout: 커넥션을 빌리기 위한 최대 대기 시간
  • maxLifetime: 커넥션의 최대 수명(서버/프록시 idle timeout보다 짧게)
  • idleTimeout: 유휴 커넥션 정리 시간

예시 설정:

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 3000
      idle-timeout: 600000
      max-lifetime: 1740000

실전 팁

  • connectionTimeout은 너무 길게 두지 마세요. 길면 장애가 “느리게” 퍼지고, 요청 스레드가 오래 묶입니다. 보통 1~5초 범위에서 서비스 SLO에 맞춥니다.
  • maxLifetime은 DB나 프록시(LB, NAT, RDS Proxy 등)의 커넥션 종료 정책보다 약간 짧게 둬서, 애플리케이션이 먼저 교체하도록 합니다.

원인 5: 웹 스레드/비동기 작업이 DB를 과도하게 때린다

커넥션 풀은 “DB 동시성 제한 장치”이기도 합니다. 그런데 애플리케이션이 다음과 같은 형태면 풀을 빠르게 소진합니다.

  • 톰캣 스레드가 너무 많고, 요청마다 DB를 사용
  • @Async 작업이 대량으로 동시에 실행되며 DB 접근
  • 스케줄러가 특정 시각에 몰려서 실행

해결책

  • 톰캣 스레드 수와 풀 사이즈를 함께 조정합니다.
  • 비동기 Executor의 동시성을 제한합니다.
  • 스케줄 작업은 분산락/셔딩/랜덤 지연으로 피크를 분산합니다.

@Async executor 예시(동시성 제한):

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean(name = "dbBoundExecutor")
    public Executor dbBoundExecutor() {
        ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor();
        ex.setCorePoolSize(8);
        ex.setMaxPoolSize(8);
        ex.setQueueCapacity(200);
        ex.setThreadNamePrefix("db-async-");
        ex.initialize();
        return ex;
    }
}

DB를 쓰는 비동기 작업은 “커넥션 풀보다 큰 동시성”을 주지 않는 것이 안전합니다.

장애 시 즉시 완화(Hot Fix) 체크리스트

근본 해결 전, 장애를 줄이기 위한 완화책입니다.

  1. connectionTimeout을 합리적으로 낮춰서 무한 대기를 막기
  2. 문제 엔드포인트에 임시 레이트 리밋/서킷 브레이커 적용
  3. 슬로우 쿼리/락을 유발하는 배치나 스케줄러 일시 중지
  4. 누수 의심 시 leak-detection-threshold를 일시적으로 낮추고 스택트레이스 확보
  5. DB의 최대 커넥션/리소스 한계를 확인하고, 애플리케이션 풀을 무작정 키우지 않기

운영 환경에서 재시작이 반복되며 상황이 더 나빠지는 경우도 있습니다. 이런 경우에는 재시작 루프 원인을 추적하는 방법이 도움이 됩니다: systemd 서비스가 계속 재시작될 때 원인 추적

재발 방지: 관측·가드레일·테스트

1) 관측 지표를 대시보드에 고정

다음 그래프를 기본 대시보드에 올려두면 “고갈 조짐”을 조기에 잡을 수 있습니다.

  • hikaricp.connections.activehikaricp.connections.max
  • hikaricp.connections.pending
  • 요청 p95/p99 latency
  • DB 슬로우쿼리 수, 락 대기 시간

2) 코드 레벨 가드레일

  • 트랜잭션 내부에서 외부 호출 금지(코드리뷰 체크리스트화)
  • JDBC는 try-with-resources 강제
  • 스트리밍 조회는 소비 범위를 트랜잭션 안으로 제한

3) 부하 테스트에서 “풀 고갈” 시나리오를 재현

단순 TPS만 보지 말고, 다음을 함께 확인합니다.

  • 동시 요청 증가 시 pending이 증가하는지
  • 특정 API 호출 패턴에서만 고갈되는지
  • 슬로우 쿼리/락 경합이 특정 테이블에서 발생하는지

결론

HikariCP 커넥션 고갈은 결과일 뿐이고, 원인은 대개 다음 다섯 가지 범주로 수렴합니다.

  • 커넥션 누수
  • 장기 트랜잭션
  • 슬로우 쿼리/락 경합
  • 풀 설정과 워크로드 불일치
  • 과도한 동시성(웹 스레드, 비동기, 스케줄러)

해결의 핵심은 maximumPoolSize를 올리는 단기 처방보다, 커넥션이 “왜 오래 잡히는지”를 메트릭과 스택트레이스로 확인하고, 트랜잭션 경계/쿼리/동시성을 정리하는 것입니다. 위의 체크리스트대로 접근하면 재현과 수정이 가능해지고, 같은 유형의 장애를 반복하지 않게 됩니다.