NOT IN 쿼리가 0건을 돌려줄 때: SQL의 NULL과 3값 논리

서브쿼리 결과에 NULL이 하나만 있어도 NOT IN 조건이 0건을 돌려주는 이유를 PostgreSQL·MySQL·SQLite 공식 문서로 정리했습니다. 3값 논리, WHERE의 행 선택 규칙, NOT EXISTS로 고치는 방법도 다룹니다.

개발데이터베이스SQL

차단 목록에 없는 사용자만 고르려고 이런 쿼리를 쓰는 경우가 많습니다.

SELECT id FROM users
WHERE id NOT IN (SELECT user_id FROM blocked);

blocked.user_id에 NULL이 한 행이라도 있으면 이 쿼리는 한 건도 돌려주지 않습니다. 오류나 경고는 나오지 않습니다. 원인은 SQL이 비교 결과를 참과 거짓 두 가지가 아니라 ’알 수 없음’까지 세 가지로 다룬다는 데 있습니다.

이 글은 PostgreSQL, MySQL, SQLite의 공식 문서를 근거로 합니다. ISO SQL 표준 원문은 열어 보지 않았습니다. 서브쿼리를 쓰는 NOT IN이 이렇게 동작한다는 점, 그리고 이것이 NULL이 섞인 논리식에 대한 SQL의 일반 규칙에 따른 결과라는 설명은 PostgreSQL 문서에 있습니다. MySQL 문서는 값 목록을 쓰는 IN()에 대해 같은 NULL 규칙을 따로 설명하면서, SQL 표준을 따르기 위한 동작이라고 적고 있습니다.

NULL과 비교하면 결과도 NULL이다

PostgreSQL 문서에 따르면 일반 비교 연산자는 입력 중 하나가 NULL이면 참이나 거짓이 아니라 NULL을 돌려줍니다. 이 NULL은 ’알 수 없음’을 뜻합니다. 7 = NULL도, 7 <> NULL도 결과는 NULL입니다. 값이 NULL인지 확인하려면 = NULL이 아니라 IS NULL을 써야 하는 것도 같은 이유입니다.

논리 연산에서 NULL은 다음과 같이 전파됩니다.

식 결과
TRUE AND NULL NULL
FALSE AND NULL FALSE
NOT NULL NULL

NOT IN을 풀어 쓰면 보인다

PostgreSQL 문서는 값 목록에 쓰는 NOT IN을 <> 비교를 AND로 이은 식의 줄임 표기라고 설명합니다. 그러면 1 NOT IN (2, NULL)은 이렇게 풀립니다.

1 <> 2 AND 1 <> NULL
-- TRUE AND NULL → NULL

첫 번째 비교는 참이지만 두 번째 비교가 NULL이라 전체도 NULL입니다. 2 NOT IN (2, NULL)은 2 <> 2가 거짓이므로 전체가 거짓입니다. 오른쪽에 NULL이 하나라도 있으면 NOT IN의 결과는 거짓이나 NULL 중 하나이고, 참은 나오지 않습니다.

서브쿼리를 쓸 때도 규칙은 같습니다. PostgreSQL 문서는 같은 값이 없고 서브쿼리 결과에 NULL이 하나라도 있으면 NOT IN의 결과가 참이 아니라 NULL이라고 적고 있습니다. MySQL 문서는 IN()이 일치하는 값을 찾지 못했고 목록에 NULL이 있으면 NULL을 돌려준다고 설명하고, NOT IN을 NOT (expr IN (...))과 같다고 정의합니다. SQLite 문서의 결과표도 같습니다.

WHERE는 참인 행만 남긴다

PostgreSQL 문서에 따르면 WHERE는 조건이 참인 행만 결과에 남기고, 거짓이거나 NULL인 행은 버립니다. NULL이 섞인 NOT IN은 모든 행에서 거짓이나 NULL이 되므로 결과가 0건이 됩니다.

IN에서는 같은 문제가 잘 드러나지 않습니다. id IN (2, NULL)이라면 id가 2인 행은 참이라 남고, 나머지 행은 NULL이라 빠집니다. 거짓이었어도 빠졌을 행이라 결과가 달라 보이지 않습니다. PostgreSQL 문서도 NOT IN에서 NULL에 걸려 넘어지기 쉬우니 가능하면 조건을 긍정형으로 쓰라고 권합니다.

SQLite에서 재현한 결과

아래는 SQLite 3.43.2에서 실제로 실행한 결과입니다.

CREATE TABLE users(id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE blocked(user_id INTEGER);

INSERT INTO users VALUES (1,'a'), (2,'b'), (3,'c');
INSERT INTO blocked VALUES (2), (NULL);
쿼리 결과
WHERE id NOT IN (SELECT user_id FROM blocked) 0건
WHERE id IN (SELECT user_id FROM blocked) 2
NOT EXISTS 1, 3
NOT IN + 서브쿼리의 WHERE user_id IS NOT NULL 1, 3

고치는 방법

방법 1은 NOT EXISTS로 바꾸는 것입니다. PostgreSQL 문서에 따르면 EXISTS는 서브쿼리가 행을 하나라도 돌려주면 참, 하나도 없으면 거짓입니다. SQLite 문서도 EXISTS는 항상 0이나 1이고, NULL이 든 행도 다르게 취급하지 않는다고 설명합니다. 결과가 NULL이 될 수 없으므로 위의 함정이 생기지 않습니다.

SELECT id FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM blocked b WHERE b.user_id = u.id
);

방법 2는 서브쿼리에서 NULL을 빼는 것입니다.

SELECT id FROM users
WHERE id NOT IN (
  SELECT user_id FROM blocked
  WHERE user_id IS NOT NULL
);

왼쪽 값이 NULL일 수 있으면 두 방법의 결과가 다르다

PostgreSQL 문서에 따르면 NOT IN의 왼쪽 값이 NULL이면 결과는 NULL입니다. 그래서 id가 NULL인 행은 방법 2에서 빠집니다. NOT EXISTS에서는 b.user_id = NULL 비교가 어느 행에서도 참이 되지 않으므로 서브쿼리가 행을 돌려주지 않고, 그 결과 해당 행이 남습니다.

위 예제의 users 테이블로는 이 차이를 재현할 수 없습니다. SQLite 문서에 따르면 일반 테이블의 INTEGER PRIMARY KEY 열은 NULL을 담을 수 없고, 이 열에 NULL을 넣으면 SQLite가 정수 값을 자동으로 골라 넣습니다. 그래서 NULL을 허용하는 테이블을 따로 만들고, 위의 blocked 테이블을 그대로 써서 실행했습니다.

CREATE TABLE nullable_users(id INTEGER, name TEXT);
INSERT INTO nullable_users VALUES (1,'a'), (2,'b'), (NULL,'x');

-- 방법 2
SELECT id, name FROM nullable_users
WHERE id NOT IN (
  SELECT user_id FROM blocked
  WHERE user_id IS NOT NULL
);

-- 방법 1
SELECT id, name FROM nullable_users n
WHERE NOT EXISTS (
  SELECT 1 FROM blocked b WHERE b.user_id = n.id
);
쿼리 결과 (id, name)
방법 2: NOT IN + IS NOT NULL (1, a)
방법 1: NOT EXISTS (1, a), (NULL, x)

어느 결과가 맞는지는 요구사항에 따라 다릅니다. 쿼리를 바꾸기 전에 왼쪽 열에 NULL이 들어갈 수 있는지 확인해야 합니다.

참고한 문서

링크와 내용은 2026년 10월 4일에 확인했습니다.

블로그로