참고 문서: 우리의 애플리케이션에서 PreparedStatement는 어떻게 동작하고 있는가 | 카카오페이 기술 블로그
JDBC의 PreparedStatement가 실제로 어떻게 동작하는지 MySQL Connector/J 설정값(useServerPrepStmts, cachePrepStmts)을 바꿔가며 실습한 코드입니다.
JDBC URL에 아래 파라미터를 추가하여 동작을 제어합니다.
jdbc:mysql://host:port/schema?useServerPrepStmts=true&cachePrepStmts=true
| 설정 | 기본값 | 설명 |
|---|---|---|
useServerPrepStmts |
false | true면 MySQL 서버에 실제로 COM_STMT_PREPARE 패킷을 전송하여 서버 측에서 쿼리를 파싱/저장. false면 MySQL에 PREPARE를 보내지 않고 JVM 레벨에서만 처리 |
cachePrepStmts |
false | true면 Connector/J가 PreparedStatement 객체를 커넥션 단위로 캐시하여 재사용. false면 매번 새로 생성 |
prepStmtCacheSize |
25 | 커넥션당 캐시할 PreparedStatement 최대 개수 (LRU 방식) |
prepStmtCacheSqlLimit |
256 | 캐시할 쿼리의 최대 바이트 수. 초과 시 캐시하지 않음 |
useServerPrepStmts=true이면 쿼리 실행 시 아래와 같이 MySQL 서버와 패킷을 주고받습니다. 각 줄이 네트워크 통신 1회입니다.
[Java App] [MySQL Server]
| |
| --- COM_STMT_PREPARE (패킷) -------> | 쿼리 파싱 및 실행계획 수립
| <-- OK + statement_id 반환 --------- |
| |
| --- COM_STMT_EXECUTE (패킷) -------> | 파라미터만 바인딩하여 즉시 실행
| (statement_id + 파라미터 포함) |
| <-- ResultSet 반환 ----------------- |
| |
| --- COM_STMT_CLOSE (패킷) ---------> | DEALLOCATE (응답 없음, 단방향)
| (statement_id 포함) |
cachePrepStmts=true일 때, prepareStatement를 생성할 때 캐시에서 가져오고 해당 statement를 close할 때 다시 캐시에 객체를 반납합니다.
prepareStatement() 캐시에서 remove, IN_USE 상태
execute()
close() 캐시에 다시 put → recache
stmt.close() 호출 시 내부적으로 아래 메서드가 실행됩니다.
// ConnectionImpl.java (MySQL Connector/J)
@Override
public void recachePreparedStatement(JdbcPreparedStatement pstmt) throws SQLException {
synchronized (getConnectionMutex()) {
if (this.cachePrepStmts.getValue() && pstmt.isPoolable()) {
synchronized (this.serverSideStatementCache) {
Object oldServerPrepStmt = this.serverSideStatementCache.put(
new CompoundCacheKey(pstmt.getCurrentDatabase(), ((PreparedQuery) pstmt.getQuery()).getOriginalSql()),
(ServerPreparedStatement) pstmt);
if (oldServerPrepStmt != null && oldServerPrepStmt != pstmt) {
((ServerPreparedStatement) oldServerPrepStmt).isCached = false;
((ServerPreparedStatement) oldServerPrepStmt).setClosed(false);
((ServerPreparedStatement) oldServerPrepStmt).realClose(true, true);
}
}
}
}
}이름이 recache인 이유는, 처음 캐시에 넣는 것이 아니라 꺼내서 쓰고 다시 돌려놓는 개념이기 때문입니다.
동작 흐름:
close() 호출
↓
recachePreparedStatement()
↓
LRUCache에 put()
↓
밀려난 oldStmt가 있는가?
├── null → 새로 추가됨, 완료
├── 자기 자신 → 중복 put, 무시
└── 다른 객체 → LRU로 밀려난 것
→ isCached = false
→ setClosed(false) ← realClose() 내부 가드 우회
→ realClose() → COM_STMT_CLOSE 전송 → MySQL에서 삭제
PrepStmtWithClose.java — close()를 호출한 경우
PREPARE 1회
EXECUTE 1회 → close() → 캐시에 반납
EXECUTE 1회 → close() → 캐시에서 꺼냄 (캐시 히트)
stmt1 == stmt2: true
PrepStmtWithoutClose.java — close()를 호출하지 않은 경우
PREPARE 1회 → IN_USE 상태
EXECUTE 1회
PREPARE 1회 → 캐시에 IN_USE 객체가 있으므로 히트 실패, 새로 PREPARE
EXECUTE 1회
stmt1 == stmt2: false
ComplexQueryTest.java
복잡한 쿼리(조인, 서브쿼리 등)를 대상으로 캐시 설정에 따른 성능 차이를 측정했습니다.
설정을 바꿔도 실행시간에 유의미한 차이가 없었습니다.
prepStmtCacheSqlLimit의 기본값은 256바이트입니다. 복잡한 쿼리는 이 크기를 초과하는 경우가 많아 캐시 대상에서 제외됩니다.
// isPoolable() 내부 - 쿼리가 너무 길면 캐시 자격 박탈
if (sql.length() > this.prepStmtCacheSqlLimit.getValue()) {
return false; // 캐시 안 함
}해결 방법으로는 prepStmtCacheSqlLimit 값을 늘리는 것이 있습니다.
?prepStmtCacheSqlLimit=2048
MaxPrepStmtCountTest.java
MySQL 서버의 max_prepared_stmt_count를 초과할 경우 1461 에러가 발생하는지 확인합니다.
-- prepared statement 최대 개수를 50으로 축소
SET GLOBAL max_prepared_stmt_count = 50;
SHOW VARIABLES LIKE 'max_prepared_stmt_count';
-- 실행 전후 상태 확인용 쿼리
SHOW GLOBAL STATUS LIKE 'Com_stmt_prepare'; -- PREPARE된 누적 횟수
SHOW GLOBAL STATUS LIKE 'Com_stmt_close'; -- CLOSE된 누적 횟수
SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count'; -- 현재 활성 상태인 개수useServerPrepStmts=true, cachePrepStmts=false 설정으로 실행했습니다.
Com_stmt_prepare : 440,412
Com_stmt_close : 220,000
Prepared_stmt_count: 50
- 애플리케이션 콘솔에 1461 에러는 찍히지 않았습니다.
Prepared_stmt_count가max_prepared_stmt_count(=50)와 동일한 값으로 나왔습니다.Com_stmt_prepare와Com_stmt_close의 차이가 큽니다.
⚠️ 이 결과에 대한 원인은 아직 분석 중입니다.
참고:
Com_stmt_prepare,Com_stmt_close는 MySQL 서버가 재시작되기 전까지 누적되는 값입니다. 정확한 측정을 위해 실험 전FLUSH STATUS로 카운터를 초기화하는 것을 권장합니다.
| useServerPrepStmts | cachePrepStmts | MySQL PREPARE | 객체 캐시 | 특이사항 |
|---|---|---|---|---|
| true | true | ✅ 최초 1회 | ✅ | 권장 설정 |
| true | false | ✅ 매번 | ❌ | 네트워크 3배, 최악의 성능 |
| false | true | ❌ | ✅ (QueryInfo 캐시) | MySQL엔 항상 Statement로 전달 |
| false | false | ❌ | ❌ | 기본값 |