애플리케이션의 데이터베이스 성능은 커넥션 풀의 효율성과 직결된다. 커넥션 풀은 데이터베이스 연결을 효율적으로 관리하는 핵심 요소다. 하지만 이를 제대로 이해하지 못한 채 기본 설정에 의존하는 경우도 있을 것이다.
Spring은 HikariCP라는 뛰어난 성능과 신뢰성을 가진 커넥션 풀을 기본적으로 제공한다. 그러나 기본 설정만으로는 모든 상황에서 최적화된 성능과 안정성을 보장하기 어렵다.
이 글에서는 HikariCP의 주요 개념과 작동 원리를 살펴보고, 적절한 풀 사이즈를 설정하는 방법에 대해 논의한다. 또한, 커넥션 풀과 관련된 대표적인 문제와 이를 방지하는 설정 전략도 함께 다룬다. HikariCP를 최적화하여 애플리케이션의 성능과 안정성을 극대화하는 방법을 알아보도록 하자.
1. DB Connection
DB Conneciton이란 애플리케이션과 데이터베이스 서버가 통신할 수 있도록 연결하는 것을 의미한다. Pool을 사용하지 않고 DB를 연결하고 사용할 때 아래와 같은 과정을 거치게 된다. (Java 기준)
- DB 연결 정보를 담은 Connection 객체를 생성한다.
- Connection 객체를 사용해 DB 관련 작업을 수행한다.
- DB 작업을 마치면 Connection 객체를 명시적으로 닫는다. connection.close()
하지만 이런 방식의 작업은 비용이 많이 발생하고 비효율적이다.
- 새로운 Connection을 생성할 때마다 DB 서버와 네트워크 연결을 설정해야 한다.
- Connection 객체를 생성하기 위해 서버의 리소스를 소모해야 한다.
이런 문제를 해결하기 위해 Connection Pool이라는 기술이 나오게 된다.
Connection Pool
DB Connection Pool이란 데이터베이스와의 연결을 효율적으로 관리하기 위해 미리 일정 수의 연결을 생성해 두고, 필요할 때 가져다 사용한 뒤 반환하도록 설계된 기술이다. 이를 통해 연결 생성 및 해제에 드는 오버헤드를 줄이고, 애플리케이션의 성능을 향상시키고, 자원을 아낄 수 있다.
HikariCP
HikariCP는 위에서 설명한 DB Connection Pool을 지원하는 라이브러리 중 하나로, 가볍고 빠른 성능을 제공하기에 많은 개발자들이 사용했고, Spring Boot 2.0부터는 기본 데이터베이스 pooling 시스템이 Tomcat Pool에서 HikariCP로 변경되었다. 아래는 Spring Boot 2.0 Release Notes 중 HikariCP에 관련된 내용이다.
HikariCP
The default database pooling technology in Spring Boot 2.0 has been switched from Tomcat Pool to HikariCP. We’ve found that Hakari offers superior performance, and many of our users prefer it over Tomcat Pool.
이말인즉슨, Spring Boot 2.0 이상에서는 따로 설정을 해주지 않아도 자동으로 HikariCP Connection Pool을 사용한다는 얘기이다.
HikariCP Configuration
HikariCP에 대한 설정은 HikariCP Github에 상세하게 설명되어 있으니, 중요한 몇 가지 설정만 살펴보도록 하자.
필수 설정
⚙️ jdbcUrl
- DriverManager 기반의 JDBC 드라이버를 구성하기 위해 사용하는 설정이다. DB의 주소값을 넣어준다.
- 만약 "구식" 드라이버를 함께 사용한다면 driverClassName 을 설정해줘야 한다. 일단 driverClassName 을 지정하지 않고 시도해본 다음, 에러가 발생한다면 그 때 찾아서 넣는 것이 좋다.
기초 설정
⚙️ username
- DB의 사용자의 아이디
⚙️ password
- DB 사용자의 패스워드
⚙️ driverClassName
- 보통 HikariCP는 jdbcUrl 만으로 DriverManager를 통해 드라이버를 해결하려 시도하지만, 일부 오래된 드라이버는 driverClassName 를 명시해줘야 한다.
- 따라서 드라이버를 찾을 수 없다는 명확한 오류 메시지가 표시되지 않는 한 이 속성은 생략해도 괜찮다.
⚙️ autoCommit
- Connection을 반환할 때 auto commit을 할 것인지에 대한 설정하는 요소다.
- Default는 true이다.
⚙️ connectionTimeout
- 사용자가 Connection Pool로부터 Connection을 받기 위해 기다리는 시간을 설정하는 요소다.
- 단위는 milliseconds이며, 실제 런타임에서 기다리는 시간이 초과되면 SQLException이 발생한다.
- Default는 30,000ms(30초)다.
⚙️ idleTimeout
- Connection Pool에서 Connection이 유휴 상태로 존재할 수 있는 최대 시간을 설정한다.
- 이 설정은 minimumIdle 이 maximumPoolSize 보다 작은 값으로 정의될 때만 적용된다.
- Connection Pool의 사이즈가 minimumIdle 에 도달하면 유휴 커넥션은 더 이상 제거되지 않는다.
- Default는 600,000ms(10분)이다.
⚙️ keepaliveTime
- 커넥션이 데이터베이스나 네트워크 인프라에 의해 타임아웃되는 것을 방지하기 위해 커넥션을 유지하려고 시도하는 빈도를 설정할 수 있는 요소다.
- 이 값은 maxLifetime 보다 작아야 하며, 이 작업은 유휴 커넥션에서만 발생한다.
- 주어진 커넥션에 대해 'keepalive'를 실행할 때가 되면, 해당 커넥션은 풀에서 제거된 후 'ping'을 실행하고, 다시 풀로 반환된다. 여기서 'ping'이란 JDBC4의 isValid() 메소드를 호출하거나, connectionTestQuery를 실행하는 것 중 하나다.
- 최소 허용 값은 30,000ms(30초)이며, 몇 분 단위의 값이 적합하다고 하다.
- Default는 120,000ms로 12분이다.
⚙️ maxLifetime
- 커넥션의 최대 수명을 제어하기 위해 사용하는 값이다. 사용 중인 커넥션은 제거되지 않으며, 커넥션이 닫혀있을 때만 제거된다.
- 이 값은 데이터베이스나 인프라에서 설정한 연결 제한 시간보다 몇 초 짧게 설정하는 것이 좋다. 참고로 MySQL에서는 SHOW VARIALBES LIKE 'wait_timeout' 을 통해 연결 시간을 확인할 수 있다. (기본 8시간으로 설정되어 있다.)
- 최소 허용 값은 30,000ms(30초)이며, 기본값은 1,800,000ms(30분)이다.
⚙️ connectionTestQuery
- Connection.isValid()가 없는 레거시 드라이버를 위한 설정으로 특정 쿼리를 설정해 'ping'을 할 때 해당 쿼리를 사용하도록 할 수 있다.
⚙️ minimumIdle
- 최소 유휴 커넥션 수를 설정하는 요소로, maximumPoolSize 보다 작아야 한다.
- HikariCP는 기본적으로 가능한 한 빠르고 효율적으로 추가 커넥션을 생성하려고 시도하지만, 이를 위한 시간과 리소스가 발생할 수밖에 없으므로 최대 성능을 보장하기 위해선 적정한 고정 크기 커넥션 풀로 작동하는 것이 좋다.
- 기본값은 maximumPoolSize 과 동일하다.
⚙️ maximumPoolSize
- 애플리케이션 데이터베이스 커넥션 풀의 최대 사이즈를 설정하는 요소이다.
- 기본값은 10이다.
⚙️ poolName
- DB Pool에 사용자 정의 이름을 넣고 싶을 때 사용하는 설정이다.
- 기본 값은 auto-generated이다.
Java Bean 예시
Hikari Connection Pool을 Spring에서 코드를 통해 Bean으로 등록하는 방법은 두 가지가 있다. HikariDataSource 객체를 생성하고 setter를 활용하는 방법과 HikariConfig 객체를 활용하는 방법이 있다.
- HikariDataSource Setter 방식
@Configuration
public class DatabaseConfig {
@Bean
public DataSoucre dataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
ds.setUsername("bell");
ds.setPassword("1234");
ds.setMaximumPoolSize(20);
ds.setMinimumIdle(15);
ds.setConnectionTimeout(5000);
return ds;
}
}
- HikariConfig 객체 활용 방식
@Configuration
public class DatabaseConfig {
@Bean
public DataSoucre dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
config.setUsername("bell");
config.setPassword("1234");
config.setMaximumPoolSize(20);
config.setMinimumIdle(15);
config.setConnectionTimeout(5000);
return new HikariDataSource(config);
}
}
Application.yaml Configuration 예시
위와 같이 코드를 통해 Config 객체를 만들어 HikariConfig를 명시적으로 선언할 수도 있지만, 다른 방법을 사용할 수도 있다. 스프링 부트에서는 기본적으로 application.yaml에서 HikariCP 풀을 설정할 수 있게 해준다.
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
username: bell
password: 1234
hikari:
minimum-idle: 5 # 최소 유지할 유휴 커넥션 수
maximum-pool-size: 10 # 최대 커넥션 풀 크기
idle-timeout: 30000 # 유휴 커넥션을 유지할 시간 (ms)
max-lifetime: 1800000 # 커넥션의 최대 수명 (ms)
connection-timeout: 30000 # 커넥션 요청 대기 시간 (ms)
pool-name: HikariPool # 풀 이름 설정
2. HikariCP 내부 동작 원리 맛보기
HikariCP Github Wiki 중 Down the Rabbit Hole(토끼굴로 들어가기)라는 글을 보면 HikariCP가 어떤 식으로 최적화를 진행했는지 자세히 살펴볼 수 있다.
성능과 Connection Pool을 생각할 때, 많은 사람들은 풀 자체가 성능 공식에서 가장 중요한 부분이라고 착각할 수도 있다. 하지만 반드시 그렇다고 말할 수는 없는게, getConnection() 호출의 횟수는 다른 JDBC 작업에 비해 적은 편이다. 성능 향상의 상당 부분은 Conneciton, Statement 등을 감싸는 위임 객체 delegates의 최적화에서 이뤄진다.
거의 측정되지 않을 정도의 미세한 최적화가 많이 포함되어 있지만, 이러한 최적화가 결합되면 전체적인 성능이 크게 향상시키고 있다고 한다.
바이트코드
HikariCP는 지금처럼 빨라지기 위해 바이트코드 수준의 엔지니어링을 넘어섰다고 한다. Just-In-Time Compiler가 개발자를 도울 수 있도록 모든 트릭을 동원했다. 컴파일러의 바이트코드 출력과 JIT의 어셈블리 출력까지 연구해 주요 루틴을 JIT의 인라인 임계값 이하로 제한한다. 상속 계층 구조를 단순화하고, 멤버 볌수를 섀도잉하며, 형 변환을 제거했다.
ArrayList
가장 중요한 최적화 중 하나는 ProxyConnection에 의해 열린 Statement 인스턴스를 추적하기 위해 사용되던 ArrayList<Statement>를 제거한 것이다. 기존에는 이 컬렉션에서 해야할 작업이 많았다. Statement가 닫힐 때 해당 Statement 제거해야 한다. Connection이 닫히면 반복을 통해 열려 있는 모든 Statement를 닫야하고, 컬렉션을 비워야 했다.
ArrayList의 get(int index)는 호출될 때마다 범위 검사를 하는데 이는 불필요한 오버헤드다. remove(Object)는 컬렉션의 head부터 tail까지 스캔을 수행해야 하는데, 일반적인 JDBC 프로그래밍의 일반적인 패턴은 사용 후 Statement를 즉시 닫거나, 열린 순서의 역순으로 닫는 경우가 많다. 이런 경우 뒤에서부터 하는 스캔이 유리하다. 따라서 범위 검사를 제거하고, 스캔을 뒤에서부터 하는 FastList라는 커스텀 클래스를 만들어 사용하고 있다고 한다.
ConcurrentBag
HikariCP는 고성능 Connection Pool 관리를 위해 ConcurrentBag이라는 구조체를 설계했다.
잠금 없는 컬렉션 lock-free collection이다. C# .NET의 ConcurrentBag 클래스에서 착안했지만, 내부 구현은 상당히 다르다고 한다. ConcurrentBag은 아래와 같은 기능을 제공한다.
- 잠금 없는 설계
- ThreadLocal 캐싱
- Queue-stealing
- Direct hand-off 최적화
이 기능을 통해 높은 수준의 동시성과 극도로 낮은 대기 시간, false-sharing 발생을 최소화한다.
참고: False sharing
분산된 일관성 캐시를 사용하는 시스템에서 가장 작은 리소스 블록 크기 단위로 캐시 메커니즘이 데이터를 관리할 때 발생할 수 있는 성능 저하 패턴
시스템의 한 참여자가 다른 참여자에 의해 변경되지 않은 데이터를 주기적으로 접근하려고 할 때, 해당 데이터가 변경되고 있는 데이터와 동일한 캐시 블록을 공유하고 있다면, 캐싱 프로토콜은 논리적으로는 불필요함에도 불구하고 첫 번째 참여자가 전체 캐시 블록을 다시 로드하도록 강제할 수 있다.
캐싱 시스템은 이 블록 내에서 이루어지는 활동에 대해 알지 못하며, 실제로 리소스를 공유 접근할 때 발생하는 오버헤드와 동일한 캐싱 시스템의 오버헤드를 첫 번째 참여자가 감당하도록 만든다.
invokevirtual vs invokestatic
HikariCP는 Connection, Statement, ResultSet 인스턴스를 위한 프록시를 생성하기 위해 처음에는 싱글턴 팩토리 singleton factory를 사용했다.
- ProxyConnection의 경우, 정적 필드에 저장되어 있었다.(PROXY_FACTORY)
public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {
return PROXY_FACTORY.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}
- 기존의 싱글톤 팩토리를 사용하면 아래와 같은 바이트코드가 만들어진다.
public final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
stack=5, locals=3, args_size=3
0: getstatic #59 // Field PROXY_FACTORY:Lcom/zaxxer/hikari/proxy/ProxyFactory;
3: aload_0
4: aload_0
5: getfield #3 // Field delegate:Ljava/sql/Connection;
8: aload_1
9: aload_2
10: invokeinterface #74, 3 // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
15: invokevirtual #69 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
18: return
- 먼저 정적 필드 PROXY_FACTORY의 값을 가져오기 위해 getstatic 호출이 있으며, 마지막으로 ProxyFactory 인스턴스에서 getProxyPreparedStatement()를 호출하기 위한 invokevirtual 호출이 이뤄지는 것을 볼 수 있다.
- HikariCP는 Javassist가 생성한 싱글턴 팩토리를 제거하고, Javassist가 메소드 본문을 생성하는 정적 메소드를 가진 final 클래스로 대체했다.
Javassist
Java 애플리케이션에서 바이트코드 조작(Bytecode Manipulation)을 쉽게 수행할 수 있도록 도와주는 라이브러리
public final PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {
return ProxyFactory.getProxyPreparedStatement(this, delegate.prepareStatement(sql, columnNames));
}
- 이제 바이트코드는 아래와 같이 만들어진다.
private final java.sql.PreparedStatement prepareStatement(java.lang.String, java.lang.String[]) throws java.sql.SQLException;
flags: ACC_PRIVATE, ACC_FINAL
Code:
stack=4, locals=3, args_size=3
0: aload_0
1: aload_0
2: getfield #3 // Field delegate:Ljava/sql/Connection;
5: aload_1
6: aload_2
7: invokeinterface #72, 3 // InterfaceMethod java/sql/Connection.prepareStatement:(Ljava/lang/String;[Ljava/lang/String;)Ljava/sql/PreparedStatement;
12: invokestatic #67 // Method com/zaxxer/hikari/proxy/ProxyFactory.getProxyPreparedStatement:(Lcom/zaxxer/hikari/proxy/ConnectionProxy;Ljava/sql/PreparedStatement;)Ljava/sql/PreparedStatement;
15: areturn
- getstatic 호출이 사라졌고,
- invokevirtual 호출이 invokestatic 호출로 대체되어 JVM에서 더 쉽게 최적화할 수 있게 되었으며,
- 스택 크기가 5개에서 4개로 줄었다. 이는 invokevirtual 호출의 경우, 스택에 ProxyFactory 인스턴스(this)가 암묵적으로 전달되기 때문이다. 또한 getProxyPreparedStatement()가 호출될 때 해당 값이 스택에서 추가적으로 pop되는 동작이 발생한다.
결론적으로 정적 필드 접근, 스택의 push와 pop 동작을 제거했으며, 호출 지점 callsite이 변경되지 않음을 보장하여 JIT 컴파일러가 호출을 더 쉽게 최적화할 수 있게 만들었다.
기타
그 외에도 코드의 모든 중요한 경로에서 명령어 수를 줄여, 각 메소드가 OS 스케줄러의 실행 퀀텀 내에 맞도록 최적화하여 캐시 라인 무효화로 인한 성능 저하를 피할 수 있었다고 한다.
3. 최적의 Connection Pool Size를 찾아서
HikariCP의 내부 동작 원리를 대충 알아봤으니, 핵심 주제인 Pool Size에 대해 알아보자.
HikariCP Github의 Wiki를 확인해보면 About Pool Sizing이라는 글이 존재한다. 여기서 제공하는 공식은 아래와 같다.
connections = ((core_count * 2) + effective_spindle_count)
예를 들어, 하나의 하드 디스크를 가진 4-Core i7 서버에서 가장 최적의 커넥션 풀 사이즈는 아래와 같이 도출해낼 수 있다.
connections = (4 * 2) + 1 = 9
숫자를 반올림해서 10으로 설정하는 게 깔끔할 것이다. HikariCP 팀은 이 설정으로 3000명의 frontend 유저가 요청하는 6000TPS를 손쉽게 처리했다고 한다.
여기서 Spindle이란 전통적인 하드 디스크 드라이브(HDD)의 물리적 디스크 회전 장치를 가리키는 용어다. 정확히 말하자면 Spindle이란 플래터를 회전시키는 구성요소이고, HDD를 작동하게 만드는 것이다.
공식의 effective_spindle_count가 의미하는 것은 병렬 처리 가능한 디스크의 I/O 경로 수를 추정한 값이다. 예를 들어, 단일 HDD라면 이 값이 1이 될 것이다. 반면 RAID 구성을 통해 병렬 처리 가능한 디스크의 수가 5개라면 이 수치가 5가 될 것이다. HDD를 동작 시킬 수 있는 Spindle 하나당 +1을 해주면 되는 것이다.
Pool Locking
하지만 위에서 제시한 공식이 항상 정답인 것은 아니다. 단일 요청이 여러 커넥션을 획득해야 하는 경우 데드락의 가능성은 존재한다. 이런 경우 풀 크기를 늘려서 문제를 해결해야할 수도 있다.
Hikari CP 팀에서 데드락을 피하기 위해 제안하는 공식은 아래와 같다.
pool size = Tn * (Cm - 1) + 1
여기서 Tn은 스레드의 최대 개수를 의미하고, Cm은 하나의 스레드에서 동시에 발생하는 커넥션의 수를 의미한다.
예를 들어, 하나의 작업을 진행할 때, 5개의 비동기 작업이 스레드에 의해 실행되고, 하나의 스레드당 4개의 커넥션이 필요하다고 가정해보자. 그렇다면 풀 사이즈는 아래와 같이 도출될 것이다.
pool size = 5 * (4 - 1) + 1 = 16
위의 공식은 최적의 풀 크기를 의미하는 것은 아니고, 데드락을 방지하기 위한 최소한의 크기라는 것을 알아두자. 또한, 풀 크기를 확장하기 전에 애플리케이션 수준에서 해결할 수 있는 방안이 있다면 먼저 검토하는 것이 좋을 것이다.
DBMS 최대 커넥션의 수
각각의 DBMS는 최대 커넥션 사이즈를 지정할 수 있으며, 관련된 환경변수는 아래와 같다.
- MySQL: max_connections
- PostgreSQL: max_connections
- Oracle: sessions & processes
- MSSQL: user connections
각각 DB를 사용하는 애플리케이션의 총 커넥션 수는 DBMS에 설정된 최대 커넥션 수를 넘지 않는 선에서 설정되어야만 한다. 만약 MySQL을 사용할 경우, max_connections 보다 많은 양의 연결을 시도하려고 하면 아래와 같은 에러가 발생하며 문제가 생길 것이다.
ERROR 1040 (HY000): Too many connections
하지만 서비스 트래픽을 감당하기 위해 DBMS의 기본 설정 값보다 많은 커넥션이 필요하다면 설정값을 변경할 수도 있다. 하지만 너무 높은 max_connections 값은 운영 중 불필요한 연결을 방치할 가능성을 높이게 된다. 또한 connection은 생각보다 자원을 많이 사용하기 때문에 connection 수의 증가에 따라 CPU나 RAM을 얼마나 사용하는지 확인하고, 성능에는 문제가 없는지 따로 확인해야 한다. 따라서 적절한 값을 설정할 수 있도록 해야 한다.
4. Hikari CP와 Metrics
Spring Boot는 지표 수집, 추적, 감사 등의 모니터링을 쉽게 할 수 있는 다양한 편의 기능을 제공하는 Actuator라는 툴을 기본으로 제공한다. 이는 Micrometer라는 벤더 독립적인 인터페이스를 내장하여 사용한 것으로 HikariCP에 대한 수치도 Actuator를 통해 수집되게 된다.
메트릭 상세 보기
메트릭 이름 | 설명 |
hikaricp.connections | 현재 Connection Pool 사이즈 |
hikaricp.connections.active | 현재 사용 중인 활성 Conneciton 수 |
hikaricp.connections.idle | 현재 유휴 상태의 Connection 수 |
hikaricp.connections.pending | 현재 대기 중인 Connection 수 |
hikaricp.connections.creation | 생성된 Connection 수 |
hikaricp.connections.timeout | Connection 요청 시간 초과 횟수 |
hikaricp.connections.acquire | Connection 획득하는 데 걸린 시간 |
hikaricp.connections.usage | Connection 사용 시간 |
hikaricp.connections.max | 설정된 최대 Connection Pool 사이즈 |
hikaricp.connections.min | 설정된 최소 유휴 Connection Pool 사이즈 |
다시 HikariCP Configuration
위에서 설명한 HikariCP의 자주 사용하는 Configuration 중 설명하지 않은 두 가지 요소가 있다. 둘 설정 값은 메트릭과 깊은 관계가 있기 때문에 현재 목차에서 설명하는 것이 맞다고 생각해 따로 빼두었다.
⚙️ metricRegistry
- Codahale/Dropwizard MetricRegistry의 인스턴스를 지정하여 풀에서 다양한 메트릭을 기록하도록 하는 옵션이다.
- 기본 값은 없다.
⚙️ healthCheckRegistry
- Codahale/Dropwizard HealthCheckRegistry의 인스턴스를 지정하여 풀에서 현재 상태 정보를 보고하도록 한다.
- 기본 값은 없다.
사실 위의 설정을 따로 해줄 필요가 없다고 봐도 무방하다. Spring Boot Actuator와 HikariCP가 통합되었기 때문에 별도의 MetricRegistry를 설정하지 않아도 자동화된 메트릭 수집 및 관리가 가능하다! 만약 설정을 커스터마이징하고 싶다면 Dropwizard 등을 활용해볼 수 있을 것이다.
Metrics 확인하기
먼저 actuator 의존성이 필요하다. SpringBoot에서 제공하는 starter-actuator 의존성을 추가하자.
implementation("org.springframework.boot:spring-boot-starter-actuator")
HikariCP의 메트릭을 직접 확인하고 싶다면 아래와 같이 application.yaml에서 /actuator/metrics의 주소를 열어주고, 보여줄 metrics를 enable 상태로 만들어줘야 한다.
- application.yaml
management:
metrics:
enable:
hikari: true
endpoints:
web:
exposure:
include: metrics
이제 서버를 시작해보자. 주소(localhost:8080 기준)로 접근할 때, localhost:8080/actuator/metrics로 접근하면 전체 메트릭을 확인해볼 수 있을 것이다.
여기서 names 중 하나를 선택에 현재 path의 뒤에 넣어주면 상세 메트릭 정보를 확인할 수 있다. 예를 들어 localhost:8080/actuator/metrics/hikaricp.connections.acquire에 접근한다고 해보자. 그러면 아래와 같은 JSON 파일을 응답받게 될 것이다.
{
"name": "hikaricp.connections.acquire",
"description": "Connection acquire time",
"baseUnit": "seconds",
"measurements": [
{
"statistic": "COUNT",
"value": 10
},
{
"statistic": "TOTAL_TIME",
"value": 0.019
},
{
"statistic": "MAX",
"value": 0.01
}
],
"availableTags": [
{
"tag": "pool",
"values": [
"write",
"read"
]
}
]
}
Metrics 시각화
수집된 Metrics를 활용해 애플리케이션의 리소스를 모니터링할 수 있으며, 아래와 같은 지표를 시각적으로 확인할 수 있게 된다. 커넥션 요청 시간 초과 횟수, 커넥션을 획득하는 데 걸린 시간 등을 확인하여 DB Connection 수를 조정하는 등 내 프로젝트에 유의미한 결과를 가져올 수 있을 것이다.
유휴 커넥션이 존재한다고 가정했을 때, 커넥션을 획득하는 데 걸리는 시간은 보통 1ms 미만이라고 한다. 만약 1ms 이상의 시간이 걸린다면 어떤 문제가 있는 것일 수도 있으니, DB 관련된 지표들을 면밀히 살펴보자.
5. HikariCP와 DB Replication
DB Replication이란 하나의 데이터베이스에 대하여 여러 개의 복제본을 만들어, 데이터의 고가용성과 성능 최적화를 도모하는 데이터베이스 관리 기술이다.
여기 DB의 분류는 크게 Primary(Master) DB와 Replica(Secondary/Slave) DB 두 가지 나뉘게 된다.
- Primary DB: INSERT, UPDATE ,DELETE 등 데이터베이스의 데이터에 변화를 일으키는 작업을 수행한다.
- Replica DB: Primary DB를 복제한 DB로 SELECT와 같이 조회하는 작업을 수행한다.
보통의 웹 서비스는 쓰기:읽기 작업의 비율이 1:9 혹은 2:8 정도로 차이가 많이 나며, 또한 쓰기에 비해 읽기의 시간이 더 오래 걸리므로 읽기 전용의 DB를 분리하여 처리를 분산하는 것이 효율적이기 때문에 위와 같이 분류된 것이다.
Read/Write Database 분리 설정
Hikari DB에서 분리된 DB를 사용하려면 각각의 DB에 대한 커넥션 풀 설정과 읽기/쓰기 작업에 대한 분기 처리를 추가해줘야 한다. 아래 예시를 확인해보자.
- DataSourceConfig.java
@Configuration
public class DataSourceConfig {
private static final String PRIMARY_DATASOURCE_KEY = "READ";
private static final String REPLICA_DATASOURCE_KEY = "WRITE";
@Bean
@ConfigurationProperties(prefix = "spring.datasource.primary")
public HikariConfig writeHikariConfig() {
return new HikariConfig();
}
@Bean
public DataSource writeDataSource(final HikariConfig writeHikariConfig) {
HikariDataSource dataSource = new HikariDataSource(writeHikariConfig);
return dataSource;
}
@Bean
@ConfigurationProperties(prefix = "spring.datasource.replica")
public HikariConfig readHikariConfig() {
return new HikariConfig();
}
@Bean
public DataSource readDataSource(final HikariConfig readHikariConfig) {
HikariDataSource dataSource = new HikariDataSource(readHikariConfig);
return dataSource;
}
@Bean
@DependsOn({"writeDataSource", "readDataSource"})
public DataSource routeDataSource() {
final AbstractRoutingDataSource dataSourceRouter =
new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? REPLICA_DATASOURCE_KEY
: PRIMARY_DATASOURCE_KEY;
}
};
final DataSource writeDataSource = writeDataSource(writeHikariConfig());
final DataSource readDataSource = readDataSource(readHikariConfig());
final Map<Object, Object> dataSources =
Map.ofEntries(
entry(WRITE_DATASOURCE_KEY, writeDataSource),
entry(READ_DATASOURCE_KEY, readDataSource));
dataSourceRouter.setTargetDataSources(dataSources);
dataSourceRouter.setDefaultTargetDataSource(writeDataSource);
return dataSourceRouter;
}
@Bean
@Primary
@DependsOn({"routeDataSource"})
public DataSource dataSource() {
return new LazyConnectionDataSourceProxy(routeDataSource());
}
}
- application.yaml
spring:
datasource:
read:
username: bell
password: 1234
jdbc-url: jdbc:mysql://mydb-host/mydb
maximum-pool-size: 10
minimum-idle: 8
connection-timeout: 5000
keepalive-time: 60000
idle-timeout: 300000
pool-name: "READ"
read-only: true
write:
username: bell
password: 1234
jdbc-url: jdbc:mysql://mydb-host/mydb
maximum-pool-size: 10
minimum-idle: 8
connection-timeout: 5000
keepalive-time: 60000
idle-timeout: 300000
pool-name: "WRITE"
read-only: false
@ConfigurationProperties라는 어노테이션을 활용함으로써 application.yaml의 spring.datasource.primary의 요소를 가져와 HikariConfig 객체에 자동으로 주입해주게 된다. HikariDataSource는 Config를 주입받아 그 값 그대로 객체를 생성한다.
@Bean
@ConfigurationProperties(prefix = "spring.datasource.primary")
public HikariConfig writeHikariConfig() {
return new HikariConfig();
}
@Bean
public DataSource writeDataSource(final HikariConfig writeHikariConfig) {
return new HikariDataSource(writeHikariConfig);
}
여기서 AbstractRoutingDataSource라는 클래스가 등장하게 된다. AbstractRoutingDataSource는 spring-jdbc 모듈에 포함되어 있는 클래스로 DataSource 인터페이스를 구현한다. determineCurrentLookupKey라는 메소드를 오버라이드해 구현하면 되는데,
TransactionSynchronizationManager의 isCurrentTransactionReadOnly메소드를 사용해 현재 트랜잭션이 readOnly로 설정되어 있다면 Replica DB로, readOnly가 아니라면 Primary DB로 라우팅하게 만들 것이다.
@Bean
@DependsOn({"writeDataSource", "readDataSource"})
public DataSource routeDataSource() {
final AbstractRoutingDataSource dataSourceRouter =
new AbstractRoutingDataSource() {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? REPLICA_DATASOURCE_KEY
: PRIMARY_DATASOURCE_KEY;
}
};
final DataSource writeDataSource = writeDataSource(writeHikariConfig());
final DataSource readDataSource = readDataSource(readHikariConfig());
final Map<Object, Object> dataSources =
Map.ofEntries(
entry(WRITE_DATASOURCE_KEY, writeDataSource),
entry(READ_DATASOURCE_KEY, readDataSource));
dataSourceRouter.setTargetDataSources(dataSources);
dataSourceRouter.setDefaultTargetDataSource(writeDataSource);
return dataSourceRouter;
}
마지막으로 RouterDataSource를 기본 dataSource로 사용하도록 IoC 컨테이너에 등록해주면 모든 준비는 완료된다.
@Bean
@Primary
@DependsOn({"routeDataSource"})
public DataSource dataSource() {
return new LazyConnectionDataSourceProxy(routeDataSource());
}
이제 트랜잭션을 사용할 때 조회를 하는 서비스에서 @Transactional 어노테이션을 달아주고, readOnly 옵션을 사용함으로써 DB Routing이 자동으로 이뤄지게 된다.
@Service
public class MemberService {
@Transactional(readOnly = true)
public Member getMember(Long id) {...}
@Transactional
public Member saveMember(Member member) {...}
}
분리된 DB의 Metrics 수집
여기서 PoolName이 중요해진다. 먼저 HikariCP Metrics가 어떻게 수집되고 있는지 확인해보자.
availableTags 배열의 values 배열에 "write"와 "read"로 수집되고 있는 것을 확인할 수 있다.
{
"name": "hikaricp.connections",
"description": "Total connections",
"measurements": [
{
"statistic": "VALUE",
"value": 10
}
],
"availableTags": [
{
"tag": "pool",
"values": [
"write",
"read"
]
}
]
}
이렇게 수집된 PoolName은 시각화를 할 때 요긴하게 사용된다. 아래는 ElasticSearch의 Kibana Dashboard이다. 해당 메트릭의 PoolName은 labels.pool이라는 Field 값으로 선택할 수 있고, 일치하는 PoolName을 골라 필터링을 할 수 있게 된다.
혹은 PoolName 조건을 통해 유의미한 값을 넣어 그래프를 만들어낼 수도 있을 것이다.
결과적으로 Read/Write DB 각각의 Connection Pool을 파악할 수 있다.
Spring Boot Actucator에서 제공하는 다양한 메트릭을 구분된 값으로 확인해 Primary와 Replica DB 중 필요한 DB의 커넥션의 사이즈를 늘리는 식으로 개선이 가능해진다는 점에서 중요하다.
6. 결론
HikariCP는 뛰어난 성능, 효율성, 그리고 신뢰성을 바탕으로 커넥션 풀의 표준으로 자리 잡았다. 특히 낮은 대기 시간과 최소한의 리소스 소비로 다른 커넥션 풀과 차별화된 성능을 제공한다.
Spring Boot는 HikariCP를 기본 커넥션 풀로 채택하며, 설정의 대부분을 자동으로 구성해주는 편리함을 제공한다. 이러한 기본 설정만으로도 강력한 성능을 발휘할 수 있지만, 설정을 깊이 이해하지 못한다면 커넥션 풀로 인한 장애를 경험할 가능성이 크다. 예를 들어, 풀 크기를 적절히 설정하지 않으면 'Too many connections' 또는 'Connection timeout'과 같은 문제가 발생할 수 있다.
HikariCP의 작동 원리와 최적화 방법을 이해하고, 환경에 맞는 설정을 적용하면 애플리케이션의 성능과 안정성을 극대화할 수 있다. 이는 단순한 성능 개선을 넘어 서비스의 신뢰성을 확보하는 핵심 요소이다. 실무에서 HikariCP를 제대로 이해하고 활용하는 것은 필수다.
참고자료
- HikariCP Github
- HikariCP Github Wiki
- Spring Boot 2.0 release notes
- Wikipedia
- Real MySQL | 백은빈, 이성욱 지음
- [장애회고] ORM(JPA) 사용 시 예상치 못한 쿼리로 인한 HikariCP 이슈