우리의 애플리케이션에서 PreparedStatement는 어떻게 동작하고 있는가

우리의 애플리케이션에서 PreparedStatement는 어떻게 동작하고 있는가

요약: 이 글은 애플리케이션에서 JDBC의 PreparedStatement가 실제로 어떻게 동작하는지를 분석한 글입니다. 특히 Hibernate, HikariCP, MySQL Connector/J 같은 다양한 추상화 계층에서 PreparedStatement가 어떻게 설정되고 동작하는지를 내부 구현까지 깊이 있게 탐구했습니다. 추가로, PreparedStatement의 설정에 따른 성능 테스트 결과와 함께 실무에서의 설정 시 고려해야 할 사항을 함께 공유합니다.

💡 리뷰어 한줄평

wade.hong DB를 다룰 때 기반 기술이 되는 PreparedStatement을 내부 구현 탐구를 통해서 어떻게 동작하는지 잘 표현한 글입니다.

jaden.jacky 속 보이는 분석을 통해, 우리가 무심코 사용하는 추상화된 기술들 간의 관계와 내부 동작을 이해하는데 도움이 되는 좋은 글입니다.

시작하며

안녕하세요. 카카오페이에서 서버 개발을 하고 있는 cdragon입니다. 저는 주로 Java, Kotlin 같은 JVM 기반 언어를 사용하고, 데이터베이스 연동에는 ORM인 Hibernate를 이용하고 있습니다. 최근 MySQL 성능 관련 스터디를 진행했는데, MySQL에서 제공하고 있는 PREPARE에 대해 다시 한번 살펴보는 계기가 됐습니다.

PREPARE는 MySQL에서 prepared statement를 생성하는 커맨드인데요. 프레임워크 없이 순수 JDBC 구현체를 이용해서 데이터베이스 연동 코드를 작성해 본 경험이 있다면 PreparedStatement 인터페이스를 통해 많이들 접해보셨을 것 같습니다. 저 역시 데이터베이스의 기능으로 보다는 JDBC의 Statement, PreparedStatement로 먼저 접했습니다.

위에서 언급한 대로 저희는 애플리케이션에서 데이터베이스 연동 시 Hibernate를 사용하고 있는데요. 애플리케이션에서 데이터베이스까지 가는 길에는 Hibernate뿐만 아니라 Connection Pool, JDBC를 거쳐야 합니다. MySQL에 대한 스터디를 진행하다 보니 문득 우리가 사용하는 추상화된 기술들 안에서 PreparedStatement가 어떻게 동작하고 있을지 궁금해졌습니다.

예측한 대로 MySQL에 PREPARE를 실행하고 있는지도 궁금했고, 그에 대한 설정들은 어디서, 어떻게 제어하고 있는지도 궁금해졌습니다. 그리고 추상화 계층을 한 꺼풀씩 벗겨가며 확인해 보기로 했습니다. 이번 글에서는 그 과정과 결과를 공유하고자 합니다.

architecture
architecture

이 글에서는 아래와 같은 내용을 다룹니다.

  • JDBC Statement와 PreparedStatement 인터페이스의 개념과 차이점을 다룹니다.
  • MySQL PREPARE의 동작방식을 살펴봅니다.
  • Hibernate, HikariCP, MySQL Connector/J에서 PreparedStatement 관련 설정은 어떻게 이루어지며, 내부 구현은 어떻게 되어있는지 분석합니다.
  • 마지막으로 PreparedStatement 관련 설정에 따른 성능 테스트와 함께 고려해야 할 사항들을 공유합니다.

다만 이 글에서 다음 내용은 다루지 않습니다.

  • 각 기술의 전반적인 사용법
  • 설정에 대한 확정적인 모범사례

이 글을 통해 다양한 추상화 계층에서 JDBC를 어떻게 활용하는지 이해하고, 효과적으로 활용하는데 도움이 되기를 바랍니다.

사전조건

이 글에서 다룰 코드 예제, 테스트 기술들의 상세 스펙은 아래와 같습니다.

기술구분상세기술
JDKJDK 21
DBMSMySQL 8.0.23
JDBCMySQL Connector/J 9.1.0
Connection PoolHikariCP 5.1.0
ORMHibernate 6.6.11.Final

테스트로 활용할 간단한 테이블의 스키마는 아래와 같습니다.

CREATE TABLE car(
    car_id BIGINT NOT NULL PRIMARY KEY,
    car_brand VARCHAR(20) NOT NULL,
    car_name VARCHAR(20) NOT NULL,
    car_type VARCHAR(10) NOT NULL
);

Statement와 PreparedStatement

JDBC Driver를 직접 사용해서 DB에 쿼리를 실행하는 고전적인 코드를 살펴보겠습니다.

-- 코드 실행 전 데이터 추가
INSERT INTO car values(1, 'HYUNDAI', 'avante', 'AUTO');
public class JdbcApplication {
    public static void main(String[] args) {
        String jdbcUrl = "jdbc:mysql://";
        String id = "";
        String password = "";

        Connection connection = null;
        Statement stmt = null;
        ResultSet rs = null;

        try {
            connection = DriverManager.getConnection(jdbcUrl, id, password);
            // Statement 객체 획득
            stmt = connection.createStatement();
            // Statement 객체를 이용해 ResultSet 객체 획득
            rs = stmt.executeQuery("SELECT * FROM car WHERE car_id = 1");

            while(rs.next()) {
                var car = createCar(rs);
                System.out.println(car);
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                rs.close();
                stmt.close();
                connection.close();
            } catch(Exception e) {
                e.printStackTrace();
            }
        }
    }

    private static Car createCar(ResultSet rs) throws SQLException {
        return new Car(rs.getLong(1), rs.getString(2), rs.getString(3), rs.getString(4));
    }

    private record Car(
            Long id,
            String brand,
            String name,
            String type
    ) { }
}

DB Connection을 획득하고, createStatement() 메서드를 호출해 Statement 객체를 얻어 쿼리를 실행합니다. 코드를 실행하면 이렇게 출력됩니다.

Car[id=1, brand=HYUNDAI, name=avante, type=AUTO]

PreparedStatement 인터페이스를 활용하는 예제도 확인해 보겠습니다.

Connection connection = null;
// PreparedStatement 인터페이스로 변경
PreparedStatement stmt = null;
ResultSet rs = null;

try {
    connection = DriverManager.getConnection(jdbcUrl + properties, id, password);
    // prepareStatement() 메서드 실행
    stmt = connection.prepareStatement("SELECT * FROM car WHERE car_id = ?");
    // 쿼리에 전달할 파라미터 세팅
    stmt.setLong(1, 1L);
    rs = stmt.executeQuery();

    while(rs.next()) {
        var car = createCar(rs);
        System.out.println(car);
    }
}

모양이 살짝 바뀌었지만 코드는 대부분 비슷하고 출력결과도 동일합니다. 그럼 Statement 인터페이스와 PreparedStatement 인터페이스는 어떤 차이가 있을까요?

// https://docs.oracle.com/en/java/javase/21/docs/api/java.sql/java/sql/Statement.html
The object used for executing a static SQL statement and returning the results it produces.

// https://docs.oracle.com/en/java/javase/21/docs/api/java.sql/java/sql/PreparedStatement.html
An object that represents a precompiled SQL statement.

각 인터페이스의 javadoc 첫 번째 줄을 가져왔습니다. Statement는 정적인 쿼리를 실행할 때, PreparedStatement는 컴파일된 쿼리를 표현하는 용도라고 하는데요. 파라미터 바인딩을 하지 않는 정적인 쿼리를 실행하는 경우는 많지 않으니 PreparedStatement가 아닌 Statement를 써야 하는 경우는 많지 않을 것 같습니다. 더욱이 PreparedStatement는 대표적인 취약점 공격인 SQL injection을 막는 간단한 방법이기도 하니 더더욱 안쓸 이유가 없습니다.

JDBC는 이런 인터페이스들을 제공하지만 말 그대로 인터페이스만 제공하고 있기 때문에 이 인터페이스를 구현하는 구현체들에서 해당 스펙들을 구현해줘야 합니다. 그럼 이런 스펙들은 JDBC 레이어에서 제공하는 걸까요? JDBC는 JVM과 DBMS 사이의 연결을 표준화한 스펙이고 미리 컴파일된 쿼리를 저장하는 건 이 글의 대상 DBMS인 MySQL에서 제공하고 있습니다. 때문에 DB만 이용해서도 prepared statement를 생성할 수 있으며, 당연히 사용도 할 수 있습니다. MySQL에서 prepared statement를 생성하고 제거하는 예제를 보겠습니다.

-- prepared statement 생성
PREPARE pstmt1 FROM 'SELECT * FROM car WHERE car_id = ?';

-- 파라미터를 지정하여 prepared statement 실행
SET @a = 1;
EXECUTE pstmt1 USING @a;

-- prepared statement 제거
DEALLOCATE PREPARE pstmt1;

prepared statement가 JDBC에서만 제공하는 것이 아니라 DBMS에서도 제공하는 거란 걸 알았다면 우리가 만드는 애플리케이션에서 prepared statement를 잘 활용하고 있는지 궁금할 텐데요. 쿼리를 이용해 현재 생성된 prepared statement를 확인할 수 있습니다.

-- 생성된 prepared statement 개수 조회
SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count';

-- 생성된 prepared statement 조회
SELECT * FROM performance_schema.prepared_statements_instances;
-- 실행결과로그
db> PREPARE pstmt1 FROM 'SELECT * FROM car WHERE car_id = ?'
[2025-04-15 13:36:45] completed in 19 ms
db> SELECT * FROM performance_schema.prepared_statements_instances
         WHERE STATEMENT_NAME = 'pstmt1'
[2025-04-15 13:37:11] 1 row retrieved starting from 1 in 327 ms (execution: 32 ms, fetching: 295 ms)
db> DEALLOCATE PREPARE pstmt1
[2025-04-15 13:37:35] completed in 26 ms

이제 우리는 JDBC에서 제공하는 Statement, PreparedStatement 인터페이스를 이용해 쿼리를 실행하는 것과 prepared statement는 JDBC 보다 더 뒤인 DBMS에서부터 제공해 주는 기술이라는 걸 알았습니다. JDBC는 인터페이스만 제공하는 표준이니, DBMS가 PREPARE를 지원한다면 사용하도록 PreparedStatement를 구현하고, 지원하지 않는다면 최대한 JVM 수준에서라도 동작하게끔 구현하면 됩니다. 오늘 대상 기술인 MySQL은 PREPARE를 지원하고 있으니, 지원하도록 구현하는 것이 올바르다고 생각됩니다.

그럼 우리의 애플리케이션은 prepared statement를 잘 활용하고 있을까요? 엔터프라이즈 환경에서 JDBC를 직접 핸들링해 개발하는 경우는 거의 없다 보니 직접 PreparedStatement 인터페이스를 이용할 일도 없습니다. JDBC 위에 Connection Pool 라이브러리를 이용하고, Connection Pool 라이브러리 위에 ORM이나 SQL Mapper까지 레이어가 추가되는데요. 이 사이에서 어떤 설정을 통해 prepared statement를 제어할 수 있을까요?

Hibernate

추상화 수준이 가장 높은 레이어부터 알아가 보기로 했습니다. 저희는 ORM과 SQL Mapper 중 ORM을 사용하고 있고, java의 ORM 표준인 JPA와 구현체로 Hibernate를 이용하고 있습니다.

때문에 Hibernate에서 관련 설정을 찾아보았는데요. PreparedStatement 관련 속성은 찾을 수 없었습니다. hibernate.query.plan_cache_enabled라는 속성이 있는데 이는 Hibernate 전용 속성으로 Hibernate는 SQL을 한번 더 추상화한 JPQL을 파싱해서 SQL로 만들어야 하는데 이 결과를 캐시하는 속성입니다. 얼핏 비슷한 면이 있지만 이번 주제인 PreparedStatement와는 무관한 설정입니다.

Hibernate를 사용할 때 실무에서 쉽게 접할 수 있는 PreparedStatement 캐시와 관련 있는 사례를 하나 소개하겠습니다.

@Entity
@DynamicUpdate
@DynamicInsert
public class Car {
    @Id
    @Column(name = "car_id")
    private Long id;
    @Column(name = "car_brand")
    private String brand;
    @Column(name = "car_name")
    private String name;
    @Column(name = "car_type")
    private CarType type;
}

JPA 표준은 아니지만 Hibernate에는 @DynamicInsert, @DynamicUpdate 설정이 있습니다. 이 설정을 이용하면 변경된 속성에 대해서만 insert, update 쿼리를 실행하게 되는데요. 전체 컬럼에 대한 변경이 아니고 변경된 것만 찾아서 쿼리를 실행해 주니 편하고, 무조건적으로 적용하는 경우도 많습니다. 다만 PreparedStatement 관점에서는 지속적으로 새로운 쿼리가 생성되기 때문에 캐시 히트율이 떨어지게 됩니다.

UPDATE car SET car_brand = ? WHERE car_id = ?
UPDATE car SET car_name = ? WHERE car_id = ?
UPDATE car SET car_brand = ?, car_name = ? WHERE car_id = ?

위 세 쿼리는 다른 쿼리이기 때문에 각각 PreparedStatement를 생성하게 되는데요. 실무에서는 컬럼이 수십 개에 달하는 경우도 많기 때문에 훨씬 많은 양의 PreparedStatement가 생성되고, 이로 인해 쿼리 재사용률이 떨어지게 됩니다.

HikariCP

HikariCP는 직접적으로 Connection들을 관리하는 주체이기 때문에 무언가 설정이 있을 거라고 예상했습니다. 하지만 특별한 설정을 찾을 수 없었는데, 오히려 Connection Pool에서 PreparedStatement를 관리하는 건 안티패턴이라는 말과 함께 그 이유를 적어놓은 걸 찾을 수 있었습니다. 해당 글에서도 언급하고 있듯이 모든 Connection Pool 라이브러리가 PreparedStatement에 대한 관리를 위임하는 건 아니고 DBCP와 같이 PreparedStatement 캐시를 지원하는 라이브러리도 있습니다.

HikariCP가 PreparedStatement 캐시를 별도로 지원하지 않는 이유를 요약하면 Connection Pool 보다 아래에서 동작하는 JDBC 구현체들이 PreparedStatement의 캐시를 더 똑똑하게 구현할 수 있다는 내용입니다. PreparedStatement를 Connection Pool에서 캐시 하게 될 경우 동일한 쿼리에 대해 Connection 당 캐시를 해야 하기 때문에 자주 사용하는 쿼리 250개가 있고, Connection Pool 사이즈가 20일 경우 MySQL 서버에는 5000개의 쿼리를 캐시 하게 되는데 JDBC Drvier를 영리하게 구현한다면 Connection끼리 중복 쿼리에 대해 별도 prepared statement를 만들지 않고 250개만 캐시 하도록 만들 수 있다는 내용인데요. 실제로 HikariCP는 Connection이나 PreparedStatement 인터페이스를 구현하고 있으나 구현체를 보면 proxy 객체로 실제 동작은 위임으로 처리하는 걸 볼 수 있습니다.

MySQL Connector/J

결국 가장 아랫단계까지 와서 JDBC를 직접 구현하고 있는 MySQL Connector/J를 살펴보게 됐습니다. 그리고 여기서 관련 속성들을 찾을 수 있었습니다.

속성기본값설명
useServerPrepStmtsfalse서버(mysql)에 prepared statement를 만들도록 한다. 즉 이 속성을 활성화하지 않으면 위에서 알아본 DB에서 prepared statement를 만드는 구문이 실행되지 않는다.
cachePrepStmtsfalseJDBC Driver 수준에서 PreparedStatement 객체의 재활용 여부이다. 해당 속성이 true이면 동일 쿼리에 대해 같은 객체를 사용하게 되고, false이면 매번 새로 만든다.
prepStmtCacheSize25cachePrepStmts 속성이 true 라면 몇 개의 객체를 캐시 할 건지 설정한다. 때문에 cachePrepStmts 속성이 false이면 큰 의미 없는 속성이다.
prepStmtCacheSqlLimit256캐시 할 쿼리의 최대 바이트 지정. 이보다 큰 바이트의 쿼리가 들어올 경우 해당 쿼리는 캐시 하지 않는다.

MySQL 서버 측에 PREPARE 명령을 실행할 가장 중요한 속성은 useServerPrepStmts입니다. 이 속성이 false이면 cachePrepStmt가 true로 되어있어도 MySQL에 PREPARE를 생성하지 않기 때문입니다. 그럼 useServerPrepStmts가 false이면 cachePrepStmt가 true여도 의미 없을까요? 그렇지는 않습니다. MySQL로 PREPARE 명령을 실행하진 않지만 JVM 수준에서 PreparedStatement 객체를 캐시하고 재활용하게 됩니다. 물론 PREPARE 명령을 실행하는 것이 성능향상엔 더 좋을 것이지만 cachePrepStmt만 true로 설정하더라도 그에 맞게 동작하게 됩니다. 하지만 MySQL 입장에서는 항상 Statement로 쿼리를 실행시키는 것과 같죠.

MySQL Connector/J는 PreparedStatement의 구현을 ClientPreparedStatement와 ServerPreparedStatement로 하고 있는데 useServerPrepStmts 속성값에 따라 어떤 구현체를 사용할지 결정합니다. useServerPrepStmts가 true이면 ServerPreparedStatement, false이면 ClientPreparedStatement를 사용하게 됩니다. 코드가 상당히 복잡해서 그대로 가져오긴 어렵지만 대략 이런 형태로 구현되어 있습니다.

// https://github.com/mysql/mysql-connector-j/blob/release/9.x/src/main/user-impl/java/com/mysql/cj/jdbc/ConnectionImpl.java#L1631
ClientPreparedStatement pStmt = null;
if (this.useServerPrepStmts.getValue()) {
    if (this.cachePrepStmts.getValue()) {
        pStmt = // get ServerPreparedStatement by cache
    } else {
        pStmt = ServerPreparedStatement.getInstance()
    }
} else {
    if (this.cachePrepStmts.getValue()) {
        pStmt = // get ClientPreparedStatement by cache
    } else {
        pStmt = ClientPreparedStatement.getInstance()
    }
}

아래는 PreparedStatement 구현의 간략한 계층도입니다. ClientPreparedStatement와 ServerPreparedStatement는 각각 별도 구현체이며, 그 자체로 상속관계를 형성하고 있습니다.

diagram
diagram

prepStmtCacheSize 속성은 커넥션당 관리하게 되는 PreparedStatement 캐시 수를 의미하는데, 해당 수를 넘어서서 새로운 객체를 캐시 하게 되면 LRU 방식으로 동작하여 가장 마지막으로 사용된 PreparedStatement가 캐시에서 제거됩니다. 이때 useServerPrepStmts가 true였다면 객체가 제거되면서 MySQL로 DEALLOCATE 명령을 전달하게 됩니다.

prepStmtCacheSqlLimit 속성은 캐시에 저장할 쿼리의 바이트를 의미합니다. 이 값을 초과하는 길이의 쿼리로 생성된 PreparedStatement는 캐시에 저장하지 않고 바로 제거됩니다.

TEST

PreparedStatement 인터페이스의 구현체 확인

PreparedStatement에 영향을 주는 속성들을 확인해 봤으니 한번 테스트해 보겠습니다.

public static void main(String[] args) throws Exception {
    // MySQL Connector/J 설정
    String properties = "?useServerPrepStmts=false&cachePrepStmts=false";
    String jdbcUrl = "jdbc:mysql://";
    String id = "";
    String password = "";

    Connection connection = DriverManager.getConnection(jdbcUrl + properties, id, password);
    PreparedStatement stmt = connection.prepareStatement("SELECT * FROM car WHERE car_id = ?");
    System.out.println("PreparedStatement 구현체: " + stmt.getClass().getSimpleName()); // ClientPreparedStatement
    stmt.setLong(1, 1);
    ResultSet rs = stmt.executeQuery();

    while(rs.next()) {
        Car car = createCar(rs);
    }
    rs.close();
    stmt.close();
    connection.close();
}

설정 값에 따라서 어떤 구현 클래스를 반환하는지 클래스명을 출력하도록 했습니다. useServerPrepStmts 값에 따라 출력되는 클래스명이 다른 걸 확인할 수 있습니다.

useServerPrepStmts=false PreparedStatement 구현체: ClientPreparedStatement
useServerPrepStmts=true PreparedStatement 구현체: ServerPreparedStatement

캐시 설정에 따른 객체 동일성 확인

String properties = "?useServerPrepStmts=true&cachePrepStmts=true";

PreparedStatement stmt1 = connection.prepareStatement("SELECT * FROM car WHERE car_id = ?");
stmt1.close();
PreparedStatement stmt2 = connection.prepareStatement("SELECT * FROM car WHERE car_id = ?");
stmt2.close();
System.out.println(stmt1 == stmt2); // true

useServerPrepStmts 값이 true이고, cachePrepStmts 값이 true일 때 connection.prepareStatement()가 동일하게 캐시 된 객체를 반환하는지 확인해 보았습니다. 위 출력문은 cachePrepStmts 값에 따라 true/false 가 출력됩니다.

여기서 한 가지 확인할 수 있는 것이 있는데 cachePrepStmts 값이 true라고 할지라도, stmt1.close()를 호출하지 않은 상태에서 stmt2를 생성하고 비교하면 false가 나오게 됩니다. 이런 구체적인 세부구현은 궁금하다면 위 계층도에서 언급된 JdbcStatement와 ServerPreparedStatement의 doClose() 메서드와 ConnectionImpl 클래스의 구현을 참고할 수 있습니다.

또한 useServerPrepStmts 값이 false이고, cachePrepStmts 값이 true일 때는 동일한 ClientPreparedStatement 객체를 반환할 거라 예상했으나 false가 출력됐습니다.

String properties = "?useServerPrepStmts=false&cachePrepStmts=true";

PreparedStatement stmt1 = connection.prepareStatement("SELECT * FROM car WHERE car_id = ?");
stmt1.close();
PreparedStatement stmt2 = connection.prepareStatement("SELECT * FROM car WHERE car_id = ?");
stmt2.close();
System.out.println(stmt1 == stmt2); // false

내부 구현을 살펴본 결과 ClientPreparedStatement의 경우 PreparedStatement 객체 자체를 캐시 하지 않고, QueryInfo 타입을 캐시 한 후 ClientPreparedStatement는 재생성하는 형태로 구현하고 있기 때문이었습니다. 위 발췌코드는 제가 코드를 간략화하는 과정에서 자세히 언급하지 않았지만 좀 더 구체적으로는 이런 식의 구현입니다.

// https://github.com/mysql/mysql-connector-j/blob/release/9.x/src/main/user-impl/java/com/mysql/cj/jdbc/ConnectionImpl.java#L639
ClientPreparedStatement pStmt = null;
if (this.cachePrepStmts.getValue()) {
    QueryInfo queryInfo = // get by cache
    pStmt = ClientPreparedStatement.getInstance(queryInfo);
} else {
    pStmt = ClientPreparedStatement.getInstance()
}

반복 쿼리 호출을 통한 성능 테스트

속성 값 설정에 따라 설정대로 구현체를 사용하는지, 캐시를 하고 있는지를 확인해 봤습니다. 이제 설정에 따른 성능은 어떤 차이가 있는지 확인해 보겠습니다. 먼저 테스트 테이블인 car 테이블에 2만 개의 데이터를 넣은 상태에서 속성을 변경해 가면서 실행시간을 측정해 보았습니다. 테스트 코드 작성 환경은 이렇습니다.

  • PreparedStatement 캐시는 Connection 당 적용이므로 Connection 객체는 1개만 생성
  • PreparedStatement 객체는 prepareStatement() 메서드는 반복문 내에서 실행(이래야 캐시가 적용될 경우 동일한 객체를 사용, 반복문 바깥에서 호출할 경우 설정과 무관하게 동일한 객체를 사용하게 됨)
  • 쿼리 종료 후 반복문 내에서 ResultSet과 PreparedStatement close() 호출(이렇게 해야 다음 반복에서 prepareStatement()가 동일 객체 반환)
public static void main(String[] args) throws Exception {
    String properties = "?useServerPrepStmts=false&cachePrepStmts=false"; // 이 부분 속성값을 변경하며 실행
    String jdbcUrl = "jdbc:mysql://";
    String id = "";
    String password = "";

    Connection connection = DriverManager.getConnection(jdbcUrl + properties, id, password);
    long start = System.currentTimeMillis();
    for(int i = 0; i < 20000; i++) {
        PreparedStatement stmt = connection.prepareStatement("SELECT * FROM car WHERE car_id = ?");
        stmt.setLong(1, i+1);
        ResultSet rs = stmt.executeQuery();

        rs.close();
        stmt.close();
    }
    long end = System.currentTimeMillis();
    System.out.println("실행시간: " + (end - start) + "ms");
    connection.close();
}
번호속성값MySQL PREPARE 실행여부PreparedStatement 객체 캐시여부실행시간비고
1useServerPrepStmts=true cachePrepStmts=true⭕️⭕️244,562ms 약 4분처음에 MySQL에 PREPARE 실행 후 끝까지 재활용
2useServerPrepStmts=true cachePrepStmts=false⭕️1,292,160ms 약 21분매번 쿼리 실행시마다 MySQL에 PREPARE 실행, 삭제 반복
3useServerPrepStmts=false cachePrepStmts=true⭕️235,395ms 약 4분MySQL에 PREPARE 실행하지 않으며, PreparedStatement 재활용
4useServerPrepStmts=false cachePrepStmts=false230,095ms 약 4분MySQL에 PREPARE 실행하지 않으며, PreparedStatement 재생성

의외로 한 가지 케이스 외에는 실행시간에 큰 차이를 보이지 않습니다. 2번 케이스 같은 경우 2만 번의 SELECT 쿼리를 실행하는데 MySQL 서버에도 prepare와 deallocate 명령을 2만 번 실행하게 됩니다. 즉 캐시를 하지만 캐시를 전혀 사용하지 않으며 성능면에서 압도적으로 좋지 않습니다. 그 외에는 테이블 내에서 오차는 있지만 수십 번 실행했을 때 편차를 감안하면 크게 유의미한 차이로 보이지 않았습니다. 기대결과는 1번 설정이 가장 성능이 좋을 거라고 예상했기에, 성능테스트를 한 후 뙇! 하고 멋진 결과와 최적의 설정을 공유하고 싶었지만 하나의 Connection을 얻어서 하나의 쿼리만 반복적으로 실행하는 환경에서는 나머지 3개의 설정이 크게 차이가 나지 않았습니다. 함께한 동료는 그만큼 MySQL의 쿼리 파싱 능력이 우수하다는 반증이 아닐까라는 의견도 덧붙였습니다. 한 가지 고려해야 하는 점은 테스트에 사용된 쿼리가 매우 단순하다는 점입니다. 단순한 쿼리로 테스트를 실행했기에 쿼리 파싱 비용이 크지 않았을 것으로 보입니다. 엔터프라이즈 환경에서는 더 다양한 종류와 복잡한 쿼리가 있을 테니 테스트때와는 결과가 다를 수 있을 거라 생각합니다.

Hibernate의 커밋터인 Vlad mihalcea도 비슷한 테스트를 진행했는데 의외로 테스트 환경에서는 큰 차이가 나지 않는다고 언급한 글도 있습니다. 위 설정에 대한 가이드는 HikariCP에서도 제공하고 있으니, 참고해 보시면 좋겠습니다.

유의사항

일반적으로 엔터프라이즈 애플리케이션에서 권장하는 모범사례는 위에서 언급한 4개의 옵션을 모두 활성화하여 MySQL 서버 캐시를 사용하는 것입니다. 이때 캐시를 설정을 조절할 때 유의해야 할 사항들을 공유합니다.

MySQL에서 생성할 수 있는 prepared statement 개수

MySQL 서버에는 생성할 수 있는 prepared statement의 개수를 지정할 수 있습니다. 기본값은 16382개이며 이를 초과할 경우 기존껄 지우는 것이 아니라 1461 에러를 일으키므로 주의해야 합니다. 때문에 MySQL에서 해당값을 조절하거나 애플리케이션에서는 prepStmtCacheSize 값을 조절해야 합니다.

아래 쿼리를 이용하여 MySQL에 설정된 최대 개수 를 확인할 수 있습니다.

SHOW VARIABLES LIKE 'max_prepared_stmt_count';

MySQL Connector/J에서 PreparedStatement를 캐시 하는 기준

HikariCP가 Connection Pool에서 PreparedStatement를 캐시 하지 않는 이유로 설명한 내용 중엔 JDBC Driver에서 영리하게 구현할 경우 Connection 당 캐시가 아니라 Connection끼리 동일쿼리에 대해선 캐시를 공유하도록 구현할 수도 있다는 내용이 있었는데요. MySQL Connector/J는 HikariCP의 바람과는 다르게 Connection 당 캐시로 구현되어 있습니다. 때문에 동일 쿼리라 할지라도 실행하는 Connection이 다르다면 Connection 개수만큼 prepared statement가 캐시 됩니다.

마치며

JDBC 연결 설정에 대한 모범사례들을 찾아보면 useServerPrepStmts, cachePrepStmts은 모두 true로 설정하고, prepStmtCacheSize, prepStmtCacheSqlLimit은 적절한 값을 넣어 이번 글에서 언급한 설정들은 모두 이용하는 걸 권장하고 있습니다. 이 글은 각 값을 어떻게 설정하는 것이 좋은지에 대한 모범사례는 다루지 않습니다. 단순히 JDBC 연결 설정 시 신경 써야 하는 속성들과 모범사례만 검색해서 적용했다면 이런 긴 분량의 글도 필요하지 않았을 것입니다.

부끄럽지만 저 같은 경우 JDBC 설정에 대해 속성 하나하나를 이해하고 제어하기보다는 기존에 누군가가 넣어놨던 설정을 의심도, 이해도 없이 갖다 쓰는 경우가 많았습니다. 하지만 추상화 계층 속에서 실제로 어떻게 구현되어 있고, 어떻게 동작하는지 궁금했고 Hibernate, HikariCP, MySQL Connector/J의 일부 구현레벨까지 볼 수 있는 좋은 기회였습니다. 오픈소스의 구현 코드를 보는 것, 특히나 이미 오랜 시간 사용되어 널리 이용되는 오픈소스 코드를 보는 건 마냥 쉬운 일은 아니었지만 세부 구현내용을 보면서 해당 부분에 대한 이해도가 많이 높아졌습니다. 다만 유의해야 할 점은 구현내용은 개발자들이 알고 있으면 좋지만 코드가 구현에 의존해서는 안된다는 점입니다. 세부 구현은 언제든 변경될 수 있으니까요.

이 글은 이만 마치겠습니다. 저와 같은 궁금증을 가지셨던 분들에게 도움이 됐으면 좋겠습니다.

cdragon.cd
cdragon.cd

카카오페이 서버개발자 cdragon입니다. 견고한 애플리케이션을 만드는데 관심이 많습니다. 한적한 카페에서 책 보는 걸 즐깁니다.

태그