Published on

Spring Boot DB 커넥션 고갈 - HikariCP 튜닝 가이드

Authors

운영 중인 Spring Boot 서비스에서 갑자기 응답 지연이 늘고, 로그에 HikariPool-1 - Connection is not available, request timed out after ...ms 같은 메시지가 반복되면 거의 항상 “DB 커넥션 고갈” 상황입니다. 증상은 단순하지만 원인은 다양합니다. 풀 사이즈가 작아서가 아니라, 트랜잭션이 오래 잡히거나 커넥션이 반환되지 않거나, DB 자체가 느려져서 풀을 소진하는 경우가 많습니다.

이 글은 HikariCP를 “크게 잡으면 해결” 같은 방식이 아니라, 병목을 분리하고 관측 가능한 근거로 튜닝하는 방법을 다룹니다.

커넥션 고갈의 전형적인 증상과 로그

다음 중 하나라도 보이면 풀 고갈을 의심하세요.

  • API 지연이 계단식으로 증가하고, 일부 요청이 타임아웃
  • 애플리케이션 스레드는 살아있지만 DB 호출이 멈춘 듯 대기
  • 에러 로그에 Connection is not available 또는 Timeout after ... 반복
  • DB CPU 또는 IOPS가 치솟고, 락 대기가 증가

HikariCP의 대표 메시지는 다음과 같습니다.

  • HikariPool-1 - Connection is not available, request timed out after 30000ms

이 메시지는 “풀에 남는 커넥션이 없음”을 의미하지만, 왜 반환이 안 되는지는 별개 문제입니다.

먼저 결론: 풀 사이즈만 늘리면 왜 위험한가

maximumPoolSize를 무작정 늘리면 일시적으로 타임아웃은 줄어들 수 있습니다. 하지만 다음 부작용이 큽니다.

  • DB 동시 쿼리 수가 증가해 DB가 더 느려짐(락 경합, CPU, I/O 증가)
  • 느린 쿼리가 더 오래 쌓이며 커넥션 점유 시간이 증가
  • 결과적으로 더 큰 풀도 결국 고갈(“더 큰 실패”)로 이어짐

즉, 풀은 “버퍼”가 아니라 “DB로 들어가는 동시성 제한 장치”로 봐야 합니다.

커넥션 고갈의 4가지 근본 원인

1) 트랜잭션이 너무 오래 열린다

  • 외부 API 호출을 트랜잭션 안에서 수행
  • 대량 처리 로직이 한 트랜잭션으로 묶임
  • @Transactional이 넓게 걸려 불필요한 구간까지 커넥션 점유

커넥션은 트랜잭션 동안 점유되는 경우가 많습니다. 특히 JPA는 flush 타이밍과 연관되어 더 길어질 수 있습니다.

2) 커넥션 누수(반환 누락)

  • JDBC 직접 사용 시 close() 누락
  • 예외 경로에서 반환 누락
  • 커넥션을 필드에 저장하거나 비동기 스레드로 넘김

Spring의 JdbcTemplate나 JPA를 정상 사용하면 누수는 드물지만, 레거시 JDBC 코드나 커스텀 데이터 접근 계층에서 자주 발생합니다.

3) DB가 느려져서 점유 시간이 늘어난다

  • 인덱스 누락, 풀스캔
  • 락 대기(동일 row 업데이트 경쟁)
  • IOPS 부족, 스토리지 지연

이 경우 애플리케이션 풀 튜닝보다 쿼리/인덱스/락이 우선입니다.

4) 애플리케이션 동시성이 풀/DB 용량을 초과한다

  • Tomcat 스레드가 너무 많아 동시에 DB를 때림
  • 배치/스케줄러가 피크에 겹침
  • 메시지 컨슈머 동시성이 과도함

“요청 스레드 수”와 “DB 커넥션 수”의 비율이 맞지 않으면 풀 고갈이 구조적으로 발생합니다.

HikariCP 핵심 파라미터: 무엇을, 어떤 근거로 조정할까

Spring Boot 기본 풀은 HikariCP이며, 설정은 application.yml에서 합니다.

기본 예시: 운영에서 자주 쓰는 안전한 뼈대

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 20
      connection-timeout: 30000
      validation-timeout: 5000
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 0

이 값이 정답은 아닙니다. 아래에서 각 항목을 “언제, 왜” 바꾸는지 정리합니다.

maximumPoolSize: 풀의 상한(가장 중요한 값)

  • 의미: 동시에 DB에 붙을 수 있는 최대 커넥션 수
  • 목표: DB가 감당 가능한 동시 쿼리 수를 넘기지 않도록 제한

산정 접근

  1. DB의 최대 커넥션 한도 확인
  • 예: PostgreSQL max_connections
  • 예: MySQL max_connections
  1. “서비스 인스턴스 수”로 나누기
  • 예: DB max_connections=300
  • 운영 애플리케이션 6개 파드
  • 단순 분배 상한은 300 / 6 = 50
  1. 여유분과 다른 클라이언트 고려
  • 배치, 어드민, 리포팅, 마이그레이션 툴 등이 DB 커넥션을 사용
  1. 최종적으로는 DB CPU/락/쿼리 시간을 보며 조정

풀을 늘렸는데 DB latency가 악화되면, 풀을 늘리는 방향이 아니라 동시성 제한(스레드/컨슈머 조절) 또는 쿼리 개선이 필요합니다.

minimumIdle: 유휴 커넥션 유지 수

  • 의미: 놀고 있어도 유지할 커넥션 수
  • 운영에서 흔한 선택: minimumIdlemaximumPoolSize와 같게 두어 고정 풀처럼 운영

트래픽이 급격히 튀는 서비스라면, 커넥션 생성 비용과 핸드셰이크 비용을 줄이기 위해 minimumIdle = maximumPoolSize가 유리할 때가 많습니다. 다만 DB가 커넥션 수에 민감하거나 다중 서비스가 공유하는 DB라면 과도한 선점이 될 수 있습니다.

connectionTimeout: 풀에서 커넥션을 기다리는 최대 시간

  • 의미: 커넥션을 못 구하면 이 시간 후 타임아웃
  • 운영 권장: 무작정 늘리지 말기

connectionTimeout을 크게 늘리면 에러는 줄어 보이지만, 실제로는 요청이 더 오래 매달리며 스레드가 잠식됩니다. 결과적으로 장애 전파가 쉬워집니다. gRPC나 MSA 환경에서는 타임아웃 전파 설계가 중요하므로, 커넥션 대기 시간을 무한정 늘리는 방식은 피하는 게 좋습니다. (관련 주제로는 gRPC MSA에서 DEADLINE_EXCEEDED 연쇄 장애 차단도 함께 참고할 만합니다.)

maxLifetimeidleTimeout: 커넥션 재생성 정책

  • maxLifetime: 커넥션의 최대 수명
  • idleTimeout: 유휴 커넥션을 정리하는 시간

운영에서 중요한 포인트는 DB 또는 프록시(LB)가 커넥션을 끊기 전에 애플리케이션이 먼저 교체하도록 하는 것입니다.

  • 일반적으로 maxLifetime은 30분(1800000) 근처가 많이 쓰입니다.
  • 만약 네트워크 장비나 프록시가 10분에 커넥션을 끊는다면, maxLifetime을 그보다 짧게(예: 9분) 설정합니다.

다만 너무 짧게 잡으면 커넥션 재생성이 잦아져 오히려 부하가 늘 수 있습니다.

leakDetectionThreshold: 누수 탐지(강력하지만 주의)

  • 의미: 커넥션을 빌린 뒤 지정 시간 내 반환하지 않으면 스택트레이스를 로그로 남김

예를 들어 5초 이상 반환이 없으면 의심 로그를 남기고 싶다면:

spring:
  datasource:
    hikari:
      leak-detection-threshold: 5000

주의할 점:

  • “진짜 누수”뿐 아니라 “정상적으로 오래 걸리는 쿼리/트랜잭션”도 누수처럼 찍힙니다.
  • 장애 상황에서 로그가 폭증할 수 있습니다.

권장 운영 방식:

  • 평상시 0(비활성)
  • 커넥션 고갈 재현 또는 의심 구간에서 일시적으로 켜서 원인 스택을 확보

Spring Boot에서 자주 터지는 실수 패턴과 수정

트랜잭션 안에서 외부 호출

잘못된 예:

@Transactional
public void placeOrder(OrderRequest req) {
    inventoryClient.reserve(req); // 외부 HTTP 호출
    orderRepository.save(...);
}

이 패턴은 외부 호출이 느려지면 DB 커넥션이 그 시간만큼 점유될 수 있습니다.

개선 예(트랜잭션 범위 축소):

public void placeOrder(OrderRequest req) {
    inventoryClient.reserve(req); // 트랜잭션 밖

    saveOrder(req);
}

@Transactional
void saveOrder(OrderRequest req) {
    orderRepository.save(...);
}

JDBC 직접 사용 시 try-with-resources 누락

잘못된 예:

Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("select 1");
ResultSet rs = ps.executeQuery();
// close 누락

개선 예:

try (Connection c = dataSource.getConnection();
     PreparedStatement ps = c.prepareStatement("select 1");
     ResultSet rs = ps.executeQuery()) {

    while (rs.next()) {
        // ...
    }
}

Spring에서는 가능하면 JdbcTemplate를 쓰면 반환 누락 위험이 크게 줄어듭니다.

풀 튜닝은 “애플리케이션 동시성”과 같이 해야 한다

HikariCP만 조정해도 어느 정도 완화되지만, 구조적으로는 다음 관계를 맞춰야 합니다.

  • 웹 서버 워커 스레드(예: Tomcat max-threads)
  • 비동기 실행 스레드 풀(예: @Async executor)
  • 메시지 컨슈머 동시성
  • Hikari maximumPoolSize

예를 들어 Tomcat이 동시에 200개 요청을 처리하게 열려 있는데 풀은 20이면, DB를 쓰는 요청이 몰릴 때 180개는 커넥션을 기다리게 됩니다. 반대로 풀을 200으로 올리면 DB가 터질 수 있습니다. 보통은 “DB를 사용하는 요청의 동시성”을 제한하는 쪽이 안전합니다.

Tomcat 설정 예:

server:
  tomcat:
    threads:
      max: 80

여기서도 정답은 없고, 서비스의 DB 의존도(요청당 DB 호출 횟수, 쿼리 시간)와 DB 용량을 기반으로 실측 조정해야 합니다.

관측과 진단: 무엇을 보면 원인이 빨리 드러나는가

1) HikariCP 풀 메트릭 확인(Micrometer)

Spring Boot Actuator와 Micrometer를 쓰면 풀 상태를 수치로 볼 수 있습니다.

의존성 예:

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator'
  implementation 'io.micrometer:micrometer-registry-prometheus'
}

주요 지표(이름은 환경에 따라 다를 수 있음):

  • 사용 중(active) 커넥션 수
  • 유휴(idle) 커넥션 수
  • 대기(pending) 스레드 수

패턴 해석:

  • active가 항상 maximumPoolSize에 붙고 pending이 증가: 풀 고갈
  • active는 낮은데 pending이 증가: DB 호출 경로가 막혔거나, 커넥션 획득 이전 단계에서 병목(스레드 풀 포화 등)

2) DB에서 “오래 열린 트랜잭션/락” 확인

애플리케이션에서 커넥션이 오래 점유되는 이유가 DB 락이라면, 풀을 손대기 전에 락 원인을 제거해야 합니다.

PostgreSQL 예시(개념):

  • 오래 실행 중인 쿼리
  • 락 대기 중인 세션

MySQL 예시(개념):

  • SHOW PROCESSLIST
  • InnoDB 락/트랜잭션 상태

SQL은 DB별로 다르므로, 운영 DB에 맞는 “장기 트랜잭션/락 대기” 쿼리를 준비해 두는 것이 좋습니다.

3) OS 레벨 리소스도 같이 점검

커넥션 고갈처럼 보이지만 실제로는 파일 디스크립터가 부족해 소켓 생성이 실패하는 경우도 있습니다. 특히 트래픽 급증 시 Too many open files가 함께 나타나면 풀 튜닝이 아니라 OS 한도 조정이 먼저입니다.

또한 컨테이너 환경에서 프로세스가 재시작을 반복한다면, 커넥션 고갈로 인한 타임아웃이 상위 레벨 헬스체크 실패로 이어졌을 수 있습니다.

실전 튜닝 체크리스트(재발 방지용)

1) “고갈”을 재현 가능한 지표로 바꾸기

  • 풀 active, idle, pending을 대시보드에 올림
  • API p95, p99 latency와 함께 비교
  • DB 쿼리 시간, 락 대기, CPU/IO와 함께 비교

2) 누수와 장기 점유를 구분하기

  • leakDetectionThreshold를 짧게 켜서 스택 확보
  • 동일 스택이 반복되면 누수 또는 트랜잭션 범위 과다 가능성 높음
  • 스택이 다양하고 DB가 느리면 쿼리/락/IO 문제 가능성 높음

3) 풀 사이즈는 “DB 한도 / 인스턴스 수”에서 시작

  • DB max_connections 확인
  • 서비스 인스턴스 수로 나눔
  • 다른 클라이언트 여유분 확보

4) 동시성 제한을 같이 조정

  • Tomcat 스레드 과다 여부 확인
  • 메시지 컨슈머 동시성 조정
  • 배치 작업 시간 분산

5) 타임아웃 계층 정리

  • HTTP 타임아웃, DB 타임아웃, 커넥션 대기 타임아웃이 서로 모순되지 않게
  • connectionTimeout을 늘려 “기다리게” 하기보다, 병목을 줄이거나 빠르게 실패시키고 재시도 정책을 설계

권장 예시 설정(출발점)

아래는 “대부분의 웹 API 서비스”에서 출발점으로 무난한 조합입니다. 실제 값은 반드시 부하 테스트와 운영 지표로 조정하세요.

spring:
  datasource:
    hikari:
      # DB 용량과 인스턴스 수로 산정 후 조정
      maximum-pool-size: 30

      # 트래픽 급증에 대비해 고정 풀처럼 운영(상황에 따라 조정)
      minimum-idle: 30

      # 커넥션 대기: 너무 길게 두지 말기
      connection-timeout: 20000

      # 커넥션 검증
      validation-timeout: 5000

      # 유휴/수명: 네트워크 장비 타임아웃보다 짧게
      idle-timeout: 600000
      max-lifetime: 1800000

      # 장애 분석 시에만 일시적으로 활성화 권장
      leak-detection-threshold: 0

마무리: HikariCP 튜닝의 핵심은 “원인 분리”

DB 커넥션 고갈은 HikariCP 설정 몇 개로 끝나는 문제가 아니라, 다음 중 어디가 병목인지 먼저 분리해야 합니다.

  • 애플리케이션이 커넥션을 오래 잡는가(트랜잭션 범위)
  • 커넥션을 반환하지 않는가(누수)
  • DB가 느린가(쿼리, 인덱스, 락, I/O)
  • 동시성이 과도한가(스레드, 컨슈머, 배치)

풀은 마지막 방어선이자 동시성 제한 장치입니다. 메트릭으로 “active가 꽉 찼는지”, “pending이 얼마나 쌓이는지”, “DB가 실제로 느린지”를 확인하고, 그에 맞춰 풀 사이즈와 타임아웃, 그리고 애플리케이션 동시성을 함께 조정하면 재발률이 크게 줄어듭니다.