TechFeedTechFeed
Security

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 + MySQL
const 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]; }
주의: Prepared Statement를 써도 컬럼명, 테이블명, ORDER BY 방향(ASC/DESC)은 파라미터 바인딩을 쓸 수 없다. 이런 동적 부분은 Allowlist(허용 목록)로 검증해야 한다. 예: 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 등은 불가능
팁: 에러 응답에 DB 에러 메시지를 그대로 노출하면 공격자에게 DB 구조와 유형 정보를 제공한다. 프로덕션에서는 모든 DB 에러를 일반 메시지(예: 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 취약
참고: 본인 소유이거나 명시적인 권한을 받은 시스템에서만 취약점 테스트를 수행해야 한다. 권한 없는 시스템에 대한 스캔 행위는 법적 문제가 될 수 있다.
SQL-injection보안데이터베이스ORM방어

관련 포스트

OWASP Top 10 2026 — 웹 보안 필수 체크리스트2026-02-18인증 구현 가이드 2026 — JWT, OAuth, Passkey2026-02-20API 보안 체크리스트 20262026-03-06JWT vs 세션 인증 — 무엇을 선택할 것인가2026-03-07