LabHub
배우기 러닝패스 코스

SQL実戦

照会とフィルタ — 三値論理と条件の順序

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

SQL 의 WHERE 는 참인 행만 통과시키고, NULL 이 섞이면 결과가 참도 거짓도 아닌 미지가 되므로, 조건을 쓸 때는 늘 세 번째 값을 염두에 둬야 한다.

概念マップ: 양 끝값을 포함한다.・순서가 곧 우선순위・정렬은 항상 유일한 값까지 포함해 tie-break 를 만들어야 한다.・참·거짓·모름

왜 이게 필요했나

대부분의 프로그래밍 언어는 참과 거짓 두 값만 다룬다. SQL 은 세 값을 다룬다. 참, 거짓, 그리고 미지다. NULL 이 관여하는 모든 비교는 미지가 되고, WHERE 는 참인 행만 통과시키므로 미지는 거짓처럼 취급된다.

이 한 가지 차이가 실무에서 조용한 버그를 만든다. 조건을 뒤집었는데 행 개수의 합이 전체와 맞지 않는 현상이 대표적이다.

SELECT count(*) FROM customers WHERE city = '서울';       -- 48
SELECT count(*) FROM customers WHERE city <> '서울';      -- 335
-- 합이 383 인데 전체는 400 이다. 나머지 17 은 city 가 NULL 인 행이다.

<> 로도 걸러지지 않으므로 NULL 행은 양쪽 어디에도 없다. 이때 필요한 것이 IS DISTINCT FROM 이다. 이 연산자는 NULL 을 하나의 값처럼 비교해 주므로 위 예에서 352를 돌려준다.

어떻게 동작하나

자주 쓰는 조건들의 성격을 정리하면 이렇다.

조건 의미 주의할 점
BETWEEN a AND b a 이상 b 이하 양 끝값을 포함한다. 날짜 범위에 쓰면 마지막 날의 자정 이후가 빠질 수 있다
IN (...) 목록 중 하나 목록에 NULL 이 있고 NOT IN 을 쓰면 결과가 통째로 사라진다
LIKE 'kim%' 접두사 일치 인덱스 탐색 가능
LIKE '%kim%' 부분 일치 시작점을 모르므로 일반 인덱스로는 탐색 불가
IS NULL 값이 없음 = NULL 은 언제나 0행
COALESCE(a, b) a 가 NULL 이면 b 표시용 치환에 쓰고, 조건에 쓰면 인덱스를 못 탄다

CASE 는 위에서부터 처음 참인 가지를 고른다. 그래서 경계가 겹치는 조건을 나열할 때 순서가 곧 우선순위가 된다. 금액 구간을 나눌 때 큰 값부터 쓰는 것이 관례인 이유다.

페이지네이션도 짚고 갈 필요가 있다. ORDER BY 없이 LIMIT 만 쓰면 어떤 행이 나올지 보장되지 않는다. 그리고 정렬 기준에 동점이 있으면 페이지 사이에서 행이 중복되거나 누락된다. 정렬은 항상 유일한 값까지 포함해 tie-break 를 만들어야 한다. 실무에서는 ORDER BY created_at DESC, id DESC 처럼 마지막에 기본 키를 붙인다.

현장에서 만나는 모습

OFFSET 방식의 페이지네이션은 뒷 페이지로 갈수록 느려진다. OFFSET 10000 은 만 행을 읽고 버리는 일이기 때문이다. 대안은 마지막으로 본 값을 기준으로 이어 읽는 커서 방식이다. 조건이 WHERE (created_at, id) < (마지막값, 마지막id) 형태가 되어 인덱스 탐색으로 곧바로 시작 지점을 찾는다.

NULL 이 만드는 세 값 논리

SQL 의 비교는 참·거짓이 아니라 참·거짓·모름 입니다. 이것이 조건절의 동작을 바꿉니다.

NULL = NULL      → UNKNOWN (참이 아니다)
NULL <> 1        → UNKNOWN
NULL IS NULL     → TRUE      ← 이것만 참이 된다

WHERE참인 행만 남깁니다. UNKNOWN 은 거짓처럼 버려집니다. 그래서 이런 일이 생깁니다.

-- status 가 NULL 인 행은 두 질의 어디에도 안 나온다
select * from orders where status = 'done';
select * from orders where status <> 'done';

전체를 다루려면 NULL 을 명시해야 합니다.

where status is distinct from 'done'    -- NULL 도 "다르다" 로 본다
where status <> 'done' or status is null
where coalesce(status, '') <> 'done'

IS DISTINCT FROM 이 가장 깔끔합니다. NULL 을 하나의 값처럼 비교합니다.

집계에서도 다릅니다. count(*) 는 행을 세지만 count(col)NULL 이 아닌 것만 셉니다. avg·sum 도 NULL 을 무시하므로, 결측을 0으로 봐야 한다면 coalesce 로 채워야 합니다.

조건의 순서는 성능을 바꾸지 않는다(대개)

WHERE a = 1 AND b = 2 에서 순서를 바꿔도 옵티마이저가 알아서 정합니다. 사람이 신경 쓸 것은 순서가 아니라 인덱스를 쓸 수 있는 형태인가 입니다.

-- ❌ 인덱스를 못 쓴다 — 컬럼에 함수를 씌웠다
where date(created_at) = '2026-09-06'
where upper(email) = 'A@B.COM'

-- ✅ 범위로 바꾸거나 표현식 인덱스를 만든다
where created_at >= '2026-09-06' and created_at < '2026-09-07'
create index on users ((upper(email)));

LIKE 도 마찬가지입니다. 'abc%' 는 인덱스를 쓰지만 '%abc' 는 못 씁니다. 뒤에서 찾아야 한다면 trigram 인덱스나 전문 검색을 씁니다.

실행 계획을 읽는 최소한

explain (analyze, buffers) select …;

analyze 는 실제로 돌려 보고, buffers 는 얼마나 읽었는지 보여 줍니다. 볼 것은 셋입니다.

다음 실습에서 할 것

실제 이커머스 스키마에서 BETWEEN, IN, LIKE, COALESCE, CASE, IS DISTINCT FROM 을 차례로 써 본다. 특히 NULL 이 섞인 컬럼에서 조건을 뒤집었을 때 행 수가 맞지 않는 경험을 직접 하게 된다.