유성

커넥션 풀의 내부 최적화 (HikariCP) 본문

Architecture

커넥션 풀의 내부 최적화 (HikariCP)

백엔드 유성 2025. 12. 18. 21:27

Spring은 과거 Tomcat JDBC CP, 또는 Apache DBCP를 사용했고

Spring Boot 2.0 에 들어서 HikariCP가 기본 Connection Pool로 자리잡았다.

HikariCP는 왜 빠를까? 바이트코드 수준의 최적화와 Bag of Tricks

트랙재션 격리 수준을 이해하는 것이 데이터베이스의 논리적 무결성을 지키는 일이라면,

커넥션 풀(Connection Pool)을 이해하는 것은 애플리케이션의 물리적 성능 한계를 돌파하는 일이다.

 

Spring Boot 2.0부터 기본 커넥션 풀로 채택된 HikariCP는 스스로를 "Zero-overhead"라고 부른다. 단순히 이름만 그런 것이 아니다.

다른 풀들이 ms 단위로 싸울 때, Hikari는 ns 단위의 오버헤드를 줄이기 위해 자바 바이트코드까지 건들였다.

 

도대체 무엇이 HikariCP를 표준 커넥션 풀로 만들었을까? 그 내부에 숨겨진 'Bag of Tricks'를 해부해 본다.

 

1. 커넥션 풀의 고질적인 딜레마

데이터베이스 커넥션을 맺는 과정은 TCP 핸드셰이크와 인증을 포함하는 꽤나 비싼 작업이다.

이를 미리 만들어 관리하는 것이 커넥션 풀의 역할이지만, 풀 자체도 소프트웨어이기에 관리에 따른 오버헤드가 발생한다.

  • 동시성 제어: 여러 스레드가 동시에 커넥션을 빌려 가려 할 때 발생하는 락(Lock) 경합.
  • 자료구조의 한계: 표준 ArrayList나 Vector를 사용할 때 발생하는 불필요한 인덱스 체크와 순차 탐색 비용.

HikariCP는 이 지점들을 "자바 표준 라이브러리를 믿지 않고 새로 만든다"는 철학으로 해결했다.

그렇기에 Connection Pool 환경에 맞는 자체적인 자료구조를 만들었다.

 

2. FastList : ArrayList의 1% 오버헤드조차 허용하지 않는다

자바의 ArrayList는 훌륭하지만, 커넥션 풀 관리에는 치명적인 약점이 있다. 바로 get(index)이나 remove(object)를 호출할 때마다 매번 수행되는 범위 체크(Range Check)순차 탐색이다.

HikariCP는 이를 커스텀한 FastList를 사용한다.

  • 역순 탐색: JDBC에서 커넥션을 닫을(close) 때는 보통 가장 최근에 사용한 리소스부터 닫는다. FastList는 리스트의 끝에서부터 탐색하여 remove 성능을 극대화했다.
    (삭제할 대상이 뒤쪽에 위치할 가능성이 크다)
  • 범위 체크 제거: 내부적으로 유효성 검사를 과감히 생략하여 CPU 사이클을 아꼈다.

 

3. ConcurrentBag : 락 없는(Lock-free) 커넥션 획득 알고리즘

HikariCP의 심장은 ConcurrentBag이라는 독창적인 자료구조다. 핵심 아이디어는 "최대한 남의 것을 뺏지 않고 내 것을 쓴다"는 것이다.

ConcurrentBag은 다음과 같은 4단계로 커넥션을 찾는다:

  1. ThreadLocal Storage: 이전에 내가 썼던 커넥션이 있는지 먼저 본다. 락이 전혀 필요 없다.
  2. Shared List: 내 것이 없으면 공유 목록에서 찾는다. (이때도 가급적 락을 최소화한다.)
  3. Thread Hand-off: 그래도 없으면 다른 스레드가 반납하기를 큐에서 기다린다.
  4. Create New: 설정된 최대치 이하라면 새로 만든다.

이 구조 덕분에 동시 요청이 폭주해도 스레드 간의 경합이 획기적으로 줄어든다.

public class ConcurrentBag<T extends IConcurrentBagEntry> implements ... {
   private final CopyOnWriteArrayList<T> sharedList; // 2순위
   private final boolean useWeakThreadLocals;

   private final ThreadLocal<List<Object>> threadLocalList; // 1순위
   private final IBagStateListener listener;
   private final AtomicInteger waiters;
   private volatile boolean closed;

   private final SynchronousQueue<T> handoffQueue;
   ...
 }

 

4. 바이트코드 레벨의 최적화 (Invokevirtual vs Invokestatic)

HikariCP 개발자들은 JIT(Just-In-Time) 컴파일러가 코드를 어떻게 최적화하는지 집요하게 연구했다.

  • 메서드 인라이닝(Inlining): 메서드 호출 오버헤드를 줄이기 위해 코드의 길이를 최적화하여 JIT 컴파일러가 메서드를 인라인으로 합쳐버리게 유도했다.
  • Field Access 최적화: 클래스의 필드에 접근하는 횟수조차 줄이기 위해 지역 변수를 최대한 활용했다.
  • 명령어 최적화: 상속과 다형성을 위해 런타임에 메서드를 찾는 invokevirtual 대신 구조적으로 정적 호출인 invokestatic이 수행되도록 필드 접근을 최소화하여 스택 프레임의 오버헤드를 ns 단위까지 깎아냈다.

실제로 HikariCP의 소스코드를 보면, 가독성을 희생하더라도 CPU 캐시 히트율을 높이고 바이트코드 명령어를 한 줄이라도 줄이려는 처절한 노력이 보인다.

 

5. 실무 적용: 적절한 Pool Size 설정 공식

성능 튜닝 시 가장 많이 하는 실수는 "풀 사이즈를 크게 잡는 것"이다. 하지만 커넥션은 늘어날수록 Context Switching 비용과 디스크 I/O 경합만 늘린다.

PostgreSQL 위키와 HikariCP가 권장하는 공식은 의외로 단순하다.

Connections = ((CoreCount * 2) + EffectiveSpindleCount)

 

(여기서 EffectiveSpindleCount는 하드디스크의 회전판 수, SSD의 경우 1에 가까운 값을 설정)

즉, CPU 코어 수의 약 2배 정도가 최적이며, 그 이상은 오히려 성능을 갉아먹을 수 있다. "풀은 작게, 대기열은 넉넉하게"가 원칙이다.

 

그러나, 주관적인 생각으로는 서버의 상태가 아닌 사용자 환경에서의 고민이 필요하다고 생각한다.

하드웨어의 조건만으로 설정을 하는 것이 아닌 peek 시간대의 처리 횟수, 각 pool의 평균 running 시간을 적절히 고려하여 계산해야 한다.

 

예를 들어, 피크 시간대에만 초당 1만번 요청, 대략 10ms(100회/s)의 평균 running 타임 인 경우 나는 다음과 같이 설정을 한다.

  • Core Pool Size : 작게 유지 (40개)
  • Max Pool Size :  10,000(요청량/s) / 100(처리량/s) * 1.1(여유분 10%) = 110개
  • 피크 시간대 이전에 웜업 : 어드민 API 또는 스케줄러를 이용하여 max까지 끌어올림

 

결론: 도구를 대하는 태도에 대하여

우리는 흔히 라이브러리를 블랙박스로 취급한다.

알지 못해도 사용하는데 무리가 없고, 고성능에서도 거의 결함없이 작동하기 때문에 당연할 수 있다.

 

그러나 HikariCP가 증명했듯, "이게 왜 빠를까?" 라는 의문을 가지고 내부를 뜯어보는 과정에서 우리는 자바 메모리 모델, 바이트코드 최적화, 동시성 알고리즘이라는 본질적인 지식을 얻게 된다.

 

기술의 명세를 익히는 것에 그치지 않고 이면의 원리를 파고드는 이유는, 그것이 복잡한 성능의 병목 앞에서 방법을 찾아내는 본질이다.