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 과 우선순위

ANDOR 보다 먼저 묶입니다. 산술에서 *+ 보다 먼저인 것과 같습니다. 이걸 잊으면 조용히 틀린 답을 얻습니다.

"노트북(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 ya >= 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_dateDATETIME 입니다. 그런데 '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 의 축약

INOR 를 짧게 쓴 것입니다. 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 <=> NULL1(TRUE) 입니다. 그래서 WHERE phone <=> NULLWHERE 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 NULLStep 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 NULLNULL 판별= NULL 은 절대 안 됨
<=>NULL-safe 등호NULL <=> NULL = TRUE. 파라미터가 NULL 일 수 있을 때
LIKE '%x%'패턴 매칭앞에 %인덱스 못 씀
LIKE BINARY대소문자 구분 LIKE
... ESCAPE '!'% _ 자체 검색
REGEXP / RLIKE정규식 (부분 일치)항상 풀스캔. MySQL 8 은 ICU 엔진
REGEXP_LIKE/SUBSTR/REPLACE/INSTRMySQL 8.0 신규 정규식 함수BINARY 와 병용 불가 (ERROR 3995)
COALESCE(a,b,...)첫 번째 non-NULL (표준)IFNULL 보다 권장
NULLIF(a,b)a=b 면 NULL0으로 나누기 방지
(a,b) < (x,y)행 생성자 비교커서 페이징의 핵심

3값 논리 한 줄 요약: WHERETRUE 인 행만 남긴다. FALSE 도 UNKNOWN 도 버린다.


연습문제

  1. products 에서 가격이 5만원 이상 15만원 이하이고 판매중(ON_SALE) 인 상품을 가격 오름차순으로 조회하세요.
  2. customers 에서 서울 또는 부산에 살면서 GOLD 또는 VIP 등급인 고객을 조회하세요. (괄호 위치에 주의!)
  3. orders 에서 2025년 3월 한 달 주문 건수를 세세요. BETWEEN 을 쓰지 말고 반열림 구간으로 쓰세요.
  4. customers 에서 전화번호가 등록되지 않은 고객의 name, city, grade 를 조회하세요. IS NULL<=> 두 가지로 각각 작성해 보세요.
  5. products 에서 상품명에 숫자가 하나도 들어있지 않은 상품을 조회하세요. (힌트: NOT REGEXP 또는 REGEXP '^[^0-9]*$')
  6. reviews 테이블을 한 번도 남기지 않은 고객을 찾으세요. NOT EXISTS 로 작성하고, 같은 것을 NOT IN 으로 썼을 때 왜 위험한지 주석으로 설명하세요.
  7. products 에서 REGEXP_REPLACE 를 써서 상품명의 숫자를 전부 # 로 바꾼 결과를 product_id, name, masked 로 조회하세요. (상품명에 숫자가 있는 것만)
  8. orderstotal_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_csREGEXP_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 의 괄호 함정을 그대로 요구합니다.
  • 문제 3BETWEEN 금지 조건을 걸어 5-3 의 반열림 구간을 강제합니다.
  • 문제 6 의 주석은 "reviews.customer_idNOT 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 쪽을 권합니다.
  • 정답 6reviews.customer_idNOT NULL 이라 NOT IN 도 "지금은" 동작하지만, 스키마가 바뀌거나 서브쿼리가 LEFT JOIN/UNION 으로 복잡해지는 순간 조용히 0건이 된다는 점을 세 가지로 정리합니다.
  • 정답 7REGEXP_REPLACE(name, '[0-9]', '#')'[0-9]+' 의 차이(256GB###GB vs #GB)를 짚습니다. 문제의 의도는 한 글자씩 바꾸는 전자입니다.
  • 정답 8 은 본문 5-12 와 달리 정렬 키를 total_amount DESC, order_id DESC 로 잡는데, 1페이지 5건의 total_amount6663900.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;