Published on

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

Authors

서버가 멀쩡히 떠 있는데도 특정 순간부터 API가 타임아웃으로 쓰러지고, 로그에는 HikariPool-1 - Connection is not available, request timed out after ...ms 같은 메시지가 반복된다면 대부분은 “DB가 죽었다”가 아니라 커넥션 풀이 고갈된 상황입니다. HikariCP는 성능이 좋고 기본값도 괜찮지만, 풀 고갈은 설정만으로 해결되지 않고 코드의 트랜잭션 경계, 쿼리 패턴, 스레드 모델과 강하게 얽혀 있습니다.

이 글에서는 Spring Boot에서 HikariCP 커넥션 고갈이 발생하는 전형적인 원인을 유형별로 나누고, 증상-진단-해결을 한 번에 연결합니다.

커넥션 고갈이 의미하는 것

HikariCP 풀은 “동시 DB 작업을 수행할 수 있는 슬롯”입니다. 풀의 maximumPoolSize가 20이면, 동시에 20개의 DB 커넥션만 대여할 수 있습니다.

고갈은 다음 중 하나로 발생합니다.

  • 대여한 커넥션이 빨리 반환되지 않는다: 트랜잭션이 길거나, 애플리케이션 레벨에서 커넥션이 새는 경우
  • 반환은 되지만 DB 작업 자체가 너무 느리다: 슬로우 쿼리, 락 대기, 인덱스 미스 등으로 커넥션이 오래 점유됨
  • 요청 동시성이 풀 크기를 초과한다: 스레드/비동기 작업이 풀과 불균형

증상은 보통 다음 3가지로 나타납니다.

  • API 응답 지연이 급증하다가 타임아웃
  • DB CPU가 높지 않은데도 애플리케이션이 멈춘 듯 보임(대기 스레드 증가)
  • 풀 관련 경고 로그 증가

1) 가장 흔한 원인: 트랜잭션 경계가 너무 넓다

Spring에서 @Transactional은 편리하지만, 범위를 넓게 잡으면 “DB를 쓰지 않는 시간”까지 커넥션을 잡고 있게 됩니다.

나쁜 패턴 예시: 외부 호출을 트랜잭션 안에서 수행

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;
  private final PaymentClient paymentClient;

  @Transactional
  public void placeOrder(Long userId) {
    // 1) DB 조회
    Order order = orderRepository.findDraftByUserId(userId)
        .orElseThrow();

    // 2) 외부 API 호출(느리거나 재시도 발생 가능)
    paymentClient.reserve(order.getId());

    // 3) DB 업데이트
    order.confirm();
  }
}

이 경우 외부 호출이 1초만 걸려도, 그 1초 동안 커넥션이 점유됩니다. 동시 요청이 늘면 풀은 빠르게 고갈됩니다.

개선: 트랜잭션을 “DB 작업 최소 구간”으로 축소

@Service
@RequiredArgsConstructor
public class OrderService {
  private final OrderRepository orderRepository;
  private final PaymentClient paymentClient;

  public void placeOrder(Long userId) {
    Long orderId = loadDraftOrderId(userId);

    // 트랜잭션 밖에서 외부 호출
    paymentClient.reserve(orderId);

    confirmOrder(orderId);
  }

  @Transactional(readOnly = true)
  protected Long loadDraftOrderId(Long userId) {
    return orderRepository.findDraftIdByUserId(userId)
        .orElseThrow();
  }

  @Transactional
  protected void confirmOrder(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.confirm();
  }
}

핵심은 “외부 I/O, 파일 작업, 대용량 연산”을 트랜잭션 밖으로 빼서 커넥션 점유 시간을 줄이는 것입니다.

2) 커넥션 누수: close 누락, 스트리밍 결과 미종료

HikariCP는 기본적으로 커넥션을 잘 회수하지만, 아래 케이스에서는 누수가 생길 수 있습니다.

  • JDBC를 직접 쓰면서 ResultSet/Statement/Connection을 닫지 않음
  • JPA에서 스트리밍 조회를 열어놓고 소비를 끝내지 않음
  • 예외 경로에서 자원 반환이 누락됨

JDBC 직접 사용 시: 반드시 try-with-resources

public List<User> findUsers(DataSource dataSource) throws SQLException {
  String sql = "select id, name from users where active = 1";

  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("id"), rs.getString("name")));
    }
    return out;
  }
}

누수 탐지 설정(진단용)

운영에서 상시 켜두기보다는, 이슈 재현/조사 중에 임시로 켜는 것을 권장합니다.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 2000 # ms
      maximum-pool-size: 20
      connection-timeout: 30000

leak-detection-threshold를 넘기면 “커넥션을 빌린 스택 트레이스”가 로그로 남아 누수 위치를 찾는 데 도움이 됩니다.

3) 느린 쿼리/락 대기: 커넥션은 반환되지 않는다

풀 고갈의 본질은 “커넥션 점유 시간이 길어지는 것”입니다. 그 원인이 누수가 아니라면, 대부분은 DB에서 시간이 소모됩니다.

대표 원인:

  • 인덱스 미스, 테이블 풀스캔
  • IN 절 남발, 비효율적 쿼리 플랜
  • 대량 정렬/임시 테이블 증가
  • 락 경합(특히 갱신 쿼리)

쿼리가 느려지면 동시 요청이 같은 풀을 더 오래 점유하므로, 풀 크기를 늘려도 “조금 늦게 죽는” 정도로 끝날 수 있습니다.

IN 조건이 커지고 플랜이 불리해져 풀을 잡아먹는 케이스는 꽤 흔합니다. 필요하면 다음 글도 같이 보세요.

또한 JPA에서는 N+1이 발생하면 “한 요청이 여러 쿼리”를 만들고, 결과적으로 커넥션 점유 시간이 늘어납니다.

실전 진단 루틴

  • 애플리케이션: 풀 메트릭에서 activemaximumPoolSize에 붙어 있는지 확인
  • DB: 슬로우 쿼리 로그, performance_schema, pg_stat_activity 등으로 오래 도는 쿼리/락 대기 확인
  • 공통: 특정 엔드포인트/배치에서만 발생하는지 상관관계 확인

4) 스레드 수와 풀 크기의 불균형

다음 조합은 풀 고갈을 쉽게 만듭니다.

  • Tomcat max-threads가 크고(예: 200)
  • Hikari maximum-pool-size는 작고(예: 10)
  • 대부분 요청이 DB를 사용

이때 트래픽이 몰리면 200개 스레드가 DB 커넥션 10개를 기다리며 적체되고, 큐잉 지연이 폭발합니다.

권장 접근

  • 풀을 무작정 키우기 전에 “요청당 커넥션 점유 시간”을 줄인다
  • DB를 쓰는 엔드포인트 비율과 평균 쿼리 시간을 기준으로 풀 크기를 산정한다
  • 애플리케이션 스레드 상한도 함께 조정한다

간단한 감으로는 아래가 출발점이 될 수 있습니다.

  • DB가 단일 인스턴스이고 CPU가 크지 않다면 maximumPoolSize를 과도하게 키우지 않는다(락/컨텍스트 스위칭 증가)
  • 대신 슬로우 쿼리/트랜잭션 범위부터 줄인다

5) open-in-view로 인한 “웹 요청 전체” 커넥션 점유

Spring Boot의 JPA 기본값은 환경에 따라 다를 수 있지만, open-in-view가 켜져 있으면 웹 요청이 끝날 때까지 영속성 컨텍스트가 열려 Lazy 로딩이 가능해집니다. 문제는 이 과정에서 트랜잭션/세션이 길어지거나, 뷰 렌더링/직렬화 과정에서 추가 쿼리가 나가면서 커넥션 점유가 늘 수 있다는 점입니다.

가능하면 API 서버에서는 꺼두고, 필요한 데이터는 서비스 계층에서 명시적으로 조회하는 편이 안전합니다.

spring:
  jpa:
    open-in-view: false

6) 풀 설정 튜닝: “진짜 원인”을 드러내는 방향으로

설정은 해결책이기도 하지만, 동시에 문제를 숨길 수도 있습니다. 다음은 실전에서 자주 쓰는 기준입니다.

필수에 가까운 기본값 점검

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20
      connection-timeout: 30000
      validation-timeout: 5000
      max-lifetime: 1800000
      idle-timeout: 600000
  • connection-timeout: 풀 고갈 시 얼마나 기다릴지. 너무 길면 장애 전파가 늦고 스레드가 쌓입니다.
  • max-lifetime: DB/프록시의 커넥션 종료 정책보다 약간 짧게 잡아 예기치 않은 끊김을 줄입니다.

minimum-idle은 신중히

minimum-idlemaximum-pool-size와 같게 두면 항상 풀을 꽉 채워두는데, DB가 작은 환경에서는 오히려 부담이 될 수 있습니다. 트래픽 패턴에 따라 조정하세요.

문제 조사 시 유용한 로그

logging:
  level:
    com.zaxxer.hikari: DEBUG

조사 기간에만 켜고, 평소에는 INFO 이하로 두는 것을 권장합니다.

7) 메트릭으로 재발 방지: Actuator + Micrometer

풀 고갈은 “발생했을 때만” 보면 늦습니다. 풀 상태를 상시 관측해야 합니다.

Spring Boot Actuator를 쓰면 Hikari 메트릭이 노출됩니다.

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

pending이 증가하거나, activemax에 붙는 시간이 늘어나면 이미 위험 신호입니다. 이때는 풀을 키우기 전에 아래를 먼저 의심하세요.

  • 특정 배포 이후 트랜잭션이 길어졌는지
  • 특정 쿼리 플랜이 바뀌었는지
  • 락 대기가 늘었는지

8) 자주 놓치는 케이스 체크리스트

아래 항목은 “원인 찾기” 단계에서 빠르게 확인하면 시간을 줄일 수 있습니다.

  • 배치/스케줄러가 동시에 여러 개 돌며 DB를 잠식하는가
  • @Async 작업이 DB를 사용하면서 동시성이 폭증하는가
  • 한 요청에서 대량 데이터를 읽고 애플리케이션에서 필터링하는가(쿼리로 줄일 수 있는가)
  • 페이징 없이 전체 조회를 하는가
  • 직렬화 과정에서 Lazy 로딩이 발생하는가(open-in-view와 함께 자주 발생)

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

HikariCP 커넥션 고갈은 대개 풀 자체 문제가 아니라, 커넥션을 오래 잡는 코드/쿼리에서 시작됩니다. 해결 우선순위를 정리하면 다음이 가장 효율적입니다.

  1. 트랜잭션 범위를 줄이고 외부 I/O를 트랜잭션 밖으로 이동
  2. 누수 탐지로 “반환되지 않는 커넥션”이 있는지 확인
  3. 슬로우 쿼리/락 대기를 찾아 쿼리와 인덱스를 개선
  4. 스레드/풀 사이징을 함께 조정하고, 메트릭으로 상시 감시

이 순서대로 접근하면 “일단 풀부터 2배” 같은 임시방편을 넘어, 재발하지 않는 구조적 해결에 가까워집니다.