PHP로 데이터베이스 연동 개발을 하다가 WHERE id IN (:ids) 처럼 배열 파라미터를 통째로 바인딩해보았는가? 분명 배열에 여러 개의 PK 값을 넣어서 넘겼는데, 쿼리 결과에는 맨 첫 번째 아이템 하나만 조회되거나 심지어 SQL 구문 에러가 발생하며 멈춰 버리는 상황을 겪어봤을 것이다.
다만 대부분의 개발자들은 왜 이런 현상이 발생하는지 정확한 원인을 모른 채, 문자열 변수를 바로 SQL에 엮어 넣는 위험한 방식을 선택하곤 한다.
이번에는 PDO Prepared Statement에서 IN 절 바인딩이 실패하는 이유와 SQL 인젝션 위협 없이 동적 플레이스홀더를 사용해 안전하고 완벽하게 해결하는 방법을 정리해서 소개하겠다.

 

1. PDO Prepared Statement와 IN 절 바인딩 실패 원인

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로 안전하게 이스케이프 처리
조회 결과첫 번째 데이터만 조회되거나 쿼리 실패배열 내 모든 조건에 맞는 데이터 정확히 조회

 

2. IN 절 바인딩을 위한 두 가지 해결 방법

PDO에서 IN 절을 안전하게 처리하기 위한 실무 기법은 크게 두 가지로 나뉜다.

첫 번째는 동적 플레이스홀더 생성(Dynamic Placeholders)이다. 배열의 요소 개수만큼 물음표(?) 지표를 만들어 쿼리문에 결합하고, 배열 요소들을 execute() 시점에 순서대로 전달하는 방식이다. 성능이 우수하고 표준 SQL 규격을 완벽히 준수하므로 가장 권장된다.

두 번째는 MySQL FIND_IN_SET 함수 활용이다. 바인딩 변수로 콤마 연결 문자열 하나만 넘기는 구조이지만, 데이터베이스 전용 함수를 사용하므로 인덱스 활용이 제한되어 대용량 데이터 조회 시 성능 저하가 발생할 수 있다.

 

3. 실전 예제: 동적 플레이스홀더 생성 및 바인딩 구현

실무 환경에서 가장 많이 쓰이는 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 값들에 해당하는 모든 레코드가 정상 반환된다.

 

4. 흔히 하는 실수와 예외 처리 방어 코드

동적 바인딩 기법을 적용할 때 개발자들이 가장 자주 놓치는 실수가 바로 빈 배열(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);
}
?>

 

5. 마무리 및 요약

PHP PDO Prepared Statement의 IN 절 동적 바인딩은 웹 애플리케이션의 데이터 조회 정확도와 DB 보안을 보장하는 핵심 기본기다. 안전한 플레이스홀더 생성 습관이 모여서 SQL 인젝션을 근본적으로 차단하는 견고한 백엔드 시스템을 만든다는 점을 잊지 말자. 이 글의 동적 바인딩 코드와 빈 배열 검증 패턴을 참고해 프로젝트의 DAO 및 리포지토리 로직을 점검하면, 쿼리 오류 없는 안정적인 데이터 레이어를 얻을 수 있을 것이다.