본문 바로가기

NULL과 세 값 논리

NULL과의 비교가 참도 거짓도 아닌 알 수 없음이 되는 세 값 논리를 고정된 실습 데이터로 확인합니다. IS NULL로 값의 부재를 검사하는 방법, NOT IN에 NULL이 섞일 때의 결과, MySQL에서 NULL이 정렬되는 위치와 그 위치를 쿼리에 적는 방법을 다룹니다.

아직 공개하지 않은 게시글의 published_at은 빈 문자열도 0도 아닌 NULL입니다. NULL은 값이 아직 없거나 알 수 없다는 표시입니다. 이를 = NULL로 비교하면 오류 없이 한 행도 찾지 못하므로, 처음 SQL을 배울 때 가장 오래 놓치는 조회 오류가 됩니다.

이 글에서는 NULL을 값 하나로 보지 않고, 비교의 결과를 알 수 없음으로 만드는 세 값 논리(three-valued logic)로 읽습니다. 이렇게 읽으면 NOT IN과 집계, 정렬에서 생기는 예외도 같은 원리로 설명할 수 있습니다. 3장 1편에서 만든 query_post_demo를 그대로 쓰며, 이 테이블에서 게시 시각이 NULL인 행은 초안인 13번과 14번입니다.

= NULL은 아무것도 찾지 못한다

공개하지 않은 게시글을 찾으려고 published_at = NULL이라고 써 봅니다. 아래 쿼리는 숫자나 문자열을 찾을 때와 똑같이 =로 비교하므로 문법 검사는 통과합니다. 이 테이블에서 기대하는 답은 앞에서 말한 초안 13번과 14번의 두 행입니다.

행을 하나도 찾지 못하는 NULL 비교
SELECT id, title, published_at
FROM query_post_demo
WHERE published_at = NULL;

게시 시각이 NULL인 초안이 두 개 있는데도 결과는 빈 집합(Empty set)입니다. 문법 오류가 없어서 더 위험한 의미의 오류입니다. 어떤 값과 NULL을 같다고 비교한 결과는 참도 거짓도 아닌 알 수 없음(UNKNOWN)이고, NULL끼리 비교해도 마찬가지입니다.

WHERE는 참인 행만 통과시키므로 알 수 없음인 행은 모두 빠집니다. published_at <> NULL도 알 수 없음이어서 반대쪽 행도 찾지 못합니다. 그래서 값이 있는지는 IS NULL과 IS NOT NULL이라는 전용 조건식으로 검사합니다.

참, 거짓, 알 수 없음

NULL이 산술식이나 대부분의 비교에 들어가면 결과도 NULL, 곧 알 수 없음이 됩니다. 이 알 수 없음이 AND와 OR, NOT을 지날 때의 규칙은 MySQL에서 직접 확인할 수 있습니다. MySQL은 참을 1, 거짓을 0, 알 수 없음을 NULL로 보여 줍니다.

SELECT
  1 AND NULL AS true_and_unknown,
  0 AND NULL AS false_and_unknown,
  1 OR NULL AS true_or_unknown,
  0 OR NULL AS false_or_unknown,
  NOT NULL AS not_unknown;
true_and_unknown | false_and_unknown | true_or_unknown | false_or_unknown | not_unknown
NULL             | 0                 | 1               | NULL             | NULL

AND는 한쪽이 거짓이면 다른 쪽과 관계없이 거짓이고, OR는 한쪽이 참이면 다른 쪽과 관계없이 참입니다. 결과를 정할 수 없는 나머지 경우가 알 수 없음으로 남고, 알 수 없음을 NOT으로 뒤집어도 알 수 없음입니다.

이 규칙이 NOT IN에서 문제를 만듭니다. 2 NOT IN (1, NULL)은 (2 <> 1) AND (2 <> NULL)로 풀어 읽을 수 있는데, 첫 비교는 참이지만 두 번째가 알 수 없음이므로 전체도 알 수 없음입니다. 1 NOT IN (1, NULL)은 첫 비교부터 거짓이어서 전체가 거짓입니다.

SELECT
  1 IN (1, NULL) AS one_in,
  2 IN (1, NULL) AS two_in,
  1 NOT IN (1, NULL) AS one_not_in,
  2 NOT IN (1, NULL) AS two_not_in;
one_in | two_in | one_not_in | two_not_in
1      | NULL   | 0          | NULL

목록에 없는 2도, 목록에 있는 1도 NOT IN의 WHERE를 통과하지 못합니다. 두 값은 서로 다른 이유로 빠지므로, "일치하지 않음"을 곧바로 참으로 바꾸어 생각하면 안 됩니다.

NULL · THREE-VALUED LOGIC

NOT IN에 NULL이 있으면 불일치도 통과하지 않는다

목록에 1과 NULL이 있습니다. x=1은 일치하지만, x=2는 TRUE가 아니라 UNKNOWN입니다. NOT은 UNKNOWN을 TRUE로 바꾸지 않습니다.

NULL이 들어 있는 목록의 일치와 불일치
xx IN (1, NULL)x NOT IN (1, NULL)
1TRUEFALSE
2UNKNOWNUNKNOWN
NULLUNKNOWNUNKNOWN
1
x IN (1, NULL): TRUE
x NOT IN (1, NULL): FALSE
2
x IN (1, NULL): UNKNOWN
x NOT IN (1, NULL): UNKNOWN
NULL
x IN (1, NULL): UNKNOWN
x NOT IN (1, NULL): UNKNOWN

WHERE는 TRUE만 통과시킵니다.

IN은 x=1만 남기고, NOT IN은 이 세 입력 중 아무 행도 남기지 않습니다. NULL을 포함하려면 IS NULL 조건을 별도로 사용합니다.

IS NULL과 IS NOT NULL, COALESCE는 표준 SQL입니다. MySQL은 NULL끼리도 같다고 판단하는 NULL 안전 동등 연산자 <=>를 제공하는데, NULL = NULL이 NULL인 것과 달리 NULL <=> NULL은 1이고 1 <=> NULL은 0입니다. 표준 SQL의 IS NOT DISTINCT FROM에 해당하는 MySQL의 연산자이므로, 일반 조회에서는 IS NULL과 분명한 분기를 먼저 씁니다.

IS NULL로 부재를 검사한다

공개하지 않은 게시글은 게시 시각이 없다는 것을 직접 검사하고, 상태도 함께 확인합니다. 아래 쿼리는 앞의 = NULL을 IS NULL로 바꾸고 status = 'DRAFT'를 AND로 더합니다. 정렬에는 작성 시각이 같은 행이 있어도 순서가 하나로 정해지도록 id까지 넣었습니다.

미공개 게시글 조회
SELECT id, title, status, published_at
FROM query_post_demo
WHERE published_at IS NULL
  AND status = 'DRAFT'
ORDER BY created_at, id;
id | title         | status | published_at
13 | JOIN 계획     | DRAFT  | NULL
14 | GROUP BY 검토 | DRAFT  | NULL

게시 시각이 NULL이면서 DRAFT인 행만 통과했습니다. 다른 상태의 행까지 포함할지는 별도의 업무 조건으로 정합니다. 값이 없음과 빈 문자열, 0, "미정"이라는 상태 코드는 서로 다른 업무의 뜻이므로, 저장할 때부터 구별해 둡니다.

COALESCE(published_at, created_at)처럼 NULL을 보여 줄 값으로 바꾸는 것은 SELECT의 결과에서 유용하지만, 원래 값의 뜻을 바꾸지는 않습니다. WHERE에서 무조건 대신할 값을 넣으면 "실제 게시 시각"과 "대신 보여 준 시각"이 섞입니다. 상태와 게시 시각이 서로 맞는지는 1장 4편의 CHECK처럼 저장하는 쪽의 규칙으로 따로 지킵니다.

NULL이 정렬되는 위치

MySQL은 오름차순에서 NULL을 맨 앞에, 내림차순에서 맨 뒤에 둡니다. 게시된 순서대로 보여 주려고 published_at의 오름차순으로 정렬하면, 미공개 글이 맨 앞에 옵니다.

SELECT id, published_at
FROM query_post_demo
ORDER BY published_at ASC, id ASC
LIMIT 3;

SELECT id, published_at
FROM query_post_demo
ORDER BY published_at IS NULL ASC, published_at ASC, id ASC
LIMIT 3;
id | published_at
13 | NULL
14 | NULL
1  | 2026-07-10 08:25:00.000000

id | published_at
1  | 2026-07-10 08:25:00.000000
2  | 2026-07-11 09:50:00.000000
3  | 2026-07-11 10:40:00.000000

둘째 쿼리는 published_at IS NULL을 첫 번째 정렬 기준으로 두었습니다. 게시 시각이 있는 행은 이 식이 0, 없는 행은 1이므로 오름차순이면 게시된 행이 먼저 오고, 미공개 글은 맨 뒤로 갑니다. 이것은 MySQL에서 불리언 식이 0과 1로 정렬되는 성질을 쓴 것입니다.

DBMS마다 NULL의 기본 위치가 다릅니다. PostgreSQL은 내림차순에서 NULL을 맨 앞에 두므로, 다른 DBMS로 옮길 때는 NULLS FIRST나 NULLS LAST를 지원하는지, 아니면 CASE 표현식을 써야 하는지 확인합니다. 기본 동작을 외우기보다 원하는 위치를 쿼리에 적는 것이 중요합니다.

NULL을 둘러싼 설계

NULL을 허용하는 열이 많아질수록 "값이 아직 없음", "해당하지 않음", "수집 오류"를 NULL 하나로 뭉뚱그리기 쉽습니다. 분석에서 셋을 구별해야 한다면 상태 열이나 별도의 이벤트가 필요합니다. 반대로 모든 선택 값에 'NONE'이나 빈 문자열 같은 가짜 값을 넣으면 타입과 제약의 뜻이 약해지므로, NULL을 허용할지는 데이터 모델에서 정할 일입니다.

집계 함수도 NULL을 다르게 다룹니다. COUNT(*)는 행을 세고 COUNT(published_at)는 NULL이 아닌 값만 세므로, 이 테이블에서는 각각 14와 12이며 그 차이가 미공개 글의 수입니다. NULL을 0으로 바꾼 뒤 평균을 내면 "측정하지 않음"을 실제 0회로 해석하게 되므로, 집계 전에 업무의 뜻을 확인합니다. 집계는 4장 3편에서 자세히 다룹니다.

API에서도 NULL의 규칙을 분명히 해야 합니다. 부분 수정 요청에서 필드를 빼는 것은 "그대로 두기"로, publishedAt: null을 보내는 것은 "값 지우기"로 해석한다면, 데이터베이스의 UPDATE도 두 경우를 구별해야 합니다. NULL을 허용하는 열의 UNIQUE는 NULL을 여러 개 받아들여 "값이 있을 때만 유일"이라는 규칙이 되므로, 값이 꼭 있어야 하는 후보 키에는 NOT NULL을 함께 겁니다.

인덱스에도 NULL인 행이 들어가므로, IS NULL로 찾는 조회가 언제나 전체를 읽는다고 단정할 수 없습니다. 값의 분포와 인덱스의 첫 열, 돌려주는 행의 비율에 따라 옵티마이저의 선택이 달라지므로, 실제 데이터로 실행 계획을 확인하고 성능만을 위해 뜻 없는 기본값을 저장하지 않습니다. 백업이나 CSV로 내보낼 때 NULL과 빈 문자열이 같은 표기로 섞이지 않는지도 점검하는데, MySQL의 SELECT ... INTO OUTFILE과 LOAD DATA는 기본으로 NULL을 \N으로 적고 읽습니다.

회원 게시판의 NULL 규칙은 뒤의 집계와 조인, 서브쿼리에서도 같은 뜻으로 지켜야 합니다. 5장 3편의 LEFT JOIN에서는 짝이 없어서 생긴 NULL과 실제로 NULL인 값이 한 결과에 함께 나타나므로 자식의 기본 키로 둘을 구별하고, 6장 2편에서는 NULL 하나에 전체가 알 수 없음이 되는 NOT IN 대신 NOT EXISTS로 관계가 없음을 나타냅니다. 데이터 사전에는 NULL을 허용하는 열마다 NULL이 무엇을 뜻하는지, 언제 값이 채워지는지를 한 문장으로 적어 둡니다.

따져 볼 것질문
의미NULL이 미정, 부재, 해당 없음 가운데 무엇을 뜻하는가
조건식IS NULL과 IS NOT NULL로 값의 존재를 검사했는가
논리알 수 없음이 AND, OR, NOT에 미치는 영향을 확인했는가
표시COALESCE로 바꾼 값이 원래 값의 뜻과 구별되는가
정렬NULL의 위치를 결과의 규칙으로 적었는가

시험 데이터에는 NULL인 행과 NULL이 아닌 행을 반드시 함께 넣습니다. 조건에 NOT을 붙였을 때 두 집합이 전체를 나눈다고 보지 말고, 알 수 없음인 집합이 따로 남는지 확인합니다.

더 깊이: 옵티마이저가 IS NULL을 다루는 방법

MySQL의 옵티마이저는 col IS NULL을 col = 상수와 같은 방식으로 최적화합니다. 인덱스가 있는 열이라면 NULL인 행도 인덱스의 범위로 찾을 수 있고, col <=> NULL도 같습니다. 그래서 published_at에 인덱스가 있다면, 미공개 글을 찾는 조회도 인덱스에서 NULL 구간만 읽는 계획이 될 수 있습니다.

NOT NULL로 선언한 열에 IS NULL 조건을 적으면, 옵티마이저는 그 조건이 언제나 거짓이라는 것을 알고 식을 미리 없애 버립니다. 다만 LEFT JOIN의 오른쪽 테이블처럼 열이 NOT NULL이어도 결과에서 NULL이 나올 수 있는 곳에서는 이 정리를 하지 않습니다. 짝이 없는 행을 찾는 LEFT JOIN ... WHERE 오른쪽.id IS NULL이 제대로 동작하는 것도 이 예외 덕분입니다.

col = 값 OR col IS NULL처럼 "이 값이거나 값이 없는 행"을 찾는 조건도 하나의 접근 방식으로 처리할 수 있습니다. 이때 EXPLAIN의 접근 유형에는 ref_or_null이 나오며, 인덱스에서 그 값을 먼저 읽은 뒤 NULL인 행을 따로 한 번 더 찾습니다. MySQL 8.4 매뉴얼에 따르면 이 최적화는 인덱스의 키 부분마다 IS NULL 하나만 처리할 수 있습니다.

정리

  • NULL은 일반적인 값이 아니며, NULL과의 비교는 알 수 없음이 되어 WHERE를 통과하지 못합니다.
  • 값의 존재는 = NULL이 아니라 IS NULL과 IS NOT NULL로 검사합니다.
  • NOT IN의 목록에 NULL이 있으면 목록에 없는 값도 알 수 없음이 되어 결과에서 빠집니다.
  • COALESCE는 보여 줄 값을 바꿀 뿐, 저장된 값의 뜻을 바꾸지 않습니다.
  • MySQL은 오름차순에서 NULL을 앞에, 내림차순에서 뒤에 두며, 원하는 위치는 쿼리에 적습니다.

연습 문제

1. 미공개 글을 뒤로 보내는 최신 목록

모든 게시글을 게시 시각의 내림차순으로 보여 주되, 게시 시각이 없는 글은 맨 뒤에 두고 그 글끼리는 최신 작성 시각과 큰 id의 순서로 보여 주세요. published_at = NULL이나 빈 문자열과의 비교는 쓰지 않습니다.

해설 보기

NULL인지 아닌지를 첫 번째 정렬 기준으로 두면, 공개된 글과 미공개 글을 한 목록에 두면서 위치를 정할 수 있습니다.

SELECT id, status, published_at
FROM query_post_demo
ORDER BY
  published_at IS NULL ASC,
  published_at DESC,
  created_at DESC,
  id DESC;
id | status    | published_at
12 | PUBLISHED | 2026-07-13 15:45:00.000000
11 | PUBLISHED | 2026-07-13 15:40:00.000000
10 | PUBLISHED | 2026-07-13 14:20:00.000000
9  | HIDDEN    | 2026-07-13 13:25:00.000000
8  | HIDDEN    | 2026-07-13 12:40:00.000000
7  | PUBLISHED | 2026-07-12 16:10:00.000000
6  | PUBLISHED | 2026-07-12 15:25:00.000000
5  | PUBLISHED | 2026-07-12 14:05:00.000000
4  | PUBLISHED | 2026-07-11 11:10:00.000000
3  | PUBLISHED | 2026-07-11 10:40:00.000000
2  | PUBLISHED | 2026-07-11 09:50:00.000000
1  | PUBLISHED | 2026-07-10 08:25:00.000000
14 | DRAFT     | NULL
13 | DRAFT     | NULL

게시 시각이 없는 두 초안은 작성 시각이 더 늦은 14번이 먼저 옵니다. MySQL에서는 내림차순이 원래 NULL을 뒤에 두므로 이 쿼리의 첫 기준이 없어도 결과는 같지만, 기준을 적어 두면 의도가 드러나고 NULL을 앞에 두는 DBMS로 옮겨도 결과가 바뀌지 않습니다. 미공개 글만 필요하다면 정렬 대신 WHERE published_at IS NULL을 씁니다.

2. 포함과 제외로 나누면

게시 시각이 2026-07-10 08:25:00인 글과 그렇지 않은 글을 다음처럼 나누어 셌습니다. 두 수를 더하면 전체 14와 같나요? 다르다면 빠진 행은 어디로 갔나요?

SELECT COUNT(*) AS in_rows
FROM query_post_demo
WHERE published_at IN ('2026-07-10 08:25:00');

SELECT COUNT(*) AS not_in_rows
FROM query_post_demo
WHERE published_at NOT IN ('2026-07-10 08:25:00');
해설 보기
in_rows
1

not_in_rows
11

두 수를 더하면 12로, 전체 14보다 2가 적습니다. 게시 시각이 NULL인 13번과 14번은 IN에서도 NOT IN에서도 비교의 결과가 알 수 없음이 되어 어느 쪽에도 들어가지 않았습니다. WHERE published_at IS NULL로 세면 2가 나오며, 세 집합을 더해야 전체가 됩니다. 제외 검색을 만들 때는 이렇게 포함, 제외, NULL의 세 집합을 각각 세어 확인합니다.

3. NULL과의 비교 결과 읽기

다음 SELECT의 결과를 실행하기 전에 예상해 보세요. 네 식은 모두 NULL이 들어간 비교로, =, <=>, IS NULL이 NULL을 각각 어떻게 다루는지 묻습니다.

SELECT NULL = NULL, NULL <=> NULL, NULL IS NULL, 1 <=> NULL;
해설 보기
NULL = NULL | NULL <=> NULL | NULL IS NULL | 1 <=> NULL
NULL        | 1             | 1            | 0

NULL = NULL은 두 값이 같은지 알 수 없으므로 NULL입니다. <=>는 NULL끼리를 같다고 보고 NULL과 값은 다르다고 보므로 1과 0을 돌려주며, IS NULL은 값이 없는지를 묻는 조건이므로 1입니다. 열 이름에 별칭을 주지 않았으므로 결과의 열 이름은 식 그대로 나옵니다.

다음 글 계산 열과 표현식에서는 고른 값을 산술식과 함수로 가공해, 저장하지 않은 값을 결과에 계산해 내는 방법을 다룹니다.