본문 바로가기
Spring/Core

[Spring] HikariCp(2)

by 기몬식 2025. 12. 29.

들어가기 전

이전 글에서는 HikariCP가 언제 커넥션 풀을 초기화하고 어떤 판단 과정을 거쳐 커넥션을 생성하는지를 살펴보았습니다. HouseKeeper는 주기적으로 풀의 상태를 점검하고, minimumIdle을 유지해야 한다고 판단되는 경우 직접 커넥션을 생성하지 않고 전용 커넥션 생성 스레드(connection-adder) 에게 작업을 위임합니다.

 

이전 글이 "커넥션을 만들어야 하는가"에 대한 판단의 영역이라면 이번 글에서는 그 결과로 생성된 커넥션들이 실제로 어디에 보관되고 어떤 방식으로 대여되고 반납되는지를 다뤄보려고 합니다. 즉 HikariPool 에서의 커넥션풀의 자료 구조를 담당하는 ConcurrentBag 입니다.

ConcurrentBag

ConcurrentBag은 커넥션 풀처럼 "아주 짧은 시간 동안 객체를 빌렸다가 즉시 반납하는" 시나리오를 초저지연으로 처리하기 위해 HikariCP에서 전용으로 설계한 특수 목적 컬렉션입니다. 대부분의 경우 커넥션은 아주 짧은 시간 동안 빌려졌다가 즉시 반납되며 동일한 스레드가 같은 커넥션을 반복해서 사용하는 경향이 강합니다.(시간 지역성(Temporal Locality))

 

ConcurrentBag은 이 전제를 바탕으로, 공정성이나 일반성을 일부 포기하는 대신 락 경쟁을 최소화하고, 캐시 친화적인 접근을 극단적으로 최적화하는 방향으로 설계되었습니다. 일반적인 큐나 스택이 제공하는 범용 동시성보다는 "커넥션 풀이라는 극도로 편향된 사용 패턴"에 최적화된 성능을 목표로 합니다.

이를 위해 ConcurrentBag은 다음과 같은 설계 철학을 가집니다.


즉, ConcurrentBag은 "모든 상황에서 안전한 컬렉션"이 아니라 "커넥션 풀이라는 특정 문제를 가장 빠르게 풀기 위한 도구"로 설계된 구조입니다.

내부 구조

이제부터는 "HikariCP가 무엇을 저장하는가"가 아니라 "HikariCP 가 어떤 상황을 빠르게 처리하기 위해 어떤 장치를 가지고 있는가"를 봐야 합니다. ConcurrentBag의 주요 필드는 대략 아래 네개를 가집니다.

ThreadLocal 캐시

private final ThreadLocal<List<T>> threadLocalList;

 

이 threadLocalList는 각 스레드마다 자기 전용으로 관리되는 엔트리 리스트로 커넥션을 반납할 때 해당 커넥션은 곧바로 공용 풀로 돌아가는 것이 아니라 반납한 스레드의 ThreadLocal 리스트에 우선 저장됩니다. 이후 동일한 스레드가 다시 커넥션을 요청하면 ConcurrentBag은 어떤 락도 잡지 않은 채 이 ThreadLocal 리스트를 가장 먼저 탐색합니다.

 

ConcurrentBag은 "먼저 반납한 커넥션을 다른 스레드에게 넘겨준다"는 개념보다는 "같은 스레드가 최근에 사용하던 리소스를 다시 쓰는 것이 가장 빠르다"는 현실적인 가정을 선택합니다.

SharedList

private final CopyOnWriteArrayList<T> sharedList;

 

ThreadLocal 캐시에서 재사용 가능한 엔트리를 찾지 못한 경우 ConcurrentBag은 모든 스레드가 공유하는 공용 컬렉션인 sharedList를 참조합니다.


위에서 언급한 컬렉션의 기본 동작 방식과 일관되게 "꺼내는 순서"라는 개념 없이 "지금 이 순간 사용 가능한 엔트리 하나만 찾는 것"에 집중합니다. 리스트 전체를 순회하며 상태가 NOT_IN_USE 인 엔트리를 스캔합니다.

 

for (T bagEntry : sharedList) {
  if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
     // If we may have stolen another waiter's connection, request another bag add.
     if (waiting > 1) {
        listener.addBagItem(waiting - 1);
     }
     return bagEntry;
  }
}

 

이 과정에서 상태 전환은 CAS(compare-and-set) 기반으로 이루어지며 다른 스레드와의 경쟁에서 실패하면 즉시 다음 엔트리로 넘어갑니다. CopyOnWriteArrayList가 사용된 이유 역시 이 맥락에서 커넥션 풀에서 읽기(스캔)는 매우 빈번하지만 쓰기(엔트리 추가/제거)는 커넥션 생성·제거 시점에만 드물게 발생하기 때문입니다.

Waiters

private final AtomicInteger waiters;

 

waiters는 단순히 대기 중인 스레드 수를 세는 카운터가 아닌 실제로는 ConcurrentBag과 HikariPool 사이를 잇는 중요한 상태 신호 역할을 합니다.


borrow 과정에서 ThreadLocal과 sharedList 모두에서 커넥션을 확보하지 못하면 해당 스레드는 waiters를 증가시키고 대기 상태로 들어갑니다.


이 값은 내부 통계를 위한 것이 아닌 HikariPool이 풀 전체 상태를 판단하기 위한 중요 지표로 사용됩니다.

예를 들어 HikariPool$PoolEntryCreator 는 waiters 와 다른 지표들을 확인하여 PoolEntry 를 생성할지 말지를 결정하합니다.

private synchronized boolean shouldContinueCreating() {
 return poolState == POOL_NORMAL && !Thread.interrupted() && getTotalConnections() < config.getMaximumPoolSize() &&
    (getIdleConnections() < config.getMinimumIdle() || connectionBag.getWaitingThreadCount() > getIdleConnections());
}

IBagStateListener

private final IBagStateListener listener;

 

위에서 잠깐 이야기가 나왔지만 ConcurrentBag은 스스로 커넥션을 생성하거나 제거하지 않습니다. 다만 내부 상태 변화가 발생했을 때 이를 HikariPool에 알리는 역할만을 수행합니다.

 

waiters가 증가하거나 더 이상 즉시 제공할 수 있는 엔트리가 없다고 판단되면 ConcurrentBag은 listener를 통해 "지금 풀이 부족하다"는 신호를 보냅니다. 실제 커넥션을 추가로 생성할지 아니면 대기시킬지는 전적으로 HikariPool의 책임이며 이는 "풀 관리 정책"과 "풀 내부 자료구조"를 명확히 분리할 수 있습니다.

PoolEntry

ConcurrentBag은 커넥션 자체를 담지 않고 커넥션의 사용 상태를 관리할 수 있는 래퍼 객체로 엔트리를 담습니다. 하지만 단순한 래퍼 객체가 아닌 내부적으로 실제 JDBC Connection 과 커넥션의 상태를 관리하고 나타내는 상태 머신(state machine) 역할을 하며 여러 스레드가 동시에 접근하며 상태 전환이 빈번하게 일어나는 경쟁 영역에서 이를 관리하는 객체입니다.

int STATE_NOT_IN_USE = 0;
int STATE_IN_USE = 1;
int STATE_REMOVED = -1;
int STATE_RESERVED = -2;

 

PoolEntry 는 lock-free CAS 연산을 위해 상태값을 AtomicIntegerFieldUpdater 를 사용하여 관리하며 원자적으로 전환되는 경쟁 제어 장치입니다. 이 설계 아이디어가 ConcurrentBag 설계의 핵심이며 HikariCP가 일반적인 큐 기반 풀보다 빠를 수 있는 근본적인 이유입니다.

반납과 대여

이제 ConcurrentBag의 가장 중요한 동작 흐름인 커넥션의 반납과 대여의 흐름을 살펴보겠습니다. 앞에서 설명한 ThreadLocal, sharedList, waiters, PoolEntry 상태가 어디서 어떻게 맞물리는지가 한 번에 정리됩니다.

borrow

borrow는 "커넥션을 하나 꺼낸다"는 행위처럼 보이지만, 실제로는 PoolEntry의 상태를 NOT_IN_USE → IN_USE로 전환하는 경쟁 과정입니다. ConcurrentBag은 큐처럼 하나를 pop 하지 않고, 지금 이 순간 사용 가능한 엔트리를 가장 빠르게 확보하는 것을 목표로 합니다.

 

가장 먼저 현재 스레드의 ThreadLocal 리스트를 확인합니다. 이 리스트에는 과거에 이 스레드가 직접 사용 후 반납한 PoolEntry 들이 들어 있습니다.

 

 

ThreadLocal 에서 확보하지 못한 경우 모든 스레드가 공유하는 sharedList를 순회합니다. 위의 상황에서는 현재 sharedList 안에 엔트리는 있으나 상태가 IN_USE이기 때문에 CAS 가 실패한 상황입니다.

 

 

따라서 위와 같이 ThreadLocal 과 sharedList 모두에서 확보하지 못한 경우 현재 스레드는 waiters 를 증가시키고 대기 상태로 진입합니다.

 

 

즉시 빌릴 수 있는 커넥션이 하나도 없어서 이제는 기다리면서 누군가 반납해주길 기다리는 단계에 진입합니다. 지정된 timeout 동안 큐를 통해 커넥션을 기다리며 전달받은 엔트리가 실제로 사용 가능한 상태인지 CAS로 재확인한 뒤에만 소유권을 획득하게 됩니다. 이를 통해 HikariCP는 불필요한 재탐색이나 락 경쟁 없이 대기 중인 스레드와 반납 스레드를 관리합니다.

requite

requite()borrow()에 비해 코드 흐름은 단순해 보이지만 ConcurrentBag이 어떤 철학으로 설계되었는지를 가장 명확하게 드러냅니다. 이 메소드는 단순히 "반납한다"가 아니라 "반납된 엔트리를 지금 누구에게, 어떤 방식으로 넘길 것인가"를 판단하는 역할을 합니다.

 

bagEntry.setState(STATE_NOT_IN_USE);

 

반납의 시작은 상태 전환입니다. 먼저 엔트리 상태를 사용 가능(NOT_IN_USE) 으로 가용 상태로 전환하고 HikariCP는 아직 이 엔트리를 대기 중인 스레드에게 줄지, 나중에 재사용하도록 보관할지 결정하지 않은 상태입니다.

 

 

먼저 대기자(waiters)가 있으면 우선 대기자에게 직접 전달(handoff)을 시도합니다.

 

bagEntry.getState() != STATE_NOT_IN_USE

 

반납 직후의 아주 짧은 순간에도 다른 스레드가 이 엔트리를 선점(CAS)해서 IN_USE로 바꿀 수 있기 때문에 state 를 확인하며 더이상 관여하지 않겠다는 방어 로직을 실행하게 됩니다.

 

handoffQueue.offer(bagEntry)

 

그 후 어딘가에서 poll()로 기다리던 스레드가 이 엔트리를 받을 수 있다는 뜻으로 이 둘 중 하나라도 만족되면 requite()의 역할은 설명을 다한 것이므로 즉시 종료됩니다.

 

하지만 handoffQueue에 바로 넣지 못하는 상황도 충분히 발생할 수 있습니다. 여러 반납 스레드가 동시에 경쟁 중일 수도 있고 대기 스레드가 아직 poll 상태에 들어가지 않았을 수도 있기 때문에 *짧은 경쟁 구간에서 CPU를 과도하게 태우지 않기 위한 백오프(backoff)를 진행합니다.

 

else if ((i & 0xff) == 0xff || (waiting > 1 && i % waiting == 0)) {
  parkNanos(MICROSECONDS.toNanos(10));
}
else {
  Thread.yield();
}

 

대기자가 없어 handoff 루프를 빠져나왔다는 것은 이제 이 커넥션을 당장 필요로 하는 스레드가 없다는 의미합니다.

 

final var threadLocalEntries = this.threadLocalList.get();
if (threadLocalEntries.size() < 16) {
  threadLocalEntries.add(useWeakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
}

 

따라서 대기자가 없는 상황에서는 sharedList(누가 쓸지 모르는 전역 저장소) 보다 threadLocalList(내가 곧 다시 쓸 가능성이 높은 로컬 캐시)에 엔트리를 저장하여 동일 스레드 재사용을 최적화합니다.

정리

ConcurrentBag은 "풀에 돌려놓는다"는 개념보다 "지금 필요한 스레드에게 직접 연결한다"는 개념을 우선합니다. 위에 정리한 내용을 도식화하여 다이어그램으로 정리하면 다음과 같습니다.

 

┌──────────────────────────────┐
│          Thread A             │
│        (borrow 호출)          │
└──────────────┬───────────────┘
               │
               ▼
        ┌──────────────┐
        │ ThreadLocal  │
        │   Cache      │
        └──────┬───────┘
               │ 있음?
               │ yes ───────────────▶ 즉시 획득 (끝)
               │
               │ no
               ▼
        ┌────────────────┐
        │  sharedList    │
        │ (CAS 탐색)     │
        └──────┬─────────┘
               │ 성공?
               │ yes ───────────────▶ CAS 선점 (끝)
               │
               │ no
               ▼
        ┌────────────────────────────┐
        │   waiters++                 │
        │   handoffQueue.poll()       │◀───────────────┐
        │   (timeout 대기)            │                │
        └──────────┬─────────────────┘                │
                   │                                  │
                   │                                  │
                   │                                  │
                   │                                  │
                   │                          ┌──────────────┐
                   │                          │  Thread B    │
                   │                          │ (작업 종료)  │
                   │                          └──────┬───────┘
                   │                                  │
                   │                                  ▼
                   │                         ┌────────────────┐
                   │                         │  requite()     │
                   │                         └──────┬─────────┘
                   │                                  │
                   │                         bagEntry.setState
                   │                         (NOT_IN_USE)
                   │                                  │
                   │                                  ▼
                   │                         waiters > 0 ?
                   │                                  │
                   │                         yes ─────┘
                   │                                  │
                   │                         handoffQueue.offer
                   │                                  │
                   │                                  ▼
                   │◀─────────────── 전달 성공 ───────┘
                   │
                   ▼
          CAS( NOT_IN_USE → IN_USE )
                   │
                   ▼
         Thread A 커넥션 획득 (borrow 종료)

 

오탈자 및 오류 내용을 댓글 또는 메일로 알려주시면, 검토 후 조치하겠습니다.

'Spring > Core' 카테고리의 다른 글

[Spring] HikariCp(1)  (0) 2025.12.18
[Spring] @TransactionalEventListener  (2) 2025.07.23
[Spring] EventListener  (1) 2025.07.10
[Spring] @Transactional 속성  (1) 2023.12.24
[Spring] Spring AOP 동작 방식  (0) 2023.08.30