[Spring] HikariCP와 Connection Pool: 동작 원리와 운영 튜닝
매번 커넥션을 새로 맺으면 무슨 일이 생기는가
JDBC가 DB 작업을 수행하는 가장 단순한 방법은 매번 새 커넥션을 맺고 끊는 것이다. 요청이 들어올 때마다 TCP 3-way handshake로 연결을 만들고, 인증 절차를 거친 뒤 쿼리를 한 번 실행하고, 다시 종료한다. 로컬에서 한두 번 돌릴 때는 별 문제가 없다. 하지만 실시간 트래픽을 받는 OLTP(Online Transaction Processing) 환경에서는 이 비용이 매 요청마다 누적된다.
MySQL 한 번 핸드셰이크에 보통 1ms 안팎이 들고, 쿼리 자체가 수 ms 안에 끝난다고 했을 때, 핸드셰이크가 전체 응답 시간의 큰 비중을 차지하게 된다. 거기에 인증, 권한 검증, 세션 변수 초기화 같은 부가 작업까지 합치면 쿼리 시간보다 연결 시간이 더 긴 비정상적인 상황이 흔히 발생한다.
이를 해결하기 위해 미리 커넥션을 일정량 만들어 두고, 요청이 들어오면 그중 하나를 빌려주고, 끝나면 돌려받아 다음 요청에 재사용한다. 이 메커니즘이 커넥션 풀(Connection Pool)이고, 스프링 부트의 기본 구현체가 HikariCP다.
이 글에서는 HikariCP가 왜 빠른지, 커넥션을 어떻게 빌려주고 회수하는지, 어떤 설정을 어떻게 잡아야 하는지, 그리고 잘못 쓰면 어떤 식으로 풀이 망가지는지를 정리한다.
블로킹 I/O와 스레드 모델
JDBC 드라이버는 블로킹 I/O 모델이다. executeQuery()를 호출한 스레드는 DB가 응답을 돌려줄 때까지 스레드를 블로킹한다.
스프링 부트의 기본 톰캣은 요청당 스레드 한 개를 점유하는 모델이다. 요청 하나마다 워커 스레드 하나가 붙고, 그 워커 스레드가 컨트롤러, 서비스, 리포지토리를 거쳐 DB 호출까지 수행한다. DB가 응답을 늦게 주면 그 워커 스레드는 응답을 받을 때까지 다른 일을 할 수 없다.
커넥션 풀 구현체 비교
JDBC 커넥션 풀 구현체는 여러 개가 존재했고, 스프링 부트 1.x 시절까지는 Tomcat JDBC 가 기본이었다. 부트 2.0 부터 HikariCP 가 기본 자리에 올라섰다.
| 구현체 | 락 모델 | 성능 특성 | 운영 메트릭 | 기본 사용처 |
|---|---|---|---|---|
| HikariCP | CAS 기반 ConcurrentBag, 락 프리 | 평균 획득/반납 마이크로초 단위, 코드 크기 약 130KB 로 가장 작다 | Micrometer 연동, JMX 노출 | Spring Boot 2.0+, Quarkus 등의 기본값 |
| Tomcat JDBC | ReentrantLock 기반 | 단순한 워크로드에서 충분히 빠르지만 락 경합이 늘면 HikariCP 보다 처리량 저하가 빠르다 | JMX, Tomcat manager | Spring Boot 1.x 기본값, 톰캣 컨테이너에 묶여 동작하는 환경 |
| Apache DBCP2 | synchronized 블록 다수 | 안정적이지만 락 경합에 약하다. Apache Commons Pool 의존성으로 의존 그래프가 무겁다 | JMX | 레거시 자카르타 EE 환경 |
| c3p0 | synchronized + helper thread | 비동기 검증/회수 스레드가 추가로 돈다. 풀 자체보다 검증 부담이 큰 환경에 적합하지만 신규 채택은 거의 없다 | 자체 JMX | 레거시 하이버네이트 튜토리얼 잔재 |
세 구현체 모두 동일한 javax.sql.DataSource 인터페이스를 구현하므로 교체에 드는 비용은 의존성과 설정 키 변경 정도다. 기본 선택은 HikariCP 로 두고, 특수한 락 패턴이나 검증 요구가 있는 경우에만 다른 구현체를 검토할 수 있다.
HikariCP의 고성능 비결
HikariCP는 다른 커넥션 풀 구현체(Apache DBCP2, Tomcat JDBC, c3p0)와 동일한 인터페이스(javax.sql.DataSource)를 제공하지만, 내부에서는 락을 최소화하고 마이크로 최적화를 극단까지 밀어붙인다.
1) FastList: ThreadLocal 에 살아 있는 가벼운 리스트
Statement나 ResultSet을 추적하기 위한 컬렉션으로 ArrayList가 아니라 자체 구현 FastList를 쓴다. ArrayList는 추가/조회 시 범위 체크를 수행하지만, FastList는 그 체크를 제거하고 마지막 인덱스에서 역방향 검색(remove)을 최적화했다.
📂 참고 소스:
com.zaxxer.hikari.util.FastList
// com.zaxxer.hikari.util.FastList (핵심 발췌)
public final class FastList<T> implements List<T>, RandomAccess, Serializable {
private final Class<?> clazz;
private T[] elementData;
private int size;
/**
* ArrayList.get() 은 rangeCheck(index) 로 IndexOutOfBoundsException 을 던지지만
* FastList 는 그 체크를 제거한다. JDBC 표준 사용 패턴에선 항상 유효 index 만 들어온다.
*/
@Override
public T get(int index) {
return elementData[index];
}
/**
* remove(Object) 를 끝에서부터 역방향으로 순회.
* Statement/ResultSet 은 LIFO 형태로 생성·해제되므로
* 가장 최근에 추가된 원소가 가장 먼저 제거되는 패턴을 최적화.
*/
@Override
public boolean remove(Object element) {
for (int index = size - 1; index >= 0; index--) {
if (element == elementData[index]) {
final int numMoved = size - index - 1;
if (numMoved > 0) {
System.arraycopy(elementData, index + 1, elementData, index, numMoved);
}
elementData[--size] = null;
return true;
}
}
return false;
}
}2) CAS(Compare-And-Set) 기반 락 프리 동기화
전통적인 풀들은 synchronized 블록이나 ReentrantLock으로 커넥션 컬렉션을 보호한다. 스레드가 많아질수록 락 경합이 심해지고 처리량이 떨어진다. HikariCP의 ConcurrentBag은 CAS 연산으로 상태(STATE_NOT_IN_USE, STATE_IN_USE, STATE_REMOVED, STATE_RESERVED)를 갱신한다.
📂 참고 소스:
com.zaxxer.hikari.pool.PoolEntry·com.zaxxer.hikari.util.ConcurrentBag.IConcurrentBagEntry
// IConcurrentBagEntry: ConcurrentBag 에 담길 엔트리가 지켜야 할 계약
public interface IConcurrentBagEntry {
int STATE_NOT_IN_USE = 0;
int STATE_IN_USE = 1;
int STATE_REMOVED = -1;
int STATE_RESERVED = -2;
boolean compareAndSet(int expectState, int newState);
void setState(int newState);
int getState();
// ...
}
// PoolEntry: 실제 구현. AtomicIntegerFieldUpdater 로 락 없이 상태 전이.
public final class PoolEntry implements IConcurrentBagEntry {
private static final AtomicIntegerFieldUpdater<PoolEntry> stateUpdater =
AtomicIntegerFieldUpdater.newUpdater(PoolEntry.class, "state");
Connection connection;
volatile int state = 0; // 0 = STATE_NOT_IN_USE
volatile boolean evict;
/** 락 없이 단일 CAS 로 점유 시도. 실패하면 다른 스레드가 가져간 것이므로 다음 후보로. */
@Override
public boolean compareAndSet(int expect, int update) {
return stateUpdater.compareAndSet(this, expect, update);
}
@Override public void setState(int update) { stateUpdater.set(this, update); }
@Override public int getState() { return stateUpdater.get(this); }
}3) 다층적 커넥션 획득 경로
getConnection()이 호출되면 HikariCP는 가장 빠른 경로부터 차례로 시도한다.
ThreadLocal의FastList: 같은 스레드가 방금 전에 반납한 커넥션이 있는지 확인. 가장 빠르다.sharedList: 풀이 전역으로 들고 있는 커넥션 리스트에서 사용 가능한 것 탐색.handoffQueue: 다른 스레드가 막 반납한 커넥션이 잠시 머무는 큐.SynchronousQueue기반이라 즉시 인계가 가능하다.
반납도 역순으로 수행된다. 커넥션을 받은 스레드는 handoffQueue에 던지고 즉시 복귀한다. 이후 ConcurrentBag이 그 커넥션을 sharedList로 옮겨 다음 요청이 가져갈 수 있게 한다.
📂 참고 소스:
com.zaxxer.hikari.util.ConcurrentBag#borrow·#requite
// com.zaxxer.hikari.util.ConcurrentBag (borrow/requite 핵심 발췌)
public class ConcurrentBag<T extends IConcurrentBagEntry> {
private final CopyOnWriteArrayList<T> sharedList;
private final ThreadLocal<List<Object>> threadList;
private final SynchronousQueue<T> handoffQueue;
private final AtomicInteger waiters;
public T borrow(long timeout, TimeUnit timeUnit) throws InterruptedException {
// ① ThreadLocal FastList: 같은 스레드의 직전 반납분 (lock-free)
final List<Object> list = threadList.get();
for (int i = list.size() - 1; i >= 0; i--) {
final Object entry = list.remove(i);
@SuppressWarnings("unchecked")
final T bagEntry = (T) entry;
if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry;
}
}
// ② sharedList: 전체 풀 스캔, CAS 로 한 명만 가져가게
final int waiting = waiters.incrementAndGet();
try {
for (T bagEntry : sharedList) {
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
if (waiting > 1) {
listener.addBagItem(waiting - 1); // 풀 확장 트리거
}
return bagEntry;
}
}
listener.addBagItem(waiting); // pool size < max 이면 비동기로 보충
// ③ handoffQueue: 다른 스레드가 막 반납하는 인계를 SynchronousQueue 로 대기
timeout = timeUnit.toNanos(timeout);
do {
final long start = currentTime();
final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry; // null 이면 connectionTimeout 만료
}
timeout -= elapsedNanos(start);
} while (timeout > 10_000);
return null; // 호출자 측에서 SQLTransientConnectionException 으로 변환
} finally {
waiters.decrementAndGet();
}
}
public void requite(final T bagEntry) {
bagEntry.setState(STATE_NOT_IN_USE);
// 대기자 있으면 handoffQueue 로 직접 인계 (sharedList 거치지 않고 즉시 전달)
for (int i = 0; waiters.get() > 0; i++) {
if (bagEntry.getState() != STATE_NOT_IN_USE || handoffQueue.offer(bagEntry)) {
return;
} else if ((i & 0xff) == 0xff) {
parkNanos(MICROSECONDS.toNanos(10)); // 백오프
} else {
Thread.yield();
}
}
// 대기자 없으면 ThreadLocal 캐시에 저장 (다음 borrow 의 ① 경로에서 재사용)
final List<Object> threadLocalList = threadList.get();
if (threadLocalList.size() < 50) {
threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
}
}
}HikariCP 핵심 설정
스프링 부트라면 application.yml에 다음과 같이 작성한다.
spring:
datasource:
url: jdbc:mysql://db:3306/app?useSSL=false&serverTimezone=UTC
username: app
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
pool-name: app-pool
maximum-pool-size: 20
minimum-idle: 20
connection-timeout: 3000 # 3초
validation-timeout: 1000 # 1초
idle-timeout: 600000 # 10분
keepalive-time: 30000 # 30초
max-lifetime: 1800000 # 30분
auto-commit: true
transaction-isolation: TRANSACTION_READ_COMMITTED
data-source-properties:
cachePrepStmts: true
prepStmtCacheSize: 250
prepStmtCacheSqlLimit: 2048
useServerPrepStmts: true
useLocalSessionState: true
rewriteBatchedStatements: true
cacheResultSetMetadata: true
cacheServerConfiguration: true
elideSetAutoCommits: true
maintainTimeStats: false
socketTimeout: 5000HikariCP 자체 옵션
| 옵션 | 기본값 | 의미 |
|---|---|---|
maximum-pool-size | 10 | 풀이 가질 수 있는 커넥션 최대 수. 톰캣 스레드 수와 DB 허용 커넥션 수를 함께 고려해 결정 |
minimum-idle | maximum-pool-size | 유지할 최소 유휴 커넥션 수. 운영에선 max와 동일하게 두는 것이 권장됨 |
connection-timeout | 30000 (30초) | 풀에서 커넥션을 얻기까지 기다리는 최대 시간 |
idle-timeout | 600000 (10분) | 유휴 커넥션이 풀에 남아 있을 수 있는 최대 시간 (minimum-idle 이상은 정리) |
keepalive-time | 0 (비활성) | 유휴 커넥션이 살아있는지 주기적으로 확인. max-lifetime보다 작아야 함 |
max-lifetime | 1800000 (30분) | 풀에서 살아있을 수 있는 절대 시간. 이 시간이 지나면 폐기 후 새로 만듦 |
validation-timeout | 5000 | 커넥션 유효성 검사 타임아웃 |
auto-commit | true | 빌려간 커넥션의 autoCommit 기본값 |
transaction-isolation | DB 기본값 | 풀 전체 트랜잭션 격리 수준 |
read-only | false | 읽기 전용 풀로 운용. 일부 DB에서 Read Replica 라우팅에 사용 |
MySQL Connector/J 최적화 옵션
data-source-properties 아래에 적는 항목들은 JDBC 드라이버에 전달된다. HikariCP의 성능을 100% 활용하려면 드라이버 튜닝도 같이 가야 한다.
| 옵션 | 의미 |
|---|---|
cachePrepStmts | PreparedStatement 객체 캐싱. 동일 쿼리 반복 시 비용 절감 |
prepStmtCacheSize | 커넥션당 캐시할 PreparedStatement 수 |
prepStmtCacheSqlLimit | 캐시할 최대 쿼리 길이. 초과 시 캐싱 안 함 |
useServerPrepStmts | MySQL 서버 측에서 prepare 처리. 파싱/플랜 캐시 활용 |
useLocalSessionState | 격리 수준, autoCommit 같은 세션 상태를 클라이언트에서 캐싱 |
rewriteBatchedStatements | 배치 INSERT/UPDATE를 단일 쿼리로 묶어 한 번에 전송 |
cacheResultSetMetadata | ResultSet 메타데이터(컬럼명, 타입) 캐싱 |
cacheServerConfiguration | MySQL 서버 설정 정보 캐싱. 매번 SHOW VARIABLES 호출 방지 |
elideSetAutoCommits | useLocalSessionState=true일 때 동작. 이미 같은 상태면 SET AUTOCOMMIT 송신 생략 |
socketTimeout | 소켓 읽기 타임아웃. 응답이 없으면 연결 종료 |
maintainTimeStats | 드라이버 시계열 통계 수집. 디버깅에 유용하지만 운영에선 false |
useLocalSessionState와 cacheServerConfiguration을 켜는 것만으로 매 쿼리마다 일어나던 부가 SQL(commit, start transaction)이 사라진다.
풀 크기를 어떻게 잡을까
트래픽이 많다고 풀을 무작정 크게 잡으면 다음 문제가 발생한다.
- DB 측
max_connections를 넘기면 새 연결이 거부된다. - 커넥션 자체가 DB 메모리(MySQL은 약 5~10MB/connection)를 점유한다.
- 락 경합이 심해져 오히려 처리량이 떨어진다.
HikariCP 공식 문서는 다음 가이드를 제시한다.
connections = ((core_count * 2) + effective_spindle_count)대개의 클라우드 환경에서는 디스크가 SSD이므로 effective_spindle_count 는 1 로 계산한다. 8 코어라면 약 17 정도가 출발점이다.
| 컴포넌트 | 의미 |
|---|---|
톰캣 워커 풀 (server.tomcat.threads.max) | 동시에 처리할 수 있는 HTTP 요청 수 |
HikariCP 풀 (maximum-pool-size) | 동시에 잡고 있을 수 있는 DB 커넥션 수 |
DB max_connections | DB가 허용하는 전체 커넥션 수 (모든 인스턴스 합) |
대략적인 규칙은 다음과 같다.
- 톰캣 워커 합이 DB 커넥션을 직접 넘지 않는 것이 중요한 게 아니라, 워커 곱하기 인스턴스 수가 DB
max_connections에서 안전 마진(50)을 뺀 값 이하여야 한다. - HikariCP 풀은 워커 풀보다 작아도 되고 같아도 된다. 워커가 100인데 풀이 20이라면, 풀에 자리가 없을 때 워커는
connectionTimeout만큼 대기한다. - 외부 API 호출이 길어서 워커 스레드가 DB를 잡고 있는 시간보다 외부 API 대기에 더 많은 시간을 쓴다면 풀은 워커보다 훨씬 작아도 된다.
Little’s Law 로 풀 크기를 역산하기
리틀의 법칙(L = λ × W)을 풀 크기 산정에 적용할 수 있다. L 은 풀에 동시에 들어 있는 커넥션 수, λ 는 초당 쿼리 수, W 는 쿼리 평균 레이턴시이다. 초당 500 쿼리가 들어오고 평균 점유 시간이 20ms 라면 L = 500 × 0.02 = 10 이다. 안전 계수 23 을 곱해 2030 정도로 풀 크기를 잡을 수 있다.
이 계산에서 가장 중요한 입력은 W 다. 트랜잭션 안에서 외부 API 호출이 있다면, W 가 갑자기 수십 배로 늘어난다. 풀 크기를 키우는 대신 W 를 줄이는 쪽이 훨씬 이점이 크다. 동일 인프라에서 같은 RPS 를 받기 위해 풀을 키우면 DB 메모리와 락 경합이 늘어나지만, W 를 줄이면 DB 와 풀 양쪽 모두 자원을 절약할 수 있기 때문이다.
멀티 인스턴스 환경의 합산 한도
쿠버네티스에서 같은 애플리케이션을 10 개 파드로 띄우고 각 파드가 풀 크기 20 을 가진다면 DB 가 받아야 할 동시 커넥션은 200 이다. 거기에 배치 잡, 어드민, 마이그레이션 도구까지 합치면 250 을 넘어선다. DB 측 max_connections 가 300 이라면 안전 마진이 거의 없는 상태다. 오토스케일링으로 파드가 갑자기 15 개로 늘면 300 을 그대로 넘기게 된다.
운영에서는 다음 원칙을 따른다.
- DB
max_connections의 70~80% 안에 모든 파드의 풀 합이 들어가도록 잡는다. - HPA(Horizontal Pod Autoscaler) 최대값을 고려해 풀 크기를 미리 작게 잡거나, ProxySQL/PgBouncer 같은 외부 풀러를 둔다.
- 배치 잡과 OLTP 워크로드의 풀을 분리해 같은 DB 라도 사용 인스턴스 한도를 명시한다.
커넥션 풀 상태 다이어그램
InUse로 표시된 커넥션은 누군가가 들고 있는 상태다. 만약 close 호출이 누락된다면, 커넥션 반납이 안되므로 누수 상태에 빠지게 된다.
풀 누수 시나리오
1) close 누락
// BAD: 예외 발생 시 close가 호출되지 않음
public List<User> findAll() {
Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT * FROM users");
ResultSet rs = ps.executeQuery();
// ... 예외가 발생하면 con이 풀에 반납되지 않는다
return mapToUsers(rs);
}
// GOOD: try-with-resources
public List<User> findAll() {
try (Connection con = dataSource.getConnection();
PreparedStatement ps = con.prepareStatement("SELECT * FROM users");
ResultSet rs = ps.executeQuery()) {
return mapToUsers(rs);
}
}JPA(Java Persistence API)나 MyBatis를 쓰면 보통 신경 쓸 일이 없지만, raw JDBC를 쓰는 코드에선 여전히 발생한다. HikariCP는 leakDetectionThreshold를 설정하면 일정 시간 이상 잡고 있는 커넥션에 대해 스택 트레이스를 로그로 남겨 누수 추적을 도와준다.
spring:
datasource:
hikari:
leak-detection-threshold: 5000 # 5초 이상 잡으면 경고2) 트랜잭션 안에서 외부 API 호출
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
paymentApiClient.charge(order); // 외부 호출 30초, 그동안 커넥션 점유
orderRepository.markPaid(order.getId());
}외부 API가 30초 걸리는 사이 워커 스레드는 커넥션을 잡은 채 대기한다. 동시 트래픽이 풀 크기를 넘으면 그 시점부터 모든 요청이 connection-timeout 만큼 대기한 뒤 예외로 직행한다.
해결은 외부 호출을 트랜잭션 밖으로 빼는 것이다.
public void placeOrder(Order order) {
saveOrderTx(order); // 트랜잭션 빠르게 끝
paymentApiClient.charge(order);
markPaidTx(order.getId()); // 별도 짧은 트랜잭션
}3) REQUIRES_NEW 의 남용
@Transactional
public void outer() {
inner.do(); // REQUIRES_NEW
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void do() { /* ... */ }REQUIRES_NEW는 기존 트랜잭션을 일시 정지하고 새 커넥션을 받는다. 즉 한 요청이 풀에서 2개의 커넥션을 동시에 점유하게 된다. 외부 트랜잭션이 끝날 때까지 두 커넥션이 모두 살아 있다. 코드 곳곳에서 남발하면 풀 고갈이 빠르게 될 수 있다.
4) 인덱스/쿼리 부재로 인한 긴 쿼리
쿼리 자체가 느리면 요청이 길어지기 때문에 그 커넥션이 풀에 돌아오기까지 시간이 걸린다. select *로 큰 테이블을 풀 스캔하거나, 인덱스 없이 정렬을 거는 경우 커넥션 점유 시간이 누적된다. 이는 HikariCP 튜닝으로 해결되지 않으며, DB 인덱스, 커버링 인덱스, 페이지네이션 같은 별도 처리가 필요하다. 인덱스 스캔이 비효율적인 경우(전체의 15~20% 이상을 읽을 때)에는 옵티마이저가 풀 스캔(Multi Block I/O)을 택하기도 한다.
timeout 파라미터 비교
운영에서 가장 자주 헷갈리는 네 가지 timeout 을 한 표로 정리한다. 이름이 비슷하지만 작동 시점과 책임이 모두 다르다.
| 파라미터 | 기본값 | 작동 시점 | 동작 |
|---|---|---|---|
connectionTimeout | 30초 | 풀에서 커넥션을 빌리려 할 때 | 지정 시간 안에 빌리지 못하면 SQLTransientConnectionException 을 던진다. 결국 호출한 비즈니스 메서드까지 예외가 전파된다 |
idleTimeout | 10분 | 풀에서 유휴 상태로 있을 때 | minimumIdle 보다 많은 유휴 커넥션을 이 시간 후 폐기한다. minimumIdle = maximumPoolSize 면 사실상 비활성화된다 |
maxLifetime | 30분 | 커넥션이 만들어진 시점부터 | 커넥션이 풀에 있든 사용 중이든 관계없이 이 시간이 지나면 다음 반납 시점에 폐기된다. DB 측 wait_timeout 보다 작게 잡아 끊긴 커넥션을 빌려주지 않도록 한다 |
leakDetectionThreshold | 0 (비활성) | 커넥션이 빌려진 시점부터 | 이 시간 이상 반납되지 않으면 누가 들고 있는지 스택 트레이스를 경고 로그로 남긴다. 운영에서는 5~10초 정도가 합리적이고, 너무 짧으면 정상적으로 오래 걸리는 쿼리도 모두 잡힌다 |
이 네 값이 어떻게 어우러지는지가 풀 안정성을 결정한다. connectionTimeout 만 짧게 잡고 leakDetectionThreshold 를 꺼두면 누수가 일어났을 때 풀이 한 번에 비어버리고 원인을 추적할 단서가 없다. 반대로 maxLifetime 을 너무 짧게 잡으면(예: 5초) 모든 트래픽이 매번 핸드셰이크 비용을 다시 지불하게 되어 풀이 사실상 무의미해진다.
운영에서 보는 풀 메트릭
HikariCP는 마이크로미터를 통해 다음 메트릭을 노출한다.
hikaricp.connections.active: 현재 사용 중인 커넥션 수hikaricp.connections.idle: 유휴 커넥션 수hikaricp.connections.pending: 커넥션 대기 중인 스레드 수hikaricp.connections.usage: 커넥션 사용 시간 히스토그램hikaricp.connections.acquire: 커넥션 획득 시간 히스토그램hikaricp.connections.timeout: 타임아웃 발생 횟수
pending이 0보다 큰 시간이 지속된다는 것은 풀이 부족하다는 신호다. active가 지속적으로 max에 붙어 있고 pending이 쌓인다면 풀을 키우거나 쿼리 시간을 줄여야 한다. timeout은 그 자체가 SLO(Service Level Objective) 위반이므로 알람 대상이다.
주의사항과 베스트프랙티스
1) 풀 크기는 톰캣 스레드 수와 함께 본다
풀이 100인데 톰캣 스레드가 200이면 동시 100개의 요청은 무조건 풀 대기에 들어간다. 반대로 풀이 200인데 톰캣 스레드가 50이면 풀은 항상 여유롭지만 절반은 노는 자원이다. 두 값은 비즈니스 패턴(쿼리/외부 호출 비율)을 보고 균형 있게 잡아야 한다.
2) minimum-idle 을 maximum-pool-size 와 같게 유지한다
minimum-idle이 작으면 트래픽이 갑자기 몰릴 때 새 커넥션을 만드는 데 시간이 들어 첫 요청들이 느려진다. 운영 환경에서는 두 값을 같게 두어 풀이 항상 일정 크기를 유지하도록 한다.
3) max-lifetime 은 DB 의 wait_timeout 보다 작게
MySQL은 wait_timeout이 지난 유휴 커넥션을 일방적으로 끊는다. HikariCP가 그 사실을 모른 채 끊긴 커넥션을 빌려주면 첫 쿼리에서 CommunicationsException이 발생한다. 보통 DB wait_timeout 28800초(8시간)에 비해 풀의 max-lifetime을 1800초(30분) 정도로 짧게 잡아 미리 커넥션을 재생성한다.
4) leak-detection-threshold 를 켜둔다
운영에서 5~10초 정도로 설정해 두면, 비정상적으로 오래 잡힌 커넥션에 대해 어떤 코드가 들고 있는지 스택 트레이스가 로그로 남는다. 처음 누수가 의심될 때 가장 먼저 켜야 할 옵션이다.
5) auto-commit 을 명시한다
기본값은 true이지만, 트랜잭션을 자주 쓰는 애플리케이션에서 auto-commit=false로 두고 명시적 트랜잭션을 강제하는 정책도 가능하다. 다만 스프링의 @Transactional은 이미 자동으로 autoCommit을 토글하므로, 기본값 그대로 두고 @Transactional을 신뢰하는 것이 무난하다.
6) keepalive-time 을 설정해 좀비 커넥션을 빨리 발견한다
방화벽이나 로드밸런서가 일정 시간 이상 idle인 TCP 연결을 끊는 환경에서, HikariCP가 그것을 모른 채 가지고 있다가 빌려줄 수 있다. keepalive-time을 30초 정도로 두면 그 주기마다 유효성을 확인해 끊긴 커넥션을 미리 폐기한다. 단 max-lifetime보다 작아야 한다.
7) 외부 API 호출은 트랜잭션 밖으로
이미 여러 번 강조했지만 풀 고갈의 단일 최대 원인이다. 트랜잭션이 길어지면 같은 트래픽에서도 풀이 빨리 동난다. 트랜잭션은 짧게, 외부 호출은 그 밖으로 빼는 것이 원칙이다.
8) Read Replica 와의 분리
읽기 쿼리가 많은 서비스라면 마스터/리플리카로 분리하고, 읽기 전용 트랜잭션(@Transactional(readOnly = true))을 리플리카로 라우팅하는 패턴이 유용하다. HikariCP를 두 개 띄우고 AbstractRoutingDataSource로 선택하거나, 클라우드 RDS의 리더 엔드포인트를 활용한다. 한 풀에서 모든 트래픽을 받지 않게 되니 마스터 풀 부담이 크게 줄어든다.
9) 커버링 인덱스로 쿼리 시간을 줄인다
select * 패턴은 인덱스로 rowId만 찾은 뒤 테이블 데이터 블록을 다시 랜덤 액세스하는 비용을 매번 지불하게 만든다. 조회 컬럼이 모두 인덱스 안에 포함되도록 인덱스를 잡으면(커버링 인덱스), 테이블 접근 자체가 사라져 응답 시간이 극적으로 줄고 그만큼 커넥션 점유 시간도 짧아진다.