SELECT * FROM users WHERE id = 42 한 줄이 DB 까지 가는 사이 lexer → parser → planner → executor 4 단계를 거친다. SQL injection 이 위험한 이유는 이 파이프라인의 가장 첫 단계 — parser 가 어떤 글자가 코드인지 어떤 글자가 데이터인지 결정하는 시점 — 을 공격자가 흔들 수 있기 때문이다. 이 가이드는 SQL parser 의 내부 구조, query planner 가 하는 일, injection 의 메카니즘, 그리고 prepared statement 가 정확히 어떻게 그걸 막는지 정리한다.
SQL 의 4 단계 파이프라인
SQL string
↓
1. Lexer — tokens (SELECT, FROM, identifier, number, string ...)
↓
2. Parser — AST (abstract syntax tree)
↓
3. Planner — query plan (어느 인덱스 / 어떤 join 순서)
↓
4. Executor — 실제 데이터 fetch
↓
결과DB 마다 세부 구현 다르지만 이 4 단계 골격은 동일. PostgreSQL / MySQL / SQLite 모두.
1. Lexer — 문자 시퀀스 → token
입력: SELECT name, age FROM users WHERE id = 42
토큰:
KEYWORD SELECT
IDENT name
PUNCT ,
IDENT age
KEYWORD FROM
IDENT users
KEYWORD WHERE
IDENT id
OP =
NUMBER 42Lexer 가 공백·줄바꿈 무시하고 의미 있는 token 추출. 상태 머신 (state machine) — 한 글자씩 읽으며:
- 알파벳·_ 만남 → IDENT 또는 KEYWORD state
- 숫자 만남 → NUMBER state
'만남 → STRING state, 다음'까지--만남 → 주석 state, 줄 끝까지
keyword 인식 — IDENT 추출 후 reserved word 표 검색. SELECT 는 keyword, users 는 identifier.
2. Parser — token → AST
대부분 LALR(1) parser generator (yacc/bison) 사용. PostgreSQL 의 문법 정의는 ~2,000 라인 bison.
AST 예시:
SelectStmt
├── targets: [name, age]
├── from: [users]
└── where: BinaryOp
├── op: =
├── left: ColumnRef(id)
└── right: Constant(42)AST 에서 핵심 — 구조와 값이 명확히 분리. WHERE clause 의 비교 연산자 = 는 AST 노드, 비교 대상 42 는 Constant 값. 이 구분이 보안의 핵심.
3. Planner — query plan 생성
같은 AST 라도 실행 방법은 여러 가지. WHERE id = 42 는:
- Sequential scan — 모든 row 읽고 id = 42 체크
- Index scan —
users.id의 B-tree 인덱스 사용 - Bitmap scan — 여러 인덱스 결합
Planner 는 statistics (행 수·distinct 값·인덱스 cardinality) 기반 cost 계산 후 가장 싼 plan 선택. PostgreSQL EXPLAIN ANALYZE 가 선택된 plan 노출.
JOIN 의 경우 더 복잡:
- nested loop — 작은 테이블 적합
- hash join — 큰 테이블 + 인덱스 X
- merge join — 둘 다 정렬된 경우
N 개 테이블 join 의 경우 N! 순서. Planner 가 dynamic programming 으로 최적 순서 탐색 (genetic algorithm 사용하는 DB 도).
4. Executor — plan 실행
Plan tree 를 위에서 아래로 실행. 각 노드가 row iterator — "다음 row 줘" 요청에 응답. 결과를 buffer 또는 cursor 로 client 에 전달.
SQL Injection — parser 의 가정 깨기
취약한 코드 (concatenation):
// JavaScript (의도적 취약)
const userInput = req.query.id; // 사용자 입력
const sql = "SELECT * FROM users WHERE id = " + userInput;
db.query(sql);
// 정상 사용자: ?id=42
// → SELECT * FROM users WHERE id = 42
// 공격자: ?id=1 OR 1=1 --
// → SELECT * FROM users WHERE id = 1 OR 1=1 --
// → 모든 user 노출 (1=1 항상 true, -- 이후 무시)무엇이 일어나나 — parser 가 1 OR 1=1 을 정상 SQL 문법으로 인식. parser 입장에선 코드와 데이터의 경계가 string 결합 시점에 무너졌다.
더 위험한 공격
?id=1; DROP TABLE users--
→ SELECT * FROM users WHERE id = 1; DROP TABLE users--
→ 두 statement 실행 (multi-statement 지원 DB)
?username=admin&password=' OR '1'='1
→ SELECT * FROM users WHERE username = 'admin' AND password = '' OR '1'='1'
→ 비밀번호 무시 로그인
?id=1 UNION SELECT credit_card FROM payments
→ 다른 테이블 데이터 결합 노출Prepared Statement — injection 차단의 원리
Prepared statement 는 SQL 을 두 단계로 분리:
// 1단계 — 템플릿 박음 (placeholder)
db.prepare("SELECT * FROM users WHERE id = $1");
// 2단계 — 값 따로 전달
db.execute([userInput]);핵심 — DB 가 템플릿을 먼저 parse 후 cache. AST 가 이미 만들어진 상태에서 placeholder 자리에 값만 들어감. 값이 SQL 처럼 보여도 parser 가 다시 안 돌아가니 코드로 해석 X.
// 공격 시도
userInput = "1 OR 1=1"
// 결과 — 1 OR 1=1 은 number 가 아닌 string 으로 평가
// → SELECT * FROM users WHERE id = '1 OR 1=1'
// → id 가 string '1 OR 1=1' 인 row 검색 (= 0 건)
// → 정상Prepared statement 가 정확히 injection 을 막는 메카니즘 — parser 가 호출되지 않으므로 입력 값이 절대 SQL 구문으로 변환 X. 이 점이 escape (특수문자 \ 처리) 보다 강력 — escape 는 누락 가능성 있지만 prepared 는 구조적 보장.
Stored Procedure 의 함정
Stored procedure 가 항상 안전 X. 내부에서 EXEC 으로 동적 SQL 만들면 같은 위험:
CREATE PROCEDURE search(@name VARCHAR(50)) AS
EXEC('SELECT * FROM users WHERE name = ''' + @name + '''')
-- @name 이 ' OR '1'='1 이면 injection.Stored procedure 안에서도 parameterized query 권장.
DB 마다 다른 parser quirk
PostgreSQL — 강한 표준 준수
- 예약어 표 가장 큼 (SQL:2016 표준 + extension)
::캐스트 (id::text) 가 비표준 ANSI extension.CAST(id AS text)가 표준$1,$2placeholder- identifier 따옴표는
"("User") — 대소문자 보존
MySQL — 관대한 grammar
--주석은 뒤에 공백 강제 (보안 의도). 옛 클라이언트 는#도 주석- identifier 따옴표는 backtick (
`User`) - 숫자 vs 문자 비교 시 자동 변환 (
WHERE id = '42'이 통함) — 미묘한 함정 ?placeholder
SQLite — 가장 단순
- dynamic type — 같은 컬럼에 string / int 혼합 저장 가능
- identifier 따옴표 양방향 호환 (
"User"또는`User`) - parser 작음 — embedded 환경 적합
SQL Formatter — parser 의 부산물
SQL formatter (자동 들여쓰기) 도 parser 활용:
- SQL 을 AST 로 parse
- AST 를 prettier rule (들여쓰기·줄바꿈) 적용해 재출력
SQL 포매터 가 PostgreSQL / MySQL / SQLite / Standard SQL dialect 별 parser 선택 가능. 같은 SQL 이라도 dialect 에 따라 keyword 표가 다름.
EXPLAIN — query plan 보기
모든 메이저 DB 가 EXPLAIN 지원:
EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'a@b.c';
QUERY PLAN
─────────────────────────────────────────────────────────
Index Scan using users_email_idx on users
(cost=0.42..8.44 rows=1 width=72)
(actual time=0.029..0.030 rows=1 loops=1)
Index Cond: (email = 'a@b.c')
Planning Time: 0.092 ms
Execution Time: 0.052 mscost = planner 의 추정 비용. actual time = 실제 실행 시간. 둘이 크게 어긋나면 statistics 오래되어 ANALYZE table_name 필요.
Sequential Scan 이 큰 테이블에서 나타나면 인덱스 누락. Hash Join 의 build/probe side 차이로 메모리 폭주 가능.
흔한 함정
1. ORM 의 raw query escape hatch
Sequelize / Prisma 의 $queryRaw 또는 findBySql 사용 시 prepared 안 자동 적용되는 케이스. 문서 확인 필수.
2. LIKE 의 % 와 _ injection
?search=%
→ WHERE name LIKE '%%%' ← 모든 row 매치 (예상 X)LIKE pattern 도 사용자 입력이면 %, _ 를 escape. str.replace(/[%_]/g, "\\$&") + LIKE ESCAPE 절.
3. ORDER BY 의 column 이름 injection
?sort=name; DROP TABLE users--
→ ORDER BY name; DROP TABLE users--ORDER BY 는 placeholder 안 받음 (column 이 syntactic). 사용자 입력은 whitelist (["name", "created_at"]) 로 검증.
4. 동적 IN clause
// Bad
const ids = req.query.ids.split(","); // "1,2; DROP TABLE--"
db.query(`SELECT * FROM users WHERE id IN (${ids.join(",")})`);
// Good
db.query("SELECT * FROM users WHERE id = ANY($1)", [ids]);
// Postgres array, 또는 placeholder N 개 자동 생성5. error message leak
DB error 를 그대로 client 에 노출 → 공격자가 table·column 이름 수집. error 항상 generic 메시지로 변환.
참고 자료
- PostgreSQL — SQL 처리 과정 — 공식 문서
- OWASP — SQL Injection — OWASP
- Bobby Tables (Prepared statement 가이드) — bobby-tables.com
- PostgreSQL grammar (gram.y) — GitHub
요약
- SQL 파이프라인 4 단계 — lexer → parser → planner → executor.
- Lexer 가 token (KEYWORD / IDENT / NUMBER / STRING) 추출. Parser 가 AST 로 구조 만듦.
- Planner 가 같은 AST 의 여러 실행 방법 중 cost 최소 plan 선택.
- SQL injection = string 결합으로 parser 의 코드/데이터 경계를 무너뜨림.
1 OR 1=1 --같은 입력이 SQL 구문이 됨. - Prepared statement = 템플릿 parse 후 cache, 값만 따로 전달. parser 가 두 번 안 돌아가 injection 불가.
- ORDER BY / IN clause / LIKE % / dynamic SQL — placeholder 못 쓰는 자리는 whitelist 검증.
- DB 별 parser quirk — PostgreSQL 표준 강함, MySQL 관대, SQLite 작음.
- 실험 — SQL 포매터 가 dialect 별 parser 사용해 들여쓰기 + keyword case 정규화.