Published on

Spring Boot HikariCP 커넥션 고갈 원인·해결 9가지

Authors

운영 중인 Spring Boot API가 갑자기 느려지고, 결국 503 또는 타임아웃으로 무너지는 패턴을 보면 상당수가 DB 커넥션 풀 고갈에서 시작합니다. HikariCP는 빠르고 안정적인 풀 구현이지만, 풀 자체가 만능은 아닙니다. 애플리케이션이 커넥션을 오래 쥐고 있거나, 트랜잭션 경계가 잘못되었거나, 풀 사이징이 트래픽과 DB 용량에 맞지 않으면 HikariPool-1 - Connection is not available, request timed out after ... 같은 로그와 함께 장애로 이어집니다.

이 글에서는 Spring Boot 환경에서 HikariCP 커넥션 고갈이 발생하는 원인 9가지를 실제로 자주 만나는 형태로 분류하고, 각 항목별로 진단 포인트와 해결책(설정, 코드, 운영)을 함께 정리합니다.

문제의 성격상 애플리케이션만 보는 것보다 인프라 관점도 함께 보는 것이 좋습니다. 예를 들어 쿠버네티스에서 503이 발생한다면 앱 내부의 풀 고갈이 원인일 수도 있으니, 외부 증상과 함께 교차 점검을 권합니다: EKS에서 503 Service Unavailable 원인 10분 진단

커넥션 고갈을 먼저 확인하는 3가지 신호

1) 전형적인 HikariCP 로그

아래 메시지가 반복된다면 거의 확정입니다.

  • Connection is not available, request timed out after ...
  • Pool stats (total=..., active=..., idle=..., waiting=...)

2) 지표에서 보는 패턴

  • activemaximumPoolSize에 붙어서 내려오지 않음
  • waiting이 증가
  • API p95, p99가 계단식으로 상승
  • DB 쿼리 시간보다 애플리케이션 대기 시간이 더 큼

3) 스레드 덤프/프로파일에서 보는 패턴

  • 많은 스레드가 커넥션 획득 대기
  • 또는 트랜잭션 안에서 외부 API 호출, 파일 IO 등으로 블로킹

기본 방어 설정(필수)

원인 분석 전에, 운영에서 최소한으로 켜두면 좋은 HikariCP 안전장치입니다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      minimum-idle: 10
      connection-timeout: 3000
      validation-timeout: 1000
      idle-timeout: 600000
      max-lifetime: 1800000
      leak-detection-threshold: 2000
  • connection-timeout을 너무 길게 두면 장애 전파가 느려집니다. 빠르게 실패하고 상위 레이어에서 폴백/서킷브레이커로 처리하는 편이 낫습니다.
  • leak-detection-threshold는 상시 켜두면 오탐이 생길 수 있지만, 고갈 이슈가 있을 때는 매우 유용합니다.

또한 Actuator와 Micrometer를 사용하면 풀 상태를 지표로 쉽게 관찰할 수 있습니다.

implementation "org.springframework.boot:spring-boot-starter-actuator"
runtimeOnly "io.micrometer:micrometer-registry-prometheus"
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus

원인·해결 9가지

1) 커넥션 릭(반납 누락)

가장 고전적이고, 여전히 자주 발생합니다. JDBC를 직접 쓰거나, 프레임워크 경계를 우회할 때 특히 빈번합니다.

증상

  • 트래픽이 낮아도 시간이 지날수록 active가 서서히 증가
  • 재시작하면 잠깐 정상, 다시 고갈

진단

  • leak-detection-threshold 로그에서 스택트레이스 확인

해결

  • try-with-resourcesConnection, PreparedStatement, ResultSet을 반드시 닫기
try (Connection con = dataSource.getConnection();
     PreparedStatement ps = con.prepareStatement(sql);
     ResultSet rs = ps.executeQuery()) {

    while (rs.next()) {
        // ...
    }
}
  • Spring JDBC 또는 JPA 사용 시에는 트랜잭션 경계 밖에서 엔티티매니저를 직접 들고 다니지 않기

2) 트랜잭션 범위가 과도하게 큼

@Transactional이 “DB 작업 구간”이 아니라 “요청 전체”를 감싸는 순간, 커넥션을 필요 이상으로 오래 점유합니다.

흔한 실수

  • 트랜잭션 안에서 외부 API 호출
  • 트랜잭션 안에서 대용량 파일 업로드/다운로드
  • 트랜잭션 안에서 메시지 큐 publish 후 응답 대기

해결

  • DB 작업만 트랜잭션으로 묶고, 외부 호출은 트랜잭션 밖으로 분리
public void placeOrder(PlaceOrderCommand cmd) {
    Long orderId = txCreateOrder(cmd); // 트랜잭션 구간
    notifyExternal(orderId);           // 트랜잭션 밖
}

@Transactional
public Long txCreateOrder(PlaceOrderCommand cmd) {
    // DB 작업만 수행
    return orderRepository.save(...).getId();
}
  • 읽기 전용은 @Transactional(readOnly = true)로 힌트 제공(특히 JPA flush 동작 억제에 도움)

3) N+1 쿼리 또는 비효율 쿼리로 커넥션 점유 시간이 증가

커넥션 고갈은 “커넥션 수” 문제이기도 하지만, 더 본질적으로는 “커넥션 점유 시간” 문제입니다. 쿼리가 느리면 동일 트래픽에서도 훨씬 빨리 풀이 찹니다.

진단

  • 슬로우 쿼리 로그
  • APM에서 DB span이 길게 늘어짐
  • 특정 API에서만 고갈

해결

  • JPA라면 fetch join, batch size, DTO projection 등으로 N+1 제거
  • 인덱스 추가, 쿼리 리라이트
  • 페이지네이션 시 count 쿼리 비용 점검

4) 풀 사이징을 무작정 키움(앱은 버티는데 DB가 죽음)

maximumPoolSize를 크게 올리면 일시적으로 타임아웃은 줄어드는 것처럼 보일 수 있습니다. 하지만 DB의 동시 처리 한계를 넘으면 락/컨텍스트 스위칭/IO 경합으로 DB가 더 느려지고, 결국 전체가 더 나빠집니다.

권장 접근

  • “앱 인스턴스 수” 곱하기 “최대 풀 사이즈”가 DB가 감당할 수 있는 동시 커넥션/쿼리 처리량을 넘지 않게
  • DB max_connections와 실제 워크로드(OLTP, OLAP)를 함께 고려

실전 가이드

  • 먼저 쿼리 최적화 및 트랜잭션 축소로 점유 시간을 줄이고
  • 그 다음에 필요한 만큼만 풀 사이즈를 키우기

5) maxLifetime 미조정으로 네트워크 장비/DB에 의해 커넥션이 끊김

클라우드 환경에서는 NAT, 로드밸런서, 방화벽, DB 프록시 등이 유휴 커넥션을 중간에서 끊는 일이 흔합니다. 이때 HikariCP가 “죽은 커넥션”을 잡고 있다가 재사용 시점에 오류가 나고, 재시도 폭풍으로 풀 고갈처럼 보이기도 합니다.

해결

  • maxLifetime을 인프라 idle timeout보다 짧게(보통 30분보다 조금 낮게 시작)
  • keepaliveTime(필요 시)로 유휴 커넥션 유지
spring:
  datasource:
    hikari:
      max-lifetime: 1500000
      keepalive-time: 300000

6) 커넥션 획득 대기 시간이 너무 길어 장애를 키움

connectionTimeout이 길면, 스레드가 오래 대기하면서 톰캣/넷티 워커 스레드까지 묶입니다. 결과적으로 앱 전체가 멈춘 것처럼 보입니다.

해결

  • connection-timeout을 짧게(예: 1초~3초) 설정
  • 상위 레이어에서 빠른 실패 처리
  • 재시도는 반드시 제한하고 지터를 적용(무한 재시도는 풀을 더 빨리 고갈시킴)

7) 읽기 트래픽과 쓰기 트래픽이 같은 풀을 공유

읽기 API가 급증하면 쓰기 트랜잭션까지 커넥션을 못 잡아 장애가 커집니다. 특히 읽기 쿼리가 무겁거나, 리포팅성 조회가 섞여 있으면 더 위험합니다.

해결

  • 읽기 전용 데이터소스(리드 레플리카) 분리
  • 풀도 분리해서 서로 영향 차단
spring:
  datasource:
    write:
      hikari:
        maximum-pool-size: 15
    read:
      hikari:
        maximum-pool-size: 30
  • 라우팅 데이터소스 또는 CQRS 패턴 적용

8) 동기 블로킹 모델에서 동시성 제한이 없음(요청 폭주 시 풀 선점)

톰캣 기반의 전형적인 동기 MVC는 요청 수가 늘수록 스레드가 늘고, 스레드가 늘수록 커넥션을 동시에 잡으려 듭니다. 결국 풀 고갈이 “트래픽 리미터”처럼 동작합니다.

해결

  • 서버 레벨 동시성 제한(예: 톰캣 스레드/큐 조정)
  • API 레벨 벌크헤드(세마포어), 레이트 리미팅
  • DB를 반드시 쓰지 않아도 되는 요청은 캐시로 우회
server:
  tomcat:
    threads:
      max: 200
    accept-count: 100

스레드 수를 무작정 키우는 것이 아니라, DB 처리량과 균형을 맞추는 것이 핵심입니다.

9) 커넥션 풀 고갈의 “진짜 원인”이 DB 락/대기(Blocking)인 경우

애플리케이션에서 보기에는 커넥션이 부족해 보이지만, 실제로는 DB에서 락 대기나 장시간 트랜잭션 때문에 쿼리가 끝나지 않아 커넥션이 반환되지 않는 경우가 많습니다.

진단

  • DB에서 lock wait, deadlock, long transaction 확인
  • 특정 테이블/인덱스에 경합 집중

해결

  • 트랜잭션을 더 짧게
  • 락 범위를 줄이는 쿼리/인덱스 설계
  • 격리 수준 점검
  • 배치/정산 작업을 OLTP 피크 시간에 돌리지 않기

재현·관찰을 위한 최소 코드(부하로 고갈 확인)

아래는 커넥션을 오래 점유하는 요청을 만들어 풀 고갈을 관찰하는 예시입니다. 운영에 넣지 말고 로컬/스테이징에서만 사용하세요.

@RestController
@RequiredArgsConstructor
public class HoldConnectionController {
    private final JdbcTemplate jdbcTemplate;

    @GetMapping("/test/hold")
    @Transactional
    public String hold(@RequestParam(defaultValue = "3000") long sleepMs) throws Exception {
        jdbcTemplate.queryForObject("select 1", Integer.class);
        Thread.sleep(sleepMs); // 트랜잭션 안에서 커넥션을 쥐고 대기
        return "ok";
    }
}
  • 위 엔드포인트에 동시 요청을 걸면 active가 빠르게 maximumPoolSize에 도달합니다.
  • 같은 패턴이 운영 코드 어디엔가 숨어 있다면(외부 호출, 대기, 대용량 처리), 그것이 고갈의 출발점이 됩니다.

운영 체크리스트(빠른 결론)

  • leak-detection-threshold로 릭 여부부터 확인
  • 트랜잭션 안의 블로킹 작업 제거(외부 호출/대기/파일 IO)
  • 슬로우 쿼리 및 N+1 제거로 점유 시간 단축
  • connection-timeout은 짧게, 재시도는 제한적으로
  • 풀 사이즈는 DB 한계와 앱 인스턴스 수를 함께 고려
  • 읽기/쓰기 풀 분리로 장애 전파 차단
  • DB 락/장기 트랜잭션이 원인인지 반드시 확인

인증/인가 문제로 401이 늘며 재시도가 폭증하는 상황도 결과적으로 DB 부하와 풀 고갈을 촉발할 수 있습니다. 보안 계층에서 반복 실패가 발생한다면 아래 글처럼 원인을 먼저 제거하는 것도 도움이 됩니다: Spring Security JWT 401 원인 - 시계오차·키롤오버

커넥션 고갈은 “풀 설정”만으로 해결되는 경우가 드뭅니다. 결국 커넥션을 오래 잡는 코드 경로를 찾아 줄이고, DB가 동시에 처리할 수 있는 양에 맞춰 애플리케이션의 동시성을 설계하는 것이 정답입니다.