웹 애플리케이션을 개발하면서 사용자 입력을 받아 데이터베이스 쿼리를 실행하다가 보안 경고나 예상치 못한 에러를 경험해봤을까?
다만 SQL 인젝션의 정확한 원인이나 Prepared Statement가 이를 어떻게 차단하는지 내부 원리를 모른 채 구글링한 코드를 그대로 붙여넣는 경우가 많다.
이번에는 PDO의 Prepared Statement가 정확히 뭔지, 왜 단순한 문자열 치환보다 안전하고 강력한지, 실무에서 어떻게 안전하게 작성하는지 완벽하게 정리해서 소개하겠다.
SQL 인젝션(SQL Injection)은 사용자가 입력한 데이터가 SQL 쿼리 문법의 일부로 해석되어, 개발자가 의도하지 않은 악의적인 쿼리가 실행되는 웹 보안 취약점이다.
가장 흔하게 발생하는 원인은 외부 입력값을 따옴표 감싸기나 단순 문자열 연결(Concatenation)을 통해 직접 SQL 문장에 이어 붙이는 방식 때문이다.
Prepared Statement(준비된 문장)는 쿼리의 문법 구조(실행 계획)와 전달되는 데이터(값)를 데이터베이스 엔진 차원에서 완전히 분리하여 처리한다.
DB 엔진은 먼저 파라미터 자리가 비어 있는 템플릿 쿼리를 파싱하고 컴파일한 뒤, 실제 파라미터 값을 순수 리터럴 데이터로만 취급하여 대입한다. 따라서 사용자가 아무리 따옴표나 OR 1=1 같은 SQL 구문을 입력해도 쿼리 구조 자체는 절대 변하지 않는다.
| 구분 | 일반 쿼리 (String Concat) | Prepared Statement |
|---|---|---|
| 쿼리 파싱 시점 | 데이터가 결합된 전체 문장을 매번 새로 파싱 | 쿼리 템플릿을 먼저 파싱 후 실행 계획 생성 |
| 데이터 처리 방식 | 입력값이 쿼리 문법(키워드/구분자)으로 해석 가능 | 입력값은 무조건 순수 데이터(Literal)로만 처리 |
| SQL 인젝션 위험 | 매우 높음 (따옴표 이스케이프 누락 시 즉시 노출) | 완벽하게 방어됨 (구조 변조 불가능) |
| 반복 실행 성능 | 동일 쿼리라도 값이 바뀌면 매번 파싱 필요 | 동일 실행 계획 재사용으로 대량 처리 시 유리 |
PHP에서 PDO를 안전하게 사용하려면 먼저 DB 연결 시 올바른 옵션을 설정해야 한다. 특히 ATTR_EMULATE_PREPARES 옵션과 ATTR_ERRMODE 옵션은 보안과 디버깅의 핵심이다.
// PDO 인스턴스 생성 및 보안 옵션 설정
$dsn = 'mysql:host=localhost;dbname=myapp;charset=utf8mb4';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 에러 발생 시 예외 던짐
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 연관 배열 기본 반환
PDO::ATTR_EMULATE_PREPARES => false, // 네이티브 Prepared Statement 강제 사용
];
$pdo = new PDO($dsn, 'db_user', 'db_password', $options);
Prepared Statement에서 파라미터를 넘기는 방법은 크게 세 가지가 있다.
실무에서는 주로 배열을 전달하는 execute($params) 방식이나 명시적 타입을 지정하는 bindValue()를 사용한다.
1) execute()에 연관 배열 직접 전달: 가장 간결하며 실무에서 가장 많이 쓰이는 방식이다.
2) bindValue(): 값을 변수 복사 방식으로 바인딩하며, 데이터 타입을 명시할 수 있다.
3) bindParam(): 변수의 참조(Reference)를 바인딩하므로, 루프 내부에서 변수 값이 바뀔 때 유용하지만 의도치 않은 참조 부작용을 주의해야 한다.
// 방법 1: execute()에 배열 바로 넘기기 (가장 추천)
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email AND status = :status');
$stmt->execute([
'email' => $userEmail,
'status' => 'ACTIVE'
]);
$user = $stmt->fetch();
// 방법 2: bindValue()로 타입 명시
$stmt = $pdo->prepare('SELECT * FROM products WHERE category_id = :catId LIMIT :limit');
$stmt->bindValue(':catId', $categoryId, PDO::PARAM_INT);
$stmt->bindValue(':limit', 10, PDO::PARAM_INT);
$stmt->execute();
$products = $stmt->fetchAll();
실제 백엔드 API에서 가장 빈번하게 사용되는 로그인 인증 처리와 다중 검색 조건 동적 쿼리 예제를 살펴보자.
✗ 잘못된 코드: 사용자 입력을 쿼리에 직접 결합하여 인증 우회가 발생한다.
// ✗ 절대 금지: SQL 인젝션 취약 코드
$email = $_POST['email'];
$password = $_POST['password'];
// 악의적인 입력: admin@example.com' OR '1'='1
$sql = "SELECT * FROM users WHERE email = '{$email}'";
$result = $pdo->query($sql);
✓ 올바른 코드: Prepared Statement로 이메일을 조회한 뒤 PHP의 password_verify()로 해시를 검증한다.
// ✓ 안전한 로그인 검증 구현
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$password = $_POST['password'] ?? '';
if (!$email || empty($password)) {
throw new InvalidArgumentException('올바른 계정 정보를 입력하세요.');
}
$stmt = $pdo->prepare('SELECT id, password_hash, status FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();
if ($user && password_verify($password, $user['password_hash'])) {
// 로그인 성공 세션 처리
session_regenerate_id(true);
$_SESSION['user_id'] = $user['id'];
} else {
// 인증 실패
throw new RuntimeException('아이디 또는 비밀번호가 일치하지 않습니다.');
}
검색 필터가 유동적인 관리자 페이지나 검색 기능에서는 조건에 따라 쿼리와 파라미터를 동적으로 빌드해야 한다.
// ✓ 동적 검색 조건 조립 및 바인딩
$conditions = [];
$params = [];
if (!empty($_GET['keyword'])) {
$conditions[] = '(title LIKE :keyword OR content LIKE :keyword)';
$params['keyword'] = '%' . $_GET['keyword'] . '%';
}
if (!empty($_GET['category'])) {
$conditions[] = 'category_id = :category';
$params['category'] = (int)$_GET['category'];
}
$sql = 'SELECT id, title, created_at FROM posts';
if (count($conditions) > 0) {
$sql .= ' WHERE ' . implode(' AND ', $conditions);
}
$sql .= ' ORDER BY id DESC LIMIT 20';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$searchResults = $stmt->fetchAll();
Prepared Statement를 쓴다고 해서 모든 SQL 인젝션이 마법처럼 사라지는 것은 아니다. 다음 세 가지 실수는 시니어 개발자들도 종종 간과하는 대표적인 패턴이다.
Prepared Statement의 플레이스홀더(?, :name)는 오직 값(Value/Literal) 자리에만 사용할 수 있다. 테이블 이름, 컬럼 이름, ASC/DESC 키워드는 바인딩 대상이 아니다.
✗ 잘못된 코드: 컬럼명이나 정렬 방향을 플레이스홀더로 치환하려고 하면 문법 에러가 나거나 직접 결합 시 취약점이 생긴다.
// ✗ 동작하지 않거나 문법 에러 발생
$stmt = $pdo->prepare('SELECT * FROM users ORDER BY :col :dir');
$stmt->execute(['col' => $_GET['sort_by'], 'dir' => $_GET['sort_dir']]);
✓ 올바른 코드: 허용 가능한 컬럼 목록(화이트리스트)을 배열로 정의하고 검증된 값만 결합한다.
// ✓ 화이트리스트 검증 방식 적용
$allowedColumns = ['id', 'created_at', 'view_count', 'name'];
$allowedDirections = ['ASC', 'DESC'];
$sortBy = in_array($_GET['sort_by'] ?? '', $allowedColumns, true) ? $_GET['sort_by'] : 'id';
$sortDir = in_array(strtoupper($_GET['sort_dir'] ?? ''), $allowedDirections, true) ? strtoupper($_GET['sort_dir']) : 'DESC';
// 안전하게 검증된 화이트리스트 값만 결합
$stmt = $pdo->prepare("SELECT * FROM users ORDER BY {$sortBy} {$sortDir} LIMIT 30");
$stmt->execute();
✗ SELECT * FROM posts WHERE title LIKE '%:keyword%' 처럼 따옴표 안에 플레이스홀더를 넣으면 PDO는 이를 바인딩 대상이 아닌 단순 텍스트로 인식한다.
✓ WHERE title LIKE :keyword 로 작성하고, PHP 바인딩 값 자체에 '%' . $keyword . '%' 형태로 넘겨주어야 한다.
기본값(true) 상태에서는 PDO가 PHP 레벨에서 단순 문자열 이스케이프 후 쿼리를 DB로 전송한다. 특정 멀티바이트 문자셋(GBK 등) 환경에서는 에뮬레이션 모드에서 인젝션 우회 취약점이 발생할 수 있으므로, 반드시 PDO::ATTR_EMULATE_PREPARES => false를 선언하여 DB 엔진의 순수 Prepared Statement를 동작시켜야 한다.
오늘 다룬 핵심 내용을 간단히 요약해보자.
1. SQL 인젝션의 근본 원인은 쿼리 문법 구조와 사용자 데이터가 동일한 문자열 스트림으로 섞이기 때문이다.
2. PDO의 Prepared Statement를 사용하면 DB 엔진이 문법을 먼저 컴파일하므로 데이터에 의한 구조 변조가 원천 차단된다.
3. ATTR_EMULATE_PREPARES => false 설정으로 네이티브 Prepared Statement를 강제해야 한다.
4. 테이블명, 컬럼명, 정렬 방향은 플레이스홀더 바인딩이 불가능하므로 반드시 엄격한 화이트리스트 검증을 거쳐야 한다.
데이터베이스 보안은 모든 웹 서비스의 안정성을 지탱하는 가장 기초적이면서도 중요한 기반이다.
외부 입력을 다룰 때 직접 문자열을 연결하지 않고 파라미터 바인딩과 화이트리스트 검증을 적용하는 작은 습관이 모여서 서비스의 치명적인 데이터 유출을 막는 거대한 방벽을 만든다는 점을 잊지 말자.
이 글의 PDO 설정 옵션과 동적 쿼리 작성 패턴을 참고해 현재 운영 중인 프로젝트의 레거시 DB 접근 코드를 점검하면, 한층 견고하고 안전한 백엔드 아키텍처를 구축할 수 있을 것이다.