부록 A — NULL 완전 정복

학습 목표

  • NULL 이 "값"이 아니라 "값이 없는 상태" 라는 것과, 그래서 생기는 3값 논리(TRUE/FALSE/UNKNOWN) 를 이해한다
  • 절(clause)마다 NULL 규칙이 다르다는 사실을 표 하나로 정리한다 — 헷갈림의 진짜 원인이 여기 있다
  • NOT IN + NULL, 안티조인, ON vs WHERE, ROLLUP 의 NULL 을 하나의 원리로 설명할 수 있다
  • NULL 을 만났을 때 어떤 도구를 꺼낼지(IS NULL / <=> / COALESCE / NOT EXISTS / GROUPING())를 즉시 고른다

선행 스텝: Step 05 · Step 06 · Step 07 · Step 08 예상 소요: 60분


A-0. 왜 헷갈리는가 — 규칙이 하나가 아니기 때문

Step 05~08 에서 NULL 함정을 하나씩 만났습니다. 그런데 배우고 나면 이런 상태가 됩니다.

  • "NULL 은 NULL 과 같지 않다"고 배웠는데, GROUP BY 는 NULL 을 한 그룹으로 묶는다
  • "NULL 끼리는 비교가 안 된다"고 배웠는데, DISTINCTUNION 은 NULL 을 중복으로 제거한다
  • UNIQUE 제약은 "중복 금지"인데 NULL 은 몇 개든 허용한다
  • WHERE 는 UNKNOWN 을 버리는데, CHECK 제약은 UNKNOWN 을 통과시킨다

전부 사실입니다. 서로 모순돼 보이지만 모순이 아닙니다. NULL 에 대한 규칙이 딱 두 종류로 나뉘기 때문입니다.

관점규칙적용되는 곳
비교(comparison) 관점NULL 과의 비교는 UNKNOWN — 같지도 다르지도 않다WHERE · ON · HAVING · CASE WHEN · IN / NOT IN · CHECK
그룹핑(grouping) 관점NULL 끼리는 같은 값 취급 — 하나로 묶인다GROUP BY · DISTINCT · UNION · ORDER BY · 인덱스 · <=>

이 부록은 이 두 줄짜리 표를 몸에 새기는 것이 목표입니다. 앞으로 나올 모든 함정은 "지금 나는 비교를 하고 있나, 그룹핑을 하고 있나?" 로 설명됩니다.

💡 왜 두 종류인가: 표준 SQL 이 그렇게 정했습니다. 비교는 "모르는 값끼리는 답할 수 없다"는 논리를 따르고, 그룹핑·정렬은 "결과를 만들어야 하니 NULL 도 하나의 자리로 다룬다"는 실용을 따릅니다. 원리가 아니라 약속이니, 외우는 게 맞습니다.


A-1. NULL 은 값이 아니라 상태다

SELECT
    NULL = NULL   AS `NULL = NULL`,
    NULL <> NULL  AS `NULL <> NULL`,
    NULL = 0      AS `NULL = 0`,
    '' = NULL     AS `'' = NULL`,
    '' IS NULL    AS `'' IS NULL`,
    LENGTH('')    AS `LENGTH('')`;

결과

+-------------+--------------+----------+-----------+------------+------------+
| NULL = NULL | NULL <> NULL | NULL = 0 | '' = NULL | '' IS NULL | LENGTH('') |
+-------------+--------------+----------+-----------+------------+------------+
|        NULL |         NULL |     NULL |      NULL |          0 |          0 |
+-------------+--------------+----------+-----------+------------+------------+

세 가지를 확실히 구분하세요. 완전히 다른 것들입니다.

의미IS NULL저장 크기집계에 포함
NULL값을 모른다 / 없다별도 NULL 비트제외
0숫자 거짓값 그대로포함
''빈 문자열(길이 0)거짓값 그대로포함

⚠️ 함정: Oracle 은 '' 를 NULL 로 취급하지만 MySQL 은 다릅니다. '' 는 어엿한 값입니다. Oracle 경험자가 MySQL 로 넘어와 WHERE name IS NULL 로 빈 값을 찾다가 못 찾는 일이 흔합니다.

NULL 은 전염된다

SELECT
    NULL + 1              AS `NULL + 1`,
    1 / NULL              AS `1 / NULL`,
    CONCAT('a', NULL)     AS `CONCAT`,
    CONCAT_WS('-','a',NULL,'b') AS `CONCAT_WS`,
    LENGTH(NULL)          AS `LENGTH(NULL)`,
    UPPER(NULL)           AS `UPPER(NULL)`;

결과

+----------+----------+--------+-----------+--------------+-------------+
| NULL + 1 | 1 / NULL | CONCAT | CONCAT_WS | LENGTH(NULL) | UPPER(NULL) |
+----------+----------+--------+-----------+--------------+-------------+
|     NULL |     NULL | NULL   | a-b       |         NULL | NULL        |
+----------+----------+--------+-----------+--------------+-------------+

거의 모든 연산자·함수는 인자에 NULL 이 하나만 있어도 결과 전체가 NULL 이 됩니다. 예외적으로 NULL 을 무시하는 것들이 이 부록의 관전 포인트입니다 — CONCAT_WS, 집계함수, COALESCE 계열.

💡 실무 팁: 주소를 조립할 때 CONCAT(city, ' ', street)street 가 NULL 이면 주소 전체가 사라집니다. CONCAT_WS(' ', city, street) 를 쓰세요. 구분자 함수는 NULL 인자를 건너뜁니다(첫 인자인 구분자가 NULL 이면 그때만 전체 NULL).


A-2. 3값 논리 — TRUE / FALSE / UNKNOWN

NULL 이 비교에 끼면 결과는 TRUE 도 FALSE 도 아닌 UNKNOWN 입니다. 그래서 AND/OR 진리표가 2값 논리보다 큽니다.

SELECT TRUE AND NULL AS `T AND ?`, FALSE AND NULL AS `F AND ?`, NULL AND NULL AS `? AND ?`;
SELECT TRUE OR  NULL AS `T OR  ?`, FALSE OR  NULL AS `F OR  ?`, NULL OR  NULL AS `? OR  ?`;
SELECT NOT NULL AS `NOT ?`;

결과

+---------+---------+---------+
| T AND ? | F AND ? | ? AND ? |
+---------+---------+---------+
|    NULL |       0 |    NULL |
+---------+---------+---------+
+---------+---------+---------+
| T OR  ? | F OR  ? | ? OR  ? |
+---------+---------+---------+
|       1 |    NULL |    NULL |
+---------+---------+---------+
+-------+
| NOT ? |
+-------+
|  NULL |
+-------+

표로 정리하면 이렇습니다. (? = UNKNOWN)

ANDTRUEFALSE?
TRUETRUEFALSE?
FALSEFALSEFALSEFALSE
??FALSE?
ORTRUEFALSE?
TRUETRUETRUETRUE
FALSETRUEFALSE?
?TRUE??

외울 것은 두 줄뿐입니다.

  • AND 에서는 FALSE 가 흡수한다FALSE AND ? = FALSE. 하나라도 확실히 거짓이면 전체가 거짓이니까.
  • OR 에서는 TRUE 가 흡수한다TRUE OR ? = TRUE. 하나라도 확실히 참이면 전체가 참이니까.
  • 그 외에 UNKNOWN 이 끼면 → UNKNOWN. 그리고 NOT UNKNOWN 은 여전히 UNKNOWN(모르는 것의 반대는 여전히 모르는 것).

이 두 줄이 A-5 의 NOT IN 함정과 A-4 의 부정 조건 누락을 동시에 설명합니다.


A-3. ★ 절마다 다른 NULL 규칙 (이 부록의 핵심 표)

같은 NULL 인데 어디에 쓰느냐에 따라 취급이 다릅니다. 이 표가 헷갈림의 정체입니다.

절 / 기능NULL 취급결과
WHERETRUE 인 행만 통과. FALSE·UNKNOWN 모두 탈락NULL 행이 조용히 사라짐
ON (조인)위와 동일. 단 탈락해도 LEFT/RIGHT 는 NULL 확장으로 행을 남김A-8
HAVINGWHERE 와 동일UNKNOWN 그룹 탈락
CASE WHEN조건이 UNKNOWN 이면 그 분기 불성립 → 다음 WHEN, 없으면 ELSE, ELSE 도 없으면 NULLA-10
CHECK 제약UNKNOWN 을 통과시킨다 (위반으로 보지 않음)A-12
GROUP BYNULL 끼리 한 그룹A-7
DISTINCTNULL 끼리 중복 → 하나만 남김A-7
UNION (DISTINCT)NULL 끼리 중복 → 하나만 남김A-7
ORDER BYNULL 을 가장 작은 값으로 정렬 (ASC 면 맨 앞)A-9
UNIQUE 인덱스NULL 은 몇 개든 허용 (서로 다르다고 봄)A-12
집계함수COUNT(*) 빼고 전부 NULL 무시A-6
<=>NULL 끼리 같다(1)A-4

한 문장 요약: WHERE 계열(비교)에서는 NULL ≠ NULL, GROUP BY 계열(그룹핑)에서는 NULL = NULL. 이 한 줄이면 위 표의 90%가 복원됩니다.


A-4. NULL 을 찾는 법 — IS NULL<=>

= 로는 NULL 을 절대 찾을 수 없습니다. 에러도 안 나서 더 위험합니다.

SELECT
    (SELECT COUNT(*) FROM customers WHERE phone =  NULL) AS `phone = NULL`,
    (SELECT COUNT(*) FROM customers WHERE phone IS NULL) AS `phone IS NULL`,
    (SELECT COUNT(*) FROM customers WHERE phone <=> NULL) AS `phone <=> NULL`;

결과

+--------------+---------------+----------------+
| phone = NULL | phone IS NULL | phone <=> NULL |
+--------------+---------------+----------------+
|            0 |             3 |              3 |
+--------------+---------------+----------------+
도구용도
IS NULL / IS NOT NULL기본. NULL 여부 판정. 인덱스도 탄다(A-9)
<=> (NULL-safe 등호)양쪽이 NULL 이어도 정상 비교. 절대 UNKNOWN 을 반환하지 않음(항상 0 또는 1)
ISNULL(expr)expr IS NULL 과 동일. MySQL 전용 함수형

<=> 의 진짜 쓸모는 바인딩 파라미터가 NULL 일 수 있는 검색변경 감지입니다.

-- 애플리케이션: ? 에 값이 오면 그 값을, NULL 이 오면 NULL 인 행을 찾는다
SELECT * FROM customers WHERE phone <=> ?;

-- 배치: 예전 값과 새 값이 "정말" 다른가? (둘 다 NULL 이면 '안 바뀜'으로 판정)
SELECT * FROM staging s JOIN target t ON t.id = s.id WHERE NOT (s.phone <=> t.phone);

💡 = 로 같은 걸 하려면 WHERE (phone = ? OR (? IS NULL AND phone IS NULL)) 같은 흉물이 됩니다. 표준 SQL 의 IS NOT DISTINCT FROM 이 같은 역할인데 MySQL 에는 없고, <=> 가 MySQL 의 답입니다.

부정 조건에서 NULL 이 새어나간다

전체 30명 중 1명을 제외하면 29명이어야 할 것 같지만:

SELECT
    (SELECT COUNT(*) FROM customers WHERE phone <> '010-1000-0001') AS `<> 만`,
    (SELECT COUNT(*) FROM customers WHERE phone <> '010-1000-0001' OR phone IS NULL) AS `NULL 포함`;

결과

+--------+-------------+
| <> 만  | NULL 포함   |
+--------+-------------+
|     26 |          29 |
+--------+-------------+

phone 이 NULL 인 3명은 NULL <> '010-...' = UNKNOWN 이라 WHERE 에서 탈락했습니다. <>, NOT LIKE, NOT IN, NOT BETWEEN 등 모든 부정 조건에서 동일하게 일어납니다.

⚠️ 함정: "제외" 요구사항을 받으면 반드시 되물으세요 — "값이 없는(NULL) 행은 포함인가요, 제외인가요?" 기획서에는 거의 항상 이 내용이 빠져 있습니다. 답에 따라 OR col IS NULL 을 붙일지가 결정됩니다.


A-5. NOT IN + NULL — 왜 결과가 통째로 사라지는가

Step 05·08 의 최대 함정을, 이제 A-2 의 진리표로 완전히 설명할 수 있습니다.

SELECT
    3 IN (1,2,NULL)     AS `3 IN (1,2,?)`,
    1 IN (1,2,NULL)     AS `1 IN (1,2,?)`,
    3 NOT IN (1,2,NULL) AS `3 NOT IN (1,2,?)`,
    1 NOT IN (1,2,NULL) AS `1 NOT IN (1,2,?)`;

결과

+--------------+--------------+------------------+------------------+
| 3 IN (1,2,?) | 1 IN (1,2,?) | 3 NOT IN (1,2,?) | 1 NOT IN (1,2,?) |
+--------------+--------------+------------------+------------------+
|         NULL |            1 |             NULL |                0 |
+--------------+--------------+------------------+------------------+

전개해 보면 답이 보입니다.

전개진리표 적용결과
1 IN (1,2,NULL)1=1 OR 1=2 OR 1=NULLTRUE OR FALSE OR ?TRUE 가 흡수1
3 IN (1,2,NULL)3=1 OR 3=2 OR 3=NULLFALSE OR FALSE OR ? → 흡수 없음NULL
1 NOT IN (1,2,NULL)1<>1 AND 1<>2 AND 1<>NULLFALSE AND ... FALSE 가 흡수0
3 NOT IN (1,2,NULL)3<>1 AND 3<>2 AND 3<>NULLTRUE AND TRUE AND ? → 흡수 없음NULL

핵심: IN찾으면 TRUE 로 확정되고(흡수), NOT IN못 찾으면 TRUE 여야 하는데 NULL 때문에 확정을 못 합니다. 그래서

  • IN : 매칭되는 행은 정상적으로 나옴 → NULL 이 있어도 상대적으로 안전
  • NOT IN : "없는 것"을 찾는 게 목적인데 그게 전부 UNKNOWN → 결과 0행

실제로 확인합니다. employees.manager_id 는 CEO 한 명 때문에 NULL 을 포함합니다.

SELECT
    (SELECT COUNT(*) FROM employees e
      WHERE e.employee_id NOT IN (SELECT manager_id FROM employees)) AS `NOT IN (버그)`,
    (SELECT COUNT(*) FROM employees e
      WHERE e.employee_id NOT IN (SELECT manager_id FROM employees WHERE manager_id IS NOT NULL)) AS `NULL 제거`,
    (SELECT COUNT(*) FROM employees e
      WHERE NOT EXISTS (SELECT 1 FROM employees m WHERE m.manager_id = e.employee_id)) AS `NOT EXISTS`,
    (SELECT COUNT(*) FROM employees e
      LEFT JOIN employees m ON m.manager_id = e.employee_id
      WHERE m.employee_id IS NULL) AS `LEFT JOIN IS NULL`;

결과

+-----------------+-------------+------------+-------------------+
| NOT IN (버그)   | NULL 제거   | NOT EXISTS | LEFT JOIN IS NULL |
+-----------------+-------------+------------+-------------------+
|               0 |          10 |         10 |                10 |
+-----------------+-------------+------------+-------------------+

정답은 10. NOT IN 만 0 입니다. 에러도 경고도 없습니다.

안티조인 3형제 — 무엇을 쓸 것인가

방법NULL 안전언제
NOT EXISTS안전기본값. 상관 조건이 NULL 이면 그냥 "매칭 안 됨"으로 처리됨
LEFT JOIN ... IS NULL⚠️ 조건부짝 없는 쪽의 컬럼도 SELECT 해야 할 때. IS NULL 대상은 반드시 NOT NULL 컬럼(보통 PK) — A-8
NOT IN (서브쿼리)위험쓰지 말 것. 상수 리스트(NOT IN (1,2,3))에만

⚠️ 함정: 지금 잘 도는 NOT IN 쿼리도, 그 컬럼에 NULL 이 처음 들어오는 날 조용히 0건이 됩니다. 배치 잡이 어느 날부터 아무 일도 안 하기 시작하고, 아무도 모릅니다. NOT EXISTS 를 습관으로 만드세요.

참고 — 서브쿼리가 빈 결과일 때

SELECT 5 IN     (SELECT customer_id FROM customers WHERE 1=0) AS in_empty,
       5 NOT IN (SELECT customer_id FROM customers WHERE 1=0) AS notin_empty;

결과

+----------+-------------+
| in_empty | notin_empty |
+----------+-------------+
|        0 |           1 |
+----------+-------------+

빈 집합은 UNKNOWN 이 아니라 확실한 FALSE/TRUE 입니다. "결과가 0행"과 "NULL 이 섞임"은 완전히 다른 상황이라는 점을 기억하세요.


A-6. 집계함수와 NULL

COUNT(*) 를 제외한 모든 집계함수는 NULL 을 건너뜁니다.

SELECT
    COUNT(*)                   AS `COUNT(*)`,
    COUNT(phone)               AS `COUNT(phone)`,
    COUNT(DISTINCT phone)      AS `COUNT(DISTINCT phone)`,
    MIN(phone)                 AS `MIN(phone)`
FROM customers;

결과

+----------+--------------+-----------------------+---------------+
| COUNT(*) | COUNT(phone) | COUNT(DISTINCT phone) | MIN(phone)    |
+----------+--------------+-----------------------+---------------+
|       30 |           27 |                    27 | 010-1000-0001 |
+----------+--------------+-----------------------+---------------+
형태세는 것
COUNT(*)행의 개수. NULL 상관없이 무조건
COUNT(col)col 이 NULL 이 아닌 행의 개수
COUNT(DISTINCT col)col 의 서로 다른 값의 개수 (NULL 제외)

AVG 의 분모가 달라진다

AVG(col) 은 정확히 SUM(col) / COUNT(col) 입니다. 분모에서 NULL 이 빠지므로, "0 을 평균에 넣을 것인가"가 결과를 바꿉니다.

SELECT
    AVG(points)            AS `AVG(0 포함)`,
    AVG(NULLIF(points, 0)) AS `AVG(0을 NULL로)`,
    SUM(points) / COUNT(*) AS `SUM / COUNT(*)`
FROM customers;

결과

+---------------+-------------------+----------------+
| AVG(0 포함)   | AVG(0을 NULL로)   | SUM / COUNT(*) |
+---------------+-------------------+----------------+
|     5959.0000 |         6621.1111 |      5959.0000 |
+---------------+-------------------+----------------+

빈 집합 — SUM 은 NULL, COUNT 는 0

SELECT SUM(points) AS s, AVG(points) AS a, MAX(points) AS m,
       COUNT(points) AS cp, COUNT(*) AS ca
FROM customers WHERE 1 = 0;

결과

+------+------+------+----+----+
| s    | a    | m    | cp | ca |
+------+------+------+----+----+
| NULL | NULL | NULL |  0 |  0 |
+------+------+------+----+----+

행이 하나도 없어도 집계 쿼리는 1행을 돌려줍니다. 그리고 SUM/AVG/MAX0 이 아니라 NULL 입니다.

⚠️ 함정: 매출 합계를 SUM(amount) 로 뽑아 그대로 화면에 뿌리면, 거래가 없는 날 0 이 아니라 빈칸/NULL 이 나옵니다. 그 값을 다시 계산에 쓰면(SUM(a) - SUM(b)) 전염되어 전체가 NULL 이 됩니다. COALESCE(SUM(amount), 0) 을 습관으로 쓰세요. 반면 COUNT 는 언제나 0 이상이라 감쌀 필요가 없습니다.

GROUP_CONCAT 도 마찬가지로 NULL 을 건너뜁니다 — 값 3개 중 1개가 NULL 이면 2개만 이어붙고, 전부 NULL 이면 결과가 빈 문자열이 아니라 NULL 입니다.


A-7. GROUP BY · DISTINCT · UNION 은 NULL 을 "같다"고 본다

여기서 규칙이 뒤집힙니다. 비교가 아니라 그룹핑이기 때문입니다.

SELECT parent_id, COUNT(*) AS cnt FROM categories GROUP BY parent_id ORDER BY parent_id;

결과

+-----------+-----+
| parent_id | cnt |
+-----------+-----+
|      NULL |   5 |    ← NULL 5건이 흩어지지 않고 한 그룹으로
|         1 |   3 |
|         2 |   3 |
|         3 |   2 |
|         4 |   2 |
|         5 |   2 |
+-----------+-----+

NULL = NULL 이 UNKNOWN 인데도 NULL 5건이 한 그룹입니다. DISTINCTUNION 도 동일하게 NULL 을 하나로 접습니다.

SELECT COUNT(DISTINCT phone) AS distinct_phone, COUNT(*) AS total FROM customers;
-- → 27, 30  : NULL 3건은 DISTINCT 결과에 아예 포함되지 않는다(COUNT 가 NULL 을 세지 않으므로)

SELECT COUNT(*) AS rows_after_union FROM (
    SELECT phone FROM customers WHERE phone IS NULL
    UNION                                    -- UNION = 중복 제거
    SELECT phone FROM customers WHERE phone IS NULL
) t;

결과

+------------------+
| rows_after_union |
+------------------+
|                1 |    ← NULL 6건(3+3)이 1건으로 접혔다
+------------------+

ROLLUP 의 NULL — "진짜 NULL"과 "소계 표시 NULL"

WITH ROLLUP 은 소계·총계 행의 그룹 컬럼을 NULL 로 채웁니다. 그런데 그 컬럼에 원래 NULL 데이터가 있었다면 둘을 구분할 수 없습니다.

SELECT city AS 도시, COUNT(*) AS 고객수, SUM(points) AS 포인트합
FROM customers WHERE city IN ('서울','부산')
GROUP BY city WITH ROLLUP;

결과

+--------+-----------+--------------+
| 도시   | 고객수    | 포인트합     |
+--------+-----------+--------------+
| 부산   |         5 |        17450 |
| 서울   |        10 |       117300 |
| NULL   |        15 |       134750 |   ← 이 NULL 은 "총계"인가 "도시 미상"인가?
+--------+-----------+--------------+

MySQL 8 의 GROUPING(col) 이 이걸 해결합니다. 그 행이 col 에 대한 소계 행이면 1, 실제 데이터 행이면 0 입니다.

SELECT
    IF(GROUPING(city) = 1, '── 전체 ──', COALESCE(city, '(도시 미상)')) AS 도시,
    COUNT(*)       AS 고객수,
    GROUPING(city) AS g_city
FROM customers WHERE city IN ('서울','부산')
GROUP BY city WITH ROLLUP;

결과

+----------------------+-----------+--------+
| 도시                 | 고객수    | g_city |
+----------------------+-----------+--------+
| 부산                 |         5 |      0 |
| 서울                 |        10 |      0 |
| ── 전체 ──           |        15 |      1 |
+----------------------+-----------+--------+

💡 판별 공식: GROUPING(col) = 1소계/총계 행. GROUPING(col) = 0 AND col IS NULL진짜 NULL 데이터. ROLLUP 리포트를 만들 땐 이 두 가지를 항상 따로 라벨링하세요. MySQL 5.7 에는 GROUPING() 이 없어서 이 구분이 사실상 불가능했습니다.

자세한 내용은 Step 06 — 6-8 참고.


A-8. JOIN 과 NULL — NULL 확장(NULL extension)

LEFT JOIN 에서 오른쪽에 짝이 없으면, MySQL 은 오른쪽 컬럼을 전부 NULL 로 채운 가짜 행을 만들어 왼쪽 행을 살립니다. 이걸 NULL 확장이라고 합니다. Step 07 의 두 함정이 전부 여기서 나옵니다.

-- 고객 3명, 그중 2명만 주문이 있다. 2번 고객의 주문에는 memo 가 NULL.
SELECT c.id, c.name, o.order_id, o.memo
FROM s26_cust c LEFT JOIN s26_order_memo o ON o.cust_id = c.id
ORDER BY c.id;

결과

+----+------+----------+--------+
| id | name | order_id | memo   |
+----+------+----------+--------+
|  1 | 김   |      100 | 급송   |
|  2 | 이   |      101 | NULL   |   ← 짝은 있다. memo 값이 NULL 일 뿐
|  3 | 박   |     NULL | NULL   |   ← 짝이 없다 (NULL 확장)
+----+------+----------+--------+

2번과 3번은 memo 만 보면 구분이 안 됩니다. 이 한 줄이 안티조인 함정의 전부입니다.

-- (A) 올바른 안티조인 : NOT NULL 컬럼(PK)으로 판정
SELECT c.id, c.name FROM s26_cust c
LEFT JOIN s26_order_memo o ON o.cust_id = c.id
WHERE o.order_id IS NULL;

-- (B) 잘못된 안티조인 : NULL 가능 컬럼으로 판정
SELECT c.id, c.name FROM s26_cust c
LEFT JOIN s26_order_memo o ON o.cust_id = c.id
WHERE o.memo IS NULL;

결과

(A) 올바름                (B) 잘못됨
+----+------+            +----+------+
| id | name |            | id | name |
+----+------+            +----+------+
|  3 | 박   |            |  2 | 이   |   ← 주문이 있는데도 딸려왔다!
+----+------+            |  3 | 박   |
                         +----+------+

⚠️ 함정: LEFT JOIN ... IS NULL 안티조인에서 IS NULL 을 거는 컬럼은 반드시 NOT NULL 이어야 합니다. 실무에서는 조인 대상 테이블의 PK 를 쓰는 것이 정석입니다(WHERE o.order_id IS NULL). 아무 컬럼이나 골랐다가 "짝은 있는데 그 컬럼이 NULL 인 행"까지 딸려오면, 이건 에러 없이 틀린 답입니다.

ON vs WHERE — NULL 확장이 살아남느냐 죽느냐

-- (A) 조건을 ON 에 : 매칭 단계에서만 적용 → 왼쪽 전부 보존(NULL 확장)
SELECT COUNT(*) AS 조건이_ON FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'DELIVERED';

-- (B) 조건을 WHERE 에 : 조인이 끝난 뒤 적용 → NULL 확장 행이 탈락
SELECT COUNT(*) AS 조건이_WHERE FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'DELIVERED';

결과

+--------------+     +-----------------+
| 조건이_ON    |     | 조건이_WHERE    |
+--------------+     +-----------------+
|          258 |     |             240 |
+--------------+     +-----------------+

258 vs 240. (B) 에서 NULL 확장된 18행의 status 는 NULL 이고, NULL = 'DELIVERED'UNKNOWN 이라 WHERE 에서 탈락했습니다. LEFT JOIN 이 INNER JOIN 으로 퇴화한 것입니다.

⚠️ 핵심 규칙: LEFT JOIN 에서 오른쪽 테이블 조건은 ON 에, 왼쪽 테이블 조건은 WHERE 에. 유일한 예외가 안티조인(WHERE 오른쪽PK IS NULL) — 이건 NULL 확장을 일부러 노리는 경우라 정상입니다.

자세한 내용은 Step 07 — 7-4, 7-5 참고.


A-9. 정렬과 인덱스에서의 NULL

ORDER BY — NULL 이 가장 작다

SELECT customer_id, name, phone FROM customers ORDER BY phone LIMIT 4;

결과

+-------------+-----------+---------------+
| customer_id | name      | phone         |
+-------------+-----------+---------------+
|          28 | 심준호    | NULL          |   ← NULL 이 맨 앞
|          14 | 남규리    | NULL          |
|           7 | 윤대현    | NULL          |
|           1 | 김민수    | 010-1000-0001 |
+-------------+-----------+---------------+

MySQL 은 NULL 을 모든 값보다 작다고 봅니다. 따라서 ASC 면 맨 앞, DESC 면 맨 뒤입니다.

표준 SQL 의 NULLS FIRST / NULLS LASTMySQL 에 없습니다. 흉내내려면 정렬 키를 하나 더 얹습니다.

-- NULL 을 맨 뒤로 (NULLS LAST 흉내)
SELECT customer_id, name, phone FROM customers
ORDER BY phone IS NULL, phone LIMIT 4;

결과

+-------------+-----------+---------------+
| customer_id | name      | phone         |
+-------------+-----------+---------------+
|           1 | 김민수    | 010-1000-0001 |
|           2 | 이지은    | 010-1000-0002 |
|           3 | 박철수    | 010-1000-0003 |
|           4 | 최영희    | 010-1000-0004 |
+-------------+-----------+---------------+

phone IS NULL 은 0/1 을 돌려주므로, 이 컬럼으로 먼저 정렬하면 값이 있는 행(0)이 앞으로 옵니다. 숫자 컬럼이라면 ORDER BY -col DESC 같은 요령도 있지만, 의도가 드러나는 IS NULL 방식을 권합니다.

⚠️ 이 방식은 정렬 키가 표현식이라 인덱스 정렬을 못 씁니다. 큰 테이블 + 페이징에서는 filesort 가 붙으니, 애초에 해당 컬럼을 NOT NULL DEFAULT ... 로 설계하는 편이 낫습니다(A-13).

인덱스 — IS NULL 도 인덱스를 탄다

흔한 오해입니다. MySQL 의 B+Tree 인덱스는 NULL 도 저장하고, IS NULL 검색에 인덱스를 씁니다.

EXPLAIN SELECT employee_id, name FROM employees WHERE manager_id IS NULL;

결과

+----+-------------+-----------+------+-----------------------+-----------------------+---------+-------+------+-----------------------+
| id | select_type | table     | type | possible_keys         | key                   | key_len | ref   | rows | Extra                 |
+----+-------------+-----------+------+-----------------------+-----------------------+---------+-------+------+-----------------------+
|  1 | SIMPLE      | employees | ref  | idx_employees_manager | idx_employees_manager | 5       | const |    1 | Using index condition |
+----+-------------+-----------+------+-----------------------+-----------------------+---------+-------+------+-----------------------+

type = ref, key = idx_employees_manager. manager_id = 1 로 조회할 때와 똑같은 접근 방식입니다. (Oracle 의 일반 B-Tree 인덱스는 NULL 을 저장하지 않아 이게 안 됩니다 — DB 마다 다릅니다.)

반면 IS NOT NULL 은 대상 행이 대부분이면 옵티마이저가 풀스캔을 고르는 게 정상입니다. 인덱스를 못 타는 게 아니라 안 타는 게 이득이라 판단한 것입니다(Step 16).

key_len = 5 도 눈여겨보세요. INT(4바이트) + NULL 허용 플래그 1바이트 = 5. NULL 을 허용하는 컬럼은 인덱스 엔트리가 1바이트씩 커집니다.


A-10. NULL 을 다루는 함수 — 무엇을 언제

함수동작비고
COALESCE(a, b, c, …)왼쪽부터 첫 번째 NULL 아닌 값표준 SQL. 기본으로 쓸 것
IFNULL(a, b)a 가 NULL 이면 bMySQL 전용. 인자 2개 고정
NULLIF(a, b)a = b 이면 NULL, 아니면 a0 을 NULL 로 바꿀 때, 0 나누기 방지
IF(cond, t, f)cond 가 UNKNOWN 이면 fMySQL 전용
ISNULL(a)a 가 NULL 이면 1a IS NULL 과 동일
CASEA-3 참고ELSE 생략 시 NULL
SELECT
    COALESCE(NULL, NULL, '기본값') AS coalesce_ex,
    NULLIF(100, 100)               AS nullif_same,
    IF(NULL, 'TRUE분기', 'FALSE분기') AS if_unknown,
    CASE WHEN 1 = 2 THEN 'x' END   AS `CASE(ELSE 없음)`;

결과

+-------------+-------------+-------------+-------------------+
| coalesce_ex | nullif_same | if_unknown  | CASE(ELSE 없음)   |
+-------------+-------------+-------------+-------------------+
| 기본값      |        NULL | FALSE분기   | NULL              |
+-------------+-------------+-------------+-------------------+

두 가지를 기억하세요.

  • IF(NULL, …) 는 FALSE 분기로 갑니다. UNKNOWN 은 참이 아니니까요. 3값 논리가 2값으로 뭉개지는 지점이라, IF 안에 NULL 가능 컬럼을 넣을 땐 의도한 결과인지 확인해야 합니다.
  • CASEELSE 를 안 쓰면 매칭 실패 시 NULL 이 나옵니다. 조건부 집계(SUM(CASE WHEN … THEN 1 END))에서는 NULL 이 집계에서 무시되므로 오히려 편리하지만, 화면에 그대로 뿌리면 빈칸이 됩니다. ELSE 를 항상 명시하는 습관을 권합니다.

NULLIF 의 정석 용법은 0 나누기 방지입니다.

-- price 가 0 이어도 에러/경고 없이 NULL 을 돌려준다
SELECT name, (price - cost) / NULLIF(price, 0) AS margin_rate FROM products LIMIT 3;

A-11. NULL 이 만들어지는 다섯 가지 경로

"내 컬럼은 NOT NULL 인데 왜 결과에 NULL 이 있지?" 라는 질문의 답입니다.

경로
① 저장된 NULLcustomers.phone, employees.manager_id
OUTER JOIN 의 NULL 확장LEFT JOIN 에서 짝이 없는 오른쪽 컬럼 (A-8)
빈 집합에 대한 집계SUM/AVG/MAX → NULL (A-6)
행이 없는 스칼라 서브쿼리(SELECT phone FROM customers WHERE customer_id = 999) → NULL
표현식·형변환 실패NULLIF, ELSE 없는 CASE, WITH ROLLUP 의 소계 행
SELECT (SELECT phone FROM customers WHERE customer_id = 999) AS no_row_scalar;

결과

+---------------+
| no_row_scalar |
+---------------+
| NULL          |
+---------------+

💡 실무 팁: ②~⑤ 때문에 테이블 컬럼이 전부 NOT NULL 이어도 쿼리 결과에는 NULL 이 나올 수 있습니다. 특히 리포트 쿼리에서 조인·집계를 거친 값은 항상 NULL 가능성을 염두에 두고 COALESCE 로 마감하세요.


A-12. NULL 이 특별대우를 받는 곳

UNIQUE 는 NULL 을 몇 개든 허용한다

CREATE TABLE s26_uniq (
  id    INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(50) UNIQUE,
  age   INT,
  CHECK (age >= 18)
);
INSERT INTO s26_uniq (email, age) VALUES (NULL, 20), (NULL, 30), ('a@x.com', NULL);
SELECT * FROM s26_uniq;

결과

+----+---------+------+
| id | email   | age  |
+----+---------+------+
|  1 | NULL    |   20 |
|  2 | NULL    |   30 |   ← UNIQUE 인데 NULL 이 2건 (에러 아님)
|  3 | a@x.com | NULL |   ← CHECK(age >= 18) 인데 NULL 통과 (에러 아님)
+----+---------+------+

한 문장에 두 가지 특별대우가 들어 있습니다.

  1. UNIQUE 는 NULL 을 중복으로 보지 않습니다. "모르는 값끼리는 같다고 단정할 수 없다"는 비교 관점이 적용됩니다. → "이메일은 유일해야 한다"는 요구를 UNIQUE 만으로 보장하려면 NOT NULL 을 함께 걸어야 합니다.
  2. CHECK 제약은 UNKNOWN 을 통과시킵니다. WHERE 는 "TRUE 인 행만 남기지만", CHECK 는 "FALSE 인 행만 거부"합니다. NULL >= 18 은 UNKNOWN 이므로 위반이 아닙니다. → 값 범위를 강제하려면 역시 NOT NULL 을 함께 걸어야 합니다.

⚠️ 함정: 제약 조건과 NULL 은 궁합이 나쁩니다. UNIQUE, CHECK, FOREIGN KEY 모두 NULL 앞에서는 무력합니다(FK 도 자식 컬럼이 NULL 이면 참조 무결성 검사를 건너뜁니다). 제약으로 무언가를 보장하고 싶다면 NOT NULL 이 전제입니다. Step 13 참고.

AUTO_INCREMENT 에 NULL 을 넣으면 번호가 생성된다

INSERT INTO s26_ai (id, v) VALUES (NULL, 10), (NULL, 20);

id 는 NULL 이 아니라 1, 2 가 됩니다. AUTO_INCREMENT 컬럼에서 NULL 은 "네가 알아서 채워라"라는 신호입니다. (DEFAULT 키워드나 컬럼 생략도 같은 효과이며, 그쪽이 의도가 더 분명합니다.)


A-13. 설계 지침 — NULL 을 줄이는 것이 최선의 방어

지금까지 본 함정 대부분은 컬럼이 NULL 을 허용하지 않았다면 아예 생기지 않습니다.

1) 기본값은 NOT NULL DEFAULT ... 로 시작하세요. NULL 허용은 "필요해서 켜는 옵션"이지 기본값이 아닙니다.

qty        INT           NOT NULL DEFAULT 0,
memo       VARCHAR(200)  NOT NULL DEFAULT '',
status     VARCHAR(20)   NOT NULL DEFAULT 'PENDING',
updated_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP

2) 그래도 NULL 을 쓸 때는, 그 NULL 의 "의미"를 문서화하세요. NULL 은 최소 세 가지 뜻으로 쓰입니다.

의미대안
모른다 (값은 있는데 미확보)birth_date 미입력그대로 NULL 이 적절
해당 없음 (구조적으로 없음)CEO 의 manager_id, 최상위 카테고리의 parent_idNULL 이 적절
아직 없다 (시간이 지나면 채워짐)shipped_at, deleted_atNULL 이 적절
0 또는 빈 값포인트 0, 메모 없음NULL 쓰지 말 것0, ''

마지막 줄이 핵심입니다. "값이 0"과 "값이 없음"을 구분할 필요가 없다면, NULL 을 쓰지 마세요. 구분이 필요 없는데 NULL 을 허용하면 A-4~A-8 의 함정만 떠안게 됩니다.

3) 조인 키·필터 조건에 쓰이는 컬럼은 특히 NOT NULL 로. 조인 키에 NULL 이 있으면 그 행은 어떤 조인에도 매칭되지 않고 조용히 사라집니다. 인덱스 크기도 1바이트씩 커집니다(A-9).

4) 이미 NULL 이 있는 컬럼을 NOT NULL 로 바꾸려면 — 데이터를 먼저 채우고(UPDATE ... SET col = '' WHERE col IS NULL), 그 다음 ALTER TABLE ... MODIFY. 순서를 바꾸면 실패하거나(엄격 모드) 조용히 기본값으로 채워집니다(Step 02, Step 11).


A-14. 자가진단 10문항

답을 가리고 풀어 보세요. 8개 이상 맞히면 NULL 은 졸업입니다.

#질문
1NULL = NULL 의 결과는?NULL(UNKNOWN). <=> 를 쓰면 1
2WHERE col <> 'A' 는 col 이 NULL 인 행을 포함하는가?아니오. UNKNOWN 이라 탈락
3COUNT(*)COUNT(col) 이 다를 수 있는가?예. col 의 NULL 개수만큼 차이
4행이 0건일 때 SUM(x) 는?NULL. (COUNT(x) 는 0)
5GROUP BY col 에서 NULL 행들은 어떻게 되는가?한 그룹으로 묶인다
6UNIQUE 컬럼에 NULL 을 2건 넣을 수 있는가?예. 그래서 NOT NULL 이 함께 필요
7CHECK (age >= 18) 인 컬럼에 NULL 을 넣으면?통과한다. UNKNOWN 은 위반이 아님
8NOT IN 서브쿼리에 NULL 이 1건 섞이면?결과가 항상 0행
9LEFT JOIN ... WHERE 오른쪽.status = 'X' 는?INNER JOIN 으로 퇴화. 조건은 ON 으로
10ORDER BY col 에서 NULL 은 어디에?맨 앞(ASC). 뒤로 보내려면 ORDER BY col IS NULL, col

정리 — 한 장 치트시트

판단 기준 한 줄: 지금 하는 것이 비교인가(→ NULL ≠ NULL, UNKNOWN 발생), 그룹핑인가(→ NULL = NULL, 하나로 묶임).

상황쓸 것쓰지 말 것
NULL 인 행 찾기IS NULL= NULL
파라미터가 NULL 일 수 있는 비교<=>=
"제외" 조건col <> 'x' OR col IS NULL (요구사항 확인!)col <> 'x' 단독
없는 것 찾기(안티조인)NOT EXISTSNOT IN (서브쿼리)
LEFT JOIN + IS NULL 안티조인오른쪽 PK 로 판정NULL 가능 컬럼으로 판정
LEFT JOIN 에 오른쪽 조건ONWHERE
합계를 화면에 표시COALESCE(SUM(x), 0)SUM(x) 그대로
"포인트 적립자 평균"AVG(NULLIF(points,0))AVG(points)
ROLLUP 소계 라벨링GROUPING(col)col IS NULL
NULL 을 정렬 뒤로ORDER BY col IS NULL, colNULLS LAST(MySQL 에 없음)
문자열 조립CONCAT_WSCONCAT
대체값COALESCE (표준)IFNULL (MySQL 전용)
애초에NOT NULL DEFAULT ...습관적 NULL 허용

스텝별 되짚기

스텝NULL 관련 절이 부록의 대응
Step 055-5 3값 논리 · 5-6 <=> · 5-7 NOT IN · 5-10 NULL 함수A-1 · A-2 · A-4 · A-5 · A-10
Step 066-2 COUNT 3형제 · 6-3 집계는 NULL 무시 · 6-8 ROLLUP/GROUPING()A-6 · A-7
Step 077-4 ON vs WHERE · 7-5 안티조인A-8
Step 088-5 EXISTS · 8-8 NOT IN + NULLA-5
Step 13제약 조건A-12 · A-13
Step 15 · 16인덱스 · 실행계획A-9

연습문제

practice.sql 로 이 부록의 모든 예제를 직접 재현한 뒤, 아래를 스스로 작성해 보세요. 전부 이 부록에 답이 있습니다.

  1. customers 에서 "전화번호가 010-1000-0001아닌" 고객을 NULL 포함 29명으로 세는 쿼리
  2. categories 에서 잎 노드(부모가 아닌 카테고리)를 세 가지 방법(NOT IN + NULL 제거 / NOT EXISTS / LEFT JOIN ... IS NULL)으로 찾고 개수가 같은지 확인
  3. 고객별 주문 건수를 뽑되, 주문이 없는 고객도 0 으로 표시 (LEFT JOIN + COUNT(o.order_id))
  4. 도시별 고객 수를 WITH ROLLUP 으로 뽑고, GROUPING() 으로 총계 행에 ── 전체 ── 라벨 붙이기
  5. customers 를 전화번호 순으로 정렬하되 NULL 을 맨 뒤로
  6. UNIQUE 제약이 있는 컬럼에 NULL 을 3건 넣어 보고, NOT NULL 을 추가하면 어떻게 되는지 확인
  7. products 의 마진율을 price = 0 인 상품이 있어도 에러 없이 계산 (NULLIF)
  8. 주문이 하나도 없는 조건(WHERE 1=0)에서 SUM(total_amount)COUNT(*) 의 차이를 보이고, 화면 표시용으로 COALESCE 처리

다음 단계

이 부록은 여기서 끝입니다. Step 09 이후를 계속 진행하세요.

Step 09 — CTE와 재귀 쿼리 → 코스 전체 목차는 MySQL 8 완전 학습 코스


실습 파일

practice.sql

이 부록의 A-1 ~ A-12 예제를 위에서부터 그대로 실행하는 스크립트입니다.

mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop --table < practice.sql
  • 공용 테이블은 읽기만 합니다. 데이터를 넣어야 하는 A-8 · A-12 예제만 s26_ 접두사 사본을 만들고, 파일 끝에서 DROP TABLE IF EXISTS 로 전부 지웁니다.
  • --table 옵션을 붙여 실행하면 교재와 같은 표 형태로 결과가 나옵니다.
  • 블록 번호([A-1] ~ [A-12])가 본문 절 번호와 1:1 대응합니다. 헷갈리는 절만 골라 그 블록만 복사해 실행해도 됩니다.
-- =====================================================================
-- 부록 A — NULL 완전 정복 : practice.sql
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop --table < practice.sql
-- * 공용 테이블은 읽기만 합니다.
-- * 데이터 변경이 필요한 예제(A-8, A-12)만 s26_ 사본을 만들고 파일 끝에서 지웁니다.
-- =====================================================================
USE shop;

-- =====================================================================
-- A-1. NULL 은 값이 아니라 상태다
-- =====================================================================

-- [A-1] NULL / 0 / '' 는 완전히 다르다
SELECT
    NULL = NULL   AS `NULL = NULL`,
    NULL <> NULL  AS `NULL <> NULL`,
    NULL = 0      AS `NULL = 0`,
    '' = NULL     AS `'' = NULL`,
    '' IS NULL    AS `'' IS NULL`,
    LENGTH('')    AS `LENGTH('')`;

-- [A-1] NULL 은 전염된다. 단 CONCAT_WS 는 NULL 인자를 건너뛴다
SELECT
    NULL + 1                    AS `NULL + 1`,
    1 / NULL                    AS `1 / NULL`,
    CONCAT('a', NULL)           AS `CONCAT`,
    CONCAT_WS('-','a',NULL,'b') AS `CONCAT_WS`,
    LENGTH(NULL)                AS `LENGTH(NULL)`,
    UPPER(NULL)                 AS `UPPER(NULL)`;

-- =====================================================================
-- A-2. 3값 논리 — TRUE / FALSE / UNKNOWN
-- =====================================================================

-- [A-2] AND 는 FALSE 가 흡수한다
SELECT TRUE AND NULL AS `T AND ?`, FALSE AND NULL AS `F AND ?`, NULL AND NULL AS `? AND ?`;

-- [A-2] OR 는 TRUE 가 흡수한다
SELECT TRUE OR  NULL AS `T OR  ?`, FALSE OR  NULL AS `F OR  ?`, NULL OR  NULL AS `? OR  ?`;

-- [A-2] 모르는 것의 반대는 여전히 모르는 것
SELECT NOT NULL AS `NOT ?`;

-- =====================================================================
-- A-4. NULL 을 찾는 법 — IS NULL 과 <=>
-- =====================================================================

-- [A-4] = NULL 은 언제나 0건. IS NULL / <=> 만 찾아낸다
SELECT
    (SELECT COUNT(*) FROM customers WHERE phone =   NULL) AS `phone = NULL`,
    (SELECT COUNT(*) FROM customers WHERE phone IS  NULL) AS `phone IS NULL`,
    (SELECT COUNT(*) FROM customers WHERE phone <=> NULL) AS `phone <=> NULL`;

-- [A-4] 부정 조건에서 NULL 이 새어나간다 (26 vs 29)
SELECT
    (SELECT COUNT(*) FROM customers WHERE phone <> '010-1000-0001')                       AS `<> 만`,
    (SELECT COUNT(*) FROM customers WHERE phone <> '010-1000-0001' OR phone IS NULL)      AS `NULL 포함`;

-- =====================================================================
-- A-5. NOT IN + NULL — 최대 함정
-- =====================================================================

-- [A-5] IN 은 TRUE 가 흡수해서 살아남고, NOT IN 은 확정을 못 한다
SELECT
    3 IN (1,2,NULL)     AS `3 IN (1,2,?)`,
    1 IN (1,2,NULL)     AS `1 IN (1,2,?)`,
    3 NOT IN (1,2,NULL) AS `3 NOT IN (1,2,?)`,
    1 NOT IN (1,2,NULL) AS `1 NOT IN (1,2,?)`;

-- [A-5] employees.manager_id 에 NULL(CEO) 이 있어서 NOT IN 만 0 이 나온다. 정답은 10
SELECT
    (SELECT COUNT(*) FROM employees e
      WHERE e.employee_id NOT IN (SELECT manager_id FROM employees))                       AS `NOT IN (버그)`,
    (SELECT COUNT(*) FROM employees e
      WHERE e.employee_id NOT IN (SELECT manager_id FROM employees WHERE manager_id IS NOT NULL)) AS `NULL 제거`,
    (SELECT COUNT(*) FROM employees e
      WHERE NOT EXISTS (SELECT 1 FROM employees m WHERE m.manager_id = e.employee_id))     AS `NOT EXISTS`,
    (SELECT COUNT(*) FROM employees e
      LEFT JOIN employees m ON m.manager_id = e.employee_id
      WHERE m.employee_id IS NULL)                                                         AS `LEFT JOIN IS NULL`;

-- [A-5] 빈 서브쿼리는 UNKNOWN 이 아니라 확실한 FALSE / TRUE 다
SELECT 5 IN     (SELECT customer_id FROM customers WHERE 1=0) AS in_empty,
       5 NOT IN (SELECT customer_id FROM customers WHERE 1=0) AS notin_empty;

-- =====================================================================
-- A-6. 집계함수와 NULL
-- =====================================================================

-- [A-6] COUNT 3형제 : COUNT(*) 만 NULL 을 센다
SELECT
    COUNT(*)              AS `COUNT(*)`,
    COUNT(phone)          AS `COUNT(phone)`,
    COUNT(DISTINCT phone) AS `COUNT(DISTINCT phone)`,
    MIN(phone)            AS `MIN(phone)`
FROM customers;

-- [A-6] AVG 의 분모가 달라진다 (5959 vs 6621)
SELECT
    AVG(points)            AS `AVG(0 포함)`,
    AVG(NULLIF(points, 0)) AS `AVG(0을 NULL로)`,
    SUM(points) / COUNT(*) AS `SUM / COUNT(*)`
FROM customers;

-- [A-6] 빈 집합 : SUM/AVG/MAX 는 NULL, COUNT 는 0
SELECT SUM(points) AS s, AVG(points) AS a, MAX(points) AS m,
       COUNT(points) AS cp, COUNT(*) AS ca
FROM customers WHERE 1 = 0;

-- [A-6] 그래서 화면 표시용 합계는 COALESCE 로 마감한다
SELECT COALESCE(SUM(points), 0) AS `COALESCE 처리` FROM customers WHERE 1 = 0;

-- =====================================================================
-- A-7. GROUP BY / DISTINCT / UNION 은 NULL 을 "같다"고 본다
-- =====================================================================

-- [A-7] NULL 5건이 흩어지지 않고 한 그룹으로 묶인다
SELECT parent_id, COUNT(*) AS cnt FROM categories GROUP BY parent_id ORDER BY parent_id;

-- [A-7] DISTINCT 는 NULL 을 값으로 세지 않는다 (27 vs 30)
SELECT COUNT(DISTINCT phone) AS distinct_phone, COUNT(*) AS total FROM customers;

-- [A-7] UNION 은 NULL 끼리 중복으로 보고 하나로 접는다 (3+3 → 1)
SELECT COUNT(*) AS rows_after_union FROM (
    SELECT phone FROM customers WHERE phone IS NULL
    UNION
    SELECT phone FROM customers WHERE phone IS NULL
) t;

-- [A-7] ROLLUP 이 만든 NULL : 이게 총계인가 "도시 미상"인가?
SELECT city AS 도시, COUNT(*) AS 고객수, SUM(points) AS 포인트합
FROM customers WHERE city IN ('서울','부산')
GROUP BY city WITH ROLLUP;

-- [A-7] GROUPING() 으로 소계 행을 정확히 구분한다 (MySQL 8)
SELECT
    IF(GROUPING(city) = 1, '── 전체 ──', COALESCE(city, '(도시 미상)')) AS 도시,
    COUNT(*)       AS 고객수,
    GROUPING(city) AS g_city
FROM customers WHERE city IN ('서울','부산')
GROUP BY city WITH ROLLUP;

-- =====================================================================
-- A-8. JOIN 과 NULL — NULL 확장
-- =====================================================================

DROP TABLE IF EXISTS s26_order_memo;
DROP TABLE IF EXISTS s26_cust;

CREATE TABLE s26_cust (
  id   INT PRIMARY KEY,
  name VARCHAR(10) NOT NULL
);
CREATE TABLE s26_order_memo (
  order_id INT PRIMARY KEY,
  cust_id  INT NOT NULL,
  memo     VARCHAR(20) NULL          -- ← 함정의 씨앗 : NULL 을 허용하는 컬럼
);
INSERT INTO s26_cust VALUES (1,'김'),(2,'이'),(3,'박');
INSERT INTO s26_order_memo VALUES (100,1,'급송'),(101,2,NULL);

-- [A-8] 2번(짝은 있고 memo 만 NULL)과 3번(짝이 없어 NULL 확장)은 memo 만 보면 구분이 안 된다
SELECT c.id, c.name, o.order_id, o.memo
FROM s26_cust c
LEFT JOIN s26_order_memo o ON o.cust_id = c.id
ORDER BY c.id;

-- [A-8] 올바른 안티조인 : NOT NULL 컬럼(PK)으로 판정 → 3번만
SELECT c.id, c.name
FROM s26_cust c
LEFT JOIN s26_order_memo o ON o.cust_id = c.id
WHERE o.order_id IS NULL;

-- [A-8] 잘못된 안티조인 : NULL 가능 컬럼으로 판정 → 주문이 있는 2번까지 딸려온다
SELECT c.id, c.name
FROM s26_cust c
LEFT JOIN s26_order_memo o ON o.cust_id = c.id
WHERE o.memo IS NULL;

-- [A-8] ON vs WHERE : 258 vs 240. WHERE 에 두면 LEFT JOIN 이 INNER JOIN 으로 퇴화한다
SELECT
    (SELECT COUNT(*) FROM customers c
       LEFT JOIN orders o ON o.customer_id = c.customer_id AND o.status = 'DELIVERED') AS 조건이_ON,
    (SELECT COUNT(*) FROM customers c
       LEFT JOIN orders o ON o.customer_id = c.customer_id
      WHERE o.status = 'DELIVERED')                                                    AS 조건이_WHERE;

-- =====================================================================
-- A-9. 정렬과 인덱스에서의 NULL
-- =====================================================================

-- [A-9] NULL 은 가장 작은 값 → ASC 면 맨 앞
SELECT customer_id, name, phone FROM customers ORDER BY phone LIMIT 4;

-- [A-9] NULLS LAST 흉내 : IS NULL(0/1) 로 먼저 정렬
SELECT customer_id, name, phone FROM customers ORDER BY phone IS NULL, phone LIMIT 4;

-- [A-9] IS NULL 도 인덱스를 탄다 (type=ref, key=idx_employees_manager)
EXPLAIN SELECT employee_id, name FROM employees WHERE manager_id IS NULL;

-- [A-9] 비교용 : = 1 로 조회할 때와 접근 방식이 같다
EXPLAIN SELECT employee_id, name FROM employees WHERE manager_id = 1;

-- =====================================================================
-- A-10. NULL 을 다루는 함수
-- =====================================================================

-- [A-10] IF(NULL, ...) 은 FALSE 분기로 간다. CASE 는 ELSE 가 없으면 NULL
SELECT
    COALESCE(NULL, NULL, '기본값')    AS coalesce_ex,
    NULLIF(100, 100)                  AS nullif_same,
    IF(NULL, 'TRUE분기', 'FALSE분기') AS if_unknown,
    CASE WHEN 1 = 2 THEN 'x' END      AS `CASE(ELSE 없음)`;

-- [A-10] NULLIF 의 정석 : 0 나누기 방지
SELECT name, price, (price - cost) / NULLIF(price, 0) AS margin_rate
FROM products ORDER BY product_id LIMIT 3;

-- =====================================================================
-- A-11. NULL 이 만들어지는 경로 ④ — 행이 없는 스칼라 서브쿼리
-- =====================================================================

SELECT (SELECT phone FROM customers WHERE customer_id = 999) AS no_row_scalar;

-- =====================================================================
-- A-12. NULL 이 특별대우를 받는 곳
-- =====================================================================

DROP TABLE IF EXISTS s26_uniq;
CREATE TABLE s26_uniq (
  id    INT AUTO_INCREMENT PRIMARY KEY,
  email VARCHAR(50) UNIQUE,          -- ← NULL 은 몇 개든 허용된다
  age   INT,
  CHECK (age >= 18)                  -- ← NULL 은 위반이 아니다(UNKNOWN 통과)
);

-- [A-12] UNIQUE 인데 NULL 2건, CHECK(age>=18) 인데 age NULL 1건이 모두 통과한다
INSERT INTO s26_uniq (email, age) VALUES (NULL, 20), (NULL, 30), ('a@x.com', NULL);
SELECT * FROM s26_uniq;

-- [A-12] 참고 : CHECK 는 "FALSE 인 행만" 거부한다.
--        아래는 age=10 이 확실한 FALSE 라서 ERROR 3819 로 정상 차단된다.
--        (스크립트가 중간에 죽지 않도록 주석 처리해 두었습니다. 콘솔에서 직접 실행해 보세요.)
-- INSERT INTO s26_uniq (email, age) VALUES ('b@x.com', 10);

-- [A-12] AUTO_INCREMENT 컬럼의 NULL 은 "네가 알아서 채워라"라는 신호다 → id 1, 2
DROP TABLE IF EXISTS s26_ai;
CREATE TABLE s26_ai (id INT AUTO_INCREMENT PRIMARY KEY, v INT);
INSERT INTO s26_ai (id, v) VALUES (NULL, 10), (NULL, 20);
SELECT * FROM s26_ai;

-- =====================================================================
-- 정리 : s26_ 사본 삭제
-- =====================================================================
DROP TABLE IF EXISTS s26_order_memo;
DROP TABLE IF EXISTS s26_cust;
DROP TABLE IF EXISTS s26_uniq;
DROP TABLE IF EXISTS s26_ai;