HikariCP는 왜 다른 Connection Pool보다 빠른가
Lock-free ConcurrentBag 구조부터 타임아웃 파라미터 설계 원칙까지, HikariCP가 고성능을 유지하는 내부 메커니즘을 추적한다.
- 01 Spring Data JPA는 인터페이스 선언만으로 어떻게 동작하는가
- 02 Spring 트랜잭션은 어떻게 비즈니스 코드를 깨끗하게 유지하는가
- 03 JPA는 어떻게 객체와 DB를 동기화하는가
- 04 Spring Data JPA의 쿼리 전략은 왜 이렇게 많은가
- 05 JdbcTemplate이 JDBC 보일러플레이트를 제거하는 방법
- 06 HikariCP는 왜 다른 Connection Pool보다 빠른가
- 07 Spring Data 테스트는 왜 이렇게 설계됐는가
DB Connection 하나를 만드는 데 TCP 핸드셰이크, 인증, 세션 초기화까지 합쳐 평균 8ms가 걸린다. 초당 1000 요청 서비스에서 매번 Connection을 새로 만들면 Connection 생성 비용만 8초다. Connection Pool이 이 문제를 해결하지만, Pool 자체도 설계 방식에 따라 성능 차이가 크다. HikariCP는 어떤 선택으로 그 차이를 만드는가?
Connection Pool의 병목은 Lock이다
전통적인 Connection Pool 구현은 동기화에 synchronized 블록이나 ReentrantLock을 사용한다. 다수의 스레드가 동시에 Connection을 요청하면 한 스레드가 Lock을 잡는 동안 나머지는 대기한다. 처리량이 올라갈수록 Lock 경합이 늘고, Lock 경합이 늘수록 처리량은 감소한다.
HikariCP는 이 문제를 ConcurrentBag이라는 자체 자료구조로 해결한다. 대여와 반환의 핵심 경로에서 Lock을 사용하지 않는다.
// ConcurrentBag.borrow() — 대여 순서
// 1단계: 스레드 로컬 캐시 (자신이 마지막 사용한 Connection)
final List<Object> list = threadList.get();
for (int i = list.size() - 1; i >= 0; i--) {
final T bagEntry = (T) list.remove(i);
if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry; // CAS 성공 → Lock 없이 대여, ~200ns
}
}
// 2단계: 전체 sharedList CAS 순회
for (T bagEntry : sharedList) {
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry; // ~500ns
}
}
// 3단계: 모두 사용 중 → handoffQueue 대기 (connectionTimeout까지)
스레드 로컬 캐시가 히트하면 약 200ns, sharedList CAS 순회도 약 500ns다. DB 쿼리 실행 시간(수 ms)에 비해 무시할 수 있는 수준이다.
반환(requite)도 대칭적으로 설계된다. 대기 스레드가 있으면 handoffQueue로 직접 전달하고, 없으면 스레드 로컬 캐시에 저장해 다음 대여 시 즉시 반환하도록 한다.
close()는 연결을 닫지 않는다
HikariCP가 반환하는 Connection 객체는 실제 JDBC Connection이 아니라 HikariProxyConnection이다. Javassist로 바이트코드를 생성해 리플렉션 없이 동작하므로 프록시 오버헤드가 최소화된다.
// 사용자 코드가 호출하는 close()
public final void close() throws SQLException {
poolEntry.recycle(lastAccess); // ConcurrentBag.requite() 호출
// 실제 Connection은 닫히지 않고 Pool에 반환됨
}
maxLifetime이 경과한 Connection만 실제 close()가 호출된다. 기본값은 30분이고, Pool 안의 Connection들이 동시에 만료되는 “Thunder Herd”를 막기 위해 ±2.5% 랜덤 지터를 추가한다.
Pool Size를 키우면 느려지는 이유
직관과 반대되는 사실이 있다. Pool Size를 늘리면 처리량이 오히려 줄어드는 구간이 존재한다.
Pool Size: 5 10 16 20 30 50 100
TPS: 450 750 980 1000 990 900 780
↑ 공식 기준값 ↑ 감소 시작
HikariCP 공식 권장 공식은 다음과 같다.
connections = (core_count × 2) + effective_spindle_count
CPU 8코어, SSD 환경: (8 × 2) + 0 = 16. Pool 크기가 16~20일 때 처리량이 최고치에 도달하고, 이후에는 DB 서버 CPU의 Context Switching 비용이 증가하면서 오히려 감소한다.
이론적 근거는 Little’s Law에서도 확인된다. 평균 쿼리 시간이 10ms이고 목표 TPS가 1000이면 필요한 Connection은 L = λ × W = 1000 × 0.01 = 10개다. Connection을 100개로 늘려도 DB CPU가 병목이면 처리량은 늘지 않는다.
Pod 10개 × Pool 30개 = DB Connection 300개. DB max_connections=200이면 일부 Pod는 Connection 획득에 실패한다. MSA에서는 DB max_connections의 80% 이하로 전체 Pool 합계를 유지해야 한다.
Connection이 끊겼을 때 — 세 가지 시점의 검증
DB 재시작이나 방화벽 타임아웃으로 Pool 안의 Connection이 무효화될 수 있다. HikariCP는 세 시점에서 이를 감지한다.
생성 시: initializationFailTimeout=0으로 설정하면 DB가 준비되지 않아도 애플리케이션이 시작된다. Kubernetes 환경에서 앱이 DB보다 먼저 뜨는 경우에 권장한다.
대여 시: JDBC 4 이상 드라이버는 isValid()로 드라이버 자체 ping을 수행한다(SELECT 1보다 10~50배 빠름). 레거시 환경에서만 connectionTestQuery를 설정한다.
유휴 중: keepaliveTime마다 유휴 Connection에 isValid()를 실행한다. 방화벽 idle timeout보다 짧게 설정해야 방화벽이 끊기 전에 ping이 실행된다.
spring:
datasource:
hikari:
keepalive-time: 60000 # 방화벽 idle timeout보다 짧게
max-lifetime: 1800000 # DB wait_timeout보다 짧게 (필수)
connection-timeout: 5000 # 빠른 실패
validation-timeout: 3000 # connectionTimeout보다 짧게
maxLifetime < DB wait_timeout은 가장 중요한 규칙이다. 이 순서가 역전되면 DB가 Connection을 먼저 끊고 Pool은 그 사실을 모른 채 끊긴 Connection을 대여해서 첫 요청이 실패한다.
트레이드오프
Pool Size 크게: 트래픽 스파이크 흡수 가능. 단 DB CPU Context Switching 증가로 적정값 이상은 역효과.
connectionTimeout 짧게(5초): 스레드 고갈을 막고 Circuit Breaker와 연계된 빠른 실패 가능. 단 일시적 DB 과부하에서 정상 요청도 실패.
고정 Pool(minimum=maximum): Connection 항상 준비 상태. 저트래픽 시에도 DB Connection 유지 비용 발생.
Statement 캐싱(cachePrepStmts=true): 반복 SQL 파싱 비용 최대 99% 절감. 단 동적 SQL(값 직접 삽입)에는 효과 없고 캐시 슬롯만 낭비.
정리
- HikariCP의 핵심은
ConcurrentBag의 Lock-free 설계다. 스레드 로컬 캐시 → CAS 순회 → handoffQueue 대기 순으로 진행하며 대여 비용은 200~500ns다. - Pool Size의 최적값은
core × 2 + spindle_count. 이 이상은 DB 서버 Context Switching으로 처리량이 역전된다. maxLifetime < DB wait_timeout을 지키지 않으면 야간 저트래픽 후 첫 요청이 간헐적으로 실패한다.- Statement 캐싱(
cachePrepStmts=true)과 파라미터 바인딩은 보안(SQL Injection 방지)과 성능을 동시에 얻는 올바른 패턴이다.
다음 글에서는 @Transactional 전파 속성이 Connection을 어떻게 공유하고 분리하는지, 그리고 REQUIRES_NEW가 Pool 크기와 데드락을 어떻게 만들어내는지 추적한다.