---
title: "SQL 조각에 컬럼 이름만 넘기면 상관 서브쿼리가 자기 자신과 비교한다"
description: "상관 서브쿼리에 한정자 없는 컬럼 이름을 넘기면 가장 안쪽 테이블의 컬럼으로 해석되어 조건이 항상 참이 됩니다. 문자열 조각으로 SQL 을 조립할 때 겪은 함정과 방어 방법입니다."
date: 2026-10-24
updated: 2026-10-24T00:11:21.919Z
tags: [postgresql, sql, subquery, code-review]
canonical: https://blog.wooncloud.com/posts/correlated-subquery-unqualified-column
---

"이 테넌트가 방을 하나라도 가지고 있는가"를 여러 쿼리에서 재사용하려고 `EXISTS` 조각을 만들어 주는 함수를 뒀습니다. 그런데 호출부 한 곳에서 필터가 조용히 꺼져 있었습니다.

![한정자 없는 컬럼은 서브쿼리 안쪽 테이블로 해석되어 조건이 항상 참이 되지만, 한정자를 붙이면 바깥 쿼리 기준으로 정확히 걸러진다.](/images/posts/correlated-subquery-unqualified-column/96ea30f9-60d6-4f96-be7a-fdfaccc016b8.webp)

## 조각 함수는 이렇게 생겼습니다

바깥 쿼리의 테넌트 컬럼을 인자로 받아 문자열에 끼워 넣는 함수입니다.

```ts
// col = 바깥 쿼리의 테넌트 컬럼 식
const ownsRoom = (col: string) =>
  `EXISTS (SELECT 1 FROM rooms r WHERE r.tenant_id = ${col})
   OR EXISTS (SELECT 1 FROM chats c WHERE c.tenant_id = ${col})`;
```

대부분의 호출부는 `ownsRoom("t.tenant_id")` 처럼 테이블 별칭을 붙여 넘겼습니다. 그런데 한 곳만 별칭 없는 FROM 에서 `ownsRoom("tenant_id")` 로 넘겼습니다.

## 왜 항상 참일까요

완성된 SQL 은 이렇게 됩니다.

```sql
SELECT tenant_id FROM usage_snapshots
 WHERE EXISTS (SELECT 1 FROM rooms r WHERE r.tenant_id = tenant_id)
```

서브쿼리 안에서 한정자 없는 `tenant_id` 는 가장 안쪽 스코프부터 찾습니다. `rooms` 에도 `tenant_id` 가 있으니 바깥 `usage_snapshots.tenant_id` 가 아니라 `r.tenant_id` 로 해석됩니다. 조건이 `r.tenant_id = r.tenant_id` 가 되는 거죠.

![별칭 없이 넘긴 컬럼은 서브쿼리 안쪽 rooms 의 컬럼으로 해석되어 조건이 항상 참이 되고, 한정자를 붙여 넘기면 바깥 쿼리 컬럼으로 해석되어 테넌트별로 걸러진다.](/images/posts/correlated-subquery-unqualified-column/38d02ec2-e48b-462d-abba-ddaad53c6c5e.svg)

`rooms` 에 행이 하나라도 있으면 **항상 참**입니다. 문법 오류도 경고도 없고, 필터가 조용히 꺼질 뿐입니다.

SQL 표준의 이름 해석 규칙대로 동작한 것입니다. 상관 서브쿼리에서 바깥 컬럼을 쓰려면 한정자가 필요합니다.

## 테스트는 왜 못 잡았을까요

픽스처에 "방이 없는 테넌트"와 "방이 있는 다른 테넌트"가 동시에 있어야 드러나기 때문입니다. 방이 하나도 없는 DB 에서는 EXISTS 가 거짓이라 우연히 맞습니다.

게다가 조각을 조립하는 함수만 단위 테스트하면, 호출부가 넘긴 인자 모양은 검증되지 않습니다.

## 고친 방법

먼저 바깥 테이블에 별칭을 주고 한정자를 붙여 넘겼습니다. `FROM usage_snapshots u … ownsRoom("u.tenant_id")` 같은 식입니다.

그리고 조각 함수 입구에서 인자 모양을 단언했습니다. 한정자(`.`)가 있거나 바인드 자리표시자(`$1`, `?`)일 때만 받습니다.

```ts
function assertQualified(col: string) {
  if (!/\./.test(col) && !/^\$\d+$|^\?$/.test(col)) {
    throw new Error(`correlated column must be qualified: ${col}`);
  }
}
```

회귀 테스트는 방 없는 테넌트와 방 있는 다른 테넌트를 같은 픽스처에 두고, 방 없는 쪽이 걸러지는지 봅니다.

SQL 을 문자열 조각으로 조립한다면 상관 서브쿼리에 들어가는 바깥 컬럼에는 항상 한정자를 붙이는 게 안전합니다. 한정자 없는 이름은 안쪽 테이블에 같은 컬럼이 생기는 순간 의미가 바뀌니까요. 코드 리뷰에서도 "조각에 넘기는 컬럼 식에 별칭이 붙어 있는가"를 기본 확인 항목으로 둘 만합니다.

## 참고

- PostgreSQL, Subquery Expressions: https://www.postgresql.org/docs/current/functions-subquery.html
- PostgreSQL, Scalar Subqueries: https://www.postgresql.org/docs/current/sql-expressions.html#SQL-SYNTAX-SCALAR-SUBQUERIES
