Prepared Statement이 정확히 뭘까
SQL 문자열을 SQLite에 넘기면, 실제로 한 행이라도 움직이기 전에 꽤 많은 일이 벌어집니다. 토큰으로 쪼개고, 파싱하고, 테이블과 컬럼이 진짜 존재하는지 확인하고, 실행 계획을 세우고, 그 계획을 SQLite 가상 머신용 바이트코드로 컴파일하죠. 이 모든 과정이 끝나야 비로소 쿼리가 돌아갑니다.
_Prepared statement_는 바로 이 "바이트코드로 컴파일된" 결과물을 그대로 손에 쥐고 있는 상태입니다. 컴파일된 프로그램에는 빈자리 — 즉 플레이스홀더 — 가 있고, 실제 값은 나중에 채워 넣습니다. 같은 프로그램을 값만 바꿔가며 여러 번 실행할 수 있고, 신뢰할 수 없는 입력값을 넣어도 안전하게 돌릴 수 있습니다.
비유하자면 이렇습니다. 요리할 때마다 레시피를 처음부터 읽어주는 것과, 레시피를 한 번 가르쳐 두고 그날그날 재료 이름만 알려주는 것의 차이라고 보면 됩니다.
생명주기: prepare → bind → step → finalize
언어를 막론하고 모든 SQLite 드라이버는 결국 동일한 네 개의 C API를 감싸고 있습니다. C 코드를 직접 짤 일이 없더라도 이 이름들은 알아두는 게 좋습니다. 에러 메시지나 공식 문서가 전부 이 용어를 쓰거든요.
sqlite3_prepare_v2— SQL 문자열을 statement 핸들로 컴파일합니다.sqlite3_bind_*— 플레이스홀더에 값을 채워 넣습니다 (타입마다 함수가 따로 있습니다).sqlite3_step— 프로그램을 실행합니다.SELECT라면 행을 하나씩 훑기 위해 반복 호출하고,INSERT/UPDATE/DELETE라면 한 번 호출로 끝납니다.sqlite3_finalize— 다 쓴 컴파일 결과물을 해제합니다.
실행이 끝난 statement는 sqlite3_reset으로 되감으면, 다시 prepare할 필요 없이 새 값을 바인딩해서 또 실행할 수 있습니다. prepared statement 재사용의 핵심이 바로 이겁니다.
SQL 안의 플레이스홀더
SQL 문자열 안에서는 값을 직접 끼워 넣는 대신, 값이 들어갈 자리마다 플레이스홀더를 표시합니다. SQLite는 다음과 같은 몇 가지 형태를 지원합니다.
-- 익명, 위치 기반:
INSERT INTO users (name, email) VALUES (?, ?);
-- 번호 기반:
INSERT INTO users (name, email) VALUES (?1, ?2);
-- 이름 기반:
INSERT INTO users (name, email) VALUES (:name, :email);
INSERT INTO users (name, email) VALUES (@name, @email);
INSERT INTO users (name, email) VALUES ($name, $email);
?는 드라이버 레벨 코드에서 가장 흔하게 쓰입니다. 파라미터가 여러 개거나 같은 값이 여러 번 등장하는 경우에는 이름 있는 플레이스홀더(:name)가 훨씬 읽기 좋습니다. 프로젝트마다 한 가지 스타일을 정해서 일관되게 쓰는 걸 추천합니다.
절대 하지 말아야 할 것은 문자열을 이어 붙여서 쿼리를 만드는 방식입니다:
-- これは絶対にやらないこと:
"INSERT INTO users (name) VALUES ('" + user_input + "')"
그렇게 짜면 SQL 인젝션의 지름길이고, 곧 설명할 바이트코드 재사용 이점도 모두 날아갑니다.
SQL만으로 보는 prepared statement 예제
호스트 언어 없이 prepare/bind/step의 동작 원리를 살펴보기 위해, SQLite가 기본으로 제공하는 SQL 기능만 사용해 같은 흐름을 재현해 보겠습니다. 먼저 테이블을 만들고, 파라미터 플레이스홀더 자리에 리터럴 값을 넣어 한 행을 INSERT 합니다:
실제 애플리케이션에서는 값을 SQL 문자열에 직접 박아 넣지 않습니다. INSERT 구문을 ?, ? 플레이스홀더와 함께 한 번만 prepare해 두고, 사용자마다 이름과 이메일 쌍을 bind한 뒤 step을 호출하는 방식이죠. 컴파일된 바이트코드는 호출마다 똑같고, 바뀌는 건 바인딩되는 값뿐입니다.
prepared statement 재사용으로 얻는 성능 이점
다음은 드라이버가 제공하는 전형적인 패턴입니다. 언어마다 표기법이 조금씩 다르긴 하지만 흐름 자체는 어디서나 똑같으니, 아래 의사 코드로 감을 잡아 보세요.
-- 한 번만 준비:
INSERT INTO users (name, email) VALUES (?, ?);
-- 그런 다음, 루프에서:
-- bind(1, name)
-- bind(2, email)
-- step()
-- reset()
준비(prepare) 단계에서 SQL을 한 번만 파싱하고 컴파일합니다. 이후 반복 실행할 때는 바이트코드만 돌리면서 슬롯에 값을 넣어줄 뿐이죠. 대량 INSERT(예: 10만 건 임포트)에서는 매번 파싱되는 문장을 10만 번 실행하는 것보다 훨씬 빠릅니다 — 보통 한 자릿수 자릿수 차이가 나고, 하나의 트랜잭션으로 묶으면 차이가 더 벌어집니다.
흔히 저지르는 실수: 루프 안에서 prepare를 호출하는 패턴입니다. 이러면 prepared statement의 이점이 통째로 날아갑니다. prepare는 루프 바깥에서 한 번, 그리고 바인딩과 step만 루프 안에서 돌리세요.
왜 이 방식이 안전한가
바인딩된 파라미터는 SQL 문자열에 끼워 넣어지는 값이 아닙니다. 타입이 정해진 슬롯 — 정수 슬롯, 텍스트 슬롯, blob 슬롯 — 을 통해 바이트코드 프로그램에 전달되는 값입니다. SQLite는 이 값을 절대 SQL로 다시 파싱하지 않기 때문에, 어떤 값을 넣어도 쿼리의 구조 자체는 바뀔 수 없습니다. 이게 바로 SQL 인젝션이 원천적으로 막히는 이유죠.
비교해 봅시다:
-- 脆弱。user_input が次のような場合: '); DROP TABLE users;--
-- クエリは破壊的になります。
"SELECT * FROM users WHERE name = '" + user_input + "'"
-- 安全。user_input は TEXT 値としてバインドされ、内容に関わらず
-- 常に文字列として比較されます。
SELECT * FROM users WHERE name = ?;
두 번째 방식은 user_input이 '); DROP TABLE users;-- 같은 값이어도 안전합니다. SQLite는 이 괴상한 문자열을 이름으로 가진 사용자를 성실히 찾고, 결과가 없으니 0행을 돌려줄 뿐입니다. 값이 무엇이든 쿼리의 구조는 절대 바뀌지 않습니다.
SQL 인젝션은 이후 문서에서 더 깊이 다루겠지만, 핵심은 이겁니다. prepared statement는 SQL 인젝션을 막는 하나의 방어책이 아니라, 바로 그 방어책입니다.
행을 반환하는 statement 다루기
SELECT 쿼리의 경우 step은 한 번에 한 행씩 결과를 돌려줍니다. 그래서 보통은 "끝났다"는 신호가 나올 때까지 드라이버에서 반복문을 돌립니다:
애플리케이션 코드에서는 드라이버가 해당 SELECT 문의 2.00 자리에 ?를 넣고 prepare한 다음, 임계값을 바인딩하고 step을 루프로 호출하면서 한 번에 한 행씩 읽어옵니다. 마지막 행을 읽고 나면 step이 완료를 알리고, 드라이버는 새 임계값으로 다시 실행하기 위해 statement를 reset하거나, 아예 finalize해서 정리합니다.
finalize를 잊지 마세요
prepared statement는 SQLite 내부에 잡혀 있는 작은 할당입니다. 이걸 흘리면 메모리도 새지만, 더 중요한 건 데이터베이스에 내부 락을 잡고 있어서 다른 쓰기 작업을 막을 수 있다는 점이죠. 다행히 어떤 드라이버든 자동으로 정리해 주는 수단을 제공합니다 — Python의 컨텍스트 매니저, C#의 using 블록, C++의 RAII 같은 것들이요. 가급적 이런 메커니즘을 쓰세요:
- Python의
sqlite3는 커서가 가비지 컬렉션될 때 finalize되지만,cursor.close()를 명시적으로 호출하는 편이 더 깔끔합니다. - better-sqlite3(Node)는
Statement객체가 GC될 때 finalize됩니다. 장기간 살아있는 prepared statement도 문제 없어요. - 순수 C에서는
sqlite3_finalize를 직접 호출해야 합니다. 빠뜨리면 진짜 버그가 됩니다.
기억할 원칙은 하나입니다. prepare한 게 있다면, 어딘가에서 반드시 finalize해야 한다.
직접 호출할 일이 많지 않을 수도 있습니다
sqlite3_prepare_v2를 직접 호출할 일은 많지 않습니다. 고수준 드라이버는 connection.execute("SELECT ... WHERE id = ?", (42,)) 한 줄을 알아서 prepare → bind → step → finalize로 풀어줍니다. 그럼에도 이 라이프사이클을 이해해야 하는 이유는 이렇습니다:
- "statement is busy"나 "cannot operate on a finalized statement" 같은 에러 메시지가 떴을 때 무슨 일이 벌어진 건지 바로 감이 옵니다.
- 빡빡한 루프 안에서 INSERT를 반복할 때, 장기간 사용할 prepared statement를 캐싱해야 한다는 판단을 내릴 수 있습니다.
- 문자열 연결이 편해 보일 때조차 본능적으로 파라미터화된 쿼리를 작성하게 됩니다 — 이게 곧 SQL 인젝션 방어로 이어지죠.
ORM이나 쿼리 빌더는 여기서 한 걸음 더 나아갑니다. SQL을 직접 만들고, prepared statement를 관리하고, 타입이 잡힌 결과까지 돌려주죠. 그래도 내부에서 일어나는 일은 똑같은 네 가지 호출입니다.
다음 글: 파라미터 바인딩
지금까지는 플레이스홀더를 추상적으로만 다뤘습니다. 다음 글에서는 sqlite 파라미터 바인딩을 본격적으로 들여다봅니다 — 위치 기반 vs 이름 기반 파라미터, 타입 처리, NULL 다루기, 그리고 실제 애플리케이션 데이터를 쿼리에 흘려 넣기 시작할 때 마주치는 자잘한 함정들까지요.
자주 묻는 질문
SQLite의 prepared statement란 무엇인가요?
한 줄로 말하면, SQL 쿼리를 미리 파싱하고 컴파일해서 재사용 가능한 바이트코드 프로그램으로 만들어 둔 것입니다. 다만 값이 들어갈 자리는 ?나 :name 같은 플레이스홀더로 비워두고, 실행 시점에 값을 따로 바인딩하죠. SQLite는 이 과정을 sqlite3_prepare_v2, sqlite3_bind_*, sqlite3_step, sqlite3_finalize API로 노출합니다.
왜 prepared statement를 써야 하나요?
이유는 크게 두 가지, 보안과 속도입니다. 바인딩된 파라미터는 SQL 문법으로 해석되지 않기 때문에 SQL injection이 원천적으로 차단됩니다. 그리고 같은 쿼리를 반복 실행할 때 — 예를 들어 10,000건을 INSERT한다고 하면 — 한 번 prepare해 두고 값만 다시 바인딩하면 매번 파서를 거치지 않아도 되니 성능 차이가 눈에 띄게 납니다.
prepared statement와 그냥 쿼리는 뭐가 다른가요?
sqlite3_exec로 한 번에 실행하는 일반 쿼리는 SQL 문자열 안에 값을 그대로 끼워 넣어 파싱과 실행을 동시에 처리합니다. 반면 prepared statement는 컴파일과 실행을 분리합니다. SQL을 한 번 prepare한 뒤, 플레이스홀더에 타입이 있는 값을 bind하고, 결과를 step으로 순회한 다음, reset으로 초기화해서 다시 쓸 수 있죠. Python의 sqlite3, better-sqlite3 같은 상위 레벨 드라이버들도 내부적으로는 전부 prepared statement를 사용합니다.