NOT IN 서브쿼리에 NULL 한 건이 들어오자 48,000건이 0건으로: ANTI NA 실행계획 추적

수신 거부 1건이 추가되자 발송 대상이 0건이 되었습니다
아래 SQL은 수신 거부 기록이 없는 회원을 발송 대상으로 고르는 쿼리입니다. Oracle 21c XE에서 회원 50,000명과 수신 거부 2,000건으로 실행했을 때 결과는 48,000건이었습니다.
SELECT COUNT(*)
FROM mkt_member m
WHERE m.member_id NOT IN (SELECT u.member_id FROM unsub_log u);
수신 거부 테이블에 회원 번호가 비어 있는 행 1건을 추가하고 같은 SQL을 다시 실행했습니다. 결과는 48,000건이 아니라 0건이었고, 오류는 발생하지 않았습니다. 같은 조건을 NOT EXISTS로 쓴 SQL은 48,000건을 그대로 반환했습니다.
결과가 바뀐 이유 자체는 NULL 비교 규칙으로 설명됩니다. 이 실험에서 확인하려 한 것은 그 다음 단계입니다. 옵티마이저가 NULL 가능성을 실행계획에서 어떤 연산으로 처리하는지, 0건을 반환할 때 실제로 무엇을 읽었는지, 그리고 이 위험을 통계나 예상 행 수로 미리 알 수 있는지입니다. 데이터는 블로그 테스트용 가상 시나리오로 만들었고, 모든 수치는 직접 실행하고 캡처한 결과입니다.
실험 환경과 데이터 설계
테스트는 Oracle Database 21c Express Edition의 PDB(XEPDB1)에서 전용 사용자 ANTI_LAB으로 진행했습니다. 클라이언트는 DBeaver입니다. 사용자 생성과 권한 부여만 SYSTEM으로 한 번 실행했고, 실행 통계 조회를 위해 테스트 사용자에게 SELECT ANY DICTIONARY를 부여했습니다.
-- SYSTEM 계정, XEPDB1 접속
CREATE USER anti_lab IDENTIFIED BY "AntiLab#2026"
DEFAULT TABLESPACE users
QUOTA 200M ON users;
GRANT CREATE SESSION, CREATE TABLE TO anti_lab;
GRANT SELECT ANY DICTIONARY TO anti_lab;
-- ANTI_LAB 계정, XEPDB1 접속
ALTER SESSION SET CURRENT_SCHEMA = anti_lab;
SELECT
(SELECT banner_full FROM v$version WHERE ROWNUM = 1) AS db_version,
SYS_CONTEXT('USERENV', 'CON_NAME') AS con_name,
SYS_CONTEXT('USERENV', 'SESSION_USER') AS session_user,
SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') AS current_schema,
(SELECT value FROM v$parameter WHERE name = 'optimizer_features_enable') AS ofe,
(SELECT value FROM v$parameter WHERE name = 'optimizer_mode') AS optimizer_mode,
(SELECT value FROM v$parameter WHERE name = 'statistics_level') AS statistics_level
FROM dual;

| 항목 | 확인 결과 |
|---|---|
| 버전 | Oracle Database 21c Express Edition Release 21.0.0.0.0 |
| 접속 컨테이너 | XEPDB1 |
| SESSION_USER / CURRENT_SCHEMA | ANTI_LAB / ANTI_LAB |
| optimizer_features_enable | 21.1.0 |
| optimizer_mode / statistics_level | ALL_ROWS / TYPICAL |
가상 시나리오와 데이터 규모
마케팅 발송 대상 추출을 가정해 회원 테이블과 수신 거부 기록 테이블을 만들었습니다. UNSUB_LOG.member_id는 비회원 수신 거부도 기록할 수 있다는 설정으로 NULL을 허용했습니다.
| 테이블 | 건수 | 핵심 컬럼 |
|---|---|---|
| MKT_MEMBER | 50,000 | member_id 기본 키(NOT NULL) |
| UNSUB_LOG | 2,000 | member_id NULL 허용, 값은 25, 50, …, 50000 (회원 2,000명, 중복 없음) |
회원 수는 해시 안티 조인이 선택될 만큼 크면서 XE에서 적재와 재실행이 몇 초 안에 끝나는 50,000건으로 정했습니다. 설계상 발송 대상은 50,000 − 2,000 = 48,000건입니다. member_id 보조 인덱스는 변수를 줄이려고 만들지 않았습니다.
CREATE TABLE mkt_member (
member_id NUMBER(10) NOT NULL,
member_name VARCHAR2(30) NOT NULL,
grade_cd VARCHAR2(10) NOT NULL,
join_dt DATE NOT NULL,
CONSTRAINT mkt_member_pk PRIMARY KEY (member_id)
);
INSERT /*+ APPEND */ INTO mkt_member (member_id, member_name, grade_cd, join_dt)
SELECT LEVEL,
'MEMBER_' || LEVEL,
CASE WHEN MOD(LEVEL, 10) = 0 THEN 'VIP' ELSE 'NORMAL' END,
DATE '2025-01-01' + MOD(LEVEL, 365)
FROM dual
CONNECT BY LEVEL <= 50000;
COMMIT;
CREATE TABLE unsub_log (
unsub_id NUMBER(10) NOT NULL,
member_id NUMBER(10),
channel_cd VARCHAR2(10) NOT NULL,
unsub_dt DATE NOT NULL,
CONSTRAINT unsub_log_pk PRIMARY KEY (unsub_id)
);
INSERT INTO unsub_log (unsub_id, member_id, channel_cd, unsub_dt)
SELECT LEVEL,
LEVEL * 25,
CASE MOD(LEVEL, 3) WHEN 0 THEN 'SMS' WHEN 1 THEN 'EMAIL' ELSE 'PUSH' END,
DATE '2025-06-01' + MOD(LEVEL, 100)
FROM dual
CONNECT BY LEVEL <= 2000;
COMMIT;
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(ownname => 'ANTI_LAB', tabname => 'MKT_MEMBER');
DBMS_STATS.GATHER_TABLE_STATS(ownname => 'ANTI_LAB', tabname => 'UNSUB_LOG');
END;
APPEND로 적재한 직후 같은 테이블을 조회하면 ORA-12838이 나므로 INSERT 바로 뒤에 COMMIT을 두었습니다. 적재 후 상태는 다음 SQL로 확인했습니다.
SELECT
(SELECT COUNT(*) FROM mkt_member) AS member_cnt,
(SELECT COUNT(*) FROM unsub_log) AS unsub_cnt,
(SELECT COUNT(DISTINCT member_id) FROM unsub_log) AS unsub_member_cnt,
(SELECT COUNT(*) FROM unsub_log WHERE member_id IS NULL) AS null_member_cnt,
(SELECT nullable FROM user_tab_columns
WHERE table_name = 'UNSUB_LOG' AND column_name = 'MEMBER_ID') AS nullable_flag,
(SELECT num_nulls FROM user_tab_col_statistics
WHERE table_name = 'UNSUB_LOG' AND column_name = 'MEMBER_ID') AS stat_num_nulls
FROM dual;

NULL이 하나도 없는데 NOT IN에는 ANTI NA가 붙었습니다
NULL 데이터가 없는 상태에서 NOT IN(TC01)과 NOT EXISTS(TC02)를 실행했습니다. 두 SQL 모두 결과는 48,000건으로 같았습니다. 실행 통계는 GATHER_PLAN_STATISTICS 힌트를 붙여 같은 SQL을 두 번 실행하고, 하드 파스가 섞이지 않은 두 번째 실행을 ALLSTATS LAST로 조회했습니다.
-- TC01: NOT IN
SELECT /*+ GATHER_PLAN_STATISTICS */ 'TC01' AS tc, COUNT(*) AS target_cnt
FROM mkt_member m
WHERE m.member_id NOT IN (SELECT u.member_id FROM unsub_log u);
-- TC02: NOT EXISTS
SELECT /*+ GATHER_PLAN_STATISTICS */ 'TC02' AS tc, COUNT(*) AS target_cnt
FROM mkt_member m
WHERE NOT EXISTS (SELECT 1 FROM unsub_log u WHERE u.member_id = m.member_id);
커서를 찾을 때는 SQL 문장이 아니라 SELECT 목록에 넣은 태그 리터럴을 사용했습니다. DBeaver가 SQL 위의 주석 줄까지 함께 서버로 보내서, 캡처에서도 V$SQL의 SQL_TEXT가 스크립트 주석으로 시작하는 것이 보입니다. 이 상태에서는 SQL_TEXT가 SELECT로 시작한다고 가정한 조건으로는 원하는 커서를 놓치기 쉽습니다.
SELECT p.plan_table_output
FROM v$sql s,
TABLE(DBMS_XPLAN.DISPLAY_CURSOR(s.sql_id, s.child_number, 'ALLSTATS LAST')) p
WHERE s.sql_text LIKE '%''TC01''%'
AND s.sql_text NOT LIKE '%v$sql%';

TC01의 조인 연산은 HASH JOIN RIGHT ANTI NA였습니다. NA는 Null-Aware를 뜻하며, 서브쿼리 쪽에 NULL이 있을 가능성까지 고려하는 안티 조인입니다. 같은 결과를 낸 TC02는 NA가 없는 HASH JOIN RIGHT ANTI였습니다. 두 계획 모두 작은 UNSUB_LOG로 해시 테이블을 만들고, 회원 쪽은 테이블 대신 MKT_MEMBER_PK 인덱스를 INDEX FAST FULL SCAN으로 읽었습니다. COUNT(*)에 필요한 컬럼이 member_id뿐이어서 인덱스만으로 충분했기 때문입니다.
NOT NULL 제약 하나로 계획이 NOT EXISTS와 같아졌습니다
데이터는 그대로 두고 UNSUB_LOG.member_id에 NOT NULL 제약만 추가한 뒤, TC01과 같은 NOT IN을 태그만 바꿔 TC03으로 실행했습니다. 확인이 끝난 뒤에는 다음 단계를 위해 제약을 다시 풀었습니다. NULL 행을 넣은 뒤에는 이 제약을 추가할 수 없으므로 순서가 중요합니다.
ALTER TABLE unsub_log MODIFY (member_id NOT NULL);
SELECT /*+ GATHER_PLAN_STATISTICS */ 'TC03' AS tc, COUNT(*) AS target_cnt
FROM mkt_member m
WHERE m.member_id NOT IN (SELECT u.member_id FROM unsub_log u);
ALTER TABLE unsub_log MODIFY (member_id NULL);

여섯 번의 실행을 한 표로 모으면 다음과 같습니다. TC04~TC06은 다음 절에서 다룰 NULL 1건 입력 이후의 실행입니다.
| TC | 조건 | SQL 방식 | 조인 연산 | Plan hash | 결과 |
|---|---|---|---|---|---|
| TC01 | NULL 허용, NULL 0건 | NOT IN | HASH JOIN RIGHT ANTI NA | 4227849561 | 48,000 |
| TC02 | NULL 허용, NULL 0건 | NOT EXISTS | HASH JOIN RIGHT ANTI | 2349481944 | 48,000 |
| TC03 | NOT NULL 제약 | NOT IN | HASH JOIN RIGHT ANTI | 2349481944 | 48,000 |
| TC04 | NULL 허용, NULL 1건 | NOT IN | HASH JOIN RIGHT ANTI NA | 4227849561 | 0 |
| TC05 | NULL 허용, NULL 1건 | NOT EXISTS | HASH JOIN RIGHT ANTI | 2349481944 | 48,000 |
| TC06 | NULL 허용, NULL 1건 | NOT IN + IS NOT NULL | HASH JOIN RIGHT ANTI | 2349481944 | 48,000 |
TC01과 TC03의 차이는 제약 하나뿐인데 조인 연산과 plan hash가 바뀌었습니다. TC01 시점의 통계상 num_nulls도 0이었습니다. 이번 결과로 보면 옵티마이저는 데이터에 NULL이 실제로 있는지나 통계값이 아니라, 컬럼이 NULL을 허용하는지를 기준으로 null-aware 처리 여부를 정했습니다.
Oracle에는 ANTI SNA로 표시되는 변형도 있지만 이번 실험에서는 한 번도 나타나지 않았습니다. 바깥쪽 member_id가 기본 키라 NULL이 없기 때문으로 추정하며, 이 부분은 직접 검증하지 않았습니다.
NULL 한 건이 들어간 뒤 세 SQL의 결과
제약을 푼 상태에서 회원 번호가 비어 있는 수신 거부 1건을 넣고, NOT IN(TC04), NOT EXISTS(TC05), 서브쿼리에 NULL 제외 조건을 추가한 NOT IN(TC06)을 실행했습니다. 결과 건수는 세 방식을 한 번에 비교하는 SQL로 확인했습니다.
INSERT INTO unsub_log (unsub_id, member_id, channel_cd, unsub_dt)
VALUES (900001, NULL, 'EMAIL', DATE '2025-09-10');
COMMIT;
SELECT 'NOT IN' AS method, COUNT(*) AS target_cnt
FROM mkt_member m
WHERE m.member_id NOT IN (SELECT u.member_id FROM unsub_log u)
UNION ALL
SELECT 'NOT EXISTS', COUNT(*)
FROM mkt_member m
WHERE NOT EXISTS (SELECT 1 FROM unsub_log u WHERE u.member_id = m.member_id)
UNION ALL
SELECT 'NOT IN + IS NOT NULL', COUNT(*)
FROM mkt_member m
WHERE m.member_id NOT IN (
SELECT u.member_id FROM unsub_log u WHERE u.member_id IS NOT NULL
);

NOT IN만 0건이 된 이유는 NOT IN이 목록의 모든 값과 같지 않음을 AND로 묶은 조건이기 때문입니다. 수신 거부 목록에 없는 회원 번호 48로 풀어 쓰면 다음과 같습니다.
48 NOT IN (25, 50, ..., 50000, NULL)
= 48 <> 25 AND 48 <> 50 AND ... AND 48 <> 50000 AND 48 <> NULL
마지막 48 <> NULL은 TRUE도 FALSE도 아닌 UNKNOWN입니다. 앞의 비교가 모두 TRUE여도 AND 결과는 UNKNOWN이 되고, WHERE 절은 TRUE인 행만 통과시킵니다. 목록에 있는 회원은 원래 FALSE이므로, 결국 어떤 회원도 TRUE가 될 수 없습니다. 반면 NOT EXISTS는 u.member_id = m.member_id를 만족하는 행이 있는지만 확인하므로, NULL 행은 어떤 회원과도 일치하지 않을 뿐 결과를 막지 않습니다.
TC06처럼 서브쿼리에 WHERE u.member_id IS NOT NULL을 추가하면 결과는 48,000건으로 돌아왔고, plan hash도 NOT EXISTS와 같은 2349481944였습니다. NOT EXISTS 계획과 다른 점은 UNSUB_LOG 단계에 filter("U"."MEMBER_ID" IS NOT NULL)가 붙어 A-Rows가 2,001이 아니라 2,000으로 줄어든 것뿐이었습니다.
바깥쪽 값이 NULL인 행도 NOT IN에서는 빠집니다
NULL이 바깥쪽에 있을 때도 두 방식은 다르게 동작합니다. 1, 2, NULL 세 값에서 2를 제외하는 조건으로 확인했습니다.
WITH outer_v AS (
SELECT 1 AS id FROM dual UNION ALL
SELECT 2 FROM dual UNION ALL
SELECT NULL FROM dual
),
inner_v AS (
SELECT 2 AS id FROM dual
)
SELECT 'NOT IN' AS method, COUNT(*) AS cnt
FROM outer_v o
WHERE o.id NOT IN (SELECT i.id FROM inner_v i)
UNION ALL
SELECT 'NOT EXISTS', COUNT(*)
FROM outer_v o
WHERE NOT EXISTS (SELECT 1 FROM inner_v i WHERE i.id = o.id);
METHOD CNT
NOT IN 1
NOT EXISTS 2
NOT IN은 1만 남겨 1건, NOT EXISTS는 1과 NULL을 남겨 2건이었습니다. NULL <> 2 역시 UNKNOWN이기 때문입니다. 서브쿼리 쪽 NULL은 결과 전체를 비우고, 바깥쪽 NULL은 그 행만 빠뜨립니다.
0건을 반환한 실행은 회원 쪽을 한 번도 읽지 않았습니다
NULL이 들어간 뒤의 NOT IN(TC04) 실행 통계를 같은 방식으로 조회했습니다.

| Id | Operation | Name | Starts | E-Rows | A-Rows | Buffers |
| 0 | SELECT STATEMENT | | 1 | | 1 | 12 |
| 1 | SORT AGGREGATE | | 1 | 1 | 1 | 12 |
|* 2 | HASH JOIN RIGHT ANTI NA | | 1 | 50000 | 0 | 12 |
| 3 | TABLE ACCESS FULL | UNSUB_LOG | 1 | 2001 | 2001 | 12 |
| 4 | INDEX FAST FULL SCAN | MKT_MEMBER_PK | 0 | 50000 | 0 | 0 |
Predicate Information
2 - access("M"."MEMBER_ID"="U"."MEMBER_ID")
A-Time과 메모리 열은 생략했습니다. NULL이 없던 TC01과 나란히 놓으면 차이가 분명합니다.
| 항목 | TC01 (NULL 0건) | TC04 (NULL 1건) |
|---|---|---|
| 조인 A-Rows | 48,000 | 0 |
| UNSUB_LOG A-Rows | 2,000 | 2,001 |
| MKT_MEMBER_PK Starts / A-Rows | 1 / 50,000 | 0 / 0 |
| 전체 Buffers | 126 | 12 |
| 조인 E-Rows | 50,000 | 50,000 |
해시 조인은 UNSUB_LOG 2,001행으로 해시 테이블을 만드는 단계까지는 실행되었습니다. 그러나 회원 쪽 인덱스는 Starts가 0으로, 한 번도 시작되지 않았습니다. 서브쿼리 결과에 NULL이 있으면 NOT IN은 어떤 행에 대해서도 TRUE가 될 수 없으므로, 바깥 50,000건을 읽지 않아도 0건이 확정됩니다. Starts 0은 실행 통계로 확인한 사실이고, ANTI NA 연산이 해시 테이블을 만들면서 NULL을 발견해 탐색 단계를 생략했다는 해석은 이 결과에서 나온 추정입니다.
운영 관점에서 이 결과가 위험한 이유는 틀린 결과가 더 빨리 끝난다는 데 있습니다. 버퍼 접근은 126에서 12로 줄었으므로, 실행 시간이나 I/O만 보는 모니터링에서는 오히려 개선처럼 보일 수 있습니다. 조인의 E-Rows는 여전히 50,000이어서 실행계획의 예상 행 수로도 0건을 미리 알 수 없었습니다.
통계로는 NULL 유입을 미리 알 수 없었습니다
처음 계획은 NULL 1건을 넣은 직후 통계 재수집 전과 후의 num_nulls를 비교하는 것이었습니다. 그런데 재수집 전에 돌려야 할 상태 조회를 실수로 DBMS_STATS 실행 뒤에 실행해서, 변화 전 값을 기록하지 못했습니다. 그래서 NULL 1건을 더 넣어 같은 비교를 다시 했습니다.
INSERT INTO unsub_log (unsub_id, member_id, channel_cd, unsub_dt)
VALUES (900002, NULL, 'SMS', DATE '2025-09-11');
COMMIT;
-- 재수집 전
SELECT (SELECT COUNT(*) FROM unsub_log WHERE member_id IS NULL) AS null_member_cnt,
(SELECT num_nulls FROM user_tab_col_statistics
WHERE table_name = 'UNSUB_LOG' AND column_name = 'MEMBER_ID') AS stat_num_nulls
FROM dual;
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(ownname => 'ANTI_LAB', tabname => 'UNSUB_LOG');
END;
-- 재수집 후: 같은 SELECT 재실행
| 시점 | 실제 NULL 건수 | 통계상 num_nulls |
|---|---|---|
| NULL 추가 직후 (재수집 전) | 2 | 1 |
| DBMS_STATS 재수집 후 | 2 | 2 |
재수집 전까지 딕셔너리 통계는 이전 값 1을 유지했고, USER_TAB_COL_STATISTICS의 NOTES 컬럼도 비어 있었습니다. 이 환경에서는 일반 DML이 컬럼 통계에 바로 반영되지 않았습니다. TC01이 num_nulls 0 상태에서도 ANTI NA로 실행된 결과와 함께 보면, 통계는 NULL 유입을 감지하는 수단이 되지 못합니다. 실행계획의 NA 표시도 데이터에 NULL이 있다는 뜻이 아니라 NULL이 들어올 수 있다는 뜻으로 읽어야 합니다.
운영 SQL에서 NOT IN을 만났을 때의 판단 기준
| 상황 | 판단 | 근거 |
|---|---|---|
| 서브쿼리 컬럼에 NOT NULL 제약이 있음 | NOT IN 결과가 NULL에 흔들리지 않고, 계획도 NOT EXISTS와 같음 | TC03 |
| NULL 허용 컬럼이고 NULL은 제외하는 것이 의도 | IS NOT NULL을 명시하거나 NOT EXISTS로 작성 | TC05, TC06 |
| NULL 허용 컬럼이고 의도가 불분명 | NULL 행이 생길 수 있는 업무 경로부터 확인 | 데이터 설계의 문제 |
| 배치 결과가 0건 | 정상적인 0건인지 NULL로 인한 0건인지 별도 검증 | TC04가 오류 없이 0건 반환 |
이미 운영 중인 SQL 가운데 이런 NOT IN을 찾으려면, 실행계획 연산의 OPTIONS에 NA가 붙은 커서를 조회하는 방법이 있습니다. 아래 SQL은 점검 방법으로 제시하는 것이며, 이번 실험에서 결과를 수집하지는 않았습니다.
SELECT DISTINCT
s.parsing_schema_name,
p.sql_id,
p.operation,
p.options
FROM v$sql_plan p
JOIN v$sql s
ON s.sql_id = p.sql_id
AND s.child_number = p.child_number
WHERE p.options LIKE '%ANTI%NA'
AND s.parsing_schema_name NOT IN ('SYS', 'SYSTEM');
조회된 SQL은 서브쿼리 컬럼이 NULL을 허용한다는 뜻일 뿐 결과가 틀렸다는 뜻은 아닙니다. 해당 컬럼에 NULL이 들어올 수 있는 경로가 있는지 확인하고, NULL 제외가 의도라면 SQL에 명시하는 쪽으로 고칩니다.
이 실험의 한계
단일 세션에서 회원 50,000건, 수신 거부 2,000건으로 실행했고, UNSUB_LOG.member_id에는 인덱스가 없었습니다. 서브쿼리 컬럼에 인덱스가 있거나 데이터 규모가 달라지면 NESTED LOOPS 계열의 안티 조인이 선택될 수 있고, 그때 NULL을 처리하는 방식과 실행 통계는 달라질 수 있습니다. A-Time은 모든 실행이 0.01초 단위로 표시되어 시간 비교에는 쓰지 않았고, 캐시 상태도 통제하지 않았습니다. 21c XE(optimizer_features_enable 21.1.0)에서 확인한 결과이며, 19c 등 다른 버전에서 같은 연산 이름과 실행 통계가 나오는지는 확인하지 않았습니다.
실습 정리
DBeaver에서 ANTI_LAB 연결을 먼저 끊은 뒤 SYSTEM으로 실행합니다. 접속이 남아 있으면 DROP USER가 ORA-01940으로 실패합니다. 인스턴스 파라미터는 바꾸지 않았으므로 원복할 설정은 없습니다.
SELECT sid, serial#, status, program
FROM v$session
WHERE username = 'ANTI_LAB';
DROP USER anti_lab CASCADE;
NOT IN의 안전성은 데이터가 아니라 컬럼 제약이 결정했습니다
이번 실험에서 결과를 바꾼 것은 NULL 1건이었지만, 실행계획을 바꾼 것은 NULL 허용 여부라는 제약이었습니다. NULL이 없을 때도 NOT IN은 ANTI NA로 실행되었고, NULL이 들어오자 회원 쪽을 읽지도 않고 0건을 반환했습니다. 통계는 재수집 전까지 변화를 반영하지 않았고, 예상 행 수는 50,000에 머물렀습니다. 서브쿼리 컬럼이 NULL을 허용하는 한 NOT IN의 결과는 데이터 한 건에 달려 있습니다. NOT NULL 제약, IS NOT NULL 명시, NOT EXISTS 중 하나로 그 의존을 SQL에서 없애 두는 것이 이번 결과에서 얻은 판단입니다.




댓글 0
첫 댓글을 남겨보세요.