목차
- SQL Injection이 여전히 살아있는 이유
- Escape 방식의 함정
- Parameterized Query 실전 패턴
- ORM의 안전 영역과 함정
- 입력 검증 — 방어선 하나 더
- 검증 시나리오와 한계점
- 언제 어떤 방식을 쓸 것인가
코드 리뷰에서 마주친 한 줄짜리 쿼리에서 시작한다. query = f"SELECT * FROM users WHERE email = '{user_input}'". SQL Injection 방지 실전을 정리할 필요를 느낀 게 이 코드를 본 직후다. 입력값에 단일 인용부호 하나만 들어가도 쿼리가 깨진다. '); DROP TABLE users;-- 같은 고전 페이로드는 막혀도, ' OR '1'='1 형태의 인증 우회는 그대로 통한다.
또한, 이 문제는 30년 가까이 알려져 있다. OWASP Top 10에서 Injection은 2003년 첫 발표부터 2021년 개정판까지 한 번도 빠진 적이 없다 (출처: OWASP Top 10 2021, A03 Injection). 그런데도 매년 새 CVE가 올라온다. NVD에 2024년 한 해 등록된 SQL Injection 관련 CVE만 1,000건이 넘는다 (출처: NVD CVE search, 2024년 기준). 이론은 충분히 알려졌는데 코드는 여전히 깨진다.
이 글은 Python과 Node.js 환경에서 검증 가능한 방어 패턴을 정리한다. parameterized query, ORM, 입력 검증을 차례로 다룬다. escape 방식이 왜 실패하는지부터 실무에서 자주 보이는 잘못된 ORM 사용 패턴까지 짚는다.
SQL Injection이 여전히 살아있는 이유
특히, 알려진 지 오래된 취약점이 사라지지 않는 데는 구조적 원인이 있다.
물론, 첫째, 문자열로 쿼리를 조립하는 게 모든 언어에서 너무 쉽다. Python의 f-string, JavaScript의 template literal, Java의 String.format. 전부 SQL을 조립하기에 자연스러운 도구로 보인다. 입력값을 그대로 끼워 넣어도 컴파일러는 막지 않는다.
둘째, ORM을 쓴다고 다 안전하지 않다. raw query 모드, where 조건의 동적 컬럼명, ORDER BY 절은 ORM의 placeholder가 닿지 않는 영역이다. Django의 raw(), SQLAlchemy의 text(), Sequelize의 query(). 이름만 봐도 "여기서부터는 너 책임이다"라고 적혀 있다.
셋째, 검색 기능과 동적 정렬이 흔하다. ORDER BY {column} {direction} 같은 패턴은 placeholder로 못 묶는다. 컬럼명을 변수로 처리해야 하는데, 이 부분에서 검증이 빠진다.
게다가, 넷째, 코드 리뷰가 사실상 유일한 안전망이다. 신입의 첫 PR뿐 아니라 경력자도 동적 검색 필터를 만들다 raw query로 빠지는 경우가 있다. 리뷰어가 놓치면 그대로 배포된다.
Escape 방식의 함정
실제로, 처음 SQL Injection을 막으려고 시도하는 패턴이 단순 escape다. 단일 인용부호를 두 개로 바꾸고, 백슬래시를 처리하면 끝이라는 발상이다. 이 접근은 실패한다. 이유는 셋이다.
DB 엔진마다 escape 규칙이 다르다
그런데, PostgreSQL은 단일 인용부호를 ''로 escape한다. MySQL은 \'도 허용한다 (NO_BACKSLASH_ESCAPES 모드가 꺼져 있을 때). SQLite는 ''만 받는다. 어플리케이션 레벨에서 만든 escape 함수는 어느 DB에 붙는지에 따라 깨진다. 한 함수로 모든 DB를 커버하려는 시도 자체가 사고의 출발점이다.
다중 바이트 문자 우회
MySQL의 latin1 또는 gbk charset 환경에서 0xbf27 같은 바이트 시퀀스가 단일 인용부호로 해석되는 사례가 보고돼 있다 (출처: Chris Shiflett, "addslashes() Versus mysql_real_escape_string()", 2006). 어플리케이션이 UTF-8로 escape 처리해도 DB 측 charset이 다르면 우회된다. mysql_real_escape_string이 등장한 배경이기도 하다.
컨텍스트 무시
escape는 문자열 리터럴 안의 문자를 처리한다. 컬럼명, 테이블명, 키워드는 escape의 대상이 아니다. ORDER BY name; DROP TABLE users-- 같은 입력이 들어오면 escape는 아무것도 못 한다. 단일 인용부호가 없으니까.
결국 escape는 부분적 방어다. 완전 방어가 아니다. parameterized query로 가야 한다.
Parameterized Query 실전 패턴
그래서, parameterized query는 쿼리 구조와 데이터를 DB 드라이버 레벨에서 분리한다. 사용자 입력이 SQL 키워드로 해석될 가능성 자체를 차단한다. SQL Injection 방지 실전의 1순위다.
Python의 psycopg2 예시다.
import psycopg2
conn = psycopg2.connect("dbname=app user=postgres")
cur = conn.cursor()
# 안전한 패턴
user_email = request.args.get("email")
cur.execute(
"SELECT id, name FROM users WHERE email = %s",
(user_email,)
)
result = cur.fetchall()
# %s는 Python의 string formatting이 아니다
# psycopg2 드라이버가 placeholder로 처리한다
%s가 헷갈리는 부분이다. f-string이나 % 연산자처럼 보이지만, 여기서는 psycopg2가 placeholder로 인식한다. 직접 문자열 포매팅을 하면 SQL Injection이 다시 살아난다.
# 절대 하면 안 되는 패턴
cur.execute(f"SELECT * FROM users WHERE email = '{user_email}'")
cur.execute("SELECT * FROM users WHERE email = '%s'" % user_email)
Node.js의 pg 라이브러리는 $1, $2 형태의 positional placeholder를 쓴다.
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function findUser(email) {
// 안전한 패턴
const { rows } = await pool.query(
"SELECT id, name FROM users WHERE email = $1",
[email]
);
return rows;
}
mysql2 라이브러리는 ? placeholder를 쓰는데, 두 가지 모드가 있다. 일반 query()는 escape 기반, execute()는 prepared statement 기반이다. 둘 다 안전한 범주에 들지만 execute()가 더 권장된다 (출처: mysql2 v3.6 README, "Using prepared statements" 섹션).
동적 컬럼명·정렬 처리
ORDER BY {column} 같은 동적 식별자는 placeholder로 못 묶는다. 이건 화이트리스트로 처리해야 한다.
ALLOWED_SORT_COLUMNS = {"name", "created_at", "email"}
ALLOWED_DIRECTIONS = {"ASC", "DESC"}
def search_users(sort_column: str, direction: str):
if sort_column not in ALLOWED_SORT_COLUMNS:
raise ValueError(f"invalid sort column: {sort_column}")
if direction.upper() not in ALLOWED_DIRECTIONS:
raise ValueError(f"invalid direction: {direction}")
# 화이트리스트 통과 후이므로 안전
query = f"SELECT id, name FROM users ORDER BY {sort_column} {direction}"
cur.execute(query)
여기서 f-string을 쓰는 게 모순처럼 보이지만, 화이트리스트를 통과한 값만 들어가니까 안전하다. 핵심은 외부 입력이 직접 쿼리로 합쳐지지 않는 것이다.
ORM의 안전 영역과 함정
Django ORM, SQLAlchemy, Prisma, Sequelize 같은 ORM은 기본 사용 시 parameterized query를 자동으로 적용한다. 그래서 안전하다는 인상이 강하다. 함정은 raw 쪽에 숨어 있다.
안전한 기본 사용
Django ORM의 일반 query는 placeholder로 변환된다.
# 안전
User.objects.filter(email=user_input)
User.objects.filter(name__icontains=search_term)
반면, SQLAlchemy 2.0의 Core API도 같다.
from sqlalchemy import select
stmt = select(User).where(User.email == user_input)
result = session.execute(stmt)
생성된 SQL을 보면 WHERE users.email = :email_1 형태로 placeholder가 들어간다. SQLAlchemy 엔진이 자동 변환한 결과다.
raw·text·extra 사용 시 위험 영역
ORM이 제공하는 raw query 인터페이스는 사용자 책임이다. 코드 리뷰 때 가장 자주 놓치는 영역이기도 하다.
# 위험한 패턴
User.objects.raw(f"SELECT * FROM users WHERE name = '{name}'")
# 안전한 패턴
User.objects.raw("SELECT * FROM users WHERE name = %s", [name])
SQLAlchemy의 text()도 마찬가지다. 문자열을 직접 조립하면 ORM 안에서도 인젝션이 발생한다.
# 위험
session.execute(text(f"SELECT * FROM users WHERE id = {user_id}"))
# 안전
session.execute(
text("SELECT * FROM users WHERE id = :id"),
{"id": user_id}
)
Sequelize의 replacements vs bind
또한, Node.js Sequelize는 replacements와 bind 두 가지 방식을 제공한다. 둘의 차이가 미묘하다.
// replacements - escape 방식 (안전 범주이지만 권장도 낮음)
sequelize.query(
"SELECT * FROM users WHERE name = :name",
{ replacements: { name: userInput }, type: QueryTypes.SELECT }
);
// bind - prepared statement (권장)
sequelize.query(
"SELECT * FROM users WHERE name = $1",
{ bind: [userInput], type: QueryTypes.SELECT }
);
이처럼, 공식 문서는 가능하면 bind를 권장한다 (출처: Sequelize v6 docs, "Raw queries" 섹션). prepared statement는 DB 레벨에서 쿼리 계획을 캐시하므로 보안과 성능 양쪽에 이득이다.
입력 검증 — 방어선 하나 더
parameterized query만으로도 SQL Injection은 차단된다. 입력 검증은 그 위에 얹는 추가 방어다. 정상 입력만 통과시키는 화이트리스트 방식이 기본이다.
한편, 이메일은 RFC 5322 정규식, UUID는 8-4-4-4-12 패턴, 숫자 ID는 int 캐스팅. 형식이 어긋난 입력은 DB 근처에도 못 가게 막는다.
Python의 Pydantic, Node.js의 Zod 같은 라이브러리가 이 일을 잘 한다.
from pydantic import BaseModel, EmailStr, Field
from typing import Literal
class UserSearchRequest(BaseModel):
email: EmailStr
sort_by: Literal["name", "created_at", "email"] = "name"
direction: Literal["asc", "desc"] = "asc"
limit: int = Field(ge=1, le=100, default=20)
Literal 타입은 화이트리스트 역할을 한다. EmailStr은 형식 검증. Field(ge, le)는 숫자 범위 제한. 이 객체를 통과한 데이터는 형식 면에서 검증된 상태다.
import { z } from "zod";
const SearchSchema = z.object({
email: z.string().email(),
sortBy: z.enum(["name", "createdAt", "email"]).default("name"),
direction: z.enum(["asc", "desc"]).default("asc"),
limit: z.number().int().min(1).max(100).default(20),
});
const parsed = SearchSchema.parse(request.body);
검증 실패 시 4xx 응답으로 끊는다. DB까지 갈 필요가 없다. 부수 효과로 API 문서화와 타입 안정성도 따라온다.
결국, 다만 입력 검증을 SQL Injection의 1차 방어로 의존하면 안 된다. 검증을 우회하는 케이스는 늘 존재한다. parameterized query가 먼저고, 입력 검증이 그 다음이다.
검증 시나리오와 한계점
반면, 방어 패턴이 실제로 작동하는지 확인하려면 공격 페이로드를 직접 넣어봐야 한다. 로컬 환경에서 검증한 결과를 정리한다 (Python 3.12 + psycopg2 2.9.9 + PostgreSQL 16, 2026년 5월 기준).
| 페이로드 | f-string 조립 | Parameterized | ORM filter | ORM raw + 문자열 |
|---|---|---|---|---|
' OR '1'='1 |
인증 우회 발생 | 차단 | 차단 | 우회 가능 |
'; DROP TABLE users-- |
쿼리 분리 시도 | 차단 | 차단 | 위험 |
1 UNION SELECT password FROM admins |
데이터 노출 가능 | 차단 (타입 오류) | 차단 | 위험 |
\\' OR 1=1-- |
일부 escape 우회 | 차단 | 차단 | 위험 |
물론, parameterized query는 표의 모든 케이스를 차단한다. 단, 다음 한계는 명확히 인지해야 한다.
따라서, 첫째, 동적 식별자(테이블명·컬럼명·정렬 방향)는 자동 보호 영역이 아니다. 화이트리스트가 필수다.
둘째, 저장 프로시저 안에서 동적 쿼리를 조립하면 같은 문제가 생긴다. DB 안쪽으로 책임이 옮겨갈 뿐 위험은 그대로다.
즉, 셋째, parameterized query는 SQL Injection만 막는다. NoSQL Injection, LDAP Injection, OS Command Injection은 별개 문제다. 같은 발상으로 접근할 수 있지만 도구가 다르다.
따라서, 넷째, 두 번째 차수(Second-order) Injection이 있다. DB에 저장된 값이 다른 쿼리에서 문자열로 합쳐질 때 터진다. 모든 쿼리 경로에서 일관되게 parameterized query를 쓰지 않으면 막을 수 없다.
언제 어떤 방식을 쓸 것인가
판단 기준을 정리한다.
CRUD 위주의 일반 어플리케이션이라면 ORM의 기본 query API로 충분하다. Django의 filter(), SQLAlchemy의 select(), Prisma의 findMany(). 이 영역에서는 SQL Injection을 의식할 필요가 거의 없다. raw·text·extra만 피하면 된다.
복잡한 보고서·집계 쿼리가 필요한 상황이라면 parameterized query를 직접 쓰는 게 낫다. ORM이 만드는 SQL이 비효율적이거나 작성하기 까다로운 영역이 있다. 이때는 raw SQL에 placeholder를 일관되게 쓰는 패턴으로 간다.
동적 컬럼·정렬·페이지네이션이 들어가는 검색 UI라면 화이트리스트 + parameterized 조합이 정답이다. 사용자가 정렬 기준을 고르는 화면은 거의 다 이 패턴이 필요하다.
따라서, 입력값이 신뢰할 수 없는 외부 출처라면 Pydantic·Zod로 형식 검증을 한 겹 추가한다. enum, 길이 제한, 타입 강제. 검증 실패는 DB 호출 전에 끊는다.
그러나, 레거시 시스템에서 raw SQL이 광범위하게 쓰이고 있는 상황이라면, 단계적 마이그레이션 계획이 필요하다. 한꺼번에 다 바꾸기는 어렵다. 새 코드는 무조건 parameterized로 가고, 기존 코드는 위험도 순으로(인증, 결제, 권한) 우선 교체한다.
물론, 당장 실행 가능한 액션 셋이다.
- 현재 코드베이스에서
f"SELECT,f"INSERT,.format(.*SELECT,+ ' WHERE같은 패턴을 grep으로 찾아낸다. 1차 후보 리스트가 나온다. - ORM의
raw,text,extra,query호출부에 문자열 조립이 있는지 추가로 점검한다. - CI에 Bandit(Python), eslint-plugin-security(Node.js), Semgrep 룰을 붙여 PR 단계에서 자동 검출한다.
이 세 가지를 한 사이클 돌리면 신규 인젝션은 거의 막힌다. 이미 들어간 것도 대부분 발견된다.
관련 글
- Redis rate limiting 구현 실전: Token Bucket vs Sliding Window와 DDoS 방어 – 단순 INCR로 안심하다가 burst 트래픽에 뚫린 경험에서 출발한다. Token Bucket과 Sliding Window의 동작 차이, …
- JWT TTL 만료 시간 보안 설계: Access/Refresh 토큰 수명과 Jitter 실전 적용 – Access 24시간/Refresh 30일에서 Access 5분+Jitter/Refresh 1일+Rotation으로 옮긴 과정이다. 후보 …
- Let’s Encrypt Nginx HTTPS 설정 완전 가이드: Certbot·Docker·Reverse Proxy 자동 갱신 – Let’s Encrypt를 Nginx에 붙일 때 발급 도구·인증 방식·배포 구조에 따라 갱신 안정성이 크게 갈린다. 실무에서 자주 마주치는…