TechFeedTechFeed
Security

SQL Injection 방어 실전 가이드

한 줄 요약: SQL Injection은 사용자 입력이 SQL 쿼리의 일부로 해석될 때 발생한다. Prepared Statement를 쓰면 원천 차단된다. SQL Injection은 1990년대부터 알려진 공격이지만 2026년 현재도 OWASP Top 10에 포함될 만큼 여전히 실제로 발생한다. 이유는 단순하다 — 구현이 쉽고, 한 줄 실수로 전체 데이터베이스가 노출된다. 이 글에서는 공격 원리를 정확히 이해하고, 코드 레벨에서 방어하는 방법을 예시와 함께 정리한다.

by

한 줄 요약: SQL Injection은 사용자 입력이 SQL 쿼리의 일부로 해석될 때 발생한다. Prepared Statement를 쓰면 원천 차단된다.


SQL Injection은 1990년대부터 알려진 공격이지만 2026년 현재도 OWASP Top 10에 포함될 만큼 여전히 실제로 발생한다. 이유는 단순하다 — 구현이 쉽고, 한 줄 실수로 전체 데이터베이스가 노출된다. 이 글에서는 공격 원리를 정확히 이해하고, 코드 레벨에서 방어하는 방법을 예시와 함께 정리한다.


공격 원리 — 입력이 코드가 되는 순간

SQL Injection은 사용자 입력을 SQL 쿼리 문자열에 직접 연결할 때 발생한다. 공격자는 입력값에 SQL 문법을 넣어 쿼리 의미를 바꾼다.


공격 원리 — 입력이 코드가 되는 순간 — 보안 아키텍처 다이어그램
SQL Injection 방어 실전 가이드 — 보안 아키텍처 다이어그램 (출처: 공식 문서 및 벤치마크 데이터 기반)
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이 원천적으로 불가능하다.


Prepared Statements — 원천 차단의 표준 — 위협 모델 시각화
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와 함께 사용해야 한다.


ORM 활용 — Prisma, TypeORM, Sequelize — 취약점 분석 플로우차트
SQL Injection 방어 실전 가이드 — 취약점 분석 플로우차트 (출처: 공식 문서 및 벤치마크 데이터 기반)
최소 권한 원칙 — 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 취약

참고: 본인 소유이거나 명시적인 권한을 받은 시스템에서만 취약점 테스트를 수행해야 한다. 권한 없는 시스템에 대한 스캔 행위는 법적 문제가 될 수 있다.

자주 묻는 질문

가장 자주 발생하는 실수나 함정은 무엇인가요?

가장 흔한 함정은 'ORM을 쓰니 안전하겠지'라는 방심입니다. Prisma의 queryRawUnsafe나 TypeORM의 raw 쿼리에 문자열 보간을 그대로 넣으면 ORM을 써도 그대로 뚫립니다. 두 번째는 컬럼명·테이블명·ORDER BY 방향(ASC/DESC)을 파라미터 바인딩으로 막으려는 시도입니다. 이 부분은 바인딩이 불가능해서 반드시 allowlist로 따로 검증해야 합니다. 세 번째는 프로덕션에서 DB 원본 에러 메시지를 그대로 응답에 노출해 공격자에게 테이블 구조를 알려주는 실수입니다. 이 세 가지가 코드 리뷰에서 가장 자주 잡히는 패턴입니다.


Prepared Statement, ORM, WAF 중 어떤 방어를 언제 써야 하나요?

기본 원칙은 Prepared Statement(파라미터 바인딩)가 모든 경우의 1순위 방어선이라는 것입니다. 신규 프로젝트라면 Prisma·TypeORM 같은 ORM을 쓰는 편이 낫습니다. ORM API를 통하면 바인딩이 자동 처리되고 코드도 읽기 쉬워집니다. 다만 queryRawUnsafe 같은 raw 쿼리 구간은 ORM을 써도 직접 검증해야 합니다. WAF(Cloudflare·AWS WAF·ModSecurity)는 단독 방어선으로는 부적합합니다. 알려진 패턴만 막기 때문입니다. 대신 코드를 당장 고치기 어려운 레거시가 섞여 있거나, 다층 방어가 필요한 환경에서 Prepared Statement 위에 얹는 보조 수단으로 적합합니다. 컬럼명·ORDER BY 방향처럼 바인딩이 불가능한 동적 부분은 어떤 경우든 allowlist 검증이 별도로 필요합니다.


더 깊게 공부하려면 어떤 자료를 보면 좋을까요?

가장 먼저 OWASP의 SQL Injection Prevention Cheat Sheet를 읽어보시길 권합니다. 이 글에서 다룬 파라미터 바인딩·allowlist·최소 권한 원칙이 언어별 예제와 함께 체계적으로 정리되어 있습니다. 실제 취약점을 손으로 다뤄보고 싶다면 PortSwigger의 Web Security Academy에 있는 무료 SQL Injection 랩을 추천합니다. Blind SQLi와 Time-based SQLi를 직접 공략해보면 본문에서 설명한 공격 유형이 머릿속에 또렷이 잡힙니다. 자동 탐지 쪽을 더 파고들려면 sqlmap 공식 위키의 옵션 문서를 보시면 됩니다.


SQL Injection 방어 실전 가이드, 한 줄로 정리하면 어떻게 되나요?

SQL Injection은 사용자 입력이 SQL 쿼리의 코드로 해석되는 순간 발생하므로, 쿼리 구조와 데이터를 분리하는 Prepared Statement(파라미터 바인딩)를 쓰면 원천 차단됩니다. 여기에 바인딩이 안 되는 컬럼명·정렬 방향은 allowlist로 검증하고, DB 계정에 최소 권한만 부여하며, 에러 메시지를 숨기는 다층 방어를 더하는 것이 핵심입니다. 즉 문자열을 직접 이어 붙인 쿼리를 코드베이스에서 모두 걷어내는 것이 가장 확실한 해결책입니다.


실무에서 처음 도입할 때 가장 먼저 확인할 것은 무엇인가요?

코드 전체를 뜯어고치기 전에 기존 코드에서 SQL 쿼리에 문자열을 직접 연결하는 부분(템플릿 리터럴이나 +로 사용자 입력을 이어 붙인 쿼리)을 먼저 grep으로 찾아내세요. 이 위치들을 모두 파라미터 바인딩(MySQL의 ? 플레이스홀더, PostgreSQL의 $1)으로 바꾸는 것이 1순위입니다. 그다음 DB 계정 권한을 점검합니다. 애플리케이션이 쓰는 계정에 SELECT·INSERT·UPDATE·DELETE만 부여하고 DROP·ALTER 같은 DDL 권한은 제거하면, 혹시 한 군데가 뚫려도 테이블 삭제 같은 치명적 피해를 막을 수 있습니다. 코드 수정과 권한 분리, 이 두 가지가 가장 먼저 손대야 할 곳입니다.


SQL-injection보안데이터베이스ORM방어

관련 포스트