PHP로 데이터베이스 연동 개발을 하다가 WHERE id IN (:ids) 처럼 배열 파라미터를 통째로 바인딩해보았는가? 분명 배열에 여러 개의 PK 값을 넣어서 넘겼는데, 쿼리 결과에는 맨 첫 번째 아이템 하나만 조회되거나 심지어 SQL 구문 에러가 발생하며 멈춰 버리는 상황을 겪어봤을 것이다.
다만 대부분의 개발자들은 왜 이런 현상이 발생하는지 정확한 원인을 모른 채, 문자열 변수를 바로 SQL에 엮어 넣는 위험한 방식을 선택하곤 한다.
이번에는 PDO Prepared Statement에서 IN 절 바인딩이 실패하는 이유와 SQL 인젝션 위협 없이 동적 플레이스홀더를 사용해 안전하고 완벽하게 해결하는 방법을 정리해서 소개하겠다.
PDO의 바인딩 시스템은 지정된 하나의 플레이스홀더(:id 또는 ?)를 단 하나의 SQL 값(Value) 리터럴로 다룬다.
많은 개발자가 범하는 대표적인 실수는 array(1, 2, 3)을 implode(',', $ids)로 묶어 '1,2,3'이라는 문자열을 만든 뒤, 단일 플레이스홀더에 통째로 바인딩하는 것이다.
이렇게 바인딩하면 실제 데이터베이스 엔진에는 WHERE id IN ('1,2,3') 형태로 전달된다.
숫자 배열을 기대한 위치에 single string 하나가 통으로 들어가면서, 데이터베이스는 이 문자열을 정수로 자동 형변환하여 WHERE id IN (1)처럼 처리해 버린다. 결과적으로 첫 번째 데이터인 1만 조회되거나 타입 캐싱 문제로 잘못된 데이터가 추출된다.
두 방식을 비교해 보면 원인을 명확하게 이해할 수 있다.
| 구분 | 잘못된 바인딩 방식 | 올바른 바인딩 방식 |
|---|---|---|
| 전달 방식 | 단일 플레이스홀더에 문자열 전송 (:ids = '1,2,3') | 배열 요소마다 개별 플레이스홀더 생성 (?, ?, ?) |
| 최종 실행 쿼리 | WHERE id IN ('1,2,3') (문자열 리터럴 1개) | WHERE id IN (1, 2, 3) (정수 리터럴 3개) |
| 보안성 | 직접 쿼리에 엮으면 SQL Injection 위험 높음 | Prepared Statement로 안전하게 이스케이프 처리 |
| 조회 결과 | 첫 번째 데이터만 조회되거나 쿼리 실패 | 배열 내 모든 조건에 맞는 데이터 정확히 조회 |
PDO에서 IN 절을 안전하게 처리하기 위한 실무 기법은 크게 두 가지로 나뉜다.
첫 번째는 동적 플레이스홀더 생성(Dynamic Placeholders)이다. 배열의 요소 개수만큼 물음표(?) 지표를 만들어 쿼리문에 결합하고, 배열 요소들을 execute() 시점에 순서대로 전달하는 방식이다. 성능이 우수하고 표준 SQL 규격을 완벽히 준수하므로 가장 권장된다.
두 번째는 MySQL FIND_IN_SET 함수 활용이다. 바인딩 변수로 콤마 연결 문자열 하나만 넘기는 구조이지만, 데이터베이스 전용 함수를 사용하므로 인덱스 활용이 제한되어 대용량 데이터 조회 시 성능 저하가 발생할 수 있다.
실무 환경에서 가장 많이 쓰이는 PDO Prepared Statement 동적 플레이스홀더 적용 코드를 살펴보자.
✗ 아래 코드는 문자열 하나로 묶어 바인딩했기 때문에 의도한 전체 데이터 조회가 불가능하다.
<?php
$user_ids = [10, 20, 30];
// 잘못된 방식: 콤마로 연결한 문자열을 단일 변수에 바인딩
$ids_str = implode(',', $user_ids); // '10,20,30'
$sql = "SELECT id, name, email FROM users WHERE id IN (:ids)";
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':ids', $ids_str);
$stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 결과: id가 10인 회원 1개만 조회됨 (DB가 '10,20,30'을 정수 10으로 자동 변환)
?>
✓ 배열의 크기만큼 물음표 자리를 만들어 주고, 배열 데이터 그대로 execute()에 전달하는 올바른 방식이다.
<?php
$user_ids = [10, 20, 30];
// 1. 배열 크기만큼 물음표(?) 플레이스홀더 생성: "?, ?, ?"
$placeholders = implode(',', array_fill(0, count($user_ids), '?'));
// 2. SQL 쿼리 구성
$sql = "SELECT id, name, email FROM users WHERE id IN ($placeholders)";
$stmt = $pdo->prepare($sql);
// 3. 배열을 그대로 execute 매개변수로 전달
$stmt->execute($user_ids);
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
?>
데이터베이스 엔진에 최종 전달되는 내역과 출력 결과는 다음과 같다.
-- Prepared Statement가 생성하는 안전한 SQL 구조
SELECT id, name, email FROM users WHERE id IN (10, 20, 30);출력 데이터는 배열에 인자로 넣었던 ID 값들에 해당하는 모든 레코드가 정상 반환된다.
동적 바인딩 기법을 적용할 때 개발자들이 가장 자주 놓치는 실수가 바로 빈 배열(Empty Array) 전달이다.
✗ 배열이 비어있는 상태에서 array_fill()을 실행하면 플레이스홀더가 빈 문자열이 되어 WHERE id IN ()이라는 SQL Syntax Error가 터진다.
<?php
// 잘못된 예시: 빈 배열에 대한 방어 로직 누락
$user_ids = [];
$placeholders = implode(',', array_fill(0, count($user_ids), '?'));
$sql = "SELECT * FROM users WHERE id IN ($placeholders)";
// 생성된 SQL: SELECT * FROM users WHERE id IN () -> Syntax Error 발생!
?>✓ 아래와 같이 쿼리를 실행하기 전 배열의 존재 및 크기를 반드시 검증하는 헬퍼 함수 패턴을 도입하는 것이 올바른 접근법이다.
<?php
function getUsersByIds(PDO $pdo, array $user_ids): array {
// 1. 빈 배열일 경우 쿼리를 실행하지 않고 즉시 빈 결과 반환
if (empty($user_ids)) {
return [];
}
// 2. 인덱스 재정렬 및 플레이스홀더 생성
$values = array_values($user_ids);
$placeholders = implode(',', array_fill(0, count($values), '?'));
$sql = "SELECT id, name, email FROM users WHERE id IN ($placeholders)";
$stmt = $pdo->prepare($sql);
$stmt->execute($values);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
?>
PHP PDO Prepared Statement의 IN 절 동적 바인딩은 웹 애플리케이션의 데이터 조회 정확도와 DB 보안을 보장하는 핵심 기본기다. 안전한 플레이스홀더 생성 습관이 모여서 SQL 인젝션을 근본적으로 차단하는 견고한 백엔드 시스템을 만든다는 점을 잊지 말자. 이 글의 동적 바인딩 코드와 빈 배열 검증 패턴을 참고해 프로젝트의 DAO 및 리포지토리 로직을 점검하면, 쿼리 오류 없는 안정적인 데이터 레이어를 얻을 수 있을 것이다.