Menu

SQLite SQL Injection 방지: 파라미터 쿼리 사용법

문자열을 이어붙여 쿼리를 만드는 게 왜 위험한지, SQL 인젝션이 실제로 어떻게 동작하는지, 그리고 SQLite의 파라미터 바인딩으로 어떻게 막는지 정리했습니다.

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

SQL 인젝션은 결국 문자열 조립 버그다

SQL 인젝션(SQL Injection)은 사용자가 입력한 값이 데이터베이스가 파싱하는 SQL 문장의 일부가 되어 버릴 때 발생합니다. 사용자가 입력한 '값'과 데이터베이스가 실행하는 '문법'의 경계가 무너지는 순간, 사용자는 여러분이 할 수 있는 모든 일을 똑같이 할 수 있게 됩니다.

어떤 언어로든 쉽게 재현할 수 있는, 가장 전형적인 안티패턴을 한번 보겠습니다.

-- これをやってはいけません
query = "SELECT * FROM users WHERE name = '" + user_input + "'"

user_inputAda라면 평범한 조회가 되겠죠. 하지만 user_input' OR 1=1 --가 들어오면 이렇게 됩니다:

SELECT * FROM users WHERE name = '' OR 1=1 --'

--로 뒤따라오는 따옴표가 주석 처리되고, OR 1=1이 모든 행에 매치되면서 공격자가 사용자 테이블을 통째로 털어가는 거죠. 더 악질적인 버전은 ;로 구문을 이어 붙여 두 번째 쿼리를 실행하는데, 테이블을 DROP하거나 데이터를 빼돌리거나 새로운 관리자 계정을 심기도 합니다.

이 취약점은 SQLite의 문제가 아닙니다. 문자열을 그렇게 조립한 코드의 문제죠.

파라미터 쿼리: 진짜 해결책

파라미터 쿼리는 SQL 문장과 값을 분리합니다. SQL에는 ?:name 같은 플레이스홀더만 두고, 값은 별도로 넘기는 방식이죠. SQLite는 SQL을 한 번 파싱·컴파일한 뒤, 컴파일된 실행 계획에 값을 바인딩합니다. 그래서 값이 SQL 코드로 둔갑할 수 없어요.

취약해 보이는 조회를 안전하게 실행하면 다음과 같습니다:

SQLite 셸에서는 값을 그대로 타이핑해서 넣지만, 실제 애플리케이션 코드에서는 이런 식으로 작성합니다 (Python의 sqlite3 드라이버 기준):

# Python — パラメータ化、安全
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

두 인자를 따로 넘기는 게 핵심입니다. SQL 문과 값 튜플을 별개의 인자로 전달하면, 드라이버는 이 둘을 SQLite에 따로따로 전송합니다. 그래서 user_input' OR 1=1 -- 같은 값이라도, SQLite는 말 그대로 이름이 ' OR 1=1 --인 사용자를 찾을 뿐이고, 당연히 그런 사용자는 없습니다.

여기서 말하는 "안전"의 진짜 의미

이 안전성은 패턴 매칭이나 이스케이프 처리에서 오는 게 아닙니다. 구조적으로 안전한 겁니다. SQLite는 여러분이 넘긴 값을 보기도 전에 이미 SQL 문을 내부 형태로 컴파일해 두기 때문이죠:

-- コンパイル済みステートメントには文字列ではなくスロットがあります。
SELECT * FROM users WHERE name = ?
                                 ^
                                 プレースホルダースロット

값을 바인딩하면 해당 자리에는 TEXT, INTEGER, BLOB 같은 타입이 정해진 데이터로 들어갑니다. SQLite는 이 값을 절대로 SQL로 다시 파싱하지 않습니다. 파서는 이미 일을 끝낸 상태이기 때문에, 공격자가 비집고 들어갈 문법적인 틈 자체가 없는 거죠.

이게 바로 파라미터 바인딩이 문자열 이스케이프 방식과는 비교가 안 될 만큼 안정적인 이유입니다. 이스케이프는 위험한 문자를 문자열에서 걸러내려는 시도지만, 바인딩은 애초에 위험한 문자열을 만들지 않습니다.

문자열 포매팅에 손대지 마세요

언어마다 솔깃한 지름길이 하나씩 있습니다. Python의 f-string, JavaScript의 템플릿 리터럴, Java의 String.format까지요. 그런데 SQL에 관한 한, 이것들은 전부 자기 발등을 찍는 도구입니다.

# 하지 말 것 — f-string은 값을 SQL 텍스트에 그대로 삽입합니다
cursor.execute(f"SELECT * FROM users WHERE name = '{user_input}'")

# 하지 말 것 — % 포맷팅도 같은 문제가 있습니다
cursor.execute("SELECT * FROM users WHERE name = '%s'" % user_input)

# 권장 — 플레이스홀더 + values 인자 사용
cursor.execute("SELECT * FROM users WHERE name = ?", (user_input,))

앞의 두 방식은 드라이버에 닿기도 전에 사용자 입력을 SQL 문자열에 그대로 끼워 넣습니다. SQLite가 쿼리를 받았을 때는 이미 늦은 거죠. 세 번째 방식은 SQL과 값을 서로 다른 차선에 두고 흘려보냅니다.

이 규칙은 기계적으로 지키면 됩니다. 값이 들어갈 자리에 +, f-string, format, 템플릿 리터럴로 SQL 문자열을 조립하고 있다는 느낌이 든다면 — 멈추고 플레이스홀더를 쓰세요.

여러 개의 파라미터와 이름 있는 플레이스홀더

실제 쿼리는 보통 값이 여러 개 들어갑니다. SQLite는 위치 기반 ? 플레이스홀더와 이름 기반 :name 플레이스홀더를 모두 지원합니다.

애플리케이션 코드에서는 이렇게 표현됩니다:

# 위치 기반
cursor.execute(
    "SELECT * FROM orders WHERE customer = ? AND status = ?",
    ("Ada", "paid"),
)

# 이름 기반 — 매개변수가 여러 개일 때 더 명확함
cursor.execute(
    "SELECT * FROM orders WHERE total > :min_total AND status = :status",
    {"min_total": 50, "status": "paid"},
)

이름 있는 파라미터는 값이 많아질수록 진가를 발휘합니다. 값이 서너 개를 넘어가면 ?, ?, ?, ?는 어느 자리에 뭐가 들어가는지 헷갈리는 추측 게임이 되지만, :customer, :total, :status, :created_at처럼 쓰면 코드 자체가 설명서 역할을 해줍니다.

식별자는 다르게 다뤄야 합니다

파라미터 바인딩은 오직 에만 동작합니다. 즉 =의 우변, IN (...) 안, VALUES (...) 안에 들어가는 값에만 쓸 수 있죠. 테이블 이름이나 컬럼 이름, 그리고 ASC/DESC 같은 SQL 키워드 자리에는 쓸 수 없습니다.

-- これは動作しません。プレースホルダはカラム名の代わりにはなりません。
SELECT * FROM users ORDER BY ? ASC

사용자가 정렬 기준 컬럼을 직접 고르는 경우처럼 식별자를 동적으로 넣어야 한다면, SQL 문자열을 만들기 전에 반드시 허용 목록(allowlist)으로 검증하세요:

# 화이트리스트 방식
ALLOWED_SORT_COLUMNS = {"name", "created_at", "role"}

if sort_column not in ALLOWED_SORT_COLUMNS:
    raise ValueError(f"잘못된 정렬 컬럼: {sort_column}")

query = f"SELECT * FROM users ORDER BY {sort_column} ASC"
cursor.execute(query)

사용자가 입력한 문자열은 SQL에 닿기 전에 미리 정해 둔 안전한 값들과 대조해서 검증합니다. 이렇게 하면 sort_column이 하드코딩해 둔 세 가지 이름 중 하나일 수밖에 없기 때문에, 이 경우에 한해서는 f-string을 써도 괜찮은 겁니다.

SQL 인젝션 시도, 실제로 막아보기

악의적인 입력을 넣었을 때 두 버전이 어떻게 다른지 나란히 비교해 보겠습니다. 먼저 간단한 users 테이블을 준비합니다:

취약한 코드는 모든 사용자를 반환하지만, 파라미터 쿼리로 작성한 코드는 말 그대로 ' OR 1=1 --이라는 이름을 가진 사용자를 찾으려 하므로 아무것도 반환하지 않습니다. 같은 입력인데 결과가 완전히 다르죠. 두 번째 경우에는 그 값이 애초에 SQL이 되지 못했기 때문입니다.

SQL 인젝션 방지 체크리스트

  • 코드 외부에서 들어오는 모든 값(사용자 입력, 요청 본문, 환경 변수 등 직접 적어 넣은 값이 아닌 것 전부)에는 ? 또는 :name 플레이스홀더를 사용하세요.
  • 값이 들어가는 자리에 +, f-string, format으로 SQL 문자열을 조합하지 마세요.
  • 테이블명이나 컬럼명을 동적으로 바꿔야 한다면, 쿼리에 끼워 넣기 전에 미리 정해 둔 허용 목록(allowlist)으로 검증하세요.
  • 드라이버를 믿으세요. 따옴표 이스케이프 함수를 직접 만들지 마시고요. 파라미터 바인딩은 훨씬 오래 검증된 메커니즘이고, 그게 정답입니다.
  • 팀 코드 리뷰 때는 딱 한 가지만 물어보면 됩니다. 사용자 입력이 SQL 문자열에 그대로 이어 붙고 있나? 그렇다면 고치세요.

이 습관이 손에 배면 SQL 인젝션은 더 이상 신경 써야 할 버그 종류가 아니게 됩니다.

다음 단계: 애플리케이션에서 연결하기

안전한 쿼리의 형태, 즉 SQL 안에는 플레이스홀더를 두고 값은 따로 전달하는 방식을 살펴봤습니다. 다음 페이지에서는 Python, Node.js를 비롯한 실제 애플리케이션 코드에서 SQLite를 어떻게 연결하는지, 커넥션 관리는 어떻게 하는지, 그리고 일반적인 요청 처리 흐름 안에서 파라미터 쿼리가 어디에 위치하는지 직접 따라가며 살펴보겠습니다.

자주 묻는 질문

SQLite도 SQL 인젝션 공격에 취약한가요?

네, 다른 SQL 데이터베이스와 똑같이 취약합니다. 애플리케이션 코드에서 문자열을 이어 붙여 쿼리를 만드는 순간 그게 SQLite든 MySQL이든 PostgreSQL이든 다 뚫립니다. 해결책은 SQLite 설정을 바꾸는 게 아니라, 값을 어떻게 넘기느냐의 문제예요. ?:name 플레이스홀더를 쓰는 파라미터 쿼리로 바꾸면 드라이버가 알아서 안전하게 처리해 줍니다.

파라미터 쿼리는 어떻게 SQL 인젝션을 막아 주나요?

? 같은 플레이스홀더를 사용하면 SQLite가 쿼리를 먼저 파싱하고 컴파일한 다음, 이미 컴파일된 statement의 슬롯에 값을 끼워 넣는 방식으로 동작합니다. 즉, 넘긴 값은 절대로 SQL 문법이 될 수 없고 그냥 데이터로만 취급돼요. 공격자가 비집고 들어갈 문자열 자체가 없는 셈입니다.

그냥 사용자 입력의 따옴표만 이스케이프하면 안 되나요?

안 됩니다. 직접 이스케이프 처리하는 방식은 너무 허술해요. 유니코드 따옴표, 인코딩 트릭, 주석 문자 같은 엣지 케이스 중 하나라도 놓치는 순간 그대로 취약점이 됩니다. 드라이버가 ?:name 파라미터를 제공하는 이유가 바로 이런 고민을 안 하게 해 주려는 거예요. '이 값은 안전하다'고 확신이 드는 경우라도 무조건 파라미터 바인딩을 쓰세요.

테이블명이나 컬럼명을 사용자 입력으로 받아야 하면 어떻게 하나요?

바인딩 파라미터는 값(value)에만 적용되고 식별자(identifier)에는 못 씁니다. 테이블이나 컬럼명을 동적으로 정해야 한다면, 미리 정의해 둔 허용 목록(allowlist)에 있는 이름인지 검증한 뒤에 SQL에 끼워 넣으세요. 사용자한테 받은 식별자를 문자열 포매팅으로 그대로 꽂는 건 절대 금물입니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기