HikariCP는 커넥션을 빌려줄 때 왜 락을 잡지 않는가 — ConcurrentBag의 상태 CAS 대여

seonwoo_jung·2026년 7월 15일

Spring Boot의 기본 커넥션 풀이 HikariCP라는 건 알아도, "빠르다"는 평판이 정확히 무엇에서 오는지는 오래 궁금했다. 나는 막연히 "풀에서 커넥션을 꺼낼 때 synchronized로 락을 잡고 리스트에서 하나 빼는 것"쯤으로 알고 있었다. 그런데 소스의 ConcurrentBag.borrow()를 따라가 보니, 흔한 경로에서는 락을 아예 잡지 않는다. 이 글은 "커넥션 대여가 어떻게 락 없는 CAS 한 번으로 끝나는가"를 소스 기준으로 정리한 것이다.

문제: 커넥션 대여는 초당 수십만 번 일어난다

커넥션 풀의 핵심 연산은 빌리기(borrow)와 되돌려주기(requite) 두 가지다. 웹 서버는 요청마다 이 왕복을 하므로, 이 경로는 애플리케이션 전체에서 가장 뜨거운 코드 중 하나가 된다. 여기에 전역 락을 걸면 스레드가 늘어날수록 락 경합이 처리량의 천장이 된다.

HikariCP의 자료구조 ConcurrentBag은 이 문제를 "공유 자료구조를 조작하는 일"에서 "개별 커넥션의 상태값을 원자적으로 바꾸는 일"로 치환해서 푼다.

핵심 개념: 대여는 리스트 조작이 아니라 상태에 대한 CAS

각 엔트리(IConcurrentBagEntry)는 AtomicInteger 하나로 자기 상태를 들고 있다.

STATE_NOT_IN_USE =  0   // 대여 가능
STATE_IN_USE     =  1   // 대여됨
STATE_REMOVED    = -1   // 풀에서 제거됨(폐기 진행)
STATE_RESERVED   = -2   // 하우스키핑이 선점(maxLifetime/idle 정리 등)

대여의 본질은 이 한 줄이다.

// 0 → 1 CAS에 성공한 스레드만 그 커넥션의 소유권을 얻는다
if (entry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
    return entry;
}

두 스레드가 같은 커넥션을 동시에 노려도 CAS는 하나만 이긴다. 진 쪽은 다음 엔트리로 넘어갈 뿐이다. 락도, 큐에서 pop도 없다. "누가 이 커넥션을 갖는가"라는 경합이 리스트 전체의 락이 아니라 엔트리 하나의 CAS로 국소화된다.

내부 동작: borrow()의 3단계

자료구조는 세 개다.

private final CopyOnWriteArrayList<T> sharedList;   // 모든 엔트리의 진짜 원장
private final ThreadLocal<List<Object>> threadList; // 스레드별 최근 사용 캐시
private final SynchronousQueue<T> handoffQueue;      // 대기자에게 직접 건네는 통로
private final AtomicInteger waiters;                  // borrow에서 블록된 스레드 수

borrow()는 이 셋을 싼 것부터 비싼 것 순으로 훑는다.

  1. ThreadLocal 스캔 — 내 threadList의 최근 항목부터 CAS(0→1)를 시도한다. 성공하면 즉시 반환하고, 공유 자료구조는 한 번도 건드리지 않는다.
  2. sharedList 스캔waiters를 올리고 sharedList 전체를 CAS(0→1)로 훑는다. 성공하면 반환한다.
  3. handoffQueue.poll(남은 timeout) — 여기까지 오면 풀이 고갈된 것이다. 반납/추가되는 커넥션을 SynchronousQueue로 직접 수령하며, 타임아웃까지 남은 시간을 깎아가며 반복 poll한다. 끝내 못 받으면 SQLTransientConnectionException.

1단계가 이 설계의 정체성이다. 방금 이 스레드가 반납한 커넥션이 threadList에 캐시돼 있으므로, 다음 쿼리에서 같은 커넥션을 공유 상태 접근 없이 되빌린다(연결 affinity). sharedListCopyOnWriteArrayList라 순회는 스냅샷 기반으로 락 없이 진행된다 — 실제 mutation(커넥션 추가/제거)은 대여/반납에 비해 훨씬 드물기 때문에 성립하는 선택이다.

블로킹은 오직 3단계까지 갔을 때만, 즉 풀이 실제로 고갈됐을 때만 발생한다는 점이 중요하다.

반납은 대기자 유무로 갈린다

entry.setState(STATE_NOT_IN_USE);
// 대기자가 있으면 리스트에 두지 않고 직접 건넨다
for (int i = 0; waiters.get() > 0; i++) {
    if (entry.getState() != STATE_NOT_IN_USE || handoffQueue.offer(entry)) {
        return;
    }
    // 짧게 양보(yield) 후 재시도
}
// 대기자가 없으면 threadList에 캐시로 push하고 끝

SynchronousQueue는 용량이 0이라 offer()지금 누군가 poll()로 받고 있을 때만 성공한다. 대기 중인 스레드가 있으면 반납 커넥션은 sharedList를 거치지 않고 대기자에게 곧장 전달된다. 대기자가 없으면 상태만 0으로 되돌려 스레드-로컬에 넣는다. waiters라는 단일 카운터가 대여·반납 양쪽에서 handoff 여부를 함께 결정하는 셈이다.

자주 헷갈리는 지점

내가 잘못 알고 있던 것 두 가지를 짚어 둔다.

  • "ThreadLocal에 캐시된 커넥션은 그 스레드 전용이다." 아니다. 캐시돼 있어도 sharedList에는 그대로 등록돼 있어 다른 스레드가 CAS로 먼저 채갈 수 있다. threadList는 소유권이 아니라 지역성 힌트일 뿐이라, 스캔할 때도 반드시 CAS로 확인한다. 그래서 이중 대여가 구조적으로 불가능하다.
// A가 반납해 A.threadList에 캐시된 커넥션 c를 B가 borrow하는 경우
// B: sharedList 스캔 중 c 발견 → c.compareAndSet(0,1) 성공 → B가 소유
// A: 다음 borrow에서 자기 캐시의 c에 CAS(0,1) 시도 → 이미 1 → 실패 → 건너뜀
  • threadListList<T>가 아니라 List<Object>인 이유. 커스텀 클래스로더 환경(getClassLoader() != systemClassLoader)에서는 커넥션을 WeakReference<T>로 감싸 넣는다(weakThreadLocals). 톰캣 같은 컨테이너에서 앱 클래스로더가 언로드될 때, 오래 사는 요청 스레드의 ThreadLocal이 커넥션을 강참조로 붙들어 클래스로더 누수를 내는 걸 막기 위해서다.

여기에 순회 자료구조까지 깎아낸 게 FastList다. ArrayList.get(int)는 매번 범위 검사를 하고 remove(Object)는 앞에서부터 선형 탐색하지만, FastList는 범위 검사를 생략하고 방금 쓴 항목이 끝에 있을 가능성이 높다는 특성을 노려 remove뒤에서부터 탐색한다. "상태 CAS로 락을 없앤다"는 큰 그림과 같은 방향의 미시 최적화다.

정리

ConcurrentBag은 커넥션 대여를 "공유 리스트 조작"에서 "엔트리 상태에 대한 CAS 한 번"으로 바꾸고, 방금 쓴 커넥션을 스레드-로컬 힌트로 되빌리게 해 공유 자료구조 경합 자체를 줄인다.

빠르다는 평판의 실체는 화려한 알고리즘이 아니라, 뜨거운 경로에서 락과 불필요한 순회를 집요하게 걷어낸 결과였다. 더 파고들 만한 주제로는 하우스키핑 스레드가 STATE_RESERVED로 엔트리를 선점해 maxLifetime/idleTimeout 커넥션을 안전하게 폐기하는 흐름, 그리고 connectionTimeout이 3단계 poll의 남은 시간으로 어떻게 환산되는지가 있다.

참고 자료

  • HikariCP 소스 com.zaxxer.hikari.util.ConcurrentBag, util/FastList.java
  • HikariCP Wiki — "Down the Rabbit Hole" (설계 배경 노트)

0개의 댓글