SQL Injection 방어 실전 가이드
SQL Injection 공격 원리부터 Prepared Statements, ORM 활용, 입력 검증까지 방어 전략 총정리.
한 줄 요약: SQL Injection은 사용자 입력이 SQL 쿼리의 일부로 해석될 때 발생한다. Prepared Statement를 쓰면 원천 차단된다.
SQL Injection은 1990년대부터 알려진 공격이지만 2026년 현재도 OWASP Top 10에 포함될 만큼 여전히 실제로 발생한다. 이유는 단순하다 — 구현이 쉽고, 한 줄 실수로 전체 데이터베이스가 노출된다. 이 글에서는 공격 원리를 정확히 이해하고, 코드 레벨에서 방어하는 방법을 예시와 함께 정리한다.
공격 원리 — 입력이 코드가 되는 순간
SQL Injection은 사용자 입력을 SQL 쿼리 문자열에 직접 연결할 때 발생한다. 공격자는 입력값에 SQL 문법을 넣어 쿼리 의미를 바꾼다.
SQL Injection 공격 시나리오// 취약한 로그인 쿼리 const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`; // 공격자가 입력하는 값: // email: admin@example.com' -- // password: anything // 완성되는 쿼리 (-- 이후는 주석 처리됨): // SELECT * FROM users WHERE email = 'admin@example.com' --' AND password = 'anything' // 결과: password 검증 없이 admin 계정으로 로그인 성공 // 더 위험한 예: // email: ' OR '1'='1 // 완성 쿼리: SELECT * FROM users WHERE email = '' OR '1'='1' ... // 결과: 모든 사용자 레코드 반환
SQL Injection의 주요 유형
- Classic SQLi: 에러 메시지나 응답 차이로 DB 정보를 직접 추출. 가장 기본적인 형태.
- Blind SQLi: 응답에 데이터가 직접 나오지 않지만 참/거짓 응답 차이로 정보를 유추. 더 느리지만 조용하다.
- Time-based Blind SQLi: DB에
SLEEP(5)같은 지연 함수를 주입해 응답 시간으로 정보 추출. - Out-of-band SQLi: DNS나 HTTP 요청을 통해 외부 서버로 데이터 전송. 방화벽 우회에 사용.
- Second-order SQLi: 입력값이 즉시 실행되지 않고, 나중에 다른 쿼리에서 사용될 때 발생. 가장 탐지하기 어려운 유형.
Prepared Statements — 원천 차단의 표준
Prepared Statement(준비된 구문)는 쿼리 구조를 먼저 컴파일하고, 사용자 입력을 별도의 파라미터로 바인딩한다. 입력값이 SQL 문법으로 해석되지 않기 때문에 SQL Injection이 원천적으로 불가능하다.
취약한 코드 vs 안전한 코드 — Node.js + MySQLconst mysql = require('mysql2/promise'); // 취약한 코드 — 문자열 직접 연결 async function findUserVulnerable(email, password) { const query = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`; const [rows] = await connection.query(query); return rows[0]; } // 안전한 코드 — Prepared Statement (파라미터 바인딩) async function findUserSafe(email, password) { const query = 'SELECT * FROM users WHERE email = ? AND password_hash = ?'; const hashedPassword = await bcrypt.hash(password, 10); const [rows] = await connection.execute(query, [email, hashedPassword]); // connection.execute()는 내부적으로 Prepared Statement 사용 return rows[0]; } // PostgreSQL (pg 패키지) — 동일 원리 async function findUserPostgres(email) { const result = await pool.query( 'SELECT id, name, email FROM users WHERE email = $1', [email] // $1에 바인딩, 절대 쿼리 문자열로 해석 안 됨 ); return result.rows[0]; }
const allowedColumns = ['name', 'email', 'created_at']; if (!allowedColumns.includes(sortBy)) throw new Error('Invalid column');ORM 활용 — Prisma, TypeORM, Sequelize
현대 Node.js 프로젝트에서 ORM을 쓰면 Prepared Statement가 자동으로 처리된다. 하지만 ORM에서도 raw 쿼리를 쓰거나, 잘못된 패턴을 사용하면 취약점이 생긴다.
Prisma — 안전한 사용 vs 위험한 raw 쿼리// 안전 — Prisma ORM API 사용 (자동으로 파라미터 바인딩) const user = await prisma.user.findFirst({ where: { email: userInputEmail, // 자동으로 파라미터화됨 isActive: true }, select: { id: true, name: true, email: true } // 필요한 필드만 선택 }); // 안전 — Prisma $queryRaw (템플릿 리터럴로 자동 이스케이프) const result = await prisma.$queryRaw` SELECT * FROM users WHERE email = ${userInputEmail} `; // 주의: 일반 문자열이 아닌 태그드 템플릿 리터럴로 사용해야 함 // 위험 — 문자열 보간 사용 (절대 금지) const badResult = await prisma.$queryRawUnsafe( `SELECT * FROM users WHERE email = '${userInputEmail}'` // SQLi 취약 ); // TypeORM — 안전한 파라미터 바인딩 const user = await userRepository .createQueryBuilder('user') .where('user.email = :email', { email: userInputEmail }) .getOne();
입력 검증과 WAF — 다층 방어
Prepared Statement로 SQLi를 원천 차단하더라도, 입력 검증과 WAF를 추가하면 방어 깊이가 높아진다. 특히 레거시 코드가 혼재하는 환경에서 추가 방어선이 된다.
입력 검증 레이어
- 이메일 필드에 SQL 예약어나 특수문자가 포함되면 애초에 거부
- 숫자 파라미터는 반드시 정수/숫자로 캐스팅 후 검증
- 긴 입력값은 DB 컬럼 길이를 초과하기 전에 거부 — Buffer Overflow 방지도 됨
WAF(Web Application Firewall)
WAF는 알려진 SQL Injection 패턴을 HTTP 레벨에서 탐지·차단한다. Cloudflare WAF, AWS WAF, ModSecurity(오픈소스)가 대표적이다. WAF는 알려진 공격만 막으므로 단독 방어선으로 쓰면 안 되고, Prepared Statement와 함께 사용해야 한다.
최소 권한 원칙 — DB 계정 권한 분리-- 애플리케이션 전용 DB 계정 생성 (최소 권한) CREATE USER 'app_user'@'%' IDENTIFIED BY 'strong_password'; -- 필요한 권한만 부여 (DROP, CREATE, ALTER 등 DDL 권한 없음) GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'app_user'@'%'; -- 관리 작업용 별도 계정 (마이그레이션 등에만 사용) CREATE USER 'app_admin'@'localhost' IDENTIFIED BY 'admin_password'; GRANT ALL PRIVILEGES ON myapp.* TO 'app_admin'@'localhost'; -- 애플리케이션 .env -- DB_USER=app_user ← 제한된 권한만 가진 계정 사용 -- 공격자가 SQLi로 쿼리를 실행해도 DROP TABLE 등은 불가능
Internal server error)로 변환하고, 상세 에러는 서버 로그에만 기록한다.탐지와 테스트 — 취약점을 직접 찾는 방법
방어 코드를 작성했다면 실제로 취약점이 없는지 테스트해야 한다. 자동화 도구와 수동 테스트를 병행한다.
자동화 도구
- sqlmap: 오픈소스 SQLi 자동 탐지 도구. CI/CD 파이프라인에 통합 가능.
sqlmap -u "https://example.com/api?id=1" --batch - OWASP ZAP: 웹 앱 전반 취약점 스캔. SQLi 탐지 모듈 포함.
- Semgrep: 정적 분석으로 코드에서 취약한 쿼리 패턴 탐지. CI에 통합해 PR 단계에서 차단.
수동 테스트 — 기본 페이로드
입력 필드에 다음 페이로드를 입력하고 에러 또는 비정상 응답이 오는지 확인한다. 비정상 응답이 있으면 즉시 취약점 분석이 필요하다.
'— 단일 따옴표로 SQL 구문 에러 유발1 OR 1=1— 항상 참인 조건1' AND SLEEP(5) --— 5초 지연이 발생하면 Time-based SQLi 취약