너는 Connection Pool을 대체 어떻게 관리하니?
저는 자바 개발자가 아니라서 HikariCp가 도대체 뭔지 몰랐습니다. 회사에 입사하고 보니 HikariCP에 대한 내용이 스물스물 나오길래 공부해보겠다고는 했지만, 그래봤자 자바 어플리케이션 개발자분들이 사용하시는 것을 코드로 몰래몰래 보는게 전부였습니다. 그러다가 Hikari에 대해서 제대로 알아야 할 날이 올 것 같은 쎄~한 느낌이 들어 제대로 공부해보기로 했습니다.
Hikari는 Database와의 Connection Pool을 관리해준다
HikariCP가 해주는 역할은 정말 간단히 말하면 Database와의 커넥션 풀을 관리해준다는 것입니다. 커넥션 풀을 관리해주는 것이 중요한 이유는 성능에 큰 영향을 미치기 때문입니다. 실제로 JDBC 커넥션을 맺는 과정은 상당히 복잡할 뿐만 아니라 꽤나 자원을 많이 소모하는 작업입니다. 만약 요청이 들어올 때 Thread가 Database와의 커넥션을 맺는다면 데이터베이스 뿐만 아니라 앱서버 입장에서도 굉장히 부하가 심하게 발생할 것입니다. 그런데 HikariCP는 미리 정해놓은 만큼에 커넥션은 Pool에 담아 놓습니다. 요청이 들어오면 Thread가 커넥션을 요청하고, Hikari는 Pool내에 있는 커넥션을 연결해줍니다. 그러면 Thread입장에서는 바로 쿼리를 날릴 수 있게 됩니다.
이렇게 좋은 라이브러리를 사용하니까 걱정할 필요가 없을 줄 알았습니다...
그러나.. 잠을 자려던 새벽에 엄청나게 많은 알람이 들이닥쳤습니다. 알람을 까보니 아래와 같은 에러 메시지가 들어 있었습니다.
org.springframework.dao.DataAccessResourceFailureException: Unable to acquire JDBC Connection; nested exception is org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
처음에 이 에러를 마주하고는 Database가 가지고 있는 max_connection 값을 넘어설만큼 요청이 들어왔다고 생각했습니다. Amazon Aurora의 max_connection 수는 16,000개입니다. Hikari의 max_conection 수를 100으로 설정했을 경우 만약 어플리케이션 인스턴스가 160대만 뜨더라도 전체 16,000개의 커넥션이 생기게 됩니다. Hikari는 커넥션을 반납하지 않고 Pool에 계속 살려두기 때문에 Aurora에서 Database Connection 지표를 보면 계속해서 커넥션 수가 유지되는 것을 보실 수 있습니다.
그런데, 정말로 요청이 순식간에 몰려서 발생한 걸까요?
저 에러가 발생했을 때의 평균 요청수는 평소 평균 요청수에 비해 그렇게 많이 늘어나지 않았습니다. 이전에 훨씬 더 많은 요청이 있었는데도, 실제 Hikari 커넥션 풀에는 문제가 없었습니다. 근데 훨씬 적은 요청을 받았음에도 불구하고 Unable to acquire JDBC Connection를 내뱉다니, 이건 참 이해할 수가 없었습니다.
그래서, 이 기회에 HikariCP를 뜯어보았습니다!
Hikari의 오픈소스를 뜯어보겠습니다.
$ git clone https://github.com/brettwooldridge/HikariCP.git
다행히 Test용 코드가 있어서 파악하기 수월했습니다.
HikariDataSource ds = new HikariDataSource(config)
-------------------------------------------------------------------
private final HikariPool fastPathPool; <-------------------- Connection Pool을 담는 변수
private volatile HikariPool pool; <-------------------- Connection Pool을 담는 변수
....
public HikariDataSource(HikariConfig configuration)
{
configuration.validate();
configuration.copyStateTo(this);
LOGGER.info("{} - Starting...", configuration.getPoolName());
pool = fastPathPool = new HikariPool(this); <-------------------------- 여기서 Hikari Pool 생성
LOGGER.info("{} - Start completed.", configuration.getPoolName());
this.seal();
}
Hikari는 Datasource 객체에서 pool, fastPathPool에 각각 HikariPool을 생성합니다. 여기서 pool이 volatile로 정의된 이유는 여러 Thread에서 해당 pool 변수를 참조할 때 값이 달라지면 안되기 때문입니다. Volatile로 설정하면 해당 변수를 읽고 쓸 때 메인메모리로 바로 접근합니다.
그러면 Hikari Pool의 내용을 한 번 살펴보겠습니다. 자세한 내용을 살펴보기 전에 해당 객체가 가지고 잇는 HikariPool의 객체가 가지고 있는 변수에 대해서 알아보도록 하겠습니다. 이해에 필요한 내용만(=제가 이해한 내용) 적어보았습니다.
| HikariPool 객체가 가지고 있는 변수(일부)
1. logger : 로그를 기록하는 Logger 객체
2. poolState : Pool의 상태
3. aliveBypassWindowMs : Connection이 살아있는지 판단할 때 ByPass할 수 있는 시간(millisecond)
4. housekeepingPeriodMs : 배치처럼 작동하는 HouseKeeping이 돌아가는 주기
5. poolEntryCreator : Pool에 새로운 Entry를 생성해주는 Creator 객체
6. postFillPoolEntryCreator : Pool에 Entry를 채우기 위한 Creator객체
7. addConnectionQueue : Connection을 새로 만들기 위한 thread를 잠시 담아 놓는 Queue
8. addConnectionExecutor : Thread를 addConnectionQueue에 넣어 놓는 Executor
9. closeConnectionExecutor : Connection 종료를 진행하는 Executor
10. connectionBag : Connection Entry를 담고 있는 객체
11. suspendResumeLock : Pool의 사용을 지속/중지할지 결정하는 변수.
12. houseKeepingExecutorService : Pool을 관리하기 위해 필요한 배치작업을 수행하는 객체
13. houseKeeperTask : HouseKeepingExecutor가 실행하는 Task
위의 변수를 바탕으로 코드를 한 번 살펴보겠습니다.
1. 먼저 Connection Bag을 새로 생성합니다.
2. isAllowPoolSuspension 값에 따라 pool의 사용을 지속할지 결정합니다.
3. HouseKeepingExecutorSerive를 정의합니다. initializeHouseKeepingExecutorService 함수에서는 새로운 Thread 생성에 필요한 threadFactory와 ScheduledThreadPoolExecutor 를 생성합니다. (corePoolSize = 1)
4. checkFailFast() 함수를 통해서 데이터베이스에 연결을 할 수 있는지 확인합니다.
- 먼저 createPoolEntry()를 통해서 새로운 커넥션 엔트리를 확보합니다.
- 엔트리가 확보되고(poolEntry != null) minimumIdle이 0보다 크면 해당 커넥션을 connectionBag에 추가합니다. 테스트에 만든 커넥션이라도 쓰겠다는 겁니다. 만약 minimumIdle이 0이면 해당 커넥션을 닫습니다.
- 만약 엔트리가 확보되지 않으면 getLastConnectionFailure() 에서 실제로 Connection이 Fail되었는지를 확인합니다. 아래 newConnection() 함수에서 username과 password로 데이터베이스에 접속하는데, 이 때 여러가지 Connection Error가 발생할 수 있습니다. 이 때 ConnectionSetupException에러인 경우에는 바로 에러를 발생시키고 테스트를 종료합니다.
- 이외의 경우에는 initializationTimeout 까지 계속 시도를 합니다.
private Connection newConnection() throws Exception
{
final long start = currentTime();
Connection connection = null;
try {
String username = config.getUsername();
String password = config.getPassword();
connection = (username == null) ? dataSource.getConnection() : dataSource.getConnection(username, password); <--------------------여기서 에러 발생 가능
if (connection == null) {
throw new SQLTransientConnectionException("DataSource returned null unexpectedly");
}
setupConnection(connection);
lastConnectionFailure.set(null);
return connection;
}
...
}
후.. 힘들지만, 이어서 더 보시죠!
이렇게 Connection 체크를 완료하고 나서 pool 관리에 필요한 각종 변수들의 초기값을 세팅합니다. 여기서 하나 보실만한 것은 handleMBeans() 함수입니다. MBeans는 hikari의 각종 지표를 수집해주는 JMX 모니터링을 등록하는 객체입니다. 즉 모니터링 지표를 수집하고 싶을 때는 이 MBeanServer를 생성해야 합니다. 이때 필요한 Java Spring Configuration은 register-mbeans값 입니다.
이후에는 minimumIdle 값까지 커넥션 pool을 채웁니다. 여기서 com.zaxxer.hikari.blockUntilFilled 값이 바로 이를 통제해주는 변수인데, 이 값 덕분에 어플리케이션은 해당 pool을 사용할 수 없습니다.
while (elapsedMillis(startTime) < config.getInitializationFailTimeout() && getTotalConnections() < config.getMinimumIdle()) { <--------- minimumIdle까지 기다립니다
quietlySleep(MILLISECONDS.toMillis(100));
}
근데 어디서 커넥션 Pool을 대체 어디서 채우는 걸까요..?
아무리 찾아봐도 이 코드 위에서는 Connection을 채우는 부분이 없습니다. 그렇다면 어딘가 다른 곳에서 작동한다는 건데, 의심가는 놈은 역시 배치작업을 하는 HouseKeeper입니다. 윗 부분에서 houseKeeperTask를 정의하는 부분에서 보면 scheduleWithFixedDelay()함수의 파라미터로 새로운 HouseKeeper 객체를 생성하여 전달합니다.
this.houseKeeperTask = houseKeepingExecutorService.scheduleWithFixedDelay(new HouseKeeper(), 100L, housekeepingPeriodMs, MILLISECONDS);
그러면 HouseKeeper객체를 한 번 살펴보겠습니다. HouseKeeper는 Runnable 인터페이스를 구현하고 있고, run()함수를 통해 실행됩니다. 이 객체의 역할은 idleConnection 수를 minimumIdle 값과 비교해서 필요 이상인 경우 idleConnection을 지우고, 더 필요한 경우 fillpool()을 통해서 새로운 idleConnection을 생성합니다.
여기까지는 Connection Pool을 생성하는 방식에 대해서 살펴보았습니다. 여기까지만 해도 벅차네요.(자바개발자도 아닌데 자바 소스 보느라고 무척이나 힘이 듭니다...) 다음 2편에서는 Hikari 커넥션 풀에서 실제로 어떻게 커넥션을 가져오는지 한 번 알아보도록 하겠습니다!!