Step 05 — 연산자와 조건
학습 목표
- 비교/논리 연산자와 연산자 우선순위(AND 가 OR 보다 강하다)를 익힌다
BETWEEN / IN / LIKE / REGEXP 를 상황에 맞게 고른다
- NULL 3값 논리를 이해하고,
IS NULL · <=> · NOT IN + NULL 함정을 피한다
- 날짜 범위 조건을 반열림 구간으로 쓰는 습관을 들인다
- OFFSET 페이징의 한계를 알고 커서(keyset) 페이징으로 바꿀 줄 안다
선행 스텝: Step 04 — SELECT 기본
예상 소요: 60분
5-1. 비교 연산자
| 연산자 | 의미 |
|---|
= | 같다 |
<> 또는 != | 같지 않다 (<> 가 표준) |
< > <= >= | 대소 비교 |
<=> | NULL-safe 같다 (5-6에서) |
SELECT product_id, name, price
FROM products
WHERE price > 1000000;
결과
+------------+-----------------------------+------------+
| product_id | name | price |
+------------+-----------------------------+------------+
| 12 | 울트라북 14 i5/16GB | 1290000.00 |
| 13 | 울트라북 14 i7/32GB | 1790000.00 |
| 14 | 게이밍 노트북 RTX4060 | 2190000.00 |
| 16 | 스마트폰 X20 256GB | 1150000.00 |
| 17 | 스마트폰 X20 Pro 512GB | 1490000.00 |
+------------+-----------------------------+------------+
5 rows in set
중요한 것은 비교 연산자가 값을 돌려주는 표현식이라는 점입니다. SELECT 목록에 그냥 넣어볼 수 있습니다.
SELECT 1 = 1 AS eq, 1 = 2 AS ne, 1 = NULL AS with_null, NULL = NULL AS null_null;
결과
+----+----+-----------+-----------+
| eq | ne | with_null | null_null |
+----+----+-----------+-----------+
| 1 | 0 | NULL | NULL |
+----+----+-----------+-----------+
TRUE 는 1, FALSE 는 0, 그리고 NULL 이 끼면 결과도 NULL 입니다. 이 세 번째 값이 SQL 초심자를 가장 많이 넘어뜨립니다. 5-5 에서 정면으로 다룹니다.
5-2. AND / OR / NOT 과 우선순위
AND 는 OR 보다 먼저 묶입니다. 산술에서 * 가 + 보다 먼저인 것과 같습니다. 이걸 잊으면 조용히 틀린 답을 얻습니다.
"노트북(21) 또는 스마트폰(22) 중에서 50만원 미만" 을 뽑으려고 이렇게 썼다고 합시다.
SELECT COUNT(*) AS 괄호없음
FROM products
WHERE category_id = 21 OR category_id = 22 AND price < 500000;
결과
+--------------+
| 괄호없음 |
+--------------+
| 5 |
+--------------+
SELECT COUNT(*) AS 괄호있음
FROM products
WHERE (category_id = 21 OR category_id = 22) AND price < 500000;
결과
+--------------+
| 괄호있음 |
+--------------+
| 1 |
+--------------+
5건과 1건. 완전히 다릅니다. 괄호 없는 쿼리는 실제로는 이렇게 해석됩니다.
category_id = 21 OR (category_id = 22 AND price < 500000)
즉 "노트북은 가격 상관없이 전부 + 스마트폰 중 50만원 미만". 실제로 뽑힌 행을 보면 확실합니다.
SELECT product_id, category_id, name, price
FROM products
WHERE category_id = 21 OR category_id = 22 AND price < 500000
ORDER BY category_id, product_id;
결과
+------------+-------------+-----------------------------+------------+
| product_id | category_id | name | price |
+------------+-------------+-----------------------------+------------+
| 12 | 21 | 울트라북 14 i5/16GB | 1290000.00 |
| 13 | 21 | 울트라북 14 i7/32GB | 1790000.00 |
| 14 | 21 | 게이밍 노트북 RTX4060 | 2190000.00 |
| 15 | 21 | 보급형 노트북 15 | 690000.00 |
| 18 | 22 | 스마트폰 A5 128GB | 399000.00 |
+------------+-------------+-----------------------------+------------+
5 rows in set
219만원짜리 게이밍 노트북이 "50만원 미만" 결과에 들어앉아 있습니다. 에러도 경고도 없습니다.
⚠️ 함정: OR 를 쓸 때는 무조건 괄호를 치세요. 우선순위를 외우고 있더라도 치세요. 6개월 뒤의 나와 동료를 위한 것입니다. 실무 버그 리포트의 상당수가 "OR 괄호 누락"입니다.
5-3. BETWEEN — 양 끝 포함
a BETWEEN x AND y 는 a >= x AND a <= y 와 완전히 같습니다. 양 끝을 포함합니다.
SELECT product_id, name, price
FROM products
WHERE price BETWEEN 100000 AND 200000
ORDER BY price;
결과
+------------+---------------------------+-----------+
| product_id | name | price |
+------------+---------------------------+-----------+
| 32 | 무쇠 프라이팬 28cm | 119000.00 |
| 6 | 실크 블라우스 | 129000.00 |
| 19 | 기계식 키보드 87키 | 129000.00 |
| 36 | 6단 책장 | 129000.00 |
| 10 | 러닝화 에어플로우 | 139000.00 |
| 3 | 라이트 다운 재킷 | 159000.00 |
| 11 | 첼시 부츠 | 189000.00 |
+------------+---------------------------+-----------+
7 rows in set
날짜에 BETWEEN 을 쓰면 반드시 사고가 난다
이것이 이 스텝에서 가장 실용적인 교훈입니다. orders.order_date 는 DATETIME 입니다. 그런데 '2025-06-30' 이라는 문자열을 주면 MySQL 은 이것을 '2025-06-30 00:00:00' 으로 해석합니다.
-- 2025년 6월 주문을 세려고 했다
SELECT COUNT(*) AS 잘못된_6월_주문수
FROM orders
WHERE order_date BETWEEN '2025-06-01' AND '2025-06-30';
결과
+--------------------------+
| 잘못된_6월_주문수 |
+--------------------------+
| 23 |
+--------------------------+
-- 올바른 방법: 반열림 구간 [시작, 다음 시작)
SELECT COUNT(*) AS 올바른_6월_주문수
FROM orders
WHERE order_date >= '2025-06-01'
AND order_date < '2025-07-01';
결과
+--------------------------+
| 올바른_6월_주문수 |
+--------------------------+
| 24 |
+--------------------------+
23건 vs 24건. 한 건이 증발했습니다. 범인은 이 주문입니다.
SELECT order_id, order_date, total_amount
FROM orders
WHERE order_date >= '2025-06-30' AND order_date < '2025-07-01';
결과
+----------+---------------------+--------------+
| order_id | order_date | total_amount |
+----------+---------------------+--------------+
| 508 | 2025-06-30 04:16:00 | 746000.00 |
+----------+---------------------+--------------+
1 row in set
2025-06-30 04:16:00 > 2025-06-30 00:00:00 이므로 BETWEEN 의 상한을 넘어버린 겁니다. 6월 30일 자정 이후의 모든 주문이 매출 집계에서 빠졌습니다.
⚠️ 함정 (실무 1순위): DATETIME 컬럼에 BETWEEN 을 쓰지 마세요.
- 나쁨:
BETWEEN '2025-06-01' AND '2025-06-30' — 하루가 통째로 날아감
- 나쁨:
BETWEEN '2025-06-01' AND '2025-06-30 23:59:59' — DATETIME(6) 마이크로초 컬럼이면 23:59:59.5 를 놓침
- 좋음:
>= '2025-06-01' AND < '2025-07-01' — 반열림 구간. 어떤 정밀도에도 안전
그리고 DATE(order_date) = '2025-06-30' 같이 컬럼을 함수로 감싸지 마세요. 인덱스를 못 씁니다(Step 20). >= '2025-06-30' AND < '2025-07-01' 로 쓰면 인덱스를 탑니다.
5-4. IN — OR 의 축약
IN 은 OR 를 짧게 쓴 것입니다. category_id IN (21, 22) 는 category_id = 21 OR category_id = 22 와 같습니다. 괄호 실수를 원천 차단해 주는 것이 덤입니다.
SELECT product_id, category_id, name
FROM products
WHERE category_id IN (21, 22)
ORDER BY product_id;
결과
+------------+-------------+-----------------------------+
| product_id | category_id | name |
+------------+-------------+-----------------------------+
| 12 | 21 | 울트라북 14 i5/16GB |
| 13 | 21 | 울트라북 14 i7/32GB |
| 14 | 21 | 게이밍 노트북 RTX4060 |
| 15 | 21 | 보급형 노트북 15 |
| 16 | 22 | 스마트폰 X20 256GB |
| 17 | 22 | 스마트폰 X20 Pro 512GB |
| 18 | 22 | 스마트폰 A5 128GB |
+------------+-------------+-----------------------------+
7 rows in set
IN 안에는 서브쿼리도 들어갑니다.
-- 600만원 넘는 주문이 배송된 도시에 사는 고객
SELECT customer_id, name, grade, city
FROM customers
WHERE city IN (SELECT shipping_city FROM orders WHERE total_amount > 6000000)
ORDER BY customer_id
LIMIT 5;
결과
+-------------+-----------+--------+--------+
| customer_id | name | grade | city |
+-------------+-----------+--------+--------+
| 5 | 정 훈 | GOLD | 인천 |
| 8 | 임수진 | SILVER | 대전 |
| 12 | 신태민 | SILVER | 인천 |
| 18 | 류하나 | GOLD | 인천 |
| 21 | 황도윤 | BRONZE | 대전 |
+-------------+-----------+--------+--------+
5 rows in set
5-5. NULL 과 3값 논리 (이 스텝의 핵심)
NULL 은 "0" 도 "빈 문자열" 도 아닙니다. "값을 모른다" 입니다.
모르는 값과 무엇을 비교하든 답은 "모른다" 입니다. 그래서 SQL 의 논리값은 참/거짓 2개가 아니라 TRUE / FALSE / UNKNOWN 3개입니다.
SELECT
NULL = NULL AS `NULL = NULL`,
NULL <> NULL AS `NULL <> NULL`,
NULL = 0 AS `NULL = 0`,
NULL + 1 AS `NULL + 1`,
CONCAT('a', NULL) AS `CONCAT(a,NULL)`;
결과
+-------------+--------------+----------+----------+----------------+
| NULL = NULL | NULL <> NULL | NULL = 0 | NULL + 1 | CONCAT(a,NULL) |
+-------------+--------------+----------+----------+----------------+
| NULL | NULL | NULL | NULL | NULL |
+-------------+--------------+----------+----------+----------------+
전부 NULL 입니다. NULL = NULL 조차 TRUE 가 아닙니다. NULL 은 전염됩니다 — 산술이든 문자열 연결이든 NULL 이 하나 끼면 결과가 통째로 NULL 이 됩니다.
WHERE 는 TRUE 인 행만 남깁니다. FALSE 는 물론이고 UNKNOWN 도 버립니다. 그래서 이렇게 됩니다.
SELECT COUNT(*) AS `phone = NULL` FROM customers WHERE phone = NULL;
결과
+--------------+
| phone = NULL |
+--------------+
| 0 |
+--------------+
phone 이 NULL 인 고객이 분명히 3명 있는데 0건입니다. phone = NULL 이 모든 행에서 UNKNOWN 이라 전부 버려진 것입니다. 에러가 안 난다는 게 무섭죠.
NULL 을 찾으려면 반드시 IS NULL 을 씁니다.
SELECT customer_id, name, phone
FROM customers
WHERE phone IS NULL;
결과
+-------------+-----------+-------+
| customer_id | name | phone |
+-------------+-----------+-------+
| 7 | 윤대현 | NULL |
| 14 | 남규리 | NULL |
| 28 | 심준호 | NULL |
+-------------+-----------+-------+
3 rows in set
부정 조건에서 NULL 이 새어나가는 문제
"1번 고객의 전화번호가 아닌 고객" 을 세어 봅시다. 전체 30명 중 1명을 뺀 29명이 나와야 할 것 같습니다.
SELECT COUNT(*) AS `<> 로 센 결과`
FROM customers
WHERE phone <> '010-1000-0001';
결과
+-------------------+
| <> 로 센 결과 |
+-------------------+
| 26 |
+-------------------+
26명입니다. 29명이 아니라. phone 이 NULL 인 3명은 NULL <> '010-...' 이 UNKNOWN 이라 조용히 빠졌습니다.
SELECT COUNT(*) AS `NULL 포함`
FROM customers
WHERE phone <> '010-1000-0001' OR phone IS NULL;
결과
+-------------+
| NULL 포함 |
+-------------+
| 29 |
+-------------+
⚠️ 함정: NULL 이 가능한 컬럼에 부정 조건(<>, NOT LIKE, NOT IN)을 쓸 때는 항상 OR ... IS NULL 을 붙일지 결정하세요. "제외" 요구사항을 받으면 "NULL 인 행은 포함인가요, 제외인가요?" 를 반드시 물어보세요. 대개 기획자는 이 질문을 생각해 본 적이 없습니다.
5-6. <=> NULL-safe 등호
<=> 는 = 와 똑같지만 NULL 을 정상적으로 비교합니다. 절대 UNKNOWN 을 돌려주지 않고 항상 1 또는 0 입니다.
SELECT
NULL <=> NULL AS `NULL <=> NULL`,
NULL <=> 1 AS `NULL <=> 1`,
1 <=> 1 AS `1 <=> 1`;
결과
+---------------+------------+---------+
| NULL <=> NULL | NULL <=> 1 | 1 <=> 1 |
+---------------+------------+---------+
| 1 | 0 | 1 |
+---------------+------------+---------+
NULL <=> NULL 이 1(TRUE) 입니다. 그래서 WHERE phone <=> NULL 은 WHERE phone IS NULL 과 같은 결과를 냅니다.
SELECT customer_id, name, phone
FROM customers
WHERE phone <=> NULL;
결과
+-------------+-----------+-------+
| customer_id | name | phone |
+-------------+-----------+-------+
| 7 | 윤대현 | NULL |
| 14 | 남규리 | NULL |
| 28 | 심준호 | NULL |
+-------------+-----------+-------+
3 rows in set
💡 실무 팁: <=> 의 진짜 쓸모는 파라미터가 NULL 일 수도 있는 검색입니다. 애플리케이션에서 WHERE phone <=> ? 라고 쓰면, ? 에 값이 오면 그 값을 찾고 ? 에 NULL 이 오면 NULL 인 행을 찾습니다. = 로는 이게 안 되어서 WHERE (phone = ? OR (? IS NULL AND phone IS NULL)) 같은 흉물을 써야 합니다. 배치 잡의 "변경 감지"(예전 값과 새 값이 정말 다른가?) 로직에도 <=> 가 딱입니다.
참고: 표준 SQL 의 IS NOT DISTINCT FROM 이 같은 역할인데, MySQL 에는 없습니다. <=> 가 MySQL 의 답입니다.
5-7. NOT IN + NULL — 최악의 함정
이건 정말로 프로덕션을 조용히 망가뜨리는 종류의 버그입니다.
"어떤 카테고리의 부모도 아닌 카테고리(= 잎 노드)" 를 찾아봅시다.
SELECT COUNT(*) AS `NOT IN 결과`
FROM categories
WHERE category_id NOT IN (SELECT parent_id FROM categories);
결과
+---------------+
| NOT IN 결과 |
+---------------+
| 0 |
+---------------+
0건입니다. 잎 카테고리가 12개나 있는데도요. 에러도 경고도 없습니다.
원인은 서브쿼리 SELECT parent_id FROM categories 의 결과에 NULL 이 섞여 있다는 것입니다 (최상위 카테고리 5개의 parent_id 는 NULL 이니까요). 그러면 이렇게 됩니다.
SELECT
3 NOT IN (1, 2) AS `3 NOT IN (1,2)`,
3 NOT IN (1, 2, NULL) AS `3 NOT IN (1,2,NULL)`;
결과
+----------------+---------------------+
| 3 NOT IN (1,2) | 3 NOT IN (1,2,NULL) |
+----------------+---------------------+
| 1 | NULL |
+----------------+---------------------+
3 NOT IN (1,2,NULL) 은 내부적으로 3<>1 AND 3<>2 AND 3<>NULL 입니다. 앞의 둘은 TRUE 인데 마지막이 UNKNOWN 이라 TRUE AND TRUE AND UNKNOWN = UNKNOWN. WHERE 는 UNKNOWN 을 버리므로 어떤 행도 통과하지 못합니다.
⚠️ 함정: 서브쿼리에 NULL 이 하나라도 있으면 NOT IN 은 항상 빈 결과를 냅니다. 지금 잘 돌아가는 NOT IN 쿼리도, 그 컬럼에 NULL 이 처음 들어오는 순간 조용히 0건이 됩니다. 배치 잡이 어느 날부터 아무 일도 안 하기 시작합니다.
해결책 ① 서브쿼리에서 NULL 을 제거
SELECT category_id, name
FROM categories
WHERE category_id NOT IN (
SELECT parent_id FROM categories WHERE parent_id IS NOT NULL
)
ORDER BY category_id
LIMIT 8;
결과
+-------------+--------------+
| category_id | name |
+-------------+--------------+
| 11 | 남성의류 |
| 12 | 여성의류 |
| 13 | 신발 |
| 21 | 노트북 |
| 22 | 스마트폰 |
| 23 | 주변기기 |
| 31 | 신선식품 |
| 32 | 가공식품 |
+-------------+--------------+
... (총 12행)
해결책 ② NOT EXISTS (권장)
SELECT c.category_id, c.name
FROM categories c
WHERE NOT EXISTS (
SELECT 1 FROM categories x WHERE x.parent_id = c.category_id
)
ORDER BY c.category_id
LIMIT 8;
결과 (①과 동일, 12행)
+-------------+--------------+
| category_id | name |
+-------------+--------------+
| 11 | 남성의류 |
| 12 | 여성의류 |
| 13 | 신발 |
| 21 | 노트북 |
| 22 | 스마트폰 |
| 23 | 주변기기 |
| 31 | 신선식품 |
| 32 | 가공식품 |
+-------------+--------------+
... (총 12행)
💡 실무 팁: NOT IN (서브쿼리) 대신 NOT EXISTS 를 기본값으로 쓰세요. NULL 에 원천적으로 안전하고(x.parent_id = c.category_id 가 NULL 이면 그냥 매칭 안 될 뿐), 옵티마이저가 안티조인으로 잘 풀어냅니다. NOT IN 은 상수 리스트(NOT IN (1,2,3))에만 쓰는 걸 권합니다. 안티조인의 세 번째 방법인 LEFT JOIN ... IS NULL 은 Step 07 에서 다룹니다.
5-8. LIKE — 패턴 매칭
% : 0자 이상의 아무 문자
_ : 정확히 1자
SELECT product_id, name FROM products WHERE name LIKE '스마트폰%';
결과
+------------+----------------------------+
| product_id | name |
+------------+----------------------------+
| 16 | 스마트폰 X20 256GB |
| 17 | 스마트폰 X20 Pro 512GB |
| 18 | 스마트폰 A5 128GB |
+------------+----------------------------+
3 rows in set
-- 이메일이 아무 글자 1개 + "im" 으로 시작하는 고객
SELECT customer_id, email FROM customers WHERE email LIKE '_im%' ORDER BY customer_id;
결과
+-------------+-----------------------+
| customer_id | email |
+-------------+-----------------------+
| 1 | kim.minsu@example.com |
| 8 | lim.sujin@example.com |
| 28 | sim.junho@example.com |
+-------------+-----------------------+
3 rows in set
LIKE 는 콜레이션을 따른다
우리 DB 의 기본 콜레이션은 utf8mb4_0900_ai_ci 입니다. 끝의 ci = case-insensitive(대소문자 구분 안 함), ai = accent-insensitive(악센트 구분 안 함). 그래서 소문자로 검색해도 대문자가 잡힙니다.
SELECT product_id, name FROM products WHERE name LIKE '%mysql%';
결과
+------------+----------------+
| product_id | name |
+------------+----------------+
| 37 | 실전 MySQL 8 |
+------------+----------------+
1 row in set
대소문자를 구분하고 싶으면 BINARY 를 붙입니다.
SELECT product_id, name FROM products WHERE name LIKE BINARY '%mysql%';
-- Empty set (소문자 mysql 은 없으므로)
SELECT product_id, name FROM products WHERE name LIKE BINARY '%MySQL%';
-- 37 | 실전 MySQL 8
% 자체를 검색하기 — ESCAPE
상품명에 % 가 들어 있는 "다크초콜릿 72% 100g" 을 찾으려면 % 를 이스케이프해야 합니다.
SELECT product_id, name FROM products WHERE name LIKE '%\%%';
-- 또는 이스케이프 문자를 직접 지정
SELECT product_id, name FROM products WHERE name LIKE '%!%%' ESCAPE '!';
결과 (둘 다 동일)
+------------+--------------------------+
| product_id | name |
+------------+--------------------------+
| 30 | 다크초콜릿 72% 100g |
+------------+--------------------------+
1 row in set
⚠️ 함정: LIKE '%검색어%' 는 인덱스를 못 씁니다. 앞에 % 가 붙으면 B-Tree 인덱스는 어디서부터 찾아야 할지 알 수 없어서 풀스캔합니다. LIKE '스마트폰%' 처럼 앞이 고정된 패턴만 인덱스를 탑니다(range scan). 수백만 행 테이블에서 %...% 검색이 필요하면 FULLTEXT 인덱스나 별도 검색엔진을 고민할 시점입니다. (Step 20)
⚠️ 함정: 사용자 입력을 LIKE 패턴에 그대로 넣지 마세요. 사용자가 % 를 입력하면 전체 매칭이 되어버립니다. 애플리케이션에서 %, _, \ 를 이스케이프한 뒤 바인딩하세요.
5-9. REGEXP — 정규식
LIKE 로 표현이 안 되는 패턴은 REGEXP(별칭 RLIKE)를 씁니다.
MySQL 8 의 변화: 8.0 부터 정규식 엔진이 ICU 로 교체되었습니다(5.7 까지는 Henry Spencer 라이브러리). 그 결과 (1) 유니코드를 제대로 지원하고, (2) REGEXP_LIKE / REGEXP_SUBSTR / REGEXP_REPLACE / REGEXP_INSTR 함수가 새로 생겼으며, (3) BINARY 와 함께 못 쓰게 되었습니다(아래 참고).
SELECT product_id, name
FROM products
WHERE name REGEXP '노트북|스마트폰' -- | 는 OR
ORDER BY product_id;
결과
+------------+-----------------------------+
| product_id | name |
+------------+-----------------------------+
| 14 | 게이밍 노트북 RTX4060 |
| 15 | 보급형 노트북 15 |
| 16 | 스마트폰 X20 256GB |
| 17 | 스마트폰 X20 Pro 512GB |
| 18 | 스마트폰 A5 128GB |
+------------+-----------------------------+
5 rows in set
앵커(^ 시작, $ 끝)와 문자 클래스를 씁니다.
SELECT product_id, name FROM products WHERE name REGEXP '^[0-9]'; -- 숫자로 시작
결과
+------------+-----------------------+
| product_id | name |
+------------+-----------------------+
| 21 | 27인치 4K 모니터 |
| 36 | 6단 책장 |
+------------+-----------------------+
2 rows in set
⚠️ 함정: LIKE 는 문자열 전체가 패턴과 맞아야 하지만, REGEXP 는 일부만 맞아도 참입니다. name LIKE '노트북' 은 이름이 정확히 "노트북"인 것만 찾지만, name REGEXP '노트북' 은 "게이밍 노트북 RTX4060" 도 찾습니다. REGEXP 로 완전 일치를 원하면 ^...$ 로 감싸야 합니다.
MySQL 8 의 새 정규식 함수
SELECT
REGEXP_LIKE('kim.minsu@example.com', '^[a-z.]+@example\\.com$') AS is_valid,
REGEXP_SUBSTR('스마트폰 X20 Pro 512GB', '[0-9]+GB') AS storage,
REGEXP_REPLACE('010-1000-0001', '[0-9]{4}$', '****') AS masked,
REGEXP_INSTR('실전 MySQL 8', 'MySQL') AS pos;
결과
+----------+---------+---------------+-----+
| is_valid | storage | masked | pos |
+----------+---------+---------------+-----+
| 1 | 512GB | 010-1000-**** | 4 |
+----------+---------+---------------+-----+
REGEXP_REPLACE 로 전화번호 뒷자리를 마스킹하는 건 실무에서 정말 자주 씁니다. 5.7 에서는 이걸 못 해서 애플리케이션으로 데이터를 다 가져와 처리해야 했습니다.
REGEXP_SUBSTR 로 비정형 문자열에서 값을 추출할 수도 있습니다.
SELECT product_id, name,
REGEXP_SUBSTR(name, '[0-9]+(GB|TB)') AS capacity
FROM products
WHERE name REGEXP '[0-9]+(GB|TB)'
ORDER BY product_id;
결과
+------------+----------------------------+----------+
| product_id | name | capacity |
+------------+----------------------------+----------+
| 12 | 울트라북 14 i5/16GB | 16GB |
| 13 | 울트라북 14 i7/32GB | 32GB |
| 16 | 스마트폰 X20 256GB | 256GB |
| 17 | 스마트폰 X20 Pro 512GB | 512GB |
| 18 | 스마트폰 A5 128GB | 128GB |
+------------+----------------------------+----------+
5 rows in set
REGEXP 의 대소문자 — MySQL 8 에서 달라진 점
REGEXP 도 콜레이션을 따라 대소문자를 무시합니다. 그런데 MySQL 8 에서는 BINARY 'MySQL' REGEXP 'mysql' 이 에러가 납니다.
ERROR 3995 (HY000): Character set 'binary' cannot be used in conjunction with
'utf8mb4_0900_ai_ci' in call to regexp_like.
ICU 엔진이 바이너리 문자셋을 받지 못하기 때문입니다. 5.7 에서 쓰던 코드를 8.0 으로 올릴 때 만나는 대표적인 비호환입니다. 대신 COLLATE 를 쓰거나, REGEXP_LIKE 의 세 번째 인자 match_type ('c' = case-sensitive, 'i' = case-insensitive)을 씁니다.
SELECT
'MySQL' REGEXP 'mysql' AS ci,
'MySQL' COLLATE utf8mb4_0900_as_cs REGEXP 'mysql' AS cs_collate,
REGEXP_LIKE('MySQL', 'mysql', 'c') AS cs_flag,
REGEXP_LIKE('MySQL', 'mysql', 'i') AS ci_flag;
결과
+----+------------+---------+---------+
| ci | cs_collate | cs_flag | ci_flag |
+----+------------+---------+---------+
| 1 | 0 | 0 | 1 |
+----+------------+---------+---------+
💡 실무 팁: REGEXP 는 절대 인덱스를 못 씁니다. 항상 풀스캔입니다. 편하다고 WHERE 절에 남발하면 테이블이 커질 때 그대로 장애가 됩니다. REGEXP 는 (1) 이미 좁혀진 결과에 추가 필터로, (2) 일회성 데이터 조사에, (3) SELECT 목록에서 값을 가공할 때 쓰세요.
5-10. NULL 을 다루는 함수
SELECT
customer_id, name, phone,
IFNULL(phone, '(미등록)') AS ifnull_ex,
COALESCE(phone, '(미등록)') AS coalesce_ex,
NULLIF(points, 0) AS points_0은_null로
FROM customers
WHERE customer_id IN (1, 7, 14, 17)
ORDER BY customer_id;
결과
+-------------+-----------+---------------+---------------+---------------+---------------------+
| customer_id | name | phone | ifnull_ex | coalesce_ex | points_0은_null로 |
+-------------+-----------+---------------+---------------+---------------+---------------------+
| 1 | 김민수 | 010-1000-0001 | 010-1000-0001 | 010-1000-0001 | 12500 |
| 7 | 윤대현 | NULL | (미등록) | (미등록) | NULL |
| 14 | 남규리 | NULL | (미등록) | (미등록) | 80 |
| 17 | 백승호 | 010-1000-0017 | 010-1000-0017 | 010-1000-0017 | NULL |
+-------------+-----------+---------------+---------------+---------------+---------------------+
| 함수 | 동작 |
|---|
IFNULL(a, b) | a 가 NULL 이면 b. 인자 2개 고정. MySQL 전용 |
COALESCE(a, b, c, ...) | 왼쪽부터 첫 번째 NULL 아닌 값. 표준 SQL. 인자 개수 무제한 |
NULLIF(a, b) | a = b 이면 NULL, 아니면 a |
NULLIF 가 어색해 보이지만 쓸모가 확실합니다. 0으로 나누기 방지의 정석입니다.
-- price 가 0 이어도 에러/경고 없이 NULL 을 돌려준다
SELECT (price - cost) / NULLIF(price, 0) AS margin_rate FROM products;
💡 실무 팁: IFNULL 보다 COALESCE 를 쓰세요. 표준이라 다른 DB 로 옮길 때 안 깨지고, 나중에 대체값을 하나 더 추가할 때(COALESCE(a, b, '기본값')) 그냥 인자만 붙이면 됩니다.
5-11. 페이징 ① — OFFSET 방식
-- 1페이지
SELECT order_id, order_date, total_amount
FROM orders
ORDER BY order_date DESC, order_id DESC
LIMIT 5 OFFSET 0;
결과
+----------+---------------------+--------------+
| order_id | order_date | total_amount |
+----------+---------------------+--------------+
| 217 | 2025-12-30 13:19:00 | 5378900.00 |
| 434 | 2025-12-29 02:38:00 | 1488000.00 |
| 138 | 2025-12-27 18:06:00 | 158000.00 |
| 355 | 2025-12-26 07:25:00 | 506000.00 |
| 572 | 2025-12-25 20:44:00 | 814000.00 | ← 이 행이 "커서"가 된다
+----------+---------------------+--------------+
-- 2페이지
SELECT order_id, order_date, total_amount
FROM orders
ORDER BY order_date DESC, order_id DESC
LIMIT 5 OFFSET 5;
결과
+----------+---------------------+--------------+
| order_id | order_date | total_amount |
+----------+---------------------+--------------+
| 59 | 2025-12-24 23:53:00 | 5430600.00 |
| 276 | 2025-12-23 12:12:00 | 17800.00 |
| 493 | 2025-12-22 01:31:00 | 178000.00 |
| 197 | 2025-12-20 17:59:00 | 694000.00 |
| 414 | 2025-12-19 06:18:00 | 238000.00 |
+----------+---------------------+--------------+
문제는 OFFSET 이 커질수록 느려진다는 것입니다. LIMIT 5 OFFSET 595 는 595행을 읽어서 버리고 5행을 줍니다.
EXPLAIN SELECT order_id, order_date FROM orders
ORDER BY order_date DESC, order_id DESC LIMIT 5 OFFSET 595;
결과
+----+-------------+--------+------+---------------+------+------+----------------+
| id | select_type | table | type | possible_keys | key | rows | Extra |
+----+-------------+--------+------+---------------+------+------+----------------+
| 1 | SIMPLE | orders | ALL | NULL | NULL | 600 | Using filesort |
+----+-------------+--------+------+---------------+------+------+----------------+
rows = 600, type = ALL(풀스캔), Using filesort. 5건을 얻으려고 600건을 전부 읽고 정렬합니다. 600건짜리 테이블이니 순식간이지만, 1,000만 건 테이블에서 OFFSET 5000000 이면 어떨까요? 그 페이지 하나가 수 초씩 걸립니다.
⚠️ 함정: OFFSET 페이징에는 두 번째 문제도 있습니다. 페이지를 넘기는 사이에 새 행이 들어오면 결과가 밀립니다. 1페이지를 본 뒤 새 주문이 하나 생기면, 2페이지의 첫 행은 1페이지에서 이미 본 행이 됩니다. 사용자는 같은 항목을 두 번 보게 됩니다.
5-12. 페이징 ② — 커서(keyset) 페이징
발상을 바꿉니다. "몇 건을 건너뛸까" 가 아니라 "마지막으로 본 지점 이후" 를 묻습니다.
1페이지의 마지막 행이 (2025-12-25 20:44:00, 572) 였으니, 이것을 커서로 삼아 "이것보다 작은 행" 을 5건 달라고 합니다.
SELECT order_id, order_date, total_amount
FROM orders
WHERE (order_date, order_id) < ('2025-12-25 20:44:00', 572) -- 행 생성자 비교
ORDER BY order_date DESC, order_id DESC
LIMIT 5;
결과
+----------+---------------------+--------------+
| order_id | order_date | total_amount |
+----------+---------------------+--------------+
| 59 | 2025-12-24 23:53:00 | 5430600.00 |
| 276 | 2025-12-23 12:12:00 | 17800.00 |
| 493 | 2025-12-22 01:31:00 | 178000.00 |
| 197 | 2025-12-20 17:59:00 | 694000.00 |
| 414 | 2025-12-19 06:18:00 | 238000.00 |
+----------+---------------------+--------------+
5 rows in set
OFFSET 5 와 결과가 완전히 동일합니다. 하지만 건너뛴 행을 읽지 않습니다. (order_date, order_id) 에 인덱스가 있다면 바로 그 지점으로 점프해서 5건만 읽습니다. 100페이지든 10000페이지든 속도가 같습니다.
(a, b) < (x, y) 는 행 생성자(row constructor) 비교입니다. 사전식(lexicographic) 비교라서, 풀어 쓰면 이렇습니다.
SELECT order_id, order_date, total_amount
FROM orders
WHERE order_date < '2025-12-25 20:44:00'
OR (order_date = '2025-12-25 20:44:00' AND order_id < 572)
ORDER BY order_date DESC, order_id DESC
LIMIT 5;
결과 (위와 동일)
+----------+---------------------+--------------+
| order_id | order_date | total_amount |
+----------+---------------------+--------------+
| 59 | 2025-12-24 23:53:00 | 5430600.00 |
| 276 | 2025-12-23 12:12:00 | 17800.00 |
| 493 | 2025-12-22 01:31:00 | 178000.00 |
| 197 | 2025-12-20 17:59:00 | 694000.00 |
| 414 | 2025-12-19 06:18:00 | 238000.00 |
+----------+---------------------+--------------+
order_date 만으로는 정렬 키가 유일하지 않을 수 있으므로 (같은 시각의 주문이 여럿일 수 있으므로) PK 를 tie-break 로 함께 넣는 것이 핵심입니다. order_id 를 빼면 같은 시각 경계에서 행이 누락되거나 중복됩니다.
💡 실무 팁: 커서 페이징의 트레이드오프
- 장점: 몇 페이지를 가든 O(1). 페이징 도중 데이터가 추가돼도 밀리지 않음.
- 단점: "5페이지로 바로 점프" 가 안 됩니다. 앞/뒤로만 이동 가능.
- 그래서 무한 스크롤 / 더보기 / API 커서에는 커서 페이징이, "1 2 3 ... 10" 페이지 번호 UI 에는 OFFSET 이 맞습니다. 다만 페이지 번호 UI 라도 뒤쪽 페이지는 아무도 안 보므로 최대 페이지 수를 제한하는 것이 현실적인 타협입니다 (구글 검색이 그렇게 합니다).
- 커서는 보통
(order_date, order_id) 를 Base64 로 인코딩해 next_cursor 로 내려줍니다.
5-13. 종합 예제
SELECT
o.order_id, o.order_date, o.status, o.shipping_city, o.total_amount
FROM orders o
WHERE o.order_date >= '2025-07-01' -- 반열림 구간
AND o.order_date < '2026-01-01'
AND o.status <> 'CANCELLED'
AND o.shipping_city IN ('서울', '인천')
AND o.total_amount >= 1000000
ORDER BY o.total_amount DESC, o.order_id
LIMIT 8;
결과
+----------+---------------------+---------+---------------+--------------+
| order_id | order_date | status | shipping_city | total_amount |
+----------+---------------------+---------+---------------+--------------+
| 57 | 2025-10-11 21:39:00 | PENDING | 서울 | 3580000.00 |
| 177 | 2025-12-10 21:39:00 | PENDING | 서울 | 3580000.00 |
| 114 | 2025-07-22 18:18:00 | SHIPPED | 서울 | 2580000.00 |
| 234 | 2025-09-20 18:18:00 | SHIPPED | 서울 | 2580000.00 |
| 354 | 2025-11-19 18:18:00 | SHIPPED | 서울 | 2580000.00 |
| 74 | 2025-07-02 02:38:00 | SHIPPED | 서울 | 1488000.00 |
| 194 | 2025-08-31 02:38:00 | SHIPPED | 서울 | 1488000.00 |
| 314 | 2025-10-30 02:38:00 | SHIPPED | 서울 | 1488000.00 |
+----------+---------------------+---------+---------------+--------------+
8 rows in set
status, shipping_city 모두 NULL 이 불가능한 컬럼(NOT NULL)이라 <> 'CANCELLED' 를 마음 놓고 썼습니다. NULL 가능 컬럼이었다면 OR status IS NULL 을 넣을지 반드시 검토해야 합니다.
정리
| 문법 | 용도 | 주의점 |
|---|
= <> < > <= >= | 비교 | NULL 과 비교하면 결과가 NULL |
AND OR NOT | 논리 결합 | AND 가 OR 보다 우선. OR 엔 괄호 필수 |
BETWEEN x AND y | >= x AND <= y (양끝 포함) | DATETIME 엔 쓰지 말 것 |
>= 시작 AND < 다음시작 | 날짜 범위의 정답 | 반열림 구간. 어떤 정밀도에도 안전 |
IN (a, b, c) | OR 의 축약 | 상수 리스트엔 안전 |
NOT IN (서브쿼리) | 부정 매칭 | ⚠️ NULL 하나면 항상 빈 결과. NOT EXISTS 를 쓸 것 |
IS NULL / IS NOT NULL | NULL 판별 | = NULL 은 절대 안 됨 |
<=> | NULL-safe 등호 | NULL <=> NULL = TRUE. 파라미터가 NULL 일 수 있을 때 |
LIKE '%x%' | 패턴 매칭 | 앞에 % 면 인덱스 못 씀 |
LIKE BINARY | 대소문자 구분 LIKE | |
... ESCAPE '!' | % _ 자체 검색 | |
REGEXP / RLIKE | 정규식 (부분 일치) | 항상 풀스캔. MySQL 8 은 ICU 엔진 |
REGEXP_LIKE/SUBSTR/REPLACE/INSTR | MySQL 8.0 신규 정규식 함수 | BINARY 와 병용 불가 (ERROR 3995) |
COALESCE(a,b,...) | 첫 번째 non-NULL (표준) | IFNULL 보다 권장 |
NULLIF(a,b) | a=b 면 NULL | 0으로 나누기 방지 |
(a,b) < (x,y) | 행 생성자 비교 | 커서 페이징의 핵심 |
3값 논리 한 줄 요약: WHERE 는 TRUE 인 행만 남긴다. FALSE 도 UNKNOWN 도 버린다.
연습문제
products 에서 가격이 5만원 이상 15만원 이하이고 판매중(ON_SALE) 인 상품을 가격 오름차순으로 조회하세요.
customers 에서 서울 또는 부산에 살면서 GOLD 또는 VIP 등급인 고객을 조회하세요. (괄호 위치에 주의!)
orders 에서 2025년 3월 한 달 주문 건수를 세세요. BETWEEN 을 쓰지 말고 반열림 구간으로 쓰세요.
customers 에서 전화번호가 등록되지 않은 고객의 name, city, grade 를 조회하세요. IS NULL 과 <=> 두 가지로 각각 작성해 보세요.
products 에서 상품명에 숫자가 하나도 들어있지 않은 상품을 조회하세요. (힌트: NOT REGEXP 또는 REGEXP '^[^0-9]*$')
reviews 테이블을 한 번도 남기지 않은 고객을 찾으세요. NOT EXISTS 로 작성하고, 같은 것을 NOT IN 으로 썼을 때 왜 위험한지 주석으로 설명하세요.
products 에서 REGEXP_REPLACE 를 써서 상품명의 숫자를 전부 # 로 바꾼 결과를 product_id, name, masked 로 조회하세요. (상품명에 숫자가 있는 것만)
orders 를 total_amount DESC, order_id DESC 로 정렬했을 때, 1페이지(5건)를 구하고, 그 마지막 행을 커서로 삼아 커서 페이징으로 2페이지를 구하세요. LIMIT 5 OFFSET 5 결과와 같은지 확인하세요.
문제만 담긴 파일은 exercise.sql, 정답과 해설은 solution.sql 입니다. 두 파일 모두 아래 실습 파일 섹션에 전문이 있습니다.
다음 단계
→ Step 06 — 집계함수와 GROUP BY
실습 파일
이 스텝은 SQL 파일 3개로 구성됩니다. 먼저 practice.sql 로 본문의 예제를 순서대로 직접 실행해 보며 눈으로 확인하고, 그 다음 exercise.sql 의 빈칸을 채워 연습문제 8개를 풀고, 마지막으로 solution.sql 로 답과 해설을 대조합니다. 세 파일 모두 맨 앞에 USE shop; 이 있으므로 Step 02 에서 띄운 컨테이너의 shop 스키마가 준비되어 있어야 합니다.
practice.sql
본문 5-1 ~ 5-13 의 모든 예제를 절 번호(-- [5-3], -- [5-7] …) 주석과 함께 한 파일에 모아 둔 강의 재현용 스크립트입니다. 파일 상단의 실행 명령 mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < practice.sql 그대로 붙여 넣으면 됩니다. -t 옵션이 결과를 본문과 똑같은 ASCII 표 형태로 그려 주므로, 페이지의 "결과" 블록과 한 줄씩 대조하며 읽기 좋습니다.
이 파일에서 특히 눈여겨볼 대목은 의도적으로 틀린 쿼리와 올바른 쿼리를 나란히 배치해 둔 부분입니다.
-- [5-2] 의 WHERE category_id = 21 OR category_id = 22 AND price < 500000 은 괄호가 없어 AND 가 먼저 묶이는 틀린 쿼리입니다. 바로 아래 괄호를 친 버전과 COUNT(*) 가 5 vs 1 로 갈리는 것을 확인하세요.
-- [5-3] 의 order_date BETWEEN '2025-06-01' AND '2025-06-30' 은 상한이 '2025-06-30 00:00:00' 으로 해석되는 함정 쿼리로, 반열림 구간(>= '2025-06-01' AND < '2025-07-01')보다 1건 적은 23건을 냅니다. 이어지는 쿼리가 사라진 주문 508 (2025-06-30 04:16:00) 을 직접 짚어 줍니다.
-- [5-7] 의 category_id NOT IN (SELECT parent_id FROM categories) 는 서브쿼리에 NULL 이 섞여 항상 0건이 나오는 최악의 함정입니다. 바로 뒤의 해결책 ①(WHERE parent_id IS NOT NULL)·②(NOT EXISTS)와 함께 실행해 12건이 돌아오는 것을 확인하세요.
-- [5-9] 의 주석에 적힌 BINARY 'MySQL' REGEXP 'mysql' 은 MySQL 8 에서 ERROR 3995 가 나기 때문에 실행문이 아니라 주석으로만 남겨 두었습니다. 대신 COLLATE utf8mb4_0900_as_cs 와 REGEXP_LIKE(..., 'c') 를 실행합니다.
모두 SELECT / EXPLAIN 뿐이라 데이터를 변경하지 않으니, 몇 번을 다시 돌려도 안전합니다.
-- =====================================================================
-- Step 05 — 연산자와 조건 : practice.sql
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < practice.sql
-- =====================================================================
USE shop;
-- [5-1] 비교 연산자 6종
SELECT product_id, name, price
FROM products
WHERE price > 1000000;
-- [5-1] 같지 않다: <> (표준) 와 != (MySQL 도 지원) 는 동일
SELECT COUNT(*) AS `<>_결과` FROM products WHERE status <> 'ON_SALE';
SELECT COUNT(*) AS `!=_결과` FROM products WHERE status != 'ON_SALE';
-- [5-1] 비교 결과 자체를 SELECT 해보기 — 1(TRUE) / 0(FALSE) / NULL(UNKNOWN)
SELECT 1 = 1 AS eq, 1 = 2 AS ne, 1 = NULL AS with_null, NULL = NULL AS null_null;
-- [5-2] AND / OR / NOT 과 우선순위
-- AND 가 OR 보다 먼저 묶인다 → 아래 두 쿼리는 결과가 다르다
SELECT COUNT(*) AS 괄호없음
FROM products
WHERE category_id = 21 OR category_id = 22 AND price < 500000;
SELECT COUNT(*) AS 괄호있음
FROM products
WHERE (category_id = 21 OR category_id = 22) AND price < 500000;
-- [5-2] 괄호 없이 쓴 쿼리가 실제로 무엇을 뽑았는지 확인
SELECT product_id, category_id, name, price
FROM products
WHERE category_id = 21 OR category_id = 22 AND price < 500000
ORDER BY category_id, product_id;
-- [5-3] BETWEEN — 양 끝 포함 (>= AND <=)
SELECT product_id, name, price
FROM products
WHERE price BETWEEN 100000 AND 200000
ORDER BY price;
-- [5-3] NOT BETWEEN
SELECT COUNT(*) AS cnt
FROM products
WHERE price NOT BETWEEN 100000 AND 200000;
-- [5-3] 날짜 BETWEEN 의 함정: DATETIME 컬럼에 날짜만 주면 00:00:00 으로 해석된다
-- '2025-06-30' 은 '2025-06-30 00:00:00' 이 되므로,
-- 6월 30일 00:00:01 이후의 주문은 전부 누락된다!
SELECT COUNT(*) AS 잘못된_6월_주문수
FROM orders
WHERE order_date BETWEEN '2025-06-01' AND '2025-06-30';
-- [5-3] 올바른 방법: 반열림 구간 [시작, 다음시작)
SELECT COUNT(*) AS 올바른_6월_주문수
FROM orders
WHERE order_date >= '2025-06-01'
AND order_date < '2025-07-01';
-- [5-3] 누락된 행을 직접 확인 — 6월 30일 04:16 의 주문 1건이 통째로 사라졌다
SELECT order_id, order_date, total_amount
FROM orders
WHERE order_date >= '2025-06-30' AND order_date < '2025-07-01'
ORDER BY order_date;
-- [5-4] IN — OR 의 축약
SELECT product_id, category_id, name
FROM products
WHERE category_id IN (21, 22)
ORDER BY product_id;
-- [5-4] IN 서브쿼리 — "600만원 넘는 주문이 배송된 도시" 에 사는 고객
SELECT customer_id, name, grade, city
FROM customers
WHERE city IN (SELECT shipping_city FROM orders WHERE total_amount > 6000000)
ORDER BY customer_id
LIMIT 5;
-- [5-5] NULL 3값 논리 — 핵심
-- NULL 은 "값이 없음"이 아니라 "값을 모름". 모르는 값끼리는 비교할 수 없다.
SELECT
NULL = NULL AS `NULL = NULL`,
NULL <> NULL AS `NULL <> NULL`,
NULL = 0 AS `NULL = 0`,
NULL + 1 AS `NULL + 1`,
CONCAT('a', NULL) AS `CONCAT(a,NULL)`;
-- [5-5] 그래서 phone = NULL 은 아무 행도 못 찾는다
SELECT COUNT(*) AS `phone = NULL` FROM customers WHERE phone = NULL;
-- [5-5] IS NULL / IS NOT NULL 을 써야 한다
SELECT customer_id, name, phone
FROM customers
WHERE phone IS NULL;
SELECT COUNT(*) AS `phone IS NOT NULL` FROM customers WHERE phone IS NOT NULL;
-- [5-5] WHERE 는 TRUE 만 남긴다 → FALSE 도 UNKNOWN 도 버려진다
-- "phone 이 010-1000-0001 이 아닌 고객" 을 찾으면 NULL 인 3명이 빠진다
SELECT COUNT(*) AS `<> 로 센 결과`
FROM customers
WHERE phone <> '010-1000-0001';
-- [5-5] NULL 도 포함하려면 명시적으로 OR IS NULL
SELECT COUNT(*) AS `NULL 포함`
FROM customers
WHERE phone <> '010-1000-0001' OR phone IS NULL;
-- [5-6] <=> NULL-safe equal : NULL <=> NULL 은 TRUE
SELECT
NULL <=> NULL AS `NULL <=> NULL`,
NULL <=> 1 AS `NULL <=> 1`,
1 <=> 1 AS `1 <=> 1`;
-- [5-6] <=> 로 "phone 이 NULL 인 고객" 찾기 (IS NULL 과 동일)
SELECT customer_id, name, phone
FROM customers
WHERE phone <=> NULL;
-- [5-6] 파라미터가 NULL 일 수도 있는 검색 — <=> 한 방으로 처리
-- (애플리케이션에서 ? 에 값을 바인딩. NULL 이면 NULL 인 행을 찾는다)
SELECT customer_id, name, phone
FROM customers
WHERE phone <=> NULL
LIMIT 3;
-- [5-7] NOT IN + NULL 의 최악의 함정
-- 서브쿼리 결과에 NULL 이 하나라도 있으면 NOT IN 은 항상 빈 결과!
SELECT COUNT(*) AS `NOT IN 결과`
FROM categories
WHERE category_id NOT IN (SELECT parent_id FROM categories);
-- [5-7] 왜? NOT IN (1,2,NULL) 은 "1도 아니고 2도 아니고 NULL도 아니다"
-- → NULL 과의 비교가 UNKNOWN 이라 전체가 절대 TRUE 가 못 된다
SELECT
3 NOT IN (1, 2) AS `3 NOT IN (1,2)`,
3 NOT IN (1, 2, NULL) AS `3 NOT IN (1,2,NULL)`;
-- [5-7] 해결책 ①: 서브쿼리에서 NULL 을 제거
SELECT category_id, name
FROM categories
WHERE category_id NOT IN (
SELECT parent_id FROM categories WHERE parent_id IS NOT NULL
)
ORDER BY category_id
LIMIT 8;
-- [5-7] 해결책 ②: NOT EXISTS (NULL 에 안전하고 보통 더 빠르다)
SELECT c.category_id, c.name
FROM categories c
WHERE NOT EXISTS (
SELECT 1 FROM categories x WHERE x.parent_id = c.category_id
)
ORDER BY c.category_id
LIMIT 8;
-- [5-8] LIKE — % (0자 이상) / _ (정확히 1자)
SELECT product_id, name FROM products WHERE name LIKE '스마트폰%';
SELECT product_id, name FROM products WHERE name LIKE '%노트북%';
SELECT customer_id, email FROM customers WHERE email LIKE '_im%' ORDER BY customer_id;
-- [5-8] LIKE 는 콜레이션을 따른다. utf8mb4_0900_ai_ci 는 대소문자 구분 안 함
SELECT product_id, name FROM products WHERE name LIKE '%mysql%';
-- [5-8] 대소문자를 구분하고 싶다면 BINARY 또는 명시적 콜레이션
SELECT product_id, name FROM products WHERE name LIKE BINARY '%mysql%';
SELECT product_id, name FROM products WHERE name LIKE BINARY '%MySQL%';
-- [5-8] % 자체를 찾고 싶을 때 — ESCAPE
SELECT product_id, name FROM products WHERE name LIKE '%\%%';
SELECT product_id, name FROM products WHERE name LIKE '%!%%' ESCAPE '!';
-- [5-9] REGEXP / RLIKE — 정규식
-- MySQL 8.0 부터 정규식 엔진이 ICU 로 교체되었습니다 (기존 Henry Spencer).
SELECT product_id, name
FROM products
WHERE name REGEXP '노트북|스마트폰'
ORDER BY product_id;
-- [5-9] 앵커(^ $) 와 문자 클래스
SELECT product_id, name FROM products WHERE name REGEXP '^[0-9]'; -- 숫자로 시작
SELECT product_id, name FROM products WHERE name REGEXP '[0-9]+GB$'; -- GB 로 끝
-- [5-9] MySQL 8 신규 정규식 함수들
SELECT
REGEXP_LIKE('kim.minsu@example.com', '^[a-z.]+@example\\.com$') AS is_valid,
REGEXP_SUBSTR('스마트폰 X20 Pro 512GB', '[0-9]+GB') AS storage,
REGEXP_REPLACE('010-1000-0001', '[0-9]{4}$', '****') AS masked,
REGEXP_INSTR('실전 MySQL 8', 'MySQL') AS pos;
-- [5-9] REGEXP_SUBSTR 로 상품명에서 용량 추출
SELECT product_id, name,
REGEXP_SUBSTR(name, '[0-9]+(GB|TB)') AS capacity
FROM products
WHERE name REGEXP '[0-9]+(GB|TB)'
ORDER BY product_id;
-- [5-9] REGEXP 도 콜레이션을 따라 대소문자를 무시한다.
-- MySQL 8 에서는 BINARY 'MySQL' REGEXP 'mysql' 이 에러가 난다 (ICU 엔진).
-- ERROR 3995: Character set 'binary' cannot be used in conjunction with
-- 'utf8mb4_0900_ai_ci' in call to regexp_like.
-- 대신 COLLATE 를 쓰거나 REGEXP_LIKE 의 match_type 'c' 를 쓴다.
SELECT
'MySQL' REGEXP 'mysql' AS ci,
'MySQL' COLLATE utf8mb4_0900_as_cs REGEXP 'mysql' AS cs_collate,
REGEXP_LIKE('MySQL', 'mysql', 'c') AS cs_flag,
REGEXP_LIKE('MySQL', 'mysql', 'i') AS ci_flag;
-- [5-10] NULL 다루는 함수들
SELECT
customer_id,
name,
phone,
IFNULL(phone, '(미등록)') AS ifnull_ex,
COALESCE(phone, '(미등록)') AS coalesce_ex,
NULLIF(points, 0) AS points_0은_null로
FROM customers
WHERE customer_id IN (1, 7, 14, 17)
ORDER BY customer_id;
-- [5-11] 페이징 ① OFFSET 방식 (직관적이지만 뒤로 갈수록 느리다)
SELECT order_id, order_date, total_amount
FROM orders
ORDER BY order_date DESC, order_id DESC
LIMIT 5 OFFSET 0;
SELECT order_id, order_date, total_amount
FROM orders
ORDER BY order_date DESC, order_id DESC
LIMIT 5 OFFSET 5;
-- [5-11] 깊은 OFFSET 은 앞의 595건을 전부 읽고 버린다
EXPLAIN SELECT order_id, order_date FROM orders
ORDER BY order_date DESC, order_id DESC LIMIT 5 OFFSET 595;
-- [5-12] 페이징 ② 커서(keyset) 페이징
-- 1페이지 마지막 행의 (order_date, order_id) 를 커서로 삼는다.
-- 위 1페이지 마지막 행은 (2025-12-25 20:44:00, 572) 였다.
-- → 결과는 OFFSET 5 와 완전히 동일하지만, 앞의 5건을 읽지 않는다.
SELECT order_id, order_date, total_amount
FROM orders
WHERE (order_date, order_id) < ('2025-12-25 20:44:00', 572)
ORDER BY order_date DESC, order_id DESC
LIMIT 5;
-- [5-12] 행 생성자(row constructor)를 안 쓰고 풀어 쓰면 이렇게 된다 (동일한 결과)
SELECT order_id, order_date, total_amount
FROM orders
WHERE order_date < '2025-12-25 20:44:00'
OR (order_date = '2025-12-25 20:44:00' AND order_id < 572)
ORDER BY order_date DESC, order_id DESC
LIMIT 5;
-- [5-13] 종합: "2025년 하반기 / 취소 아님 / 서울·인천 배송 / 100만원 이상" 주문
SELECT
o.order_id,
o.order_date,
o.status,
o.shipping_city,
o.total_amount
FROM orders o
WHERE o.order_date >= '2025-07-01'
AND o.order_date < '2026-01-01'
AND o.status <> 'CANCELLED'
AND o.shipping_city IN ('서울', '인천')
AND o.total_amount >= 1000000
ORDER BY o.total_amount DESC, o.order_id
LIMIT 8;
exercise.sql
본문 "연습문제" 8개를 문제 설명 주석만 남기고 답을 비워 둔 파일입니다. 각 문제 블록 아래 빈 줄에 직접 SQL 을 써 넣은 뒤 mysql ... shop -t < exercise.sql 로 실행하면 자신이 쓴 쿼리만 실행됩니다. 지금 상태로 그대로 실행하면 USE shop; 만 수행되고 아무 결과도 나오지 않는 것이 정상입니다.
문제는 이 스텝의 함정을 하나씩 되짚도록 배치되어 있습니다.
- 문제 2 는 "서울/부산" 과 "GOLD/VIP" 두 OR 덩어리를 AND 로 묶는 것이라 5-2 의 괄호 함정을 그대로 요구합니다.
- 문제 3 은
BETWEEN 금지 조건을 걸어 5-3 의 반열림 구간을 강제합니다.
- 문제 6 의 주석은 "
reviews.customer_id 는 NOT NULL 인데도 왜 NOT EXISTS 를 권하는지?" 라고 한 걸음 더 파고듭니다 — 답은 solution.sql 에 있습니다.
-- =====================================================================
-- Step 05 — 연산자와 조건 : exercise.sql (문제)
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < exercise.sql
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [문제 1] 가격대 + 상태 필터
-- products 에서 가격이 50,000 이상 150,000 이하이고
-- status 가 'ON_SALE' 인 상품을 가격 오름차순으로 조회하세요.
-- (product_id, name, price, status)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 2] 괄호 함정 피하기
-- customers 에서 "서울 또는 부산에 살면서" "GOLD 또는 VIP 등급"인
-- 고객을 조회하세요. (customer_id, name, grade, city)
-- ※ 괄호를 어디에 쳐야 할지 반드시 생각하고 쓰세요.
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 3] 날짜 범위는 반열림 구간으로
-- orders 에서 2025년 3월 한 달 동안의 주문 건수를 세세요.
-- BETWEEN 을 쓰지 말고 >= / < 로 작성하세요.
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 4] NULL 찾기 (두 가지 방법)
-- customers 에서 전화번호가 등록되지 않은 고객의
-- name, city, grade 를 조회하세요.
-- (a) IS NULL 로
-- (b) <=> 로
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 5] 숫자가 없는 상품명
-- products 에서 상품명에 숫자가 하나도 들어있지 않은 상품을 조회하세요.
-- 힌트: NOT REGEXP '[0-9]' 또는 REGEXP '^[^0-9]*$'
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 6] 안티조인 (NOT EXISTS)
-- 후기(reviews)를 한 번도 남기지 않은 고객을 찾으세요.
-- - NOT EXISTS 로 작성하세요.
-- - 같은 것을 NOT IN 으로 썼을 때 왜 위험한지 주석으로 설명하세요.
-- (reviews.customer_id 는 NOT NULL 인데도 왜 NOT EXISTS 를 권하는지?)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 7] REGEXP_REPLACE 로 마스킹
-- products 에서 상품명의 숫자를 전부 '#' 로 바꾼 결과를
-- product_id, name, masked 로 조회하세요. (숫자가 있는 상품만)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 8] 커서 페이징
-- orders 를 total_amount DESC, order_id DESC 로 정렬합니다.
-- (a) 1페이지 5건을 구하세요.
-- (b) (a) 의 마지막 행을 커서로 삼아, 커서 페이징으로 2페이지를 구하세요.
-- (c) LIMIT 5 OFFSET 5 로 구한 2페이지와 결과가 같은지 확인하세요.
-- ---------------------------------------------------------------------
solution.sql
exercise.sql 8문제의 정답 쿼리와 해설 주석입니다. 답만 있는 게 아니라 각 정답 위 주석에 기대 결과 건수(정답 1 은 10건, 정답 3 은 26건, 정답 6 은 26명)까지 적혀 있어 자기 답을 채점할 수 있습니다. 먼저 스스로 풀어 본 뒤에 열어 보세요.
해설에서 놓치면 아까운 대목입니다.
- 정답 2 는 괄호 버전과
IN 버전을 둘 다 보여 주며, IN 을 쓰면 괄호 실수 자체가 불가능해진다는 이유로 IN 쪽을 권합니다.
- 정답 6 은
reviews.customer_id 가 NOT NULL 이라 NOT IN 도 "지금은" 동작하지만, 스키마가 바뀌거나 서브쿼리가 LEFT JOIN/UNION 으로 복잡해지는 순간 조용히 0건이 된다는 점을 세 가지로 정리합니다.
- 정답 7 은
REGEXP_REPLACE(name, '[0-9]', '#') 과 '[0-9]+' 의 차이(256GB → ###GB vs #GB)를 짚습니다. 문제의 의도는 한 글자씩 바꾸는 전자입니다.
- 정답 8 은 본문 5-12 와 달리 정렬 키를
total_amount DESC, order_id DESC 로 잡는데, 1페이지 5건의 total_amount 가 6663900.00 으로 전부 동점입니다. 그래서 커서를 WHERE total_amount < 6663900 으로만 잡으면 동점인 5건이 통째로 날아가고, (total_amount, order_id) < (6663900.00, 2) 처럼 PK 를 tie-break 로 함께 넣어야 한다는 것이 눈으로 증명됩니다.
-- =====================================================================
-- Step 05 — 연산자와 조건 : solution.sql (정답 + 해설)
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < solution.sql
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [정답 1] 가격대 + 상태 필터
-- BETWEEN 은 양 끝을 포함합니다. 숫자 범위에는 안심하고 써도 됩니다.
-- (문제가 되는 건 DATETIME 뿐입니다 — 정답 3 참고)
-- 결과: 10건 (플리츠 롱스커트 59000 ~ 러닝화 에어플로우 139000)
-- ---------------------------------------------------------------------
SELECT product_id, name, price, status
FROM products
WHERE price BETWEEN 50000 AND 150000
AND status = 'ON_SALE'
ORDER BY price ASC;
-- ---------------------------------------------------------------------
-- [정답 2] 괄호 함정 피하기
-- OR 조건 두 덩어리를 AND 로 묶으려면 각각을 괄호로 싸야 합니다.
-- 괄호를 빼면 AND 가 먼저 묶여서
-- city='서울' OR (city='부산' AND grade='GOLD') OR grade='VIP'
-- 가 되어 "서울 사는 BRONZE" 까지 딸려옵니다.
--
-- IN 을 쓰면 괄호 실수 자체가 불가능해집니다 — 이쪽을 권합니다.
-- 결과: 9명 (서울 8명 + 부산 1명(하준서))
-- ---------------------------------------------------------------------
SELECT customer_id, name, grade, city
FROM customers
WHERE city IN ('서울', '부산')
AND grade IN ('GOLD', 'VIP')
ORDER BY customer_id;
-- (괄호로 쓴 동일한 쿼리)
SELECT customer_id, name, grade, city
FROM customers
WHERE (city = '서울' OR city = '부산')
AND (grade = 'GOLD' OR grade = 'VIP')
ORDER BY customer_id;
-- ---------------------------------------------------------------------
-- [정답 3] 날짜 범위는 반열림 구간으로
-- order_date 는 DATETIME 입니다.
-- BETWEEN '2025-03-01' AND '2025-03-31' 로 쓰면
-- '2025-03-31 00:00:00' 이 상한이 되어 3월 31일 낮의 주문이 전부 누락됩니다.
-- >= 시작 AND < 다음달1일 이 어떤 시간 정밀도에도 안전한 정답입니다.
-- 결과: 26건
-- ---------------------------------------------------------------------
SELECT COUNT(*) AS 주문건수_2025_03
FROM orders
WHERE order_date >= '2025-03-01'
AND order_date < '2025-04-01';
-- ---------------------------------------------------------------------
-- [정답 4] NULL 찾기 (두 가지 방법)
-- phone = NULL 은 절대 안 됩니다 (항상 UNKNOWN → 0건).
-- 결과: 윤대현(광주) / 남규리(울산) / 심준호(대구)
--
-- (a) IS NULL — 가장 표준적이고 읽기 쉬운 방법
-- ---------------------------------------------------------------------
SELECT name, city, grade
FROM customers
WHERE phone IS NULL;
-- ---------------------------------------------------------------------
-- (b) <=> — NULL-safe 등호. NULL <=> NULL 이 TRUE 라서 동작합니다.
-- 단순히 NULL 을 찾는 목적이라면 IS NULL 이 더 명확합니다.
-- <=> 의 진가는 비교 대상이 "값일 수도, NULL 일 수도 있는 파라미터"
-- 일 때 나옵니다: WHERE phone <=> ?
-- ---------------------------------------------------------------------
SELECT name, city, grade
FROM customers
WHERE phone <=> NULL;
-- ---------------------------------------------------------------------
-- [정답 5] 숫자가 없는 상품명
-- NOT REGEXP '[0-9]' = "숫자가 한 개도 매칭되지 않는다"
-- 결과: 17건 (의류/신발 대부분, 무선 마우스 프로, 노이즈캔슬링 헤드폰 등)
-- "27인치 4K 모니터", "6단 책장", "실전 MySQL 8" 등은 숫자가 있어 제외됩니다.
-- ---------------------------------------------------------------------
SELECT product_id, name
FROM products
WHERE name NOT REGEXP '[0-9]'
ORDER BY product_id;
-- (동일한 결과 — 전체가 "숫자 아닌 문자" 로만 이루어졌다는 표현)
SELECT product_id, name
FROM products
WHERE name REGEXP '^[^0-9]*$'
ORDER BY product_id;
-- ---------------------------------------------------------------------
-- [정답 6] 안티조인 (NOT EXISTS)
-- 후기를 한 번도 안 남긴 고객 = 26명 (전체 30명 중 4명만 후기를 남겼습니다)
--
-- [NOT IN 이 위험한 이유]
-- 지금 reviews.customer_id 는 NOT NULL 이므로
-- WHERE customer_id NOT IN (SELECT customer_id FROM reviews)
-- 도 "현재는" 정상 동작합니다. 그런데도 NOT EXISTS 를 권하는 이유:
--
-- 1) NOT IN 의 정확성이 "서브쿼리 컬럼이 NOT NULL 인가"에 의존합니다.
-- 누군가 스키마를 바꿔 NULL 을 허용하는 순간, 이 쿼리는 에러 없이
-- 조용히 0건을 반환하기 시작합니다. 배치가 아무 일도 안 하는데
-- 로그에는 아무 흔적도 안 남습니다. 최악의 버그입니다.
-- 2) 서브쿼리가 조인/UNION 등으로 복잡해지면 NOT NULL 보장이 쉽게 깨집니다.
-- (예: LEFT JOIN 결과 컬럼은 NOT NULL 컬럼이어도 NULL 이 될 수 있습니다)
-- 3) NOT EXISTS 는 애초에 "매칭되는 행이 있는가"만 보므로 NULL 과 무관하게
-- 항상 옳습니다. 옵티마이저도 안티조인으로 잘 풀어냅니다.
--
-- → 결론: NOT IN 은 상수 리스트에만. 서브쿼리에는 NOT EXISTS.
-- ---------------------------------------------------------------------
SELECT c.customer_id, c.name, c.grade, c.city
FROM customers c
WHERE NOT EXISTS (
SELECT 1
FROM reviews r
WHERE r.customer_id = c.customer_id
)
ORDER BY c.customer_id;
-- ---------------------------------------------------------------------
-- [정답 7] REGEXP_REPLACE 로 마스킹
-- REGEXP_REPLACE(대상, 패턴, 치환문자열) — MySQL 8.0 신규 함수입니다.
-- [0-9] 로 쓰면 숫자 "한 글자씩" 치환되므로 256GB → ###GB 가 됩니다.
-- [0-9]+ 로 쓰면 연속된 숫자 덩어리가 하나의 # 이 되어 256GB → #GB 입니다.
-- 문제의 의도("숫자를 전부 # 로")는 전자입니다.
-- ---------------------------------------------------------------------
SELECT
product_id,
name,
REGEXP_REPLACE(name, '[0-9]', '#') AS masked
FROM products
WHERE name REGEXP '[0-9]'
ORDER BY product_id;
-- ---------------------------------------------------------------------
-- [정답 8] 커서 페이징
-- (a) 1페이지 — total_amount 가 6,663,900 으로 5건이 전부 동점입니다!
-- order_id DESC 가 tie-break 를 해주지 않으면 순서가 뒤죽박죽이 되고
-- 페이징이 깨집니다. 이 문제가 tie-break 의 중요성을 잘 보여줍니다.
-- 결과: 482 / 362 / 242 / 122 / 2 (전부 6663900.00)
-- ---------------------------------------------------------------------
SELECT order_id, total_amount
FROM orders
ORDER BY total_amount DESC, order_id DESC
LIMIT 5;
-- ---------------------------------------------------------------------
-- (b) 커서 페이징 — 1페이지 마지막 행 (6663900.00, 2) 를 커서로 삼습니다.
-- 행 생성자 비교 (a,b) < (x,y) 는 사전식 비교입니다:
-- total_amount 가 더 작거나,
-- total_amount 가 같으면서 order_id 가 더 작은 행
-- total_amount 만으로 커서를 잡았다면(WHERE total_amount < 6663900)
-- 동점인 5건이 통째로 사라졌을 겁니다. order_id 가 반드시 필요합니다.
-- 결과: 520 / 400 / 280 / 160 / 40 (전부 6599000.00)
-- ---------------------------------------------------------------------
SELECT order_id, total_amount
FROM orders
WHERE (total_amount, order_id) < (6663900.00, 2)
ORDER BY total_amount DESC, order_id DESC
LIMIT 5;
-- ---------------------------------------------------------------------
-- (c) OFFSET 방식 2페이지 — (b) 와 결과가 완전히 동일합니다.
-- 차이는 성능입니다. OFFSET 은 앞의 5건을 읽고 버리지만,
-- 커서 방식은 인덱스로 바로 그 지점에 점프합니다.
-- 600건에선 체감이 없지만 1000만 건에선 하늘과 땅 차이입니다.
-- ---------------------------------------------------------------------
SELECT order_id, total_amount
FROM orders
ORDER BY total_amount DESC, order_id DESC
LIMIT 5 OFFSET 5;