Skip to main content

Command Palette

Search for a command to run...

HikariCP 내부 구조 — ConcurrentBag과 FastList 위에 세운 Connection Pool

Updated
12 min read

Spring Boot 기본 DataSource인 HikariCP가 왜 "가장 빠른 커넥션 풀"이라 불리는지를 OSS 소스(브랜치 dev) 기준으로 분해합니다. ConcurrentBag이 어떻게 락 없이 핸드오프를 만드는지, FastList가 왜 ArrayList보다 좁은지, 그리고 HouseKeeper가 어떻게 풀의 건강 상태를 유지하는지를 같은 그림 위에서 풀어냅니다. 대상 독자는 한 번이라도 HikariCP 설정을 만져 본 백엔드 개발자입니다.

왜 Connection Pool인가

JDBC Connection 객체 하나를 얻는 비용은 작지 않습니다. 실제 비용은 다음 네 단계의 합입니다.

  1. TCP 3-way handshake (RTT 1회)
  2. TLS handshake (TLS 1.3 기준 RTT 1회, 1.2면 2회)
  3. 데이터베이스 인증 핸드셰이크 (PostgreSQL StartupMessage/Authentication*, MySQL Handshake/HandshakeResponse)
  4. 세션 변수 초기화 (SET TIME ZONE, SET application_name 등)

같은 데이터센터 안이라도 한 커넥션을 새로 만드는 데 수 ms~수십 ms가 걸립니다. 단일 요청 시간이 50ms 안쪽인 OLTP 서비스에서 매 요청마다 이 비용을 내면 응답 시간이 두 배가 됩니다. Connection Pool은 이 비용을 애플리케이션 부팅 시점으로 옮겨 놓고, 실행 중에는 이미 살아 있는 커넥션을 빌려주고 돌려받는 큐로 동작합니다.

문제는 "빌려주고 돌려받는" 부분이 그 자체로 동시성 코드라는 점입니다. 매 요청마다 락 한 번이 끼면 풀이 곧 병목이 됩니다. HikariCP의 거의 모든 설계 결정은 이 락을 어떻게 없애거나 짧게 잡느냐는 한 질문으로 모입니다.

전체 구성도

먼저 외곽부터 짚습니다.

flowchart TB
    App[Application Thread] -->|getConnection| DS[HikariDataSource]
    DS --> Pool[HikariPool]
    Pool -->|borrow / requite| Bag[ConcurrentBag]
    Bag --> Entry[PoolEntry]
    Entry -->|wraps| Real[(Real JDBC Connection)]
    App -->|uses| Proxy[ProxyConnection]
    Proxy -.delegates.-> Real
    Pool -->|background| HK[HouseKeeper]
    Pool -->|background| AddExec[addConnectionExecutor]
    Pool -->|background| CloseExec[closeConnectionExecutor]
    HK -->|schedules| Pool
    AddExec -->|creates| Entry
  • HikariDataSourcejavax.sql.DataSource 구현체로 외부에 노출되는 진입점입니다.
  • HikariPool은 풀의 라이프사이클, 메트릭, 백그라운드 작업을 들고 있는 컨트롤러입니다.
  • ConcurrentBag은 사용 가능한 PoolEntry 컬렉션입니다. HikariCP 성능의 핵심이 여기 있습니다.
  • PoolEntry는 실제 JDBC 커넥션을 감싸는 메타데이터(생성 시각, 마지막 접근 시각, 상태)입니다.
  • ProxyConnection은 호출자에게 반환되는 프록시로, close()가 실제로 풀로 돌려보내는 역할을 합니다.
  • HouseKeeper/addConnectionExecutor/closeConnectionExecutor는 풀을 채우고 비우는 백그라운드 스레드 풀입니다.

이 글의 나머지는 이 구성 요소를 안쪽부터 풀어 갑니다.

ConcurrentBag — 락 없는 빌림과 핸드오프

ConcurrentBag은 HikariCP가 제공하는 사용자 정의 컨테이너입니다. 이름과 달리 "주머니"라기보다 상태 머신이 달린 멀티프로듀서·멀티컨슈머 자유 큐에 가깝습니다.

4개의 필드, 4개의 역할

// ConcurrentBag.java 핵심 필드 (개념도)
private final CopyOnWriteArrayList<T> sharedList;          // 전체 엔트리
private final ThreadLocal<List<Object>> threadList;        // 스레드별 캐시
private final AtomicInteger waiters;                       // 대기 중 스레드 수
private final SynchronousQueue<T> handoffQueue;            // 직접 핸드오프

각 필드의 역할은 다음과 같이 분담됩니다.

  • sharedList는 풀이 들고 있는 모든 PoolEntry를 담는 단일 원본입니다. CopyOnWriteArrayList라 읽기 경합이 없고, 추가/제거는 풀 사이즈가 작아 큰 비용이 아닙니다.
  • threadList는 한 스레드가 직전에 반납한 엔트리를 그 스레드 안에 잠시 캐싱합니다. 같은 스레드가 다시 borrow하면 락 없이 즉시 집어 갑니다. 스레드 캐시 크기는 제한적이며, 캐시 회전이 큰 운영 환경에서는 의도된 trade-off입니다.
  • waiters는 현재 빌리려고 대기 중인 스레드 수입니다. 반납자가 이 값을 보고 곧장 핸드오프할지 캐시할지를 결정합니다.
  • handoffQueueSynchronousQueue로, 용량이 0인 동기 큐입니다. offer/poll이 양쪽이 모두 도착했을 때만 성공합니다. 즉, 반납자가 대기자에게 객체를 직접 건네는 동기화 지점입니다.

borrow 알고리즘

borrow(timeout, unit)은 다음 순서로 진행됩니다.

flowchart TB
    Start([borrow timeout]) --> TL{threadList<br/>has entry?}
    TL -->|yes| CAS1[CAS NOT_IN_USE to IN_USE]
    CAS1 -->|success| Ret([return entry])
    CAS1 -->|fail| TL
    TL -->|no| IncW[increment waiters]
    IncW --> Scan{scan sharedList}
    Scan -->|found| CAS2[CAS NOT_IN_USE to IN_USE]
    CAS2 -->|success| Signal[signal listener if waiters > 1]
    Signal --> DecW[decrement waiters]
    DecW --> Ret
    CAS2 -->|fail| Scan
    Scan -->|none| Poll[handoffQueue.poll timeout]
    Poll -->|got entry| DecW
    Poll -->|timeout| Err([SQLException])

핵심은 모든 상태 전이가 compareAndSet 한 번으로 끝난다는 점입니다. 락이 잡히는 구간이 없습니다.

IConcurrentBagEntry 인터페이스는 네 가지 상태를 정의합니다.

상태 의미
STATE_NOT_IN_USE 0 빌릴 수 있음
STATE_IN_USE 1 이미 빌린 상태
STATE_REMOVED -1 풀에서 제거됨
STATE_RESERVED -2 관리 작업 중 (예: HouseKeeper가 idle 제거 후보로 표시)

STATE_RESERVED는 풀이 엔트리를 "다른 빌리는 스레드의 시야에서 일시적으로 빼는" 용도입니다. 예를 들어 softEvictConnections()가 만료 임박 엔트리를 RESERVED로 두면, 그 동안 다른 스레드는 그것을 빌리지 못합니다. 작업 완료 후 RESERVEDREMOVED로 전이하면 안전하게 제거됩니다.

requite — 반납 시의 분기

반납 경로 requite(bagEntry)는 더 흥미롭습니다. 단순히 "리스트에 다시 넣는" 게 아닙니다.

// 의사 코드
public void requite(T entry) {
    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;                               // 대기자에게 직접 전달
        } else if (i % 256 == 255) {
            LockSupport.parkNanos(MICROSECONDS.toNanos(10));
        } else {
            Thread.yield();
        }
    }
    threadList.get().add(weakReferenceIfEnabled(entry));   // 대기자 없으면 스레드 캐시로
}

waiters 값을 보고 "지금 빌리려는 스레드가 있다"는 사실이 감지되면 handoffQueue.offer()로 직접 건넵니다. 큐를 거치지만 큐의 내부 자료구조는 SynchronousQueue 한 슬롯의 CAS 한 번이므로 사실상 핸드오프입니다. 대기자가 없으면 자신의 ThreadLocal 리스트에 캐싱합니다.

이 분기 덕분에 두 가지 시나리오 모두 효율적입니다.

  • 동일 스레드가 반복적으로 빌리고 돌려주는 경우 (예: 짧은 트랜잭션 루프) → 스레드 캐시에서 락 없이 회전합니다.
  • 여러 스레드가 풀을 공유하며 경합하는 경우 → handoff로 곧장 넘어가 추가 락 없이 흐릅니다.

SynchronousQueue가 왜 빠른가

SynchronousQueue는 일반적인 큐와 달리 내부 저장 공간이 없습니다. put은 짝지을 take가 나타날 때까지 블록되고 그 반대도 같습니다. JDK 구현(Doug Lea의 dual queue)은 CAS 두어 번으로 끝나는 매우 얇은 동기화 지점입니다. HikariCP는 이 특성을 이용해 "다음 반납자가 다음 대기자에게 곧장 객체를 건넨다"는 의미론을 만들었습니다. 별도 락 없이 wakeup-handoff가 가능한 이유입니다.

세 가지 경로의 우선순위

borrow 메서드가 실제로 검사하는 위치는 세 군데입니다. 그 순서가 곧 효율 순서입니다.

  1. threadList (스레드 캐시). 가장 빠릅니다. 같은 스레드가 직전에 반납한 엔트리이므로 CAS 한 번이면 끝납니다. 다른 스레드와 공유되지 않으므로 Volatile 이외의 동기화 비용이 없습니다.
  2. sharedList 스캔. 다른 스레드가 풀어 둔 엔트리가 있으면 그것을 잡습니다. CopyOnWriteArrayList이므로 순회는 락 없이 가능하지만, 각 엔트리에 대해 CAS 시도는 경합이 발생할 수 있습니다.
  3. handoffQueue 대기. 가용 엔트리가 없을 때만 도달합니다. 이 경로에서는 누가 반납해 줄 때까지 블록되며, connectionTimeout이 만료되면 SQLException을 던집니다.

핫 패스가 모두 1번에서 끝난다는 점이 핵심입니다. 한 요청 스레드가 같은 트랜잭션 범위 안에서 반복적으로 풀에 접근하는 패턴(예: Spring @Transactional 내에서 여러 Repository 호출)은 거의 1번 경로만 탑니다. JIT가 인라인하기에도 매우 좋은 형태입니다.

FastList — ArrayList를 대체한 좁은 컨테이너

PoolEntry는 자신이 발급한 Statement를 추적해야 합니다. Connection이 닫힐 때 자신이 열어 둔 Statement도 닫아야 하기 때문입니다. 표준 선택은 ArrayList<Statement>이지만 HikariCP는 FastList라는 자체 구현을 씁니다.

// FastList의 핵심 — JDK ArrayList와 다른 점
public T get(int index) {
    return elementData[index];                 // 범위 검사 없음
}

public boolean remove(Object element) {
    for (int index = size - 1; index >= 0; index--) {
        if (element == elementData[index]) {   // == 비교, equals 아님
            // ... 꼬리에서 머리 방향으로
        }
    }
}

FastList가 좁은 이유는 네 가지입니다.

  1. get()에서 bounds 체크 생략. JDK ArrayList.getrangeCheck(index)가 매번 호출됩니다. HikariCP의 사용 패턴은 자신이 add한 인덱스만 꺼내므로 안전합니다.
  2. remove()가 꼬리부터 스캔. JDBC에서 Statement는 보통 생성한 역순으로 닫힙니다 (try-with-resources). 꼬리부터 찾으면 평균 비교 횟수가 줄고, 마지막 원소면 arraycopy 호출이 0회입니다.
  3. == 비교. Statement는 동일성으로 추적하면 충분합니다. equals() 호출이 사라지면 다형성 디스패치 한 단계가 사라집니다.
  4. Iterator/sort/indexOf 등 미구현. 쓰지 않는 메서드는 UnsupportedOperationException을 던지거나 아예 없습니다. 클래스 파일 크기와 JIT의 인라인 결정이 더 단순해집니다.

이 모든 결정은 "HikariCP는 자기가 어떻게 쓰는지 안다"는 사실에 의존합니다. 표준 컬렉션은 범용 계약을 지키기 위해 안전망을 둬야 하지만 풀 내부 자료구조는 그럴 필요가 없습니다.

ProxyConnection — 호출자에게 돌아가는 가짜

사용자가 받는 Connection 객체는 실제 JDBC 드라이버 객체가 아닙니다. HikariCP가 만든 ProxyConnection입니다. 가장 중요한 메서드는 단연 close()입니다.

@Override
public void close() throws SQLException {
    // 실제로 커넥션을 닫지 않는다 — 풀로 돌려보낸다
    closeStatements();                         // FastList에 추적된 stmt 정리
    if (delegate != ClosedConnection.CLOSED_CONNECTION) {
        leakTask.cancel();
        try {
            if (isCommitStateDirty && !isAutoCommit) {
                delegate.rollback();           // 미커밋 트랜잭션 안전망
            }
            resetConnectionState();            // autoCommit, isolation 등 복원
        } catch (SQLException e) {
            throw checkException(e);
        } finally {
            delegate = ClosedConnection.CLOSED_CONNECTION;
            poolEntry.recycle(now);            // 풀로 반납
        }
    }
}

recycle()ConcurrentBag.requite()로 이어집니다. 즉, 호출자 코드 입장에서는 그냥 try-with-resources의 자연스러운 종료지만, 내부적으로는 다음 대기자에게 직접 핸드오프가 됩니다.

ProxyFactory의 바이트코드 트릭

ProxyConnection/ProxyStatement/ProxyResultSet은 매번 호출되는 핫 패스이므로, 메서드 디스패치 한 단계 줄이는 차이가 누적되면 큽니다. HikariCP는 빌드 시 Javassist로 프록시 클래스를 미리 생성합니다.

빌드 시점에 다음을 수행합니다.

  • 인터페이스의 모든 메서드를 순회하며 delegate.{method}(args)로 위임하는 메서드 본문을 자동 생성합니다.
  • getstatic 호출을 invokestatic으로 바꿔 JIT가 인라인하기 쉽게 만듭니다.
  • 메서드를 작게 유지해 JIT 인라이닝 가능성을 높이는 부수 효과가 있습니다.

런타임 reflection 비용은 0이고, 인라인 가능성을 우선으로 두고 생성된 코드라 메서드 디스패치가 거의 무료에 가깝습니다.

HikariPool — 라이프사이클 컨트롤러

HikariPoolConcurrentBag 위에 다음을 얹습니다.

getConnection 흐름

flowchart TB
    Start([getConnection]) --> Borrow[bag.borrow connectionTimeout]
    Borrow -->|null| Timeout([SQLException 30001])
    Borrow -->|entry| Bypass{now - lastAccessed<br/>< aliveBypassWindowMs?}
    Bypass -->|yes| Schedule[schedule leak task]
    Bypass -->|no| Validate[isConnectionDead?]
    Validate -->|alive| Schedule
    Validate -->|dead| Close[close + retry]
    Close --> Borrow
    Schedule --> Return([ProxyConnection])

핵심 디테일은 aliveBypassWindowMs입니다. 직전에 사용된 지 얼마 안 된 커넥션은 검증을 건너뜁니다. JDBC Connection.isValid() 호출도 DB 왕복이 한 번 발생하기 때문에 매 요청마다 호출하면 풀의 의의가 무색해집니다.

Leak Detection

leakDetectionThreshold (기본 0=비활성)을 설정하면 getConnection에서 ScheduledFuture를 하나 등록합니다. 지정 시간 내에 close()가 호출되지 않으면 등록된 스레드의 스택트레이스를 로그로 남깁니다. 트랜잭션 누수 디버깅 시 거의 유일한 단서입니다.

spring.datasource.hikari.leak-detection-threshold: 30000   # 30초

풀 채우기 / 비우기 백그라운드

  • addConnectionExecutor: 단일 스레드 풀. PoolEntryCreator가 여기서 돌면서 minimumIdle 미달이거나 대기자가 있는데 풀이 비었을 때 새 커넥션을 만듭니다. 실패 시 10ms → 20ms → 40ms ... 최대 5초까지 지수 백오프합니다. 단일 스레드인 이유는 DB가 동시 연결 시도에 약할 수 있기 때문입니다.
  • closeConnectionExecutor: 만료/제거된 커넥션을 실제로 닫는 작업을 백그라운드로 미룹니다. Connection.close()가 의외로 비쌀 수 있기 때문에 호출자 스레드에서 분리합니다.
  • houseKeepingExecutorService: 단일 스레드 ScheduledExecutorService. HouseKeeper를 30초마다 실행합니다.

HouseKeeper의 책임

HouseKeeper는 다음을 차례로 수행합니다.

  1. 클럭 이상 감지. 직전 실행 시각과 현재 시각의 차이가 예상보다 크면(혹은 음수면) 로그로 경고합니다. NTP 점프나 가상화 환경에서 시계가 튀는 경우를 보기 위함입니다.
  2. idle 초과 제거. idleTimeout을 넘긴 엔트리 중 minimumIdle을 보존하는 한도 내에서 제거합니다.
  3. fillPool(). minimumIdle 미달이면 addConnectionExecutor에 작업을 던집니다.
  4. 동적 설정 반영. JMX/Programmatic으로 setMaximumPoolSize 등이 호출되었으면 다음 사이클부터 반영합니다.

이와 별개로 각 엔트리에는 자기 스케줄이 두 개 더 붙습니다.

  • MaxLifetimeTask. maxLifetime (기본 30분) 시점에 자동 evict. 모든 엔트리가 같은 순간에 만료되어 동시 재생성이 일어나는 것을 막기 위해 ±2.5%의 jitter를 넣습니다.
  • KeepaliveTask. keepaliveTime (기본 0=비활성) 마다 idle 상태 엔트리에 isValid() 또는 connectionTestQuery로 ping. DB·LB·방화벽 idle timeout보다 충분히 짧게 설정해야 죽은 커넥션이 다음 borrow 때까지 잠자고 있다가 사용자 요청을 망치는 상황을 막을 수 있습니다.

Pool Sizing의 철학

HikariCP 위키의 "About Pool Sizing"은 도구 사용보다 메시지가 강한 페이지입니다. 핵심은 PostgreSQL 커뮤니티에서 유도한 공식입니다.

connections = ((core_count * 2) + effective_spindle_count)

4코어 + SSD 한 개라면 약 9~10이 시작점입니다. maximumPoolSize의 기본값 10도 이 공식과 무관하지 않습니다.

"왜 더 키우면 안 되는가"라는 질문에 대한 답은 컨텍스트 스위치 비용입니다. DB 서버의 CPU 코어가 N개라면 동시에 진짜 진행 중인 쿼리는 최대 N개입니다. 그 이상은 큐잉되거나 컨텍스트 스위치로 낭비됩니다. 풀이 클수록 DB가 받는 동시 요청은 늘지만 처리량은 늘지 않거나 줄어듭니다.

권장은 "스레드는 풀이 빌 때까지 기다리게 두라"입니다. 풀 앞에 줄을 세우면 DB가 안정적이고, 풀 뒤에 줄을 세우면 DB가 무너집니다.

한계와 운영 노트

HikariCP의 설계 결정이 모든 워크로드에 정답인 것은 아닙니다. 몇 가지 한계를 짚습니다.

  • ThreadLocal 누수 위험. 과거 버그(이슈 #148)로 Tomcat/Spring Reload 환경에서 클래스로더 누수가 보고된 적이 있습니다. 현재는 weak reference 옵션(com.zaxxer.hikari.useWeakReferences)으로 대응하지만, 핫리로딩이 잦은 환경이면 옵션을 켜두는 게 안전합니다.
  • CopyOnWriteArrayList의 추가/제거. sharedList는 풀 크기가 보통 수십 이하라 OK지만, 수백 단위면 추가/제거 비용이 무시 못 합니다. HikariCP가 권장하는 사이즈 정책을 따르면 자연히 회피됩니다.
  • DB 다운 시 동작. connectionTimeout 동안 addConnectionExecutor가 백오프하며 재시도합니다. 그 시간 동안 호출자는 모두 SQLTransientConnectionException을 받습니다. 회로 차단기(Resilience4j, Spring Cloud Circuit Breaker)를 상위에 두는 게 일반적입니다.
  • statement caching 없음. HikariCP는 풀만 합니다. PreparedStatement 캐싱은 드라이버 측 설정(cachePrepStmts, prepStmtCacheSize for MySQL)을 사용해야 합니다.

Spring Boot에서의 기본값

Spring Boot 3.x는 spring-boot-starter-jdbc 또는 spring-boot-starter-data-jpa를 의존성에 두면 클래스패스에서 HikariCP를 자동으로 감지해 기본 DataSource로 등록합니다. 설정은 application.ymlspring.datasource.hikari.* 키로 노출됩니다.

spring:
  datasource:
    url: jdbc:postgresql://db:5432/app
    username: app
    password: ${DB_PASSWORD}
    hikari:
      pool-name: AppHikariPool
      maximum-pool-size: 10
      minimum-idle: 10
      connection-timeout: 3000
      idle-timeout: 600000
      max-lifetime: 1800000
      keepalive-time: 1800000
      leak-detection-threshold: 30000

흔히 마주치는 운영 이슈와 설정의 연결고리는 다음과 같습니다.

증상 원인 후보 설정
트래픽 피크 시 SQLTransientConnectionException: Connection is not available 풀이 부족하거나 슬로우 쿼리로 점유됨 maximumPoolSize 상향, 쿼리 튜닝, 회로 차단기
새벽 첫 요청에서 종종 Connection is closed DB/방화벽이 idle 커넥션을 끊었는데 풀이 모름 keepaliveTime 설정, maxLifetime을 DB의 wait_timeout 미만으로
트랜잭션 누수 의심, 풀이 점점 비어 감 Connection.close() 누락 leakDetectionThreshold 설정 후 로그 확인
동일 시각에 다수 커넥션 동시 만료 → 일시적 latency 스파이크 maxLifetime이 같은 시점에 만료 HikariCP의 jitter는 ±2.5%만 → maxLifetime 자체를 짧게 + idle 분산

minimumIdlemaximumPoolSize와 같게 두는 패턴이 자주 권장됩니다. 풀 크기를 늘렸다 줄이는 동적 풀은 만들기 시점의 latency가 사용자 요청 경로에 보이기 쉽고, 풀 크기가 작으면 동적으로 굴려도 큰 절약이 안 되기 때문입니다.

비교 — 왜 HikariCP가 선택받았는가

JVM 진영의 주요 풀 라이브러리는 다음 정도가 있습니다.

  • Apache Commons DBCP/DBCP2: Tomcat의 오랜 기본. GenericObjectPool 기반으로 wait/notify + 락. 안정적이지만 락 경합에서 손해.
  • Tomcat JDBC Pool: Tomcat 7부터 DBCP를 대체한 자체 구현. ConcurrentLinkedQueue 기반으로 락을 줄임. HikariCP 등장 전까지 가장 빠른 축.
  • c3p0: 매우 오래된 구현. 기능은 많지만 락 경합이 심함. 현재는 거의 레거시.
  • Vibur DBCP: 락 없는 설계를 시도한 후발주자. 일부 벤치마크에서 HikariCP에 근접.

HikariCP의 차별점은 두 가지로 모입니다. 첫째, 모든 핫 패스에서 락을 없애고 CAS로 대체했습니다. 둘째, 위에서 본 것처럼 표준 컬렉션·표준 프록시 패턴까지 직접 다시 작성해 일반성 비용을 잘라냈습니다. Spring Boot 2.0이 기본 DataSource를 Tomcat JDBC에서 HikariCP로 바꾼 결정은 이 두 가지 차이를 인정한 결과입니다.

결론 — "빠르다"의 정체

HikariCP가 빠른 이유는 한 줄로 요약하기 어렵습니다. 그러나 거의 모든 디테일이 한 방향을 가리킵니다.

  • 상태 머신 + CAS로 락 구간을 0에 가깝게 만들고
  • ThreadLocal 캐시 + SynchronousQueue 핸드오프로 같은 스레드 회전과 스레드 간 전달을 모두 짧게 끝내고
  • **자체 컬렉션(FastList)**과 빌드 시 프록시 생성으로 표준 라이브러리의 일반성 비용을 잘라내고
  • 잘 설계된 백그라운드 작업으로 호출자 스레드를 풀 운영 부담에서 분리한다

설정 화면에서 보면 maximumPoolSizeconnectionTimeout 같은 평범한 키만 보이지만, 그 아래에는 동시성 라이브러리 한 권 분량의 결정이 쌓여 있습니다. HikariCP가 Spring Boot의 기본 DataSource로 들어간 것이 우연이 아닌 이유입니다.

다음 글에서는 이 풀 위에 얹히는 JDBC 자체의 트랜잭션 모델, 그리고 Spring @Transactional이 풀에서 커넥션을 빌리는 정확한 시점을 살펴보면 한 그림이 닫힐 것입니다. getConnection()이 호출되는 그 한 줄의 위쪽과 아래쪽이 어떻게 연결되는지가 다음 주제입니다.

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ