소스: sql-src/mid_02_exists_in/02_not_in_null.sql, 03_dialects.sql. NOT IN 함정을 재현하고, 세 가지 해결법과 DB별 차이를 비교합니다.
SELECT
CASE WHEN 1 IN (2, NULL) THEN 'TRUE'
WHEN NOT (1 IN (2, NULL)) THEN 'FALSE'
ELSE 'UNKNOWN'
END AS logic_check;LOGIC_CHECK
-----------
UNKNOWN
(1행)1 은 2 와도 NULL 과도 같지 않지만, IN 도 NOT 도 참이 되지 못하고 UNKNOWN 이 나옵니다. NOT IN 함정은 이 UNKNOWN 이 WHERE 절에서 통째로 걸러지며 생깁니다.
SELECT
e.name
, e.dept
FROM emp e
WHERE e.id NOT IN (SELECT o.emp_id FROM orders o)
ORDER BY e.id;NAME | DEPT
-----+-----
(0행)"주문이 없는 직원"은 실제로 7명(1, 4, 6~10번)인데 0행이 나왔습니다. orders.emp_id 목록에 106번 주문의 NULL 이 포함돼, 모든 직원 행에서 e.id NOT IN (...) 이 UNKNOWN 이 되기 때문입니다.
SELECT
e.name
, e.dept
FROM emp e
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.emp_id = e.id)
ORDER BY e.id;NAME | DEPT
-------+-----
김대표 | 경영
최사원 | 개발
서팀장 | 인사
강팀장 | 지원
한주임 | 영업
문사원 | 인사
오사원 | 지원
(7행)의도한 7행이 정확히 나옵니다. NOT EXISTS 는 NULL 과 값을 비교하지 않고 "일치하는 행이 있는가"만 확인하므로 서브쿼리의 NULL 에 영향받지 않습니다.
SELECT
e.name
, e.dept
FROM emp e
WHERE e.id NOT IN
(SELECT o.emp_id
FROM orders o
WHERE o.emp_id IS NOT NULL)
ORDER BY e.id;NAME | DEPT
-------+-----
김대표 | 경영
최사원 | 개발
서팀장 | 인사
강팀장 | 지원
한주임 | 영업
문사원 | 인사
오사원 | 지원
(7행)같은 7행입니다. 서브쿼리 안에서 NULL 을 미리 걸러 목록에 아예 들어가지 않게 했습니다. NOT IN 을 꼭 써야 하는 상황이라면 이 조건을 습관처럼 붙입니다.
SELECT
e.name
, e.dept
FROM emp e
LEFT JOIN orders o ON o.emp_id = e.id
WHERE o.id IS NULL
ORDER BY e.id;NAME | DEPT
-------+-----
김대표 | 경영
최사원 | 개발
서팀장 | 인사
강팀장 | 지원
한주임 | 영업
문사원 | 인사
오사원 | 지원
(7행)역시 같은 7행입니다. emp 를 orders 에 LEFT JOIN 하면 주문이 없는 직원은 o.* 가 모두 NULL 로 채워지고, WHERE o.id IS NULL 로 그 행만 남깁니다. 세 방법 모두 결과가 같으므로 팀 컨벤션에 맞는 것을 고르되, NOT IN 은 피하는 편이 안전합니다.
| 기능 | Oracle · Tibero | MySQL | MSSQL |
|---|---|---|---|
빈 문자열 '' |
NULL 로 취급 | 빈 문자열 그대로 | 빈 문자열 그대로 |
| NOT IN + NULL 함정 | '' 로 더 자주 발생 |
값 자체가 NULL 일 때만 | 표준과 동일 |
| IN 서브쿼리 최적화 | 세미 조인 변환 | 5.5 이하 구버전 이슈 | 세미 조인 변환 |
-- Oracle · Tibero
SET MODE Oracle;
CREATE TABLE skip_dept (code VARCHAR2(10));
INSERT INTO skip_dept VALUES
('지원'),
('');
SELECT
code
, code IS NULL AS is_null
FROM skip_dept
ORDER BY code;CODE | IS_NULL
-----+--------
NULL | true
지원 | false
(2행)빈 문자열로 넣은 행이 실제로는 NULL 로 저장됐습니다. Oracle(과 Oracle 호환인 Tibero)은 VARCHAR2 에 들어간 '' 를 NULL 과 같게 취급합니다. IS NULL 이 참으로 나온 이유입니다. H2 를 Oracle 모드로 돌려 직접 재현했습니다.
SELECT
d.code
, d.dname
FROM dept d
WHERE d.code NOT IN (SELECT code FROM skip_dept)
ORDER BY d.code;CODE | DNAME
-----+------
(0행)'지원' 부서 하나만 빼려고 만든 목록인데, '' 가 NULL 로 바뀌면서 NOT IN 전체가 UNKNOWN 이 되어 0행입니다. 개발 DB(H2·MySQL)에서 '' 를 넣고 테스트할 때는 통과했다가 Oracle 운영 DB에 배포한 뒤에야 드러나는 전형적인 사고 패턴입니다.
주의Oracle 계열은 빈 문자열과 NULL 을 구분하지 않습니다. "값이 없으면 빈 문자열을 넣는다"는 습관은 Oracle 에서 NOT IN 함정을 더 자주 일으킵니다. 애초에 NULL 을 그대로 쓰고 NOT EXISTS 로 확인하는 편이 안전합니다.
-- MySQL
SET MODE MySQL;
CREATE TABLE skip_dept2 (code VARCHAR(10));
INSERT INTO skip_dept2 VALUES
('지원'),
('');
SELECT
code
, code IS NULL AS is_null
FROM skip_dept2
ORDER BY code;CODE | IS_NULL
-----+--------
| false
지원 | false
(2행)MySQL 은 '' 를 그대로 빈 문자열로 저장합니다. IS NULL 이 둘 다 false 로, Oracle 과 반대입니다. MySQL 에서 NOT IN 함정은 실제 NULL 값이 섞였을 때만 생깁니다.
-- MSSQL
SET MODE MSSQLServer;
SELECT
e.name
FROM emp e
WHERE e.id NOT IN (SELECT o.emp_id FROM orders o)
ORDER BY e.id;NAME
----
(0행)MSSQL 도 빈 문자열을 NULL 로 바꾸지 않지만, orders.emp_id 처럼 실제 NULL 값이 있는 목록에 NOT IN 을 쓰면 표준과 똑같이 0행이 됩니다. MSSQL 이 다른 부분은 '' 처리뿐이고, NOT IN 의 3값 논리 자체는 Oracle·MySQL·MSSQL 모두 표준과 동일합니다.
MySQL 5.5 이하 버전은 IN (서브쿼리) 를 세미 조인으로 바꾸지 못하고 상관 EXISTS 로 고쳐 실행했습니다. 실행 계획에 DEPENDENT SUBQUERY 로 나오며, 바깥 테이블 행마다 서브쿼리를 다시 실행해 바깥 테이블이 크면 매우 느렸습니다. MySQL 5.6 부터 세미 조인 최적화가 들어가 해소됐습니다. 지금은 오래된 버전을 만났을 때만 참고할 이력입니다.