6. HikariCP 커넥션 풀 최적화

DB 커넥션은 생성 비용이 매우 비쌉니다. TCP 핸드셰이크, TLS 협상, DB 인증, 세션 초기화까지 한 번 연결에 50~200ms가 소요됩니다. 커넥션 풀 없이 요청마다 연결을 새로 맺으면 P99 레이턴시가 폭발합니다. HikariCP가 왜 빠르고, 어떻게 설정하며, 실무에서 어떤 함정이 있는지 설명합니다.

1. 커넥션 생성 비용 — 왜 풀이 필요한가

커넥션 없이 매번 생성
200ms+
TCP 3-way handshake
TLS 협상
DB 인증
세션 초기화
요청마다 이 비용 반복 → P99 폭발
HikariCP 풀에서 꺼내기
~0.1ms
풀에서 idle 커넥션 획득
Connection.setAutoCommit()

이미 연결된 커넥션을 재사용
커넥션 풀 고갈 시
타임아웃
connectionTimeout 대기
(기본 30초)
초과 시 SQLException
풀 크기 설정이 핵심

2. 커넥션 풀 동작 시각화

사용 중
사용 중
사용 중
유휴
유휴
유휴
유휴
대기
대기
대기
사용 중 (3개)
유휴 (4개) — 즉시 제공 가능
대기 중 스레드 (connectionTimeout 카운트다운)
maximumPoolSize = 7 (사용 중 + 유휴의 합)

3. HikariCP 핵심 설정 완전 정리

설정기본값의미실무 권장
maximumPoolSize 10 최대 커넥션 수 (idle + active) 가장 중요한 설정. 공식으로 계산 (하단 참조)
minimumIdle maximumPoolSize와 동일 최소 유지 idle 커넥션 수 HikariCP 공식 권장: maximumPoolSize와 같게 설정 (고정 풀 크기)
connectionTimeout 30000ms (30초) 풀에서 커넥션 획득 대기 시간 30초는 너무 길다. 3~5초로 줄이면 빠른 실패로 시스템 보호
idleTimeout 600000ms (10분) 유휴 커넥션이 풀에서 제거되는 시간 minimumIdle < maximumPoolSize일 때만 의미 있음
maxLifetime 1800000ms (30분) 커넥션 최대 수명. 초과 시 재생성 DB 서버의 wait_timeout보다 반드시 짧게 설정! 안 하면 DB가 먼저 끊어서 "Connection closed" 에러
leakDetectionThreshold 0 (비활성) 커넥션이 이 시간 이상 반환 안 되면 누수로 경고 개발/스테이징 환경에서 5000ms 설정 권장. 누수 탐지 필수 도구
keepaliveTime 0 (비활성) 유휴 커넥션에 keepalive 쿼리를 보내는 주기 DB 방화벽 타임아웃이 있는 환경에서 유휴 커넥션이 끊기는 것 방지

4. maximumPoolSize 계산 — "많을수록 좋다"는 오해

PostgreSQL 팀 권장 공식 (I/O 바운드 기준)
Pool Size = (Core Count × 2) + Effective Disk Count
CPU 4코어, SSD 1개 기준: (4 × 2) + 1 = 9 → 약 10개
단, 이는 "이상적 최소값"이며 실제는 부하 테스트로 검증 필요
풀 크기가 크다고 무조건 좋지 않은 이유

DB 서버는 각 커넥션을 별도 프로세스/스레드로 처리합니다. 커넥션이 100개라면 DB에서 동시에 100개 실행 컨텍스트를 유지해야 하고, CPU 코어가 4개라면 96개 컨텍스트는 항상 컨텍스트 스위칭 대기 상태입니다. 즉, 풀 크기를 늘릴수록 DB 서버의 CPU 컨텍스트 스위칭 오버헤드가 증가해 오히려 전체 처리량(Throughput)이 감소합니다. 실제로 HikariCP 벤치마크 결과, 풀 크기 9~10이 1000개보다 처리량이 높게 나오기도 합니다.

❌ 설정 실수 사례
spring:
  datasource:
    hikari:
      # "많으면 많을수록 좋겠지"
      maximum-pool-size: 200

# 결과:
# - DB 서버 CPU 부하 급증
# - 컨텍스트 스위칭으로 전체 처리량 하락
# - DB 서버 max_connections 초과 시
#   "too many connections" 에러
# - 커넥션 유지 비용(메모리)도 선형 증가
✅ 권장 설정 예시 (4코어 서버)
spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      minimum-idle: 10    # 고정 풀 크기
      connection-timeout: 3000  # 3초 빠른 실패
      max-lifetime: 1800000     # 30분
      # DB wait_timeout=3600s 이면
      # max-lifetime < 3600s 로 설정 필수
      keepalive-time: 60000     # 1분마다 keepalive
      leak-detection-threshold: 5000 # 개발환경 누수 탐지

5. 커넥션 풀 데드락 — 중첩 트랜잭션의 함정

풀 크기가 작고 중첩 트랜잭션이 있을 때 데드락이 발생할 수 있습니다. 특히 @Transactional(propagation = REQUIRES_NEW)를 잘못 사용할 때 위험합니다.

Thread-A (outer tx)
커넥션 #1 획득 (outer tx 시작)
작업 처리 중...
커넥션 #2 대기 (inner tx 시작 시도)
⏳ connectionTimeout 카운트다운...
🔒
DEADLOCK
maximumPoolSize=1일 때
Thread-B (outer tx)
커넥션 #? — 없음!
풀 비어서 대기 중...
풀 크기 = 1이라면:
Thread-A가 커넥션 1개를 들고
inner tx를 위한 추가 커넥션을 기다림
→ 영원히 대기 → Timeout
❌ 데드락 유발 패턴
@Service
public class OrderService {
    @Autowired
    private AuditService auditService;

    @Transactional // 커넥션 #1 획득
    public void placeOrder(Order order) {
        processOrder(order);

        // REQUIRES_NEW → 별도 커넥션 #2 요청
        auditService.log(order); // 커넥션 #2 대기!
        // maximumPoolSize=1이면 영원히 대기
    }
}

@Service
public class AuditService {
    @Transactional(
        propagation = REQUIRES_NEW) // 새 커넥션 필요
    public void log(Order order) { ... }
}
✅ 해결: 풀 크기 여유 + 구조 개선
// 방법 1: maximumPoolSize 여유롭게 설정
// 중첩 TX 깊이만큼 여유 커넥션 확보
// max = 예상 동시 요청 수 + 중첩 깊이

// 방법 2: REQUIRES_NEW 사용 줄이기
// 감사 로그는 비동기로 분리
@Transactional
public void placeOrder(Order order) {
    processOrder(order);
    // 감사 로그는 이벤트로 발행 → 비동기 처리
    eventPublisher.publishEvent(
        new OrderPlacedEvent(order));
}

@Async // 별도 스레드, 별도 커넥션
@TransactionalEventListener
public void onOrderPlaced(OrderPlacedEvent e) {
    auditRepository.save(e.getOrder());
}

6. 커넥션 누수 탐지와 방지

커넥션이 반환되지 않고 계속 사용 중으로 남으면, 결국 풀이 고갈되어 전체 서비스가 먹통이 됩니다.

❌ 커넥션 누수 원인 (실수 코드)
// @Transactional 없이 직접 커넥션 사용
public void badQuery() {
    Connection conn = dataSource.getConnection();
    // 예외 발생 시 conn.close() 호출 안 됨!
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("SELECT ...");
    // 처리 중 예외 발생
    processResult(rs); // ← 여기서 예외!
    conn.close(); // ← 실행 안 됨 → 누수!
}

// 증상:
// leakDetectionThreshold 경고 로그:
// "Connection leak detection triggered for ..."
// 풀이 서서히 고갈 → 새벽에 서비스 중단
✅ try-with-resources로 안전한 반환
// 방법 1: try-with-resources (AutoCloseable)
public void safeQuery() {
    try (Connection conn = dataSource.getConnection();
         Statement stmt = conn.createStatement();
         ResultSet rs = stmt.executeQuery("...")) {
        processResult(rs);
    } // 예외 발생해도 자동으로 close() 호출
}

// 방법 2: Spring @Transactional 사용
// 트랜잭션 종료 시 커넥션 자동 반환
@Transactional
public void safeService() {
    // Spring이 커넥션 생명주기 관리
    repository.findAll();
} // 커넥션 자동 반환
leakDetectionThreshold 설정과 로그 해석

leakDetectionThreshold=5000(5초) 설정 시, 커넥션을 획득한 후 5초 이상 반환되지 않으면 HikariCP가 다음 경고 로그를 출력합니다:
WARN HikariPool-1 - Connection leak detection triggered for com.zaxxer.hikari...
스택 트레이스도 함께 출력되므로 어느 코드가 커넥션을 오래 점유하는지 바로 확인 가능합니다. 프로덕션에서는 느린 쿼리(30초 이상)가 오탐 가능하므로 쿼리 평균 실행 시간의 3~5배로 설정합니다.

7. HikariCP가 빠른 이유 — 내부 최적화

최적화 항목설명
ConcurrentBag 커넥션 관리용 자체 자료구조. ThreadLocal을 활용해 같은 스레드가 반환한 커넥션을 우선 재사용 (L1 캐시 효과)
Proxy 최소화 JDBC의 Connection, Statement 프록시를 바이트코드 수준에서 직접 생성. 리플렉션 기반 프록시보다 월등히 빠름
Invocation counting 제거 일부 JDBC 드라이버가 내부적으로 getAutoCommit()을 호출하는데, HikariCP가 이를 캐싱해 실제 DB 왕복 제거
Pool suspending DB failover 중 새 커넥션 생성 요청을 즉시 거부하지 않고 suspend 상태로 대기, DB 복구 후 자동 resume

8. 면접 대비 핵심 Q&A

Q1. "maximumPoolSize를 크게 설정하면 왜 안 좋을 수 있나요?"

DB 서버는 각 커넥션을 별도 실행 컨텍스트로 처리합니다. 커넥션이 많아질수록 DB 서버에서 컨텍스트 스위칭이 증가하고, CPU 코어 수 대비 활성 커넥션이 많으면 오히려 전체 처리량이 감소합니다. 실제 HikariCP 벤치마크에서는 풀 크기 9~10이 1000개보다 TPS가 높게 나오는 결과도 있습니다. 또한 DB 서버의 max_connections를 초과하면 연결 자체가 거부됩니다. 부하 테스트를 통해 최적 크기를 측정하되, (CPU cores × 2) + 1을 초기 기준점으로 사용합니다.

Q2. "maxLifetime 설정을 잘못했을 때 어떤 에러가 발생하나요?"

maxLifetime을 DB 서버의 wait_timeout보다 길게 설정하면, DB가 먼저 idle 커넥션을 끊어버립니다. 이미 끊긴 커넥션을 HikariCP가 유효하다고 생각하고 애플리케이션에 제공하면 "Connection is closed" 또는 "Communications link failure" 에러가 발생합니다. maxLifetime은 DB의 wait_timeout보다 수 초~수분 짧게 설정해야 합니다. 또한 keepaliveTime을 설정하면 유휴 커넥션에 주기적으로 ping을 날려 DB가 끊는 것을 방지할 수 있습니다.

Q3. "커넥션 누수를 어떻게 탐지하고 방지하나요?"

탐지 방법: leakDetectionThreshold를 설정하면 해당 시간 이상 반환되지 않는 커넥션에 대해 HikariCP가 경고 로그와 스택 트레이스를 출력합니다. Prometheus의 hikaricp_connections_pending 지표가 꾸준히 증가하는 경우도 누수 신호입니다.
방지 방법: try-with-resources로 Connection.close()를 자동 보장하거나, Spring @Transactional을 사용해서 스프링이 커넥션 생명주기를 관리하게 합니다. 장기 실행 배치 메서드에서 단일 트랜잭션으로 처리하면 커넥션을 오래 점유하므로 청크 처리로 나누는 것이 좋습니다.

이 챕터의 핵심 한 줄

커넥션 풀 크기는 많을수록 좋지 않다 — (CPU cores × 2) + 1을 기준으로 부하 테스트로 검증하고, maxLifetime은 DB wait_timeout보다 짧게, leakDetectionThreshold는 반드시 설정하라.