HikariCP가 왜 빠를까?

심규민·2024년 8월 9일
post-thumbnail

Spring Data JPA를 처음 공부하면 항상 듣는 말이 있습니다.
"데이터베이스 커넥션 풀을 만들어서 관리하는데, 커넥션풀 라이브러리 중 HikariCP가 제일 빨라서 사용합니다."

그러면 왜? HikariCP가 빠를까요? 우선 커넥션 풀에 대해서 먼저 살펴보겠습니다.

커넥션 풀이란?(connection pool)

리소스 풀은 생성하는 데 비용이 많이 드는 리소스를 매번 생성하고 제거하는 것을 피하기 위해 사용합니다. 즉, 커넥션 풀은 커넥션의 빈번한 생성과 제거를 방지하기 위해 사용됩니다.

애플리케이션에서 커넥션 풀을 사용하면 일정 수의 커넥션을 런타임에 유지합니다. 또한 쿼리 처리가 필요할 때, 커넥션 풀에 사용 중이지 않은 커넥션을 가져와 쿼리를 처리한 뒤, 쿼리 처리가 완료되면 사용한 커넥션을 제거하지 않고 커넥션 풀에 반환하게 됩니다.

그러면 HikariCP를 활용하여 커넥션 풀을 만들고, 커넥션 풀에서 커넥션을 획득한 뒤 쿼리를 처리하는 과정을 일반적으로 다음과 같습니다.

  1. 데이터소스(datasource)로부터 커넥션을 획득합니다.
  2. 커넥션을 통해 Statement를 생성합니다.
  3. Statement를 통해 쿼리를 실행합니다.
  4. ResultSet을 통해 쿼리 결과를 반환 받습니다.
  5. ResultSet을 해제합니다.
  6. Statement를 해제합니다.
  7. 데이터베이스 커넥션을 해제합니다.

위 과정을 코드를 통해 설명해보겠습니다.

public static void main(String[] args) {
	// HikariCP 설정
	HikariConfig config = new HikariConfig();
	config.setMinimumIdle(1);
	config.setMaximumPoolSize(2);
	config.setConnectionInitSql("select 1");
    config.setDataSourceClassName("com.mysql.cj.jdbc.MysqlDataSource");
	config.addDataSourceProperty("url", "jdbc:mysql://localhost:3306/test");

	// 데이터소스 선언
	DataSource dataSource = new HikariDataSource(config);
	Connection conn = null;
	Statement stmt = null;
	ResultSet rs = null;
	try{
		// 커넥션 획득
		conn = dataSource.getConnection();
		// Statement 생성
		stmt = conn.createStatement();
		// 쿼리 실행
		rs = stmt.executeQuery("select 1");
		// ResultSet 획득
		while(rs.next()){
			System.out.println(rs.getString(1));
		}
	}catch (Exception e){
		System.out.println(e.getMessage());
	}finally {
		// ResultSet 닫기
		close(rs);
		// Statement 닫기
		close(stmt);
		// Connection 닫기
		close(conn);
	}
}

private static void close(AutoCloseable rs){
	try{
		if(rs != null){
			rs.close();
		}
	}catch (Exception e){
		System.out.println(e.getMessage());
	}
}

위 과정에서 datasource.getConnection()을 통해 커넥션을 획득할 때 새 커넥션을 생성하지 않고, 커넥션 풀로부터 커넥션을 획득합니다. 이와 비슷하게 close(conn)을 호출할 때는 커넥션을 제거하지 않고, 커넥션 풀에 커넥션을 반환합니다.

그러면 본론으로 돌아와 HikariCP는 왜 빠를까요?

HikariCP 빠른 이유

HikariCP의 깃허브 위키를 확인해보면, HikariCP는 바이트코드 수준의 최적화를 진행했다는 것을 확인할 수 있습니다. 하지만 최적화에 대한 자세한 이야기는 찾아볼 수 없습니다. 그래서 HikariCP에 대해서 더 찾아본 결과 HikariCP는 FastListConcurrentBag 두 개의 자료구조를 통해 최적화를 진행했음을 알게 됐습니다.

두 자료구조를 차례대로 살펴보겠습니다.

FastList

앞서 예제에서 커넥션을 획득하고 쿼리를 실행한 후 ResultSet, Statement, Connection을 순서대로 해제했습니다. 이 순서를 지켜야 하기 때문에 개발자는 순서를 신경 쓸 수밖에 없습니다.

그러면 Connection이 해제될 때 Statement를 자동으로 해제할 수 있다면 순서를 신경쓰지 않아도 되지 않을까요? 이를 위해서는 ConnectionStatement를 추적해야 합니다.

이를 가장 간단하게 구현하려면, ArrayListStatement를 저장하고, Connection이 종료될 때, ArrayList를 순회하며 Statement를 해제하면 됩니다.

그러나 HikariCP는 StatementArrayList에 관리하기에는 너무 느리다고 판단했습니다. 어느 부분에서 느렸을까요? 답은 ArrayListremove() 메소드가 동작하는 방식에 있습니다.

일반적으로 Connection을 통해 6개의 Statement(s1,s2,s3,s4,s5,s6)가 생성된 후, Statement를 해제할 때는 (s6,s5,s4,s3,s2,s1) 순서대로 해제합니다. 이때 ArrayListremove(Object o) 메소드를 사용하게 되면, 제거할 대상을 찾기 위해 인덱스 0부터 증가하며 탐색한 뒤 찾고자하는 값을 발견하면 해당 값을 제거합니다. 이 탐색 과정에서 불필요한 연산이 발생하게 됩니다.

public class ArrayList<E> ..{
	...
	public boolean remove(Object o) {
		if (o == null) {
			...
		} else {
	    	for (int index = 0; index < size; index++)
				if (o.equals(elementData[index])) {
			    	fastRemove(index);
			    	return true;
				}
		}
		return false;
    }
}

그러면 어떻게 최적화할 수 있을까요?

역순 검색을 통해 최적화 할 수 있습니다.

HikariCP의 최적화 중 하나인 FastListArrayList와 달리 remove(Object o) 메소드가 동작할 때, 순 검색이 아닌 역순 검색으로 동작합니다. 즉, 마지막 원소부터 앞으로가며 검색을 진행한 후 제거합니다.

public final class FastList<T> ...{
	@Override
   	public boolean remove(Object element){
      for (var index = size - 1; index >= 0; index--) {
         if (element == elementData[index]) {
			...
            elementData[--size] = null;
            return true;
         }
      }

      return false;
   }
}

추가로 FastListget(int index) 메소드는 인덱스 범위 검증(out-of-bounds)를 하지 않음으로써 최적화되었습니다. 이는 HikariCP에서 out-of-bounds가 절대 발생하지 않을 것이라 확신하기 때문에 검증을 진행하지 않은 것입니다.

// FastList
public T get(int index){
	return elementData[index];
}
// ArrayList
public E get(int index) {
	RangeCheck(index);
	return elementData[index];
}

즉, HikariCP는 FastList를 설계하는 과정에서 Statement를 해제할 때, 역순으로 해제하는 특징을 활용하여 성능 최적화를 진행한 것입니다.

그 다음으로 ConcurrentBag에 대해서 살펴보겠습니다.

ConcurrentBag

커넥션 풀을 만든다고 했을 때 어떻게 구현할 수 있을까요?

간단하게 접근해보면 blocking queue를 두개 사용해서 구현할 수 있습니다. 예를 들면, 사용하지 않는 커넥션을 보관할 큐(idle queue)와 현재 사용중인 커넥션을 보관하는 큐(busy queue)를 통해 구현할 수 있을겁니다.

커넥션이 필요한 경우, idle queue에서 busy queue로 커넥션을 이동시킨 뒤, 해당 커넥션을 반환하면 됩니다. 그 후 커넥션이 종료되면 busy queue에서 idle queue로 커넥션을 다시 이동시키면 됩니다.

하지만 위 방법을 사용하면 blocking queue의 동기화 과정에 의해 커넥션을 획득하는 과정에서 오버헤드가 많이 발생할 수 있습니다.

// Busy Queue
BlockingQueue<Connection> busy;

// Idle Queue
BlockingQueue<Connection> idle;

그래서 HikariCP는 blocking queue를 사용하지 않습니다. 대신, HikariCP에서 만든 컨테이너인 ConcurrentBag을 사용합니다.

ConcurrentBag의 4가지의 대표 필드는 다음과 같습니다.

  1. sharedList : 데이터베이스 커넥션을 저장하는 공유 큐
  2. threadList : 커넥션을 관리하기 위한 Thread-Local 저장소
  3. waiters : 데이터베이스 커넥션을 획득하기 위해 대기하고 있는 요청 수
  4. handoffQueue : SynchronousQueue을 사용하며, 스레드간 데이터 교환을 위해 사용하는 큐
public class ConcurrentBag<T extends IConcurrentBagEntry> implements AutoCloseable {
	//  모든 커녁션을 저장하기 위해 사용
	private final CopyOnWriteArrayList<T> sharedList;

	// 커넥션들을 저장하는 Thread-Local
	private final ThreadLocal<List<Object>> threadList;
    
    // 커넥션을 획득하기 위해 대기하는 요청 수
    private final AtomicInteger waiters;
    
    // 스레드간 커넥션 교환 위해 사용
    private final SynchronousQueue<T> handoffQueue;
}

ConcurrentBagadd(), borrow(), require() 메소드를 통해서 커넥션을 관리합니다. 우선 add() 메소드를 먼저 살펴보겠습니다.

add()

커넥션 풀에 데이터베이스 커넥션이 추가될 때, ConcurrentBagadd() 메소드를 호출합니다.

add() 메소드는 아래와 같이 구현되어 있습니다.

public void add(final T bagEntry){
	if (closed) {
		throw new IllegalStateException("ConcurrentBag has been closed, ignoring add()");
	}

	// sharedList에 커넥션 추가
	sharedList.add(bagEntry);

	// 커넥션 획득 대기하고 있는 요청이 있다면, handoffQueue를 통해 커넥션을 할당해줌
	while (waiters.get() > 0 
		&& bagEntry.getState() == STATE_NOT_IN_USE 
        && !handoffQueue.offer(bagEntry)) {
			Thread.yield();
	}
}

add() 메소드는 호출되면 커넥션을 sharedList에 추가합니다. 그리고 커넥션을 획득하기 위해 대기하고 있는 요청이 있다면 handoffQueue를 통해 해당 요청으로 커넥션을 할당해줍니다.

다음으로는 borrow() 메소드를 살펴보겠습니다.

borrow()

borrow() 메소드는 ConcurrentBag에 의해 관리되는 커넥션 중, 사용가능한(idle connection)을 반환하는 메소드입니다.

borrow() 메소드의 코드와 핵심 기능은 다음과 같습니다.

public T borrow(long timeout, final TimeUnit timeUnit){
	// thread-local list 에서 우선 커넥션을 획득하기위해 시도합니다.
	final var list = threadList.get();
	for (int i = list.size() - 1; i >= 0; i--) {
		final var entry = list.remove(i);
		final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
		if (bagEntry != null && bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
			return bagEntry;
		}
	}

	// Otherwise, scan the shared list ... then poll the handoff queue
	final int waiting = waiters.incrementAndGet();
	try {
		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;
			}
		}

		listener.addBagItem(waiting);

		timeout = timeUnit.toNanos(timeout);
		do {
			final var start = currentTime();
			final T bagEntry = handoffQueue.poll(timeout, NANOSECONDS);
			if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
               return bagEntry;
            }

            timeout -= elapsedNanos(start);
		} while (timeout > 10_000);

		return null;
	}
	finally {
		waiters.decrementAndGet();
	}
}
  • 우선, Thread-Local에 사용가능한 커넥션이 존재한다면 해당 커넥션을 반환합니다.
  • Thread-Local에 사용가능한 커넥션이 없다면, sharedList로부터 사용가능한 커넥션을 조회하고 반환합니다.
  • sharedList로부터 커넥션을 획득할 수 없다면, 커넥션을 획득할때까지 대기합니다.

HikariCP는 Thread-Local에 저장된 커넥션이 스레드간 경합으로 인해 다른 스레드에도 해당 커넥션이 할당될 수 있습니다. 이러한 상황을 방지하기 위해 CAS(Compare-And-Swap) 메소드를 사용했습니다. 즉, sharedList에서 커넥션을 찾고 할당하는 과정에서 중복 할당을 방지하기 위해 CAS를 활용한 것입니다.

CAS란?
CAS 알고리즘은 동시성 프로그래밍에서 사용되는 락-프리 알고리즘의 일종으로, 변수의 값을 비교한 후 예상하는 값이면 새로운 값으로 교체하는 원자적 연산을 수행합니다. 대표적인 예로 Java의 Atomic 클래스에서 제공하는 compareAndSet() 메소드가 있습니다.

require()

커넥션을 다 사용하고 해제하는 과정에서 ConcurrentBagrequire() 메소드를 호출해야 합니다. 이 메소드는 매우 간단하게 동작하는데, 우선 커넥션의 상태를 STATE_NOT_IN_USE로 변경합니다. 그 후 커넥션을 획득하기 위해 대기하는 요청이 있다면, 해당 요청에 커넥션을 할당해줍니다. 없다면 Thread-Local 저장소에 저장합니다.

public void requite(final T bagEntry){
	bagEntry.setState(STATE_NOT_IN_USE);

	for (var 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();
		}
	}

	final var threadLocalList = threadList.get();
	if (threadLocalList.size() < 50) {
		threadLocalList.add(weakThreadLocals ? new WeakReference<>(bagEntry) : bagEntry);
	}
}

ConcurrentBag의 경우 CAS 알고리즘을 통해 락의 범위를 최소화시켰으며, Thread-Local을 통해 선할당 전략을 사용하여 커넥션을 획득하기 위해 스레드간 경합이 발생하는 것을 줄였습니다. 이를 통해 성능 최적화를 달성할 수 있었습니다.

요약

  • HikariCP는 FastList, ConcurrentBag을 통해 성능 최적화를 진행했습니다.
  • FastListremove() 메소드를 호출하면 첫 번째 값이 아닌 마지막 값부터 검색하며 값을 제거했습니다. 또한 get() 메소드에서 범위 검증을 진행하지 않고 바로 원소에 접근합니다.
  • ConcurrentBag은 커넥션을 Thread-Local에 미리 할당하며, CAS 알고리즘을 통해 중복 할당을 방지했습니다.

참고
https://github.com/brettwooldridge/HikariCP/wiki/Down-the-Rabbit-Hole
https://blog.devgenius.io/hikaricp-a-high-performance-database-connection-pool-8f8f43d01032
https://f-lab.kr/insight/java-synchronization-and-cas

0개의 댓글