NOT IN 에 NULL 이 섞이면 결과가 0건 — NULL 과 인덱스
NOT IN 으로 걸렀는데 에러도 없이 결과가 0건이에요. 데이터는 분명히 있는데요. NULL 하나가 결과를 통째로 비우는 이유와, DB 마다 NULL 을 다르게 다루는 지점을 짚었어요.
NULL 인 행을 찾는 쿼리가 인덱스를 안 타요. 컬럼에 인덱스가 분명히 있는데도요.
반대로 NOT NULL 조건은 잘 타는 것 같기도 하고, 어떤 DB 에서는 또 안 타요. 같은 코드를 MySQL 에서 PostgreSQL 로 옮겼더니 동작이 달라지기도 해요.
그리고 더 골치 아픈 건 결과가 틀리는 경우예요. NOT IN 을 썼는데 아무 행도 안 나와요. 데이터는 분명히 있는데요.
성능 문제인 줄 알고 파다가 사실은 값이 틀렸다는 걸 나중에 발견해요. 에러가 아니라 0행이라 아무도 이상하다고 안 합니다. 이 편은 성능 이야기 반, 정확성 이야기 반이에요.
왜 그런가
NULL 은 값이 아니라 "값이 없음"이에요. 그래서 비교 규칙 자체가 달라요.
NULL 과의 비교는 참도 거짓도 아닌 알 수 없음이 돼요. NULL = NULL 도 참이 아니에요. 그래서 IS NULL 이라는 별도 문법이 필요한 거고요.
이 규칙이 여러 곳에서 예상 밖의 결과를 만들어요.
NOT IN 이 대표적이에요. NOT IN 목록에 NULL 이 하나라도 있으면 전체 결과가 비어요. "x 가 목록에 없다"를 판단하려면 목록의 모든 값과 다르다는 걸 확인해야 하는데, NULL 과의 비교가 알 수 없음이라 전체가 알 수 없음이 되거든요. 조건이 참이 아니니 행이 하나도 안 나옵니다.
집계에서도 달라요. COUNT(컬럼) 은 NULL 을 안 세지만 COUNT(*) 는 행을 세요. 평균이나 합계도 NULL 을 빼고 계산하고요. 그래서 같은 데이터에 대해 다른 숫자가 나옵니다.
정렬에서도 위치가 달라요. PostgreSQL 은 오름차순에서 NULL 을 마지막에 두고, MySQL 은 처음에 둬요. 같은 쿼리가 DB 에 따라 다른 순서를 내는 거예요.
인덱스 쪽은 DB 마다 구현이 갈려요. PostgreSQL 의 B-tree 는 NULL 도 인덱스에 저장해서 IS NULL 조회에 인덱스를 쓸 수 있어요. 오라클은 전통적으로 단일 컬럼 인덱스에 NULL 을 안 넣어서 IS NULL 조회에 못 씁니다.
코드로 보면
NULL 은 별도 문법이 필요하다:
WHERE col = NULL -- 항상 결과 없음
WHERE col IS NULL
· NOT IN 에 NULL 이 섞이면 결과가 통째로 빈다
SELECT * FROM a WHERE id NOT IN (SELECT b_id FROM b);
-- b_id 에 NULL 이 하나라도 있으면 0행
SELECT * FROM a WHERE NOT EXISTS (
SELECT 1 FROM b WHERE b.b_id = a.id);
· NULL 만 찾는 조회가 잦으면 부분 인덱스
CREATE INDEX idx_pending ON orders (created_at)
WHERE processed_at IS NULL;
· 정렬에서 NULL 의 자리를 명시한다
ORDER BY updated_at DESC NULLS LAST✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
실무에서 NULL 때문에 가장 자주 겪는 세 가지를 정리하면 이래요.
첫째, NOT IN 의 함정. 결과가 조용히 비어요. 에러가 아니라 0행이라 "데이터가 없나 보다"로 넘어가기 쉬워요. NOT EXISTS 로 바꾸면 이 문제가 없어요. 서브쿼리의 컬럼이 NOT NULL 이라고 확신하지 못하면 항상 NOT EXISTS 를 쓰는 게 안전합니다.
둘째, 외부 조인 뒤의 조건. 왼쪽 외부 조인을 하면 매칭 안 된 쪽이 NULL 로 채워져요. 그 뒤에 WHERE 로 오른쪽 테이블의 컬럼을 조건에 걸면, NULL 인 행이 걸러지면서 사실상 내부 조인이 돼요. 조건을 ON 절로 옮기거나 IS NULL 을 함께 써야 의도대로 동작합니다.
셋째, 유니크 제약과 NULL. 대부분의 DB 에서 NULL 은 서로 다르다고 봐서, 유니크 인덱스가 걸려 있어도 NULL 은 여러 개 들어가요. "이메일은 유일해야 한다"고 유니크를 걸었는데 이메일이 없는 회원이 여럿 생기는 거죠. 의도한 동작일 수도 있고 아닐 수도 있으니 확인해야 해요.
성능 쪽에서 유용한 도구가 부분 인덱스예요. "아직 처리 안 된 것"을 NULL 로 표현하는 설계가 흔한데, 그런 행은 대개 전체의 아주 작은 비율이에요. NULL 인 행만 담는 부분 인덱스를 만들면 인덱스가 작게 유지되고 조회도 빨라집니다. 앞서 본 소프트 삭제와 같은 구조예요.
근본적으로는 NULL 을 줄이는 게 좋아요. NULL 이 여러 의미를 갖는 설계가 특히 나빠요 — 어떤 컬럼의 NULL 이 "아직 모름"인지 "해당 없음"인지 "0 과 같음"인지 코드마다 다르게 해석되면, 그건 버그의 온상입니다.
처방
- NOT IN 대신 NOT EXISTS 를 쓰세요. 서브쿼리 컬럼의 NOT NULL 을 확신할 수 없다면 예외 없이요.
- NULL 을 허용할 이유가 없는 컬럼에는 NOT NULL 을 거세요. 기본값을 정할 수 있으면 정하세요. 문제를 없애는 게 다루는 것보다 낫습니다.
- NULL 이 필요하다면 그 의미를 문서화하세요. "아직 처리 안 됨"인지 "해당 없음"인지 팀이 같은 뜻으로 읽어야 해요.
- NULL 인 행만 자주 찾는다면 부분 인덱스를 만드세요. 대개 전체의 작은 비율이라 효과가 큽니다.
- 정렬에 NULL 의 위치를 명시하세요. DB 마다 기본값이 달라서 마이그레이션 때 순서가 바뀝니다.
- 외부 조인 뒤의 WHERE 조건을 점검하세요. 의도치 않게 내부 조인이 되고 있을 수 있어요.
- 유니크 제약이 NULL 을 어떻게 다루는지 확인하세요. 여러 개가 들어가는 게 의도인지 아닌지 정하세요.
- 집계에서 COUNT(*) 와 COUNT(컬럼) 을 구분해서 쓰세요. 의도한 쪽을 명시적으로 고르세요.
지금 확인해 보세요
NULL 이 어디에 얼마나 있나:
· 컬럼별 NULL 비율 — 부분 인덱스 후보를 찾는다
SELECT count(*) FILTER (WHERE processed_at IS NULL) AS nulls,
count(*) AS total,
round(100.0*count(*) FILTER (WHERE processed_at IS NULL)
/ nullif(count(*),0), 2) AS pct
FROM orders;
5% 미만이면 부분 인덱스가 아주 유리하다
· NOT IN 이 쓰인 곳을 찾아 NOT EXISTS 로 바꾼다
코드베이스에서 'NOT IN (SELECT' 를 검색한다
· 빈 문자열과 NULL 이 섞여 있지는 않은지
SELECT count(*) FILTER (WHERE col IS NULL) AS is_null,
count(*) FILTER (WHERE col = '') AS empty
FROM t;
둘 다 0 이 아니면 통일이 필요하다
· 부분 인덱스가 실제로 쓰이는지
EXPLAIN 으로 Index Scan using idx_pending 확인핵심 한 줄NULL 은 값이 아니라 모름이다. 비교의 규칙부터 다르다.
자주 묻는 것
NOT IN 결과가 0건인데 데이터는 있어요. 왜 그런가요?
NOT IN 목록에 NULL 이 하나라도 있으면 NULL 과의 비교가 '알 수 없음'이 되어 조건 전체가 참이 되지 않아요. 그래서 행이 하나도 안 나와요. NOT EXISTS 로 바꾸면 이 문제가 없어요.