PostgreSQL 42P18 — 조건부 SQL 빌더에서 파라미터를 미리 넣으면 안 되는 이유
PostgreSQL에서 조건부 SQL 빌더 사용 시 참조되지 않는 파라미터가 포함되면 42P18 오류가 발생하므로 절과 파라미터를 함께 관리하고 실제 DB 파싱 테스트를 통해 검증해야 합니다

증상
기능 플래그로 WHERE 절 하나가 빠졌더니, 그 절과 상관없어 보이는 쿼리 전체가 죽었다.
error: could not determine data type of parameter $1
code: 42P18문법 오류도 아니고 타입 캐스팅 오류도 아니다. 파라미터를 하나 남겼을 뿐이다.
원인
PostgreSQL 은 prepared statement 를 파싱할 때 각 $n 의 타입을 문장 안에서의 사용 위치로부터 추론한다. WHERE owner_id = $1 이면 owner_id 컬럼 타입에서 text 를 유도하는 식이다.
그래서 어디에서도 참조되지 않는 $n 은 추론할 근거가 없다. 드라이버가 값을 함께 보내줘도 소용없다 — 서버는 파싱 단계에서 문장만 보고, 근거가 없으면 그 자리에서 42P18 로 거부한다. 값이 남는 게 아니라 문장이 컴파일되지 않는다.
빌더 코드에서 이런 모양이 나오기 쉽다.
// ✗ 위험 — ownerId 를 먼저 넣어두고, 쓸지 말지는 나중에 정한다
const params: unknown[] = [ownerId];
const ownerClause = perUserMode ? "" : " AND f.owner_id = $1";
const sql = `SELECT ... FROM files f WHERE f.deleted_at IS NULL${ownerClause}`;
await client.query(sql, params);perUserMode 가 켜지는 순간 $1 을 쓰는 절이 사라진다. 플래그가 꺼져 있던 개발·테스트 환경에서는 멀쩡히 돌고, 켠 환경에서만 쿼리 전체가 500 이 된다.
고치는 법
파라미터는 그 절을 실제로 만들 때만 push 한다. 번호는 params.length 로 파생시켜 순서가 자동으로 맞게 한다.
// ✓ 절과 파라미터를 한 덩어리로 — 절이 없으면 파라미터도 없다
const params: unknown[] = [];
let ownerClause = "";
if (!perUserMode) {
params.push(ownerId);
ownerClause = ` AND f.owner_id = $${params.length}`;
}
const sql = `SELECT ... FROM files f WHERE f.deleted_at IS NULL${ownerClause}`;
await client.query(sql, params);이 규칙 하나면 절을 추가·삭제·재배치해도 번호가 어긋나지 않는다. 조건부 절이 여러 개인 쿼리라면 더 그렇다.
if (dateFrom) {
params.push(dateFrom);
filters.push(`AND f.updated_at >= $${params.length}::timestamptz`);
}
if (types?.length) {
params.push(types);
filters.push(`AND f.name ILIKE ANY($${params.length})`);
}두 가지 덧붙임
하나 — 명시 캐스트는 대증요법이다. $1::text 를 붙이면 42P18 자체는 사라진다. 하지만 아무도 안 쓰는 파라미터가 문장에 남아 있다는 사실은 그대로고, 다음에 절을 하나 더 조건부로 만들 때 같은 실수가 반복된다. 캐스트로 덮지 말고 push 위치를 옮기는 편이 낫다.
둘 — 이 함정은 번호 바인딩($n)에서만 난다. ? 를 쓰고 드라이버가 순서대로 번호로 치환해주는 계층을 거치면, 절이 빠질 때 파라미터도 같이 빠지는 구조라 애초에 이 상태가 만들어지지 않는다. 같은 코드베이스에 두 경로가 섞여 있다면, 위험한 쪽은 $n 을 직접 쓰는 경로다.
왜 늦게 발견되나
이 버그가 오래 숨어 있는 이유는 플래그가 꺼진 상태가 기본값이기 때문이다. 꺼져 있으면 절이 살아 있어 모든 파라미터가 참조되고, 쿼리는 완벽하게 동작한다. 켜는 순간에만 깨진다.
그래서 회귀 테스트는 SQL 문자열을 정규식으로 검사하는 것만으로는 부족하다 — 실제 PostgreSQL 에 그 쿼리를 던져 파싱을 통과하는지 봐야 한다. 결과가 빈 배열이어도 상관없다. 파싱만 통과하면 42P18 은 잡힌다.
it("플래그 on 상태에서 쿼리가 실제 PG 에서 실행된다 (42P18 가드)", async () => {
const rows = await selectStarredFiles({ ownerId: "u1" });
expect(Array.isArray(rows)).toBe(true);
});여러 leg 을 Promise.all 로 묶어 응답하는 엔드포인트라면 더 신경 쓸 값어치가 있다. 한 leg 의 42P18 이 나머지 정상 leg 까지 통째로 500 으로 만들기 때문에, 증상은 "기능 하나가 안 된다"가 아니라 "화면 전체가 안 뜬다"로 나타난다.
정리
- 42P18 은 값의 문제가 아니라 문장의 문제다. 참조되지 않는
$n은 타입을 유도할 자리가 없어 파싱 단계에서 거부된다. - 조건부 SQL 빌더에서는 절과 파라미터를 같은
if블록 안에 묶고, 번호는params.length로 파생시킨다. - 캐스트로 덮지 말고, 실제 DB 에 쿼리를 던지는 테스트로 가드한다.