NULL이 다른 이유 — 왜 3-valued logic인가
대부분의 프로그래밍 언어에서 null은 "값이 없음"을 의미하는 단순한 상수처럼 다뤄집니다. x == null이라고 쓰면 명확하게 true 또는 false를 돌려주죠. 그러나 SQL의 NULL은 전혀 다른 철학 위에 서 있습니다. SQL에서 NULL은 "값이 없다"가 아니라 "값을 모른다(UNKNOWN)"는 뜻입니다. 이 미묘한 차이가 수많은 버그의 원인이 됩니다.
일반적인 불리언 논리는 TRUE와 FALSE라는 두 개의 값만 갖습니다. 그러나 SQL은 표준(ISO/IEC 9075)에 따라 3-valued logic(3VL)을 따릅니다. 즉, 비교의 결과는 TRUE, FALSE, UNKNOWN 세 가지 중 하나가 될 수 있습니다. NULL이 포함된 거의 모든 연산은 결과가 UNKNOWN이 되며, WHERE 절은 결과가 TRUE인 행만 반환합니다. UNKNOWN인 행은 통과하지 못합니다.
이 글은 SQL 기초를 아는 개발자가 실무에서 가장 자주 마주치는 NULL 관련 실수 3가지를 분석하고, 표준 SQL 기준의 올바른 해결 패턴을 정리합니다. MySQL, PostgreSQL, MSSQL 모두에서 일관되게 동작하는 방식 위주로 다룹니다.
확인 체크리스트:
- NULL이 왜 UNKNOWN 상태인지 한 문장으로 설명할 수 있다
= NULL이 결과 0건을 돌려주는 이유를 안다NOT IN안에 NULL이 섞이면 어떤 일이 벌어지는지 안다- COALESCE / IS NULL / NOT EXISTS를 상황별로 골라 쓸 수 있다
이 글을 읽기 전에 알고 있으면 좋은 것
SELECT, WHERE, JOIN, GROUP BY 같은 기본 SQL 구문은 익숙하다고 가정합니다. 또한 IN과 EXISTS 서브쿼리의 차이, 그리고 집계 함수(COUNT, SUM, AVG)의 기본 동작을 이해하고 있어야 합니다. 데이터베이스 종류는 가리지 않지만, 예제는 MySQL 8 또는 PostgreSQL 14 이상에서 실행하면 동일하게 재현됩니다.
실습 환경과 준비물
예제는 가상의 e-커머스 도메인을 가정합니다. orders 테이블에는 order_id, customer_id, coupon_id(NULL 가능), total_amount, shipped_at(NULL 가능)이 있고, products 테이블에는 product_id, name, discontinued_at(NULL 가능)이 들어 있다고 보겠습니다. 모든 쿼리는 표준 SQL 문법을 따르므로 PostgreSQL 14+, MySQL 8.x, MSSQL 2019 이상에서 동일하게 검증할 수 있습니다.
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
coupon_id BIGINT, -- NULL 허용
total_amount DECIMAL(12,2) NOT NULL,
shipped_at TIMESTAMP -- 미배송이면 NULL
);
CREATE TABLE products (
product_id BIGINT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
discontinued_at TIMESTAMP -- 단종일, 판매중이면 NULL
);
권장 설정은 다음과 같습니다. MySQL이라면 sql_mode에 STRICT_TRANS_TABLES를 켜고, ORM을 쓰는 경우라면 NULL 허용 컬럼을 명시적으로 Optional로 모델링하는 것이 일반적입니다. 코드를 작성할 때 "이 컬럼이 NULL이 될 수 있는가?"를 항상 먼저 묻는 습관이 NULL 버그를 절반쯤 줄여줍니다.
SQL에서 NULL의 동작 원리 — UNKNOWN이라는 세 번째 진리값
NULL을 일종의 "값"이라고 생각하는 한 NULL 버그는 끊이지 않습니다. SQL 표준에서 NULL은 값의 부재가 아니라 알 수 없는 값입니다. 어떤 비교 연산자에 NULL이 한쪽이라도 들어가면, 그 연산의 결과는 TRUE/FALSE가 아니라 UNKNOWN이 됩니다.
비유하자면 이렇습니다. 봉투 안에 카드가 들어 있는데 내용물을 보지 않은 상태라고 생각해보세요. 이 봉투의 카드가 "스페이드 에이스인가?"라고 물으면, 답은 "예/아니오"가 아니라 "모른다"가 정답입니다. SQL의 NULL이 정확히 이 상황입니다. coupon_id = 5를 평가할 때 coupon_id가 NULL이라면, 결과는 FALSE가 아니라 UNKNOWN입니다. WHERE 절은 TRUE인 행만 통과시키므로 결과적으로 그 행은 제외되지만, FALSE라서 제외된 것과는 이유가 다릅니다.
이 차이는 NOT 연산자와 만났을 때 비로소 모습을 드러냅니다. NOT FALSE는 TRUE지만, NOT UNKNOWN은 여전히 UNKNOWN입니다. 즉, WHERE coupon_id = 5와 WHERE NOT (coupon_id = 5)를 합쳐도 NULL이 포함된 행은 어느 쪽에도 들어가지 않습니다. 전체를 합집합으로 모아도 NULL 행만 빠지는 기이한 결과가 나옵니다. 많은 사람이 직관적으로 "둘을 합치면 전체가 되겠지"라고 생각하지만, NULL 때문에 그렇지 않습니다.
같은 원리로 NULL = NULL도 UNKNOWN입니다. 두 개의 "모르는 값"이 같은지 아닌지는 알 수 없기 때문입니다. 그래서 SQL은 NULL 비교 전용 연산자 IS NULL / IS NOT NULL을 따로 두고 있습니다. 이 둘은 결과가 항상 TRUE 또는 FALSE이지 UNKNOWN이 되지 않는 유일한 NULL 비교 연산자입니다.
실수 1 — = NULL이 절대 매칭되지 않는 이유
가장 자주 마주치는 첫 번째 실수는 동등 비교 연산자 =를 NULL에도 그대로 쓰는 것입니다. 다음 쿼리는 "쿠폰을 안 쓴 주문"을 찾으려는 의도지만, 결과는 항상 0건입니다.
-- 잘못된 예제: 결과가 항상 0건
SELECT order_id, customer_id, total_amount
FROM orders
WHERE coupon_id = NULL;
앞 절에서 설명한 대로 coupon_id = NULL의 결과는 모든 행에 대해 UNKNOWN이며, WHERE 절은 TRUE인 행만 통과시키므로 결과 집합이 비게 됩니다. 올바른 표현은 IS NULL입니다.
-- 올바른 예제
SELECT order_id, customer_id, total_amount
FROM orders
WHERE coupon_id IS NULL;
실무에서 이 실수는 동적 쿼리 빌더에서 자주 발생합니다. 예를 들어 애플리케이션 코드가 파라미터 값에 따라 다음과 같이 쿼리를 합성한다고 해보겠습니다.
// 잘못된 패턴: 파라미터가 null이면 = NULL이 만들어진다
String sql = "SELECT * FROM orders WHERE coupon_id = ?";
ps.setObject(1, couponId); // couponId가 null이면 결과 0건
이런 경우 NULL 가능성을 코드 레벨에서 분기 처리하거나, NULL-safe equal 연산자를 사용해야 합니다. MySQL에는 <=>, PostgreSQL에는 IS NOT DISTINCT FROM이 있습니다.
-- PostgreSQL / 표준 SQL
SELECT * FROM orders
WHERE coupon_id IS NOT DISTINCT FROM :coupon_param;
-- MySQL
SELECT * FROM orders
WHERE coupon_id <=> :coupon_param;
실수 2 — NOT IN과 NULL이 만나면 전체 결과가 사라진다
두 번째 실수는 한층 더 교묘합니다. "단종되지 않은 상품을 제외한 모든 상품을 보여달라"처럼 NOT IN 서브쿼리를 쓰는 경우를 봅시다.
-- 의도: 단종 상품 목록에 포함되지 않은 상품 조회
SELECT product_id, name
FROM products
WHERE product_id NOT IN (
SELECT discontinued_product_id
FROM discontinued_list
);
만약 discontinued_list.discontinued_product_id 컬럼에 NULL이 단 하나라도 섞여 있다면, 이 쿼리는 결과가 0건이 됩니다. 이유는 NOT IN이 내부적으로 != a AND != b AND != NULL ...로 풀리는데, != NULL은 UNKNOWN이고, TRUE AND UNKNOWN은 UNKNOWN이며, WHERE는 UNKNOWN을 통과시키지 않기 때문입니다.
해결 패턴은 두 가지입니다. 첫째, NULL을 명시적으로 걸러내기. 둘째, NOT EXISTS로 바꾸기. 일반적으로 NOT EXISTS가 더 안전하고 옵티마이저 친화적입니다.
-- 패턴 A: NULL 제거
SELECT product_id, name
FROM products
WHERE product_id NOT IN (
SELECT discontinued_product_id
FROM discontinued_list
WHERE discontinued_product_id IS NOT NULL
);
-- 패턴 B: NOT EXISTS로 전환 (권장)
SELECT p.product_id, p.name
FROM products p
WHERE NOT EXISTS (
SELECT 1
FROM discontinued_list d
WHERE d.discontinued_product_id = p.product_id
);
NOT EXISTS는 행 단위 매칭으로 동작하므로 NULL이 섞여 있어도 의미가 명확합니다. 매칭되지 않은 행은 "존재하지 않는다"로 처리되며 결과가 사라지지 않습니다.
실수 3 — NULL 비교 연산의 함정과 COALESCE 패턴
세 번째 실수는 NULL 가능 컬럼에 산술/비교 연산을 거리낌 없이 적용하는 것입니다. 예를 들어 "할인액이 1000원 미만인 주문"을 찾으려고 다음과 같이 작성한다고 합시다.
-- discount_amount가 NULL인 행은 결과에서 빠진다
SELECT order_id, total_amount, discount_amount
FROM orders
WHERE discount_amount < 1000;
discount_amount가 NULL인 주문은 "할인이 없으므로 0원, 즉 1000원 미만"이라고 직관적으로 생각하기 쉽지만, SQL은 그렇게 추론하지 않습니다. NULL과의 비교는 UNKNOWN이 되어 그 행은 제외됩니다. 의도와 결과가 어긋나는 전형적인 사례입니다.
이때 COALESCE가 등장합니다. COALESCE(a, b, c, ...)는 인자를 왼쪽부터 평가하여 NULL이 아닌 첫 번째 값을 돌려줍니다. 비즈니스적으로 의미 있는 기본값으로 NULL을 치환하는 가장 표준적인 방법입니다.
-- COALESCE로 NULL을 0으로 치환
SELECT order_id, total_amount, discount_amount
FROM orders
WHERE COALESCE(discount_amount, 0) < 1000;
주의할 점은 COALESCE를 컬럼에 직접 씌우면 인덱스를 사용하지 못할 수 있다는 것입니다. 데이터가 큰 테이블에서는 다음과 같이 OR 조건으로 풀어 쓰는 편이 옵티마이저에게 유리한 경우가 많습니다.
-- 인덱스 활용 가능성을 높인 형태
SELECT order_id, total_amount, discount_amount
FROM orders
WHERE discount_amount < 1000
OR discount_amount IS NULL;
두 방식 중 어느 것을 택할지는 데이터 분포와 실행 계획에 따라 다릅니다. EXPLAIN을 돌려서 실제 비용을 확인한 뒤 결정하는 것을 권장합니다.
실전 예제 — Orders 테이블에서 NULL 방어 쿼리 작성
이제 세 가지 실수를 모두 피한 실전 쿼리를 작성해 보겠습니다. 시나리오는 "지난 30일 동안 발생한 주문 중, 쿠폰을 사용하지 않았거나 특정 쿠폰 블랙리스트에 포함되지 않은 주문의 총액을 집계하라"입니다.
SELECT
DATE(o.created_at) AS order_date,
COUNT(*) AS order_count,
SUM(o.total_amount) AS gross_amount,
SUM(COALESCE(o.discount_amount, 0)) AS total_discount,
SUM(o.total_amount - COALESCE(o.discount_amount, 0)) AS net_amount
FROM orders o
WHERE o.created_at >= CURRENT_DATE - INTERVAL '30' DAY
AND (
o.coupon_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM coupon_blacklist b
WHERE b.coupon_id = o.coupon_id
)
)
GROUP BY DATE(o.created_at)
ORDER BY order_date;
위 쿼리는 다음 세 가지 방어 장치를 모두 갖추고 있습니다. 첫째, "쿠폰을 사용하지 않은 주문"을 IS NULL로 정확히 표현했습니다. 둘째, 블랙리스트 비교에 NOT IN 대신 NOT EXISTS를 써서 NULL 함정을 피했습니다. 셋째, 할인 금액을 합산할 때 COALESCE로 NULL을 0으로 치환해 의도된 의미를 보존했습니다.
운영 환경이라면 로깅과 테스트도 함께 가져가야 합니다. 단위 테스트에서는 NULL이 섞인 시나리오를 별도 케이스로 만들어 두고, 의도한 행 수가 나오는지 매번 검증하는 것이 좋습니다. 다음은 예시 시나리오입니다.
-- 테스트 데이터
INSERT INTO orders(order_id, customer_id, coupon_id, total_amount, discount_amount, created_at)
VALUES
(1, 100, NULL, 10000, NULL, CURRENT_TIMESTAMP), -- 쿠폰 없음
(2, 101, 7, 20000, 3000, CURRENT_TIMESTAMP), -- 정상 쿠폰
(3, 102, 9, 30000, 5000, CURRENT_TIMESTAMP); -- 블랙리스트 쿠폰
INSERT INTO coupon_blacklist(coupon_id) VALUES (9), (NULL);
-- 기대: order_id 1, 2 (2건), 총 30000, 할인 3000, 순매출 27000
여기서 coupon_blacklist에 일부러 NULL을 섞었습니다. NOT EXISTS로 작성했기에 결과가 0건으로 사라지지 않고 의도대로 동작합니다. 같은 상황을 NOT IN으로 짰다면 1건도 안 나왔을 것입니다.
NULL과 집계 함수 — COUNT(*) vs COUNT(column)의 결정적 차이
NULL의 또 다른 얼굴은 집계 함수에서 드러납니다. 표준 SQL의 집계 함수 대부분(SUM, AVG, MIN, MAX, COUNT(column))은 NULL을 무시합니다. 반면 COUNT(*)는 행 자체를 세므로 NULL과 무관합니다.
SELECT
COUNT(*) AS total_rows, -- 모든 행
COUNT(coupon_id) AS rows_with_coupon, -- coupon_id가 NULL이 아닌 행만
COUNT(DISTINCT coupon_id) AS unique_coupons,
AVG(discount_amount) AS avg_discount -- NULL은 평균 계산에서 제외
FROM orders;
여기서 자주 발생하는 오해는 AVG입니다. AVG(discount_amount)는 NULL 행을 빼고 나머지 행의 평균을 구합니다. 만약 "할인을 안 받은 주문은 할인이 0원"이라는 의미로 평균을 내고 싶다면 명시적으로 NULL을 0으로 바꿔야 합니다.
SELECT AVG(COALESCE(discount_amount, 0)) AS avg_discount_with_zero
FROM orders;
또 한 가지 함정은 SUM의 결과가 NULL일 수 있다는 점입니다. 한 행도 매칭되지 않으면 SUM은 0이 아니라 NULL을 돌려줍니다. 이 NULL을 그대로 애플리케이션 코드로 흘려보내면 또 다른 NPE의 시작이 됩니다.
SELECT COALESCE(SUM(total_amount), 0) AS revenue
FROM orders
WHERE created_at >= CURRENT_DATE;
흔한 실수와 FAQ
다음은 코드 리뷰에서 반복적으로 발견되는 패턴들입니다.
- Q1. WHERE col != 'X'를 썼는데 NULL 행도 빠집니다. 의도한 행동입니다.
NULL != 'X'는 UNKNOWN이라 WHERE를 통과하지 못합니다. NULL도 포함하고 싶다면WHERE col IS NULL OR col != 'X'로 명시하세요. - Q2. CASE WHEN col = NULL THEN ... 으로 분기했는데 항상 ELSE로 갑니다. CASE 식 안에서도
= NULL은 UNKNOWN입니다.CASE WHEN col IS NULL THEN ...으로 바꿔야 합니다. - Q3. JOIN 키가 NULL인 행이 매칭되지 않습니다. SQL의 동등 조인은 NULL = NULL을 일치로 보지 않습니다. NULL끼리도 매칭하고 싶다면
ON a.x IS NOT DISTINCT FROM b.x(PostgreSQL) 또는ON a.x <=> b.x(MySQL)를 사용하세요. 단, 인덱스 활용도가 떨어질 수 있어 데이터 모델을 먼저 의심해 보는 것이 좋습니다. - Q4. UNIQUE 제약이 걸린 컬럼에 NULL 값을 여러 번 넣을 수 있었습니다. 표준 SQL상 NULL은 서로 동일하다고 판단되지 않으므로, 대부분 DBMS는 UNIQUE 컬럼에 NULL을 여러 개 허용합니다. NULL이 한 번만 들어가야 한다면 부분 인덱스나 NOT NULL 제약을 검토하세요.
NULL 처리 체크리스트 — 다음에 살펴볼 주제
NULL을 안전하게 다루는 마지막 체크리스트는 다음과 같이 정리할 수 있습니다.
- NULL 비교는 무조건
IS NULL/IS NOT NULL로 - NOT IN 서브쿼리에는 NULL이 섞일 가능성을 항상 의심, 가능하면
NOT EXISTS로 전환 - 비즈니스적 기본값이 있다면
COALESCE로 명시적 치환 - 집계 함수 결과가 NULL일 수 있는 경계 조건(빈 결과셋)을 잊지 않기
- 스키마 설계 단계에서 "이 컬럼은 정말 NULL이 필요한가?"를 한 번 더 묻기
한 걸음 더 나아가고 싶다면 다음 주제를 권장합니다. 윈도우 함수에서의 NULL 동작, LEFT JOIN 결과의 NULL과 외부 조인 필터 패턴, NULL 안전 인덱스 설계(부분 인덱스, 함수 기반 인덱스), 그리고 ORM(JPA, SQLAlchemy 등)에서 Optional 매핑과 NULL의 상호작용입니다. 이들을 묶어서 보면 NULL은 단순한 "값 없음"이 아니라 데이터 모델 전체의 의미론을 좌우하는 일급 시민이라는 점이 분명해집니다.
더 깊이 읽어볼 만한 자료
- PostgreSQL Documentation — Comparison Functions and Operators
- PostgreSQL Documentation — COALESCE, NULLIF, GREATEST, LEAST
- MySQL Reference Manual — Working with NULL Values
- Microsoft Learn — NULL and UNKNOWN (Transact-SQL)
- Modern SQL — Three-Valued Logic
- ISO/IEC 9075-2 — SQL Standard, Part 2: Foundation
댓글 0
아직 댓글이 없습니다.