본문으로 건너뛰기
yutils

SQL 은 어떻게 파싱될까? (그리고 왜 injection 이 위험할까)

SQL 파이프라인 내부 — lexer · parser · AST · query planner · executor. 왜 문자열 결합 SQL 이 위험한지, prepared statement 가 injection 을 정확히 어떻게 막는지, PostgreSQL · MySQL · SQLite 의 parser quirk.

약 9분 읽기

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      42

Lexer 가 공백·줄바꿈 무시하고 의미 있는 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 scanusers.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, $2 placeholder
  • 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 활용:

  1. SQL 을 AST 로 parse
  2. 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 ms

cost = 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 메시지로 변환.

참고 자료

요약

  • 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 정규화.
가이드 목록으로