Menu
Playground에서 시도하기

SQLite 파라미터 바인딩: ?와 :name 안전하게 쓰기

SQLite에서 파라미터 바인딩이 어떻게 동작하는지 정리했습니다. 위치 기반 ? 플레이스홀더와 :name 형태의 이름 기반 파라미터, 그리고 값을 안전하게 전달하는 규칙까지 살펴봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 — 편집하고 실행하면 결과를 바로 볼 수 있습니다.

바인딩으로 prepared statement에 값 채워 넣기

prepared statement는 군데군데 구멍이 뚫린 SQL이라고 보면 됩니다. 바인딩 은 그 구멍을 값으로 메우는 작업인데, 문자열을 이리저리 이어 붙이는 게 아니라 드라이버 API를 통해 한 개씩 안전하게 꽂아 넣는 방식입니다.

흐름은 늘 똑같습니다. 먼저 플레이스홀더가 들어간 SQL을 작성하고, 값은 따로 분리해서 넘겨주는 거죠.

CLI 환경에서는 바인딩을 제대로 보여주기 어렵습니다. 셸에는 애플리케이션 코드가 붙어 있지 않으니까요. 하지만 위 SQL이 바로 여러분의 애플리케이션이 실제로 전송하는 모습입니다. ? 기호가 바로 SQLite 플레이스홀더죠. Python의 sqlite3, Node의 better-sqlite3, Rust의 rusqlite 같은 드라이버가 별도의 bind 호출을 통해 이 자리에 값을 채워 넣습니다.

이렇게 생각하면 쉽습니다. SQL은 레시피, 바인딩되는 값은 재료입니다. 둘은 절대 섞이지 않습니다.

위치 기반 플레이스홀더: ?

가장 단순한 형태의 SQLite ? 플레이스홀더입니다. 바인딩한 값이 순서대로 하나씩 ? 자리에 들어갑니다.

INSERT INTO users (name, email) VALUES (?, ?);

Python에서는 다음과 같이 작성합니다:

cursor.execute(
    "INSERT INTO users (name, email) VALUES (?, ?)",
    ("Rosa", "rosa@example.com"),
)

첫 번째 ?에는 "Rosa"가, 두 번째에는 "rosa@example.com"가 들어갑니다. 값을 너무 적게 넘기거나 반대로 너무 많이 넘기면, 쿼리가 실행되기도 전에 드라이버에서 에러가 발생합니다.

?1, ?2, ?3처럼 번호를 직접 매겨서 쓸 수도 있는데, 같은 값이 여러 번 등장할 때 특히 편리합니다:

SELECT ?1 AS greeting, ?1 AS still_the_same;

?1을 쓰면 첫 번째로 바인딩한 값을 그대로 재사용할 수 있습니다. 번호를 붙이지 않으면 같은 값을 두 번 바인딩해야 하죠.

이름 파라미터: :name

플레이스홀더가 두세 개를 넘어가는 순간, 위치 기반 바인딩은 곧장 숫자 맞추기 퍼즐이 되어버립니다. 이럴 때 SQLite 이름 파라미터를 쓰면 깔끔하게 해결됩니다:

INSERT INTO users (name, email)
VALUES (:name, :email);

파이썬에서:

cursor.execute(
    "INSERT INTO users (name, email) VALUES (:name, :email)",
    {"name": "Boris", "email": "boris@example.com"},
)

딕셔너리에 들어가는 키의 순서는 중요하지 않습니다. 이름만 맞으면 됩니다. SQLite는 @name$name도 대체 접두사로 받아들이는데, 동작 방식은 모두 동일합니다. 그중에서도 :name 형태가 압도적으로 많이 쓰입니다.

이런 SQLite 이름 파라미터 방식은 다섯 개 컬럼을 한꺼번에 업데이트하는 UPDATE 문이나, 같은 값을 WHERERETURNING에서 모두 써야 하는 쿼리에서 진가를 발휘합니다.

NULL 바인딩하기

NULL을 넣는 올바른 방법은 사용 중인 언어의 null 값을 그대로 바인딩 API에 넘기는 것입니다. 변환은 드라이버가 알아서 처리해 줍니다:

INSERT INTO users (name, email) VALUES (?, ?);
-- バインド: ("Cyrus", None)   Python の場合
-- バインド: ["Cyrus", null]   Node の場合

SELECT id, name, email FROM users;

None이든 null이든 nil이든, 사용하는 언어에서 부르는 이름이 무엇이든 드라이버가 알아서 SQL NULL로 변환해 줍니다. 문자열 "NULL"을 바인딩하면 안 됩니다. 그러면 네 글자짜리 텍스트 "NULL"이 그대로 저장됩니다. 또한 NULL이라는 단어를 SQL 문자열에 직접 끼워 넣어서도 안 됩니다. 그러면 바인딩의 의미 자체가 사라지니까요.

숫자, BLOB, 날짜도 마찬가지 원칙입니다. 네이티브 값 그대로 넘기고, 바인딩은 드라이버에 맡기세요.

같은 statement를 여러 값으로 재사용하기

파라미터 바인딩은 prepared statement와 짝을 이룰 때 가장 빛을 발합니다. 한 번 prepare 해두고, 값만 바꿔가며 바인딩과 실행을 반복하는 방식입니다. 파서는 단 한 번만 동작하고, 그 뒤로는 바인딩되는 값마다 컴파일된 실행 계획을 그대로 재사용합니다.

INSERT INTO users (name, email) VALUES (?, ?);
-- ("Ada",   "ada@example.com") をバインド   -> 実行
-- ("Boris", "boris@example.com") をバインド -> 実行
-- ("Cyrus", NULL) をバインド                -> 実行

SELECT id, name, email FROM users ORDER BY id;

대부분의 드라이버는 이 과정을 executemany(Python)나 .run() 반복문(Node) 같은 형태로 감싸 둡니다. 어떤 방식이든, 절약되는 건 결국 파싱 비용인데요. 한 문장 단위로는 미미하지만 수천 건을 INSERT하는 상황이라면 무시할 수 없는 차이가 됩니다.

한 문장에 여러 플레이스홀더 스타일을 섞지 말 것

SQLite는 기술적으로 위치 기반 플레이스홀더와 이름 파라미터를 한 문장 안에서 함께 쓰는 걸 허용합니다. 하지만 절대 권장하지 않습니다.

-- 合法だが落とし穴:
INSERT INTO users (name, email) VALUES (?, :email);

두 가지 바인딩 API를 머릿속에서 동시에 굴려야 하니 읽는 사람도 피곤하고, 대부분의 드라이버는 이 둘을 섞어 쓰는 걸 깔끔하게 지원하지도 않습니다. 한 statement 안에서는 한 가지 스타일만 쓰세요. 값이 한두 개면 ?, 그 외에는 :name을 쓰는 걸 권장합니다.

흔한 함정: 파라미터 바인딩은 문자열 포맷팅이 아니다

파라미터 바인딩의 핵심은 값이 SQL 파서를 거치지 않는다 는 점입니다. 다음 두 줄의 Python 코드를 비교해 보세요.

# 잘못된 방법 — 문자열 포매팅:
cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")

# 올바른 방법 — 파라미터 바인딩:
cursor.execute("SELECT * FROM users WHERE name = ?", (name,))

첫 번째 줄은 문자열을 이어 붙여서 SQL을 만듭니다. 만약 name"'; DROP TABLE users; --"라면, 데이터베이스는 아무 의심 없이 주입된 구문을 파싱하고 그대로 실행해 버립니다. 반면 두 번째 줄은 SQL과 값을 서로 다른 통로로 보냅니다. 값은 어떤 문자가 들어 있든 그저 문자열로 바인딩될 뿐이죠. 모든 가이드가 바인딩을 권하는 이유가 바로 여기에 있습니다. 취향 문제가 아니라, 파서가 무엇을 보느냐의 문제니까요.

SQL injection 방지에 대한 자세한 내용은 다음 페이지에서 본격적으로 다루겠습니다.

또 하나의 함정: 식별자는 바인딩할 수 없다

SQLite 플레이스홀더는 — 문자열, 숫자, blob, NULL — 에만 사용할 수 있습니다. 테이블 이름이나 컬럼 이름, SQL 키워드에는 쓸 수 없습니다:

-- これは期待通りに動作しません:
SELECT * FROM ? WHERE id = ?;
-- 最初の ? はテーブル名ではなく、文字列リテラルとしてバインドされます。

만약 정말로 테이블명이나 컬럼명을 동적으로 바꿔야 한다면(애플리케이션 코드에서는 드문 경우입니다) 허용 목록(allow-list)으로 검증한 뒤 직접 SQL 문자열에 이어붙이세요. 절대 사용자 입력을 그대로 넣어서는 안 됩니다. 그 외의 모든 값은 바인딩으로 처리하는 것이 원칙입니다.

실전 예제로 정리하기

지금까지 살펴본 내용을 종합해서, 작은 users 테이블을 처음부터 끝까지 파라미터 바인딩만으로 쓰고 읽어 보겠습니다:

실제 코드에서는 INSERTSELECT든 전부 플레이스홀더를 사용해야 합니다. CLI에는 값을 바인딩해 줄 앱이 없다 보니, 리터럴이 바인딩 결과를 대신 보여주고 있을 뿐입니다.

다음 주제: SQL injection 방지하기

파라미터 바인딩은 어디까지나 수단입니다. 이게 SQL injection을 막아주는지, 그리고 바인딩만으로는 부족한 몇 가지 상황은 무엇인지 — 다음 페이지에서 이어서 다루겠습니다.

자주 묻는 질문

SQLite에서 파라미터 바인딩이란 무엇인가요?

파라미터 바인딩은 SQL 문장과 실제 값을 분리해서 전달하는 방식입니다. SQL에는 ?:name 같은 플레이스홀더만 써 두고, 실제 값은 드라이버의 bind API로 따로 넘겨주는 거죠. 이렇게 하면 SQLite는 바인딩된 값을 SQL 코드가 아닌 순수한 데이터로만 취급하기 때문에 SQL 인젝션 걱정 없이 안전하게 쓸 수 있습니다.

?와 :name은 어떤 차이가 있나요?

?는 위치 기반 플레이스홀더라서, 값이 등장하는 순서대로 바인딩됩니다. 반면 :name(그리고 @name, $name)은 이름 기반이라 위치가 아니라 이름으로 바인딩하죠. 값이 두세 개 넘어가면 이름 기반이 훨씬 읽기 좋고, 순서를 바꿔도 헷갈리지 않습니다.

SQLite에서 NULL 값은 어떻게 바인딩하나요?

사용 중인 언어의 null/None/nil 값을 바인딩 API에 그대로 넘기면 됩니다. 드라이버가 알아서 SQL의 NULL로 변환해 줘요. 절대 'NULL'이라는 문자열로 쓰거나 SQL 문장 안에 NULL이라는 단어를 직접 끼워 넣으면 안 됩니다. 바인딩의 핵심은 값이 SQL 파서를 거치지 않게 하는 것이니까요.

한 쿼리에서 위치 기반과 이름 기반을 섞어 써도 되나요?

SQLite 자체는 허용하지만, 권장하지 않습니다. 한 문장 안에 ?:name이 섞여 있으면 가독성이 떨어지고 잘못 바인딩하기도 쉬워요. 한 쿼리 안에서는 한 가지 스타일로 통일하는 게 좋습니다. 값이 두세 개 이상이면 이름 기반으로 가는 걸 추천해요.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기