스프링부트의 공식 문서를 보면 다음과 같은 문구가 있습니다.
We prefer HikariCP for its performance and concurrency. If HikariCP is available, we always choose it.
성능과 동시성의 이유로 선택할 수 있다면 항상 HikariCP를 선택한다고 합니다.
데이터베이스 커넥션 풀은 종류가 HikariCP만 있는 것은 아닙니다. Tomcat JDBC Pool, Apache Commons DBCP2 등 다양한 커넥션 풀들이 존재합니다. HikariCP는 다른 커넥션 풀들과 비교해 얼마나 빠르고, 얼마나 동시성 처리가 뛰어나길래 스프링 부트가 기본 커넥션 풀로 선택하게 되었을까요?
성능 테스트
HikariCP가 얼마나 빠른지 직접 체감해 보면 좋을 것 같습니다. 따라서 다음 세 가지 경우를 비교해서 테스트를 진행해보려고 합니다.
- 매번 커넥션을 생성하는 경우
- Apache Commons DBCP2를 사용하는 경우
- HikariCP를 사용하는 경우
매번 커넥션을 생성하는 경우(DriverManagerDataSource)
우선 매번 커넥션을 생성하는 경우에는 얼마큼의 비용이 필요한지 테스트해 보겠습니다. 테스트는 다음과 같은 코드로 진행됩니다.
@ParameterizedTest
@ValueSource(ints = {100, 1000, 10000, 100000})
void driverManager(int connectionSize) throws SQLException {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
long before = System.currentTimeMillis();
for (int i = 0; i < connectionSize; i++) {
doInsert(dataSource);
}
long after = System.currentTimeMillis();
logger.info("[Driver Manager] connection size: {}, total time : {}(ms)",
connectionSize, after - before);
}
void doInsert(DataSource dataSource) throws SQLException {
try (var connection = dataSource.getConnection();
var pstmt = connection.prepareStatement("insert into public.members (name, age) values ('test', 25)")) {
pstmt.executeUpdate();
}
}
간단하게 H2 데이터베이스를 사용해 DataSource를 설정해 주었습니다. 그리고 100번부터 10만 번까지 시도 횟수를 늘려가 보면서 시간을 측정했고 결과는 다음과 같았습니다. 10만 번의 루프를 도는 경우에는 엄청나게 많은 시간(약 30분)이 걸리는 것을 확인할 수 있었습니다.
100 Connections | 1227ms |
1,000 Connections | 9956ms |
10,000 Connections | 158081ms |
100,000 Connections | 1834591ms |
Apache Commons DBCP2를 사용하는 경우
이번에는 Apache Commons DBCP2를 사용하는 경우를 테스트 해보겠습니다. 커넥션 풀을 사용하는 방식이기 때문에 매번 커넥션을 획득하는 방법보다는 더 효율적일 것 같다고 예상됩니다.
Apache Commons DBCP2를 사용하기 위해서 아래와 같이 추가적인 의존성을 설정해 줍니다.
// build.gradle
implementation 'org.apache.commons:commons-dbcp2:2.10.0'
이후 비슷한 방식으로 테스트 코드를 작성해 줍니다. Apache Commons DBCP2의 경우 BasicDataSource를 사용하면 됩니다.
@ParameterizedTest
@ValueSource(ints = {100, 1000, 10000, 100000})
void apacheCommons(int connectionSize) throws SQLException, InterruptedException {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
Thread.sleep(2000);
long before = System.currentTimeMillis();
for (int i = 0; i < connectionSize; i++) {
doInsert(dataSource);
}
long after = System.currentTimeMillis();
logger.info("[Apache Commons DBCP2] connection size : {}, total time : {}(ms)", connectionSize, after - before);
dataSource.close();
}
커넥션 풀 초기화 시간을 감안하여 커넥션 풀을 생성한 뒤 2초간 기다리도록 설정했습니다. 그리고 결과는 다음과 같았습니다.
100 Connections | 237ms |
1,000 Connections | 114ms |
10,000 Connections | 196ms |
100,000 Connections | 866ms |
확실히 매번 커넥션을 생성하는 방식보다는 성능 측면에서 우월하다는 것을 확인할 수 있었습니다 그렇다면 HikariCP는 어떨까요?
HikariCP를 사용하는 경우
HikariCP 역시 Apache Commons DBCP2를 사용하는 경우와 마찬가지로 비슷하게 테스트 코드를 작성했습니다. 다만 DataSource의 구현체만 HikariDataSource로 바꿔주었습니다.
@ParameterizedTest
@ValueSource(ints = {100, 1000, 10000, 100000})
void hikariCP(int connectionSize) throws SQLException, InterruptedException {
HikariConfig hikariConfig = new HikariConfig();
hikariConfig.setJdbcUrl(URL);
hikariConfig.setUsername(USERNAME);
hikariConfig.setPassword(PASSWORD);
HikariDataSource dataSource = new HikariDataSource(hikariConfig);
Thread.sleep(2000);
...
}
그리고 실험 결과는 다음과 같았습니다.
100 Connections | 35ms |
1,000 Connections | 52ms |
10,000 Connections | 104ms |
100,000 Connections | 637ms |
비교 결과
세 방식의 비교 결과를 정리하면 다음과 같습니다. BasicDataSoruce(Apache Commons DBCP2) 방식보다 HikariDataSource가 커넥션 획득 횟수에 상관없이 평균적으로 좋은 성능을 보여주고 있다는 것을 확인할 수 있었습니다.
DriverManagerDataSource | BasicDataSource | HikariDataSource | |
100 Connections | 1227ms | 237ms | 35ms |
1,000 Connections | 9956ms | 114ms | 52ms |
10,000 Connections | 158081ms | 186ms | 104ms |
100,000 Connections | 1834591ms | 886ms | 637ms |
HikariCP의 경우 초경량으로 용량이 130KB 정도인 라이브러리입니다. 반면 Apache Commons DBCP2는 200KB가량 육박합니다. 어떻게 HikariCP는 이렇게 좋은 성능을 내주고 있는걸까요?
HikariCP의 최적화
1. 바이트코드 최적화
HikariCP는 바이트코드 레벨까지의 최적화가 이루어져 있습니다. 컴파일러가 바이트코드를 만들어내는 부분부터 시작해서 JIT(Just In Time) 컴파일러의 어셈블리어 출력까지 연구해 최적화를 진행했다고 합니다.
2. 마이크로 최적화
전체 성능을 높이기 위해 조그마한 부분이라도 최적화했다고 합니다. 그중 일부는 수백만 번 호출되었을 때 비로소 밀리초 단위로 측정된다고 합니다. 어떤 부분이 최적화되었는지 메이저 한 부분들만 하나씩 살펴보겠습니다.
2.1 ArrayList 대신 FastList 사용
JDBC를 사용하게 되면 1. Connection 객체를 획득하고, 2. Connection 객체를 통해 Statement 객체를 획득한 뒤 작업을 수행하게 됩니다.
여기서 주의 깊게 생각해야 할 점은 하나의 Connection에서 여러 Statement가 처리될 수 있다는 점입니다.
Connection connection = dataSource.getConnection();
PreparedStatement pstmt1 = connection.prepareStatement("INSERT INTO ...");
PreparedStatement pstmt2 = connection.prepareStatement("INSERT INTO ...");
PreparedStatement pstmt3 = connection.prepareStatement("INSERT INTO ...");
pstmt1.executeUpdate();
pstmt2.executeUpdate();
pstmt3.executeUpdate();
이런 Statement 경우에도 결국 데이터 계층에 접근하기 위한 자원을 사용하므로 close 해주어야 합니다. 하지만 사용자의 부주의로 인해 Statement를 close 해주지 못하는 상황이 생길 수 있는데, 우리가 사용하는 대부분의 커넥션 풀들은 Connection이 closed 처리가 되었을 때 해당 Connection과 연관된 Statement를 자동으로 닫아줍니다.
그런데 그런 기능을 제공하기 위해서는 Statement들을 컬렉션으로 관리해야 합니다. close가 호출되었을 때 컬렉션을 순회하면서 존재하는 모든 Statement를 close 처리해주어야 하니까요.
HikariCP를 사용하게 되면 Connection의 구현체는 ProxyConnection을 사용하게 되는데, 해당 클래스의 close 처리 부분을 보면 다음과 같이 closeStatements 메소드를 호출하고 있는 것을 확인할 수 있습니다.
// ProxyConnection.java
@Override
public final void close() throws SQLException
{
// Closing statements can cause connection eviction, so this must run before the conditional below
closeStatements();
...
}
그렇다면 Statement를 관리하는 컬렉션으로는 무엇을 사용해야 할까요? Java에서 제공하는 ArrayList를 사용할 수도 있었지만, HikariCP에서는 자체적으로 구현한 FastList를 사용해 성능 최적화를 이루어냈다고 합니다. 그 이유는 다음과 같습니다.
- ArrayList는 get 메소드가 실행되었을 때 범위 검사를 수행하는데, Statement를 닫을 때에는 어차피 모든 요소를 탐색하므로 범위 검사를 수행할 필요가 없다.
- ArrayList의 remove는 리스트의 시작부터 끝까지 탐색을 수행하는데, 일반적으로 Statement를 닫는 사용 패턴은 '가장 최근에 열린 Statement를 닫는 경우'가 대부분이므로 시작부터 탐색할 필요가 없다.
FastList는 이런 단점들을 보완한 List의 구현체로, 컬렉션의 역할을 하는 건 동일하지만 불필요한 오버헤드를 줄인 버전이라고 생각하시면 좋을 것 같습니다.
2.2 ConcurrentBag 활용
Connection 객체들도 Statement가 관리되는 방식과 유사하게 컬렉션에서 관리되어야 합니다. Connection 획득 요청이 발생할 때마다 유휴 상태의 커넥션이 있는지 체크해서 결과를 반환해야 하기 때문입니다. 그렇지만 일반적인 컬렉션(ArrayList 등)으로는 커넥션들을 관리하기에는 동시성 문제가 발생합니다. 따라서 동시성을 보장해 줄 수 있는 컬렉션을 찾아야 하는데, HikariCP의 경우에는 직접 커스텀한 ConcurrentBag이라는 컬렉션을 사용해서 동시성과 성능을 개선합니다.
HikariCP는 PoolEntry라는 Wrapping 객체로 Connection을 감싸서 ConcurrentBag 컬렉션에 저장한다고 합니다.
HikariCP의 ConcurrentBag는 다음의 특징들을 갖고 있습니다. 세세한 구현 방식에 대해서는 본문의 내용을 벗어날 것 같아서, 단순이 '이런 기능을 제공하는구나' 정도만 이해하고 넘어가면 좋을 것 같습니다.
- 잠금을 사용하지 않고 동시성 처리를 함(lock-free design)
- ThreadLocal 캐싱
- 큐 스틸링(Queue-stealing)
- 직접적인 핸드오프 최적화(direct hand-off optimizations)
2.3 invokevirtual을 invokestatic으로 변경
HikariCP에서는 원래 아래와 같이 싱글톤 팩토리 인스턴스를 통해 Connection, Statement, ResultSet 프록시들을 관리했다고 합니다.
public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
return PROXY_FACTORY.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}
하지만 인스턴스(PROXY_FACTORY)로 메소드를 실행시키는 경우에는 바이트 코드 레벨에서 invokevirtual 명령어가 작동하는데요, 이는 해당 메소드를 실행시킬 때 실제 타입을 확인한 뒤에 실행시키는 명령어를 의미합니다.
HikariCP의 경우 아래와 같이 싱글톤 팩토리 인스턴스를 사용하는 부분을 정적 메소드를 호출하는 부분으로 바꿈으로써 invokevirtual 명령어가 아닌 invokestatic 명령어가 수행될 수 있도록 최적화했다고 합니다. invokestatic 명령어의 경우에는 클래스명으로 바로 메소드에 대해 접근이 가능하기 때문에 JVM에 의해 보다 더 많은 최적화가 가능합니다.
public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException
{
return ProxyFactory.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}
마치며
다양한 DataSource의 성능을 테스트해 보고 HikariCP가 성능 최적화를 어떻게 진행했는지 알아보았습니다. 이전에는 단순히 '바이트코드 수준까지 극단적으로 최적화되어 있다'라고만 알고 있었는데, 실제로 어떻게 최적화되어 있는지 알아보니 HikariCP에 대한 이해도가 더 높아진 것 같습니다.
참고 자료
HikariCP - Down the Rabbit Hole
HikariCP Dead Lock에서 벗어나기 (이론편)
'Database' 카테고리의 다른 글
DDL과 트랜잭션, 그리고 Atomic DDL (0) | 2023.09.21 |
---|