초창기 SQL 인터페이스에서는 모든 쿼리를 문자열로 조합해 실행했습니다. 예를 들어 "SELECT * FROM user WHERE name = '" + name + "'" 같은 방식입니다. 이런 구조는 다음과 같은 문제를 가지고 있었습니다:
이러한 문제를 해결하기 위해 Prepared Statement(준비된 쿼리) 개념이 등장했습니다. 미리 SQL 구조를 컴파일하고, 실행 시점에는 파라미터만 바인딩하는 방식입니다.
MySQL에서 Prepared Statement는 MySQL 4.1 (2004년) 버전부터 도입되었습니다. 이는 MySQL 서버가 처음으로 바이너리 프로토콜(Binary Protocol) 기반의 서버사이드 Prepared Statement를 지원하기 시작한 시점입니다.
JDBC 드라이버(Connector/J)는 이 기능을 감싸는 형태로 발전했습니다. 초창기에는 Client-side prepared statement만 존재했지만, useServerPrepStmts=true 옵션을 통해 실제 서버 커서 기반 실행이 가능해졌습니다. Hibernate, JPA 역시 내부적으로 JDBC PreparedStatement를 사용합니다.
Prepared Statement는 크게 3단계로 처리하게 되는데요,
PREPAREEXECUTEDEALLOCATE각 단계의 의미를 간단히 정의하면 다음과 같습니다.
PREPARE
서버가 전달받은 SQL 문장을 컴파일 및 최적화하여 실행 계획을 세우는 단계입니다.
이때 서버는 해당 쿼리에 대한 내부 식별자(statement_id)를 생성하고, 이를 통해 나중에 동일한 SQL 구조를 재사용할 수 있습니다.
EXECUTE
실행 시점에서 실제 파라미터 값들을 바인딩하여 준비된 쿼리를 수행하는 단계입니다.
이미 컴파일된 실행 계획을 재사용하므로, 매 실행마다 SQL 파싱이나 최적화 과정을 거치지 않습니다.
DEALLOCATE
더 이상 Prepared Statement를 사용할 필요가 없을 때, 서버 리소스를 해제하는 단계입니다.
MySQL은 각 Prepared Statement를 메모리 상에 유지하기 때문에, 명시적으로 DEALLOCATE하지 않으면 세션이 끝날 때까지 남게 됩니다.
먼저 예시를 보겠습니다.
PREPARE stmt FROM 'SELECT * FROM users WHERE id = ?';
SET @id = 10;
EXECUTE stmt USING @id;
DEALLOCATE PREPARE stmt;
String sql = "SELECT * FROM users WHERE id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setInt(1, 10);
ResultSet rs = ps.executeQuery();
이 방식은 쿼리 구조를 한 번만 파싱하고, 여러 번 재사용할 수 있습니다.
MySQL 서버와 클라이언트는 다음 4단계를 거칩니다:
Prepare (COM_STMT_PREPARE)
SQL 텍스트를 서버로 전송하고, 서버는 이를 파싱 및 컴파일하여 statement_id를 부여합니다.
Execute (COM_STMT_EXECUTE)
실행 시점에 바인딩된 파라미터와 함께 statement_id를 기반으로 실행 요청을 보냅니다.
Fetch (COM_STMT_FETCH)
커서 기반으로 데이터를 배치 단위로 가져옵니다. (옵션적으로 사용)
Close (COM_STMT_CLOSE)
실행 완료 후 서버 리소스를 해제합니다.
Prepared Statement는 MySQL의 바이너리 프로토콜(Binary Protocol) 을 사용합니다. 텍스트 기반 쿼리(COM_QUERY)와는 완전히 다릅니다.
| 단계 | 명령 | 설명 |
|---|---|---|
| 1 | COM_STMT_PREPARE | SQL을 서버에 준비 요청 |
| 2 | COM_STMT_EXECUTE | 준비된 쿼리 실행 (파라미터 포함) |
| 3 | COM_STMT_FETCH | (커서 기반일 경우) 데이터 청크 단위로 가져옴 |
| 4 | COM_STMT_CLOSE | 리소스 해제 |
이때 MySQL 서버는 Prepared Statement를 “컴파일된 쿼리 캐시”로 관리합니다. 클라이언트는 동일한 statement_id로 여러 번 실행할 수 있습니다.
이점
주의점
statement_id가 많아지면 서버 리소스를 많이 점유합니다.COM_STMT_FETCH)이 완벽히 지원되지 않을 수 있습니다.MySQL 서버는 세션별로 Prepared Statement를 메모리에 보관합니다.
이때 각 Prepared Statement는 내부적으로 statement_id → 실행 계획(Execution Plan) 형태로 매핑되어 캐시됩니다.
만약 캐시가 포화 상태가 되면 다음과 같은 현상이 발생합니다:
새로운 PREPARE 요청 시 에러 발생
서버는 더 이상 새로운 statement_id를 발급할 수 없으며,
Prepared statement needs to be deallocated before it can be re-prepared 와 같은 오류가 발생할 수 있습니다.
서버 메모리 사용량 증가
재사용되지 않는 Prepared Statement가 누적되면,
세션 메모리(performance_schema, max_prepared_stmt_count)가 점점 커져 OOM(Out of Memory)을 유발할 수 있습니다.
성능 저하 및 세션 누수
Statement 캐시가 꽉 차면 새 PREPARE가 실패하거나, 오래된 Statement가 강제 해제되며
재컴파일이 반복되어 성능이 일시적으로 저하됩니다.
해결 방법은 다음과 같습니다:
DEALLOCATE PREPARE 또는 PreparedStatement#close()로 해제합니다.max_prepared_stmt_count 파라미터를 조정하여 서버가 동시에 유지할 수 있는 Prepared Statement 개수를 늘립니다.cachePrepStmts, prepStmtCacheSize)합니다.즉, Prepared Statement 캐시는 유한하며, 지속적으로 PREPARE만 반복하고 DEALLOCATE하지 않으면 결국 메모리 누수로 이어질 수 있습니다.
⚠️ 클라이언트가 DEALLOCATE 하기 전에 죽어버리면요?
연결된 세션이 끝나면, MySQL이 자동으로 해당 세션에 붙어있던 Prepared Statement를 전부 deallocate 하기 때문에, 걱정할 필요는 없습니다.
Prepared Statement는 단순한 성능 최적화 기술이 아니라, 안전성과 일관성을 확보하기 위한 DB 통신 패턴의 진화형입니다.
PostgreSQL에서도 물론 마찬가지로 Prepared Statement를 지원하는데요, SQL표준에 포함된 기능이기 때문입니다. MySQL보다 훨씬 먼저 도입되었습니다.