Search

REMINDER 03-06. NOT IN 서브쿼리의 NULL 함정을 기억하자

상황: 기준일 달력 startdbpg.cm_base_dt에서 2025년 1월 23일~30일 중 픽업이 한 번도 발생하지 않은 날짜를 찾는다. 주문 테이블 startdbpg.tr_ord의 pkup_dtm은 TIMESTAMP이며, 실제 730,327건 중 NULL이 20건 있다. 테이블 데이터를 변경하거나 NULL을 인위적으로 추가하지 않고 확인한 사례다.

BAD SQL — NULL이 포함된 서브쿼리를 NOT IN으로 비교

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT c.base_dt FROM startdbpg.cm_base_dt c WHERE c.base_dt BETWEEN DATE '2025-01-23' AND DATE '2025-01-30' AND c.base_dt NOT IN ( SELECT o.pkup_dtm::date FROM startdbpg.tr_ord o ) ORDER BY c.base_dt;
SQL
복사
실측 결과: 0건 — 픽업이 없는 날짜까지 모두 누락
Index Only Scan using cm_base_dt_pk on cm_base_dt c (actual time=1834.868..1834.869 rows=0 loops=1) Index Cond: ((base_dt >= '2025-01-23'::date) AND (base_dt <= '2025-01-30'::date)) Filter: (NOT (ANY (base_dt = (SubPlan 1).col1))) Rows Removed by Filter: 8 Heap Fetches: 0 Buffers: shared hit=16657 read=662, temp read=8397 written=1250 SubPlan 1 -> Materialize (actual time=0.024..157.147 rows=705066 loops=8) Buffers: shared hit=16654 read=662, temp read=8397 written=1250 -> Seq Scan on tr_ord o (actual time=0.036..359.799 rows=730327 loops=1) Buffers: shared hit=16654 read=662 Planning: Buffers: shared hit=151 Planning Time: 0.748 ms Execution Time: 1837.847 ms
Plain Text
복사

GOOD SQL — 일치하는 픽업 이력이 없는 날짜를 NOT EXISTS로 검색

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF) SELECT c.base_dt FROM startdbpg.cm_base_dt c WHERE c.base_dt BETWEEN DATE '2025-01-23' AND DATE '2025-01-30' AND NOT EXISTS ( SELECT 1 FROM startdbpg.tr_ord o WHERE o.pkup_dtm::date = c.base_dt ) ORDER BY c.base_dt;
SQL
복사
실측 결과: 5건
2025-01-26 2025-01-27 2025-01-28 2025-01-29 2025-01-30
Plain Text
복사
실측 실행계획 (GOOD)
Sort (actual time=441.916..441.920 rows=5 loops=1) Sort Key: c.base_dt Sort Method: quicksort Memory: 25kB Buffers: shared hit=16692 read=630 -> Hash Right Anti Join (actual time=441.831..441.839 rows=5 loops=1) Hash Cond: ((o.pkup_dtm)::date = c.base_dt) Buffers: shared hit=16689 read=630 -> Seq Scan on tr_ord o (actual time=0.039..283.270 rows=730327 loops=1) Buffers: shared hit=16686 read=630 -> Hash (actual time=0.055..0.057 rows=8 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 9kB Buffers: shared hit=3 -> Index Only Scan using cm_base_dt_pk on cm_base_dt c (actual time=0.044..0.046 rows=8 loops=1) Index Cond: ((base_dt >= '2025-01-23'::date) AND (base_dt <= '2025-01-30'::date)) Heap Fetches: 0 Buffers: shared hit=3 Planning: Buffers: shared hit=204 Planning Time: 1.381 ms Execution Time: 442.090 ms
Plain Text
복사
NOT IN도 NULL을 제외하면 정상 동작하는지 검증
SELECT c.base_dt FROM startdbpg.cm_base_dt c WHERE c.base_dt BETWEEN DATE '2025-01-23' AND DATE '2025-01-30' AND c.base_dt NOT IN ( SELECT o.pkup_dtm::date FROM startdbpg.tr_ord o WHERE o.pkup_dtm IS NOT NULL ) ORDER BY c.base_dt;
SQL
복사
실측 결과: GOOD과 같은 5건. 서브쿼리에서 NULL을 제거하자 의도한 결과가 반환됐다.
왜 0건이 되는가? NOT IN (값1, 값2, NULL)은 목록에 없는 날짜도 <> NULL 비교 때문에 UNKNOWN이 된다. WHERE는 TRUE인 행만 반환하므로 해당 날짜가 제외된다. 반면 NOT EXISTS는 해당 날짜와 일치하는 픽업 행이 존재하는지만 판단해 NULL의 영향을 받지 않는다.

핵심

•
NOT IN의 서브쿼리가 NULL을 반환할 수 있다면 결과가 예상과 달라진다. 먼저 NULL 가능성을 확인하고, 존재 여부를 배제하는 요구에는 NOT EXISTS를 우선 검토하자.
실행계획·성능 주의: 이번 실측에서는 BAD가 SubPlan + Materialize로 1,837.847ms, GOOD이 Hash Right Anti Join으로 442.090ms였다. 하지만 이 차이는 단일 실행 및 각 실행계획에 따른 것으로 NOT EXISTS가 항상 더 빠르다는 뜻은 아니다. 또한 pkup_dtm::date는 날짜 단위 비교를 설명하기 위한 표현이다. pkup_dtm 인덱스를 이용한 범위 탐색이 중요한 환경에서는 o.pkup_dtm >= c.base_dt AND o.pkup_dtm < c.base_dt + INTERVAL '1 day' 형태도 검토하자.