Hikari CP 파헤치기

SeungHoon·2025년 4월 9일

Spring

목록 보기
11/15
post-thumbnail

0. 시작하기 전에

  • Spring JDBC 를 공부하다 직접 Connection Pool(이하 CP로 표기) 을 생성하여 Connection 을 가져오는 실습을 진행하다가 의문점이 생겼고, 이를 해결하는 과정을 서술해보고자 한다.

1. Hikari CP가 뭐지?

  • ​HikariCP는 자바 애플리케이션에서 데이터베이스 연결을 효율적으로 관리하기 위한 고성능 JDBC 커넥션 풀 라이브러리이다.
  • 빠르고 안정적이며, 최소한의 설정으로도 사용할 수 있다.
  • spring-boot-starter-data-jdbc 또는 spring-boot-starter-data-jpa 의존성을 사용하면 자동으로 Hikari CP를 의존하게 된다.

2. 이 글을 쓴 원인

  • JDBC를 사용하게 되면 Connection, PreparedStatement, ResultSet 를 다루게 되고, 이것들을 일일히 열어주고 닫아줘야 한다.
  • 아래 코드는 CP를 사용하지 않고 DriverManager를 사용하여 Connection 객체를 만들어주는 코드이다.
public static Connection getMySQLConnection() {
        try {
            return DriverManager.getConnection(
                    MysqlDBConnectionConstant.URL,
                    MysqlDBConnectionConstant.USERNAME,
                    MysqlDBConnectionConstant.PASSWORD
            );
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
  • 자신의 로컬 MySQL 설정들을 넣어주면 알아서 Connection을 반환해준다.
  • 아래 코드는 열려있다면 닫아주는 예시이다.
if (result != null) {
            try {
                result.close();
            } catch (SQLException e) {
                log.error(e.getMessage());
            }
        }

        if(pstmt != null) {
            try {
                pstmt.close();
            } catch (SQLException e) {
                log.error(e.getMessage());
            }
        }

        if (stmt != null) {
            try {
                stmt.close();
            } catch (SQLException e) {
                log.error(e.getMessage());
            }
        }
        if (conn != null) {
            try {
                conn.close();
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }
  • 열려있다면 닫아주는 코드이다. 솔직히 이걸 매 연결마다 설정하는건 너무나 슬픈 일이다.
  • 해당 코드는 CP를 사용하지 않고 내가 직접 Connection을 만들었을 때 사용한다. Connection은 만드는 데 비용이 많이 들기 때문에 DriverManager로 매번 새로 만드는 것은 좋지 않고, CP에 미리 Connection을 여러 개 만들어두고, 이를 재사용하는 방식으로 사용해야 한다.

그래, 그럼 CP를 활용하는 실습을 해보자.

  • 그래서 실습에서는 JDBC를 사용한 Repository 를 만들었고 간단한 형태만 보면,
@RequiredArgsConstructor
public class SimpleJdbcCrudRepository implements SimpleCrudRepository {
    private final DataSource dataSource;

    private Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }

    private void closeConnection(Connection c, Statement s, ResultSet r) {
        JdbcUtils.closeConnection(c);
        JdbcUtils.closeStatement(s);
        JdbcUtils.closeResultSet(r);
    }
    ...
  • DataSource 의존성 주입을 받고 closeConnection() 메서드를 활용해 CP에 반환해주는 것을 의도한 코드이다. 그런데 JdbcUtils.closeConnection() 내부 코드를 살펴보면..
public static void closeConnection(@Nullable Connection con) {
        if (con != null) {
            try {
                con.close();
            } catch (SQLException ex) {
                logger.debug("Could not close JDBC Connection", ex);
            } catch (Throwable ex) {
                logger.debug("Unexpected exception on closing JDBC Connection", ex);
            }
        }

    }
  • 반환은 안해주고 그냥 close()를 해버리는거 아닌가? 아니 분명 CP에 반환한다며. 재사용한다며? 왜 close()만 해주고 반환은 안해주지? 의문이 생겼다.

답은 역시 Spring의 마법인가.. 는 아니고

3. 정답부터 말하면 HikariProxyConnection 였다.

처음에 Datasource 을 주입받을 때 Spring은 기본적으로 HikariDataSource을 주입해주는데, 여기서 만들어주는 Connection은 HikariProxyConnection 이다. 여기에는 close()를 오버라이딩해서 단순히 닫아주는게 아니라 CP에 반환하는 로직이 작성되어 있다고 한다!

  • 아하 정말 그런가? 이를 검증하기 위해 메서드를 하나하나씩 파보기 시작했다.

4. 증명하기

4.1 정말 HikariProxyConnection 을 사용하는지 확인하자.

  • HikariDataSource를 만들어서 Connection을 반환하는 테스트 코드를 작성해서 확인해보자.
@Test
@DisplayName("hikari")
void test2() throws Exception {
    HikariDataSource hikariDataSource = new HikariDataSource();
    hikariDataSource.setJdbcUrl(MysqlDBConnectionConstant.URL);
    hikariDataSource.setUsername(MysqlDBConnectionConstant.USERNAME);
    hikariDataSource.setPassword(MysqlDBConnectionConstant.PASSWORD);

    hikariDataSource.setMaximumPoolSize(5);
    
    Connection conn = hikariDataSource.getConnection();
    System.out.println("conn class = " + conn1.getClass());
conn class = class com.zaxxer.hikari.pool.HikariProxyConnection
  • HikariProxyConnection 가 실제로 반환되는 것을 확인했다.

4.2 close() 메서드를 확인해보자.

  • HikariProxyConnectionProxyConnection 추상 클래스를 상속받고 있고, ProxyConnection 에서 close() 메서드를 확인할 수 있었다.
public final void close() throws SQLException {
    this.closeStatements();
    if (this.delegate != ProxyConnection.ClosedConnection.CLOSED_CONNECTION) {
        this.leakTask.cancel();

        try {
            if (this.isCommitStateDirty && !this.isAutoCommit) {
                this.delegate.rollback();
                LOGGER.debug("{} - Executed rollback on connection {} due to dirty commit state on close().", this.poolEntry.getPoolName(), this.delegate);
            }

            if (this.dirtyBits != 0) {
                this.poolEntry.resetConnectionState(this, this.dirtyBits);
            }

            this.delegate.clearWarnings();
        } catch (SQLException e) {
            if (!this.poolEntry.isMarkedEvicted()) {
                throw this.checkException(e);
            }
        } finally {
            this.delegate = ProxyConnection.ClosedConnection.CLOSED_CONNECTION;
            this.poolEntry.recycle();
        }
    }
}
  • 여기서 주목할 부분은 finally 안에 있는 this.poolEntry.recycle(); 이다. 여기서 재활용을 해준다는 것을 확인했다.

5. 의문점은 해결되었다. 그래도 하나만 더 해보자.

  • close()를 확인했으니, getConnection()도 확인해보고 싶었다. 그래서 추가로 알아보았다.

5.1 HikariDataSource 의 getConnection()

public Connection getConnection() throws SQLException {
        if (this.isClosed()) {
            throw new SQLException("HikariDataSource " + this + " has been closed.");
        } else if (this.fastPathPool != null) {
            return this.fastPathPool.getConnection();
        } else {
            HikariPool result = this.pool;
            if (result == null) {
                synchronized(this) {
                    result = this.pool;
                    if (result == null) {
                        this.validate();
                        LOGGER.info("{} - Starting...", this.getPoolName());

                        try {
                            this.pool = result = new HikariPool(this);
                            this.seal();
                        } catch (HikariPool.PoolInitializationException pie) {
                            if (pie.getCause() instanceof SQLException) {
                                throw (SQLException)pie.getCause();
                            }

                            throw pie;
                        }

                        LOGGER.info("{} - Start completed.", this.getPoolName());
                    }
                }
            }

            return result.getConnection();
        }
    }
  • 처음 코드를 보면 애초에 datasource가 닫혀있으면 바로 예외를 발생시킨다.
  • fastPathPool 이 존재한다면 여기서 getConnection() 을 진행한다. 하지만 Hikari 개발자만 개발용으로 만들어 둔 것으로 추측된다고 한다… 실상은 사용이 안됨. 기본생성자로 HikariDataSource 을 생성하면 fastPathPool == null 이 된다.
  • 그래서 대부분 맨 아래 result.getConnection(); 가 호출되어 아래 코드가 실행된다. HikariPool 에서 getConnection() 메서드이다.
public Connection getConnection(long hardTimeout) throws SQLException {
        this.suspendResumeLock.acquire();
        long startTime = ClockSource.currentTime();

        try {
            long timeout = hardTimeout;

            do {
                PoolEntry poolEntry = (PoolEntry)this.connectionBag.borrow(timeout, TimeUnit.MILLISECONDS);
                if (poolEntry == null) {
                    break;
                }

                long now = ClockSource.currentTime();
                if (!poolEntry.isMarkedEvicted() && (ClockSource.elapsedMillis(poolEntry.lastAccessed, now) <= this.aliveBypassWindowMs || !this.isConnectionDead(poolEntry.connection))) {
                    this.metricsTracker.recordBorrowStats(poolEntry, startTime);
                    Connection var10 = poolEntry.createProxyConnection(this.leakTaskFactory.schedule(poolEntry));
                    return var10;
                }

                this.closeConnection(poolEntry, poolEntry.isMarkedEvicted() ? "(connection was evicted)" : "(connection is dead)");
                timeout = hardTimeout - ClockSource.elapsedMillis(startTime);
            } while(timeout > 0L);

            this.metricsTracker.recordBorrowTimeoutStats(startTime);
            throw this.createTimeoutException(startTime);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new SQLException(this.poolName + " - Interrupted during connection acquisition", e);
        } finally {
            this.suspendResumeLock.release();
        }
    }
  • 여기서 하나씩 분석해보자.
PoolEntry poolEntry = (PoolEntry) this.connectionBag.borrow(timeout, TimeUnit.MILLISECONDS);
  • connectionBag 의 경우 커넥션을 보관하고 있는 장소라고 이해하면 될듯.
  • borrow() 메서드는 사용 가능한 커넥션이 있다면 꺼내고, 없으면 timeout 만큼 기다리다가 초과되면 예외를 반환해주는 듯 하다.
Connection var10 = poolEntry.createProxyConnection(this.leakTaskFactory.schedule(poolEntry));
  • poolEntry 의 경우 커넥션 풀을 감싸고 있는 wrapper 객체이다. 내부에 Connection 을 들고 있다.
  • 내부는 팩토리 패턴으로 proxy을 생성하고 있고, HikariProxyConnection 을 반환해준다.

6. 결론

우리는 HikariProxyConnection 을 Connection 인터페이스로 편하게 사용하고 있는 것이다. 그래서 close()을 호출해도 재정의된 close()가 실행되는 것이다!

profile
공유하며 성장하는 Spring 백엔드 취준생입니다

0개의 댓글