NULL은 빈 문자열이나 0이 아니라 “모름”을 의미합니다. 이 차이를 놓치면 조건문, 집계, JOIN 결과가 예상과 다르게 나옵니다. NULL 처리 규칙을 정확히 알아야 데이터 누락과 잘못된 통계를 피할 수 있습니다.
NULL의 3값 논리를 이해하면 WHERE 조건의 예상치 못한 결과, NOT IN 함정, 집계함수의 NULL 무시 등 흔한 SQL 버그를 예방할 수 있습니다.
- 13값 논리(3VL) — TRUE, FALSE, UNKNOWN
- 2NULL과 비교 연산자의 함정
- 3NOT IN 리스트에 NULL이 있을 때의 치명적 문제
- 4COALESCE — NULL을 기본값으로 대체하는 표준 방법
- 5NULLIF — 특정 값을 NULL로 변환하는 함수
- 6IS DISTINCT FROM — NULL 안전 비교 연산자
NULL 처리 — IS NULL, COALESCE, NULLIF 함정들
월별 활성 사용자 집계를 짜서 올렸더니, QA에서 숫자가 이상하다고 돌아왔다. 분명히 맞게 짠 것 같은데 총합이 실제보다 적었다. 한참 뒤에 발견했다 — phone 컬럼이 NULL인 사용자들이 WHERE 조건에서 통째로 빠지고 있었다. WHERE phone != '삭제됨' 이라고 썼는데, NULL != '삭제됨'은 TRUE가 아니라 UNKNOWN이 돼서 그 행들이 모두 제외된 거였다. 그날 처음 알았다, NULL은 빈 값이 아니라 "모른다"는 의미라서 일반 비교 연산자가 통하지 않는다는 걸. NULL 하나 때문에 집계 버그가 생기고, NOT IN에 NULL이 섞이면 결과가 0건이 되고, COUNT(*) 와 COUNT(column)이 다르게 나온다. 이 챕터를 지나면 NULL 관련 버그를 QA에서 잡히기 전에 먼저 잡을 수 있게 된다.
NULL의 3값 논리 — NULL = NULL이 FALSE인 이유
WHERE email = NULL로 쿼리를 짰는데 결과가 없습니다. 분명히 NULL인 데이터가 있는데 왜 조회가 안 되는지 모릅니다. NULL = NULL은 TRUE가 아니라 UNKNOWN입니다. 이 3값 논리를 이해하지 못하면 NULL 관련 쿼리에서 계속 예상과 다른 결과를 보게 됩니다.

NULL은 '알 수 없음'이다
NULL은 "값이 없다"가 아니라 "값을 알 수 없다"를 의미합니다. 이 차이가 SQL에서 NULL이 특별하게 동작하는 근본 이유입니다. "알 수 없는 값"과 어떤 값을 비교하면 그 결과 역시 "알 수 없음(UNKNOWN)"입니다. NULL = NULL조차 "두 개의 알 수 없는 값이 같은지 알 수 없다"는 이유로 UNKNOWN을 반환합니다.
NULL을 확인하는 유일한 올바른 방법은 IS NULL과 IS NOT NULL입니다.
SELECT
NULL = NULL AS "NULL = NULL",
NULL != NULL AS "NULL != NULL",
NULL = 1 AS "NULL = 1",
NULL != 1 AS "NULL != 1",
NULL IS NULL AS "NULL IS NULL",
NULL IS NOT NULL AS "NULL IS NOT NULL";
실행 완료 또는 조회 결과가 표시됩니다.
- NULL 비교—= NULL이 아니라 IS NULL 조건을 사용했는지 확인합니다.
- 집계 결과—COUNT(*)와 COUNT(column)의 차이가 결과에 반영됐는지 봅니다.
- 기본값 처리—COALESCE가 실제 비즈니스 기본값과 맞는지 점검합니다.
결과: 처음 네 비교는 모두 NULL(UNKNOWN)이고, IS NULL만 TRUE, IS NOT NULL만 FALSE를 반환합니다.
3값 논리(3VL) — AND/OR 진리표
SQL은 TRUE/FALSE의 2값 논리가 아닌 TRUE/FALSE/UNKNOWN의 3값 논리를 사용합니다. UNKNOWN이 포함된 논리 연산 결과를 이해해야 WHERE 절의 동작을 예측할 수 있습니다.
AND 진리표 — FALSE가 하나라도 있으면 FALSE, 나머지는 UNKNOWN이 전파됩니다:
| AND | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| TRUE | TRUE | FALSE | UNKNOWN |
| FALSE | FALSE | FALSE | FALSE |
| UNKNOWN | UNKNOWN | FALSE | UNKNOWN |
OR 진리표 — TRUE가 하나라도 있으면 TRUE, 나머지는 UNKNOWN이 전파됩니다:
| OR | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
| FALSE | TRUE | FALSE | UNKNOWN |
| UNKNOWN | TRUE | UNKNOWN | UNKNOWN |
WHERE 절은 결과가 TRUE인 행만 반환합니다. UNKNOWN은 FALSE와 같은 효과를 냅니다. 이것이 NULL 비교가 포함된 조건에서 예상치 못하게 행이 제외되는 이유입니다.
WHERE 절에서 NULL 함정
부서가 NULL인 직원이 "개발팀이 아닌 직원" 필터에서 누락되는 경우입니다. NULL != '개발팀'은 UNKNOWN이 되어 WHERE에서 제외됩니다.
CREATE TABLE employees (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
department VARCHAR(50),
salary NUMERIC(10, 2)
);
INSERT INTO employees (name, department, salary) VALUES
('김철수', '개발팀', 5000000),
('이영희', '마케팅', 4500000),
('박민준', NULL, 3800000),
('최수진', '개발팀', NULL);
SELECT * FROM employees WHERE department != '개발팀';
SELECT * FROM employees
WHERE department != '개발팀'
OR department IS NULL;
첫 번째 쿼리는 박민준(department=NULL)을 누락합니다. 두 번째 쿼리에서 OR department IS NULL을 추가하면 박민준도 포함됩니다.
NULL 값을 필터링하려고 = NULL을 사용하면 항상 결과가 비어 있습니다.
SELECT * FROM employees WHERE department = NULL;
SELECT * FROM employees WHERE salary != NULL;
department = NULL은 UNKNOWN을 반환하고, WHERE는 UNKNOWN을 FALSE로 취급하여 모든 행이 제외됩니다. 이 실수는 쿼리가 오류 없이 실행되고 단지 결과가 0건이기 때문에 발견하기 어렵습니다.
NULL 비교에는 반드시 IS NULL 또는 IS NOT NULL을 사용해야 합니다.
SELECT * FROM employees WHERE department IS NULL;
SELECT * FROM employees WHERE salary IS NOT NULL;
NOT IN 함정 — 가장 위험한 NULL 버그
NOT IN 리스트에 NULL이 포함되면 전체 쿼리 결과가 0건이 됩니다. 이유는 x NOT IN (1, 2, NULL)이 x != 1 AND x != 2 AND x != NULL로 전개되는데, x != NULL이 항상 UNKNOWN이 되어 AND 전체가 UNKNOWN이 되기 때문입니다.
SELECT * FROM products
WHERE category_id NOT IN (1, 2, NULL);
이 쿼리는 결과가 0건입니다. 리스트에 NULL이 포함되어 있으면 어떤 category_id 값도 UNKNOWN 비교를 피할 수 없습니다.
서브쿼리가 반환하는 컬럼에 NULL이 단 하나라도 있으면 NOT IN 전체가 0건을 반환합니다. 문제는 서브쿼리 결과에 NULL이 있는지 직접 확인하지 않으면 원인을 찾기 어렵다는 점입니다.
SELECT * FROM orders
WHERE customer_id NOT IN (
SELECT customer_id FROM blacklist
);
blacklist.customer_id에 NULL 값이 하나라도 있으면 이 쿼리는 아무 행도 반환하지 않습니다.
안전한 대안은 NOT EXISTS 또는 LEFT JOIN + IS NULL 패턴입니다.
SELECT * FROM orders o
WHERE NOT EXISTS (
SELECT 1 FROM blacklist b
WHERE b.customer_id = o.customer_id
);
SELECT o.* FROM orders o
LEFT JOIN blacklist b ON o.customer_id = b.customer_id
WHERE b.customer_id IS NULL;
NOT EXISTS는 NULL에 안전합니다. 서브쿼리 내에서 NULL = o.customer_id가 UNKNOWN이 되어도 EXISTS 자체는 FALSE로 처리되어 NOT EXISTS는 TRUE가 됩니다.
"직원 수와 평균 급여를 계산했는데 숫자가 맞지 않는다"는 보고서 버그가 발생합니다. 이 문제의 원인은 집계 함수가 NULL을 처리하는 방식에 있습니다.
COUNT(*)는 모든 행을 셉니다. 반면 COUNT(salary)는 salary가 NULL인 행을 제외합니다. AVG(salary) 역시 NULL을 제외한 행의 평균만 계산합니다.
SELECT
COUNT(*) AS 전체직원수,
COUNT(department) AS 부서있는직원수,
COUNT(salary) AS 급여확정직원수,
AVG(salary) AS 평균급여_null제외,
AVG(COALESCE(salary, 0)) AS 평균급여_null을0으로
FROM employees;
결과: 전체직원수=4, 부서있는직원수=3(박민준 NULL 제외), 급여확정직원수=3(최수진 NULL 제외), 평균급여_null제외는 3명 기준 평균, 평균급여_null을0으로는 4명 기준 평균입니다.
어느 쪽이 비즈니스적으로 올바른지는 맥락에 따라 다릅니다. "급여가 아직 확정되지 않은 직원은 평균 계산에서 제외"라면 AVG(salary)가 맞고, "미확정 급여를 0으로 간주"라면 COALESCE 처리가 맞습니다. 중요한 것은 의도를 명시적으로 코드에 표현하는 것입니다.
COALESCE, NULLIF, NULLS FIRST/LAST — NULL 다루는 함수들
NULL을 포함한 컬럼을 화면에 표시하면 "null"이라는 문자열이 보입니다. 정렬하면 NULL이 맨 앞에 올지 맨 뒤에 올지 DB마다 다릅니다. 나눗셈 분모에 NULL이 들어오면 오류가 납니다. 이런 상황에서 COALESCE, NULLIF, NULLS FIRST/LAST를 쓰면 애플리케이션 코드 없이 SQL에서 바로 처리할 수 있습니다.
COALESCE — NULL을 기본값으로 대체
COALESCE는 인자를 왼쪽부터 평가해 NULL이 아닌 첫 번째 값을 반환합니다. 모든 인자가 NULL이면 NULL을 반환합니다. 연락처 우선순위 폴백, 집계의 NULL을 0으로 처리, 디스플레이용 기본값 설정에 광범위하게 사용됩니다.
SELECT
name,
COALESCE(nickname, name) AS 표시이름,
COALESCE(phone, '연락처 없음') AS 연락처,
COALESCE(salary, 0) AS 급여
FROM employees;
SELECT
COALESCE(mobile_phone, office_phone, home_phone, '연락 불가') AS 대표연락처
FROM contacts;
SELECT
SUM(COALESCE(bonus, 0)) AS 총보너스,
AVG(COALESCE(score, 50)) AS 평균점수
FROM employees;
NULLIF — 특정 값을 NULL로 변환
NULLIF(a, b)는 a = b이면 NULL을 반환하고, 다르면 a를 반환합니다. 가장 일반적인 사용 패턴은 0으로 나누기 방지입니다.
SELECT
NULLIF(status, 'unknown') AS clean_status,
NULLIF(score, -1) AS valid_score,
NULLIF(phone, '') AS phone_or_null
FROM users;
SELECT
total_sales,
total_orders,
total_sales / NULLIF(total_orders, 0) AS avg_order_value
FROM monthly_stats;
SELECT
completed_tasks,
total_tasks,
ROUND(completed_tasks * 100.0 / NULLIF(total_tasks, 0), 1) AS completion_rate
FROM projects;
total_orders가 0일 때 NULLIF(total_orders, 0)이 NULL을 반환하고, 나누기 결과도 NULL이 되어 Division by zero 오류를 방지합니다.
IS DISTINCT FROM — NULL 안전 비교
일반 != 연산자는 NULL이 포함되면 UNKNOWN을 반환합니다. IS DISTINCT FROM은 NULL을 하나의 구체적인 값처럼 비교해 항상 TRUE 또는 FALSE를 반환합니다. NULL IS DISTINCT FROM NULL은 FALSE (두 NULL은 같다)이고, 1 IS DISTINCT FROM NULL은 TRUE입니다.
| a | b | a != b (일반) | a IS DISTINCT FROM b |
|---|---|---|---|
| 1 | 1 | FALSE | FALSE |
| 1 | 2 | TRUE | TRUE |
| 1 | NULL | NULL (UNKNOWN) | TRUE |
| NULL | NULL | NULL (UNKNOWN) | FALSE |
SELECT
a,
b,
a != b AS 일반비교,
a IS DISTINCT FROM b AS NULL안전비교
FROM (VALUES
(1, 1),
(1, 2),
(1, NULL),
(NULL, NULL)
) AS t(a, b);
변경 감지 로직에서 IS DISTINCT FROM은 특히 유용합니다. 이전/신규 값 모두 NULL일 수 있는 컬럼을 비교할 때 일반 !=를 쓰면 NULL 컬럼의 변경이 감지되지 않습니다.
UPDATE users
SET
profile_updated_at = NOW(),
version = version + 1
WHERE
old_email IS DISTINCT FROM new_email
OR old_name IS DISTINCT FROM new_name;
ORDER BY에서 NULL 위치 지정
NULL을 포함한 컬럼을 정렬할 때 NULL이 어디 위치할지는 DBMS마다 다릅니다. PostgreSQL은 ASC 정렬에서 NULL이 마지막, DESC 정렬에서 NULL이 처음에 옵니다. NULLS FIRST와 NULLS LAST로 명시적으로 지정하면 DBMS 간 차이 없이 일관된 동작을 보장합니다.
SELECT name, last_login FROM users
ORDER BY last_login DESC NULLS LAST;
SELECT name, salary FROM employees
ORDER BY salary ASC NULLS FIRST;
SELECT name, salary FROM employees
ORDER BY COALESCE(salary, 999999999) ASC;
실전 NULL 처리 패턴 모음
SELECT * FROM contacts
WHERE NULLIF(TRIM(phone), '') IS NOT NULL;
SELECT * FROM products
WHERE (category IS NULL OR category = '미분류')
AND is_active = true;
SELECT
data->>'phone' AS json_phone,
(data->>'phone') IS NULL AS is_missing
FROM user_profiles;
SELECT
COUNT(*) AS total_rows,
COUNT(department) AS has_dept,
COUNT(DISTINCT department) AS unique_dept,
COUNT(DISTINCT COALESCE(department, '미배정')) AS with_null_dept
FROM employees;
NULLIF(TRIM(phone), '')는 공백만 있는 문자열도 NULL로 처리해 "진짜 빈 연락처"를 제외합니다. COUNT(department)는 NULL을 제외하고, COUNT(*)는 포함하므로 두 값의 차이가 NULL 행 수입니다.
다음 모듈에서는 실무에서 가장 자주 쓰이는 날짜 및 문자열 함수와 코호트 분석 활용 패턴을 다룹니다.