---
title: "PostgreSQL 42P18 — 조건부 SQL 빌더에서 파라미터를 미리 넣으면 안 되는 이유"
description: "PostgreSQL에서 조건부 SQL 빌더 사용 시 참조되지 않는 파라미터가 포함되면 42P18 오류가 발생하므로 절과 파라미터를 함께 관리하고 실제 DB 파싱 테스트를 통해 검증해야 합니다"
date: 2026-08-04
updated: 2026-08-04T14:31:29.467Z
tags: [postgresql, sql, backend, debugging]
canonical: https://blog.wooncloud.com/posts/postgresql-42p18-unused-parameter
---

![끊어진 고리 하나가 남은 사슬](/images/posts/postgresql-42p18-unused-parameter/468a20dd-9bd0-4e17-967f-1d7c086ff041.webp)

## 증상

기능 플래그로 WHERE 절 하나가 빠졌더니, 그 절과 상관없어 보이는 쿼리 **전체**가 죽었다.

```text
error: could not determine data type of parameter $1
code: 42P18
```

문법 오류도 아니고 타입 캐스팅 오류도 아니다. 파라미터를 **하나 남겼을 뿐**이다.

## 원인

PostgreSQL 은 prepared statement 를 파싱할 때 각 `$n` 의 타입을 **문장 안에서의 사용 위치로부터** 추론한다. `WHERE owner_id = $1` 이면 `owner_id` 컬럼 타입에서 `text` 를 유도하는 식이다.

그래서 **어디에서도 참조되지 않는 `$n` 은 추론할 근거가 없다.** 드라이버가 값을 함께 보내줘도 소용없다 — 서버는 파싱 단계에서 문장만 보고, 근거가 없으면 그 자리에서 42P18 로 거부한다. 값이 남는 게 아니라 **문장이 컴파일되지 않는다.**

빌더 코드에서 이런 모양이 나오기 쉽다.

```ts
// ✗ 위험 — 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` 로 파생시켜 순서가 자동으로 맞게 한다.

```ts
// ✓ 절과 파라미터를 한 덩어리로 — 절이 없으면 파라미터도 없다
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);
```

이 규칙 하나면 절을 추가·삭제·재배치해도 번호가 어긋나지 않는다. 조건부 절이 여러 개인 쿼리라면 더 그렇다.

```ts
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 은 잡힌다.

```ts
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 에 쿼리를 던지는 테스트로 가드한다.

참고: [PostgreSQL — PREPARE](https://www.postgresql.org/docs/current/sql-prepare.html) · [Error Codes (Class 42)](https://www.postgresql.org/docs/current/errcodes-appendix.html)
