SQL 조각에 컬럼 이름만 넘기면 상관 서브쿼리가 자기 자신과 비교한다
상관 서브쿼리에 한정자 없는 컬럼 이름을 넘기면 가장 안쪽 테이블의 컬럼으로 해석되어 조건이 항상 참이 됩니다. 문자열 조각으로 SQL 을 조립할 때 겪은 함정과 방어 방법입니다.
"이 테넌트가 방을 하나라도 가지고 있는가"를 여러 쿼리에서 재사용하려고 EXISTS 조각을 만들어 주는 함수를 뒀습니다. 그런데 호출부 한 곳에서 필터가 조용히 꺼져 있었습니다.
조각 함수는 이렇게 생겼습니다
바깥 쿼리의 테넌트 컬럼을 인자로 받아 문자열에 끼워 넣는 함수입니다.
// 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 은 이렇게 됩니다.
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 에 행이 하나라도 있으면 항상 참입니다. 문법 오류도 경고도 없고, 필터가 조용히 꺼질 뿐입니다.
SQL 표준의 이름 해석 규칙대로 동작한 것입니다. 상관 서브쿼리에서 바깥 컬럼을 쓰려면 한정자가 필요합니다.
테스트는 왜 못 잡았을까요
픽스처에 "방이 없는 테넌트"와 "방이 있는 다른 테넌트"가 동시에 있어야 드러나기 때문입니다. 방이 하나도 없는 DB 에서는 EXISTS 가 거짓이라 우연히 맞습니다.
게다가 조각을 조립하는 함수만 단위 테스트하면, 호출부가 넘긴 인자 모양은 검증되지 않습니다.
고친 방법
먼저 바깥 테이블에 별칭을 주고 한정자를 붙여 넘겼습니다. FROM usage_snapshots u … ownsRoom("u.tenant_id") 같은 식입니다.
그리고 조각 함수 입구에서 인자 모양을 단언했습니다. 한정자(.)가 있거나 바인드 자리표시자($1, ?)일 때만 받습니다.
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