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 과 같음"인지 코드마다 다르게 해석되면, 그건 버그의 온상입니다.

처방

  1. NOT IN 대신 NOT EXISTS 를 쓰세요. 서브쿼리 컬럼의 NOT NULL 을 확신할 수 없다면 예외 없이요.
  2. NULL 을 허용할 이유가 없는 컬럼에는 NOT NULL 을 거세요. 기본값을 정할 수 있으면 정하세요. 문제를 없애는 게 다루는 것보다 낫습니다.
  3. NULL 이 필요하다면 그 의미를 문서화하세요. "아직 처리 안 됨"인지 "해당 없음"인지 팀이 같은 뜻으로 읽어야 해요.
  4. NULL 인 행만 자주 찾는다면 부분 인덱스를 만드세요. 대개 전체의 작은 비율이라 효과가 큽니다.
  5. 정렬에 NULL 의 위치를 명시하세요. DB 마다 기본값이 달라서 마이그레이션 때 순서가 바뀝니다.
  6. 외부 조인 뒤의 WHERE 조건을 점검하세요. 의도치 않게 내부 조인이 되고 있을 수 있어요.
  7. 유니크 제약이 NULL 을 어떻게 다루는지 확인하세요. 여러 개가 들어가는 게 의도인지 아닌지 정하세요.
  8. 집계에서 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 로 바꾸면 이 문제가 없어요.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

인덱스·쿼리 성능 글 20편 모두 보기 →