Step 03 — 예제 데이터베이스 구축과 탐색

학습 목표

  • 이 코스 전체에서 쓸 shop 데이터베이스를 구축한다
  • 테이블 8개의 관계(ER)를 머릿속에 넣는다 — 이걸 알아야 이후 모든 스텝이 쉬워진다
  • 처음 보는 DB를 만났을 때 구조를 파악하는 방법을 익힌다
  • 언제든 초기 상태로 되돌리는 법을 안다

선행 스텝: Step 02 예상 소요: 40분


3-1. 한 번에 설치하기

cd mysql8/sql
./install.sh          # 스키마 + 데이터 (5초)
./install.sh --big    # + 100만 행 access_logs (Step 15부터 필요, 30초~1분)

결과

▶ MySQL 접속 확인 (127.0.0.1:3307)
mysql_version
8.0.46
▶ 01_schema.sql
▶ 02_seed_master.sql
msg                     categories  customers  products  employees  tally
02_seed_master.sql 완료         17         30        40         18  10000
▶ 03_seed_orders.sql
msg                     orders  order_items  payments  reviews
03_seed_orders.sql 완료     600         1200       540       80
status     cnt  sum_amount
DELIVERED  240  326,280,000
SHIPPED    120  162,834,000
PAID       120  132,630,000
CANCELLED   60   47,742,000
PENDING     60   95,112,000
✅ 완료

언제든 이 스크립트를 다시 돌리면 완전한 초기 상태로 돌아갑니다. 실습하다 데이터를 망가뜨렸다면 주저 말고 다시 실행하세요.

왜 데이터가 항상 똑같은가

시드 스크립트 03_seed_orders.sql(mysql8/sql 디렉터리)은 RAND()를 쓰지 않습니다. 대신 나머지 연산(%) 으로 값을 만듭니다.

-- 주문 600건을 만드는 핵심 부분
SELECT
    t.n                                   AS order_id,
    c.customer_id,                        -- customer_id = 1 + (n * 17) % 30
    DATE_ADD('2024-01-01', INTERVAL (t.n * 37) % 730 DAY)  AS order_date,
    ELT(1 + (t.n * 7) % 10,               -- 상태를 10개 슬롯에 분배
        'DELIVERED','DELIVERED','PAID','SHIPPED','DELIVERED',
        'CANCELLED','PAID','DELIVERED','SHIPPED','PENDING') AS status
FROM tally t
JOIN customers c ON c.customer_id = 1 + (t.n * 17) % 30
WHERE t.n <= 600;

덕분에 여러분 화면의 결과가 이 교재의 결과와 정확히 일치합니다. 숫자가 다르면 뭔가 잘못된 것이니 바로 알아챌 수 있습니다.

💡 실무 팁: tally(1~10000 숫자 테이블)를 JOIN해서 행을 불려 데이터를 만드는 이 기법은 실무에서도 매우 유용합니다. 성능 테스트용 더미 데이터, 날짜 구간 채우기(Step 09), 리포트의 빈 칸 메우기에 두루 씁니다.


3-2. shop 데이터베이스 구조

가상의 온라인 쇼핑몰입니다.

                     ┌──────────────┐
                     │  categories  │◄─┐ parent_id (자기참조: 대분류 ─ 소분류)
                     └──────┬───────┘  │
                            │ 1        └──┘

                            │ N
   ┌────────────┐    ┌──────▼───────┐    ┌──────────────┐
   │ customers  │    │   products   │    │  employees   │◄─┐ manager_id
   └─────┬──────┘    └──────┬───────┘    └──────────────┘  │ (자기참조: 조직도)
         │ 1                │ 1                            └──┘
         │                  │                          (주문과 무관. 계층 쿼리 연습용)
         │ N                │ N
   ┌─────▼──────┐    ┌──────▼───────┐
   │   orders   │───►│ order_items  │
   └─────┬──────┘ 1:N└──────────────┘
         │ 1
         │ N
   ┌─────▼──────┐         ┌──────────────┐
   │  payments  │         │   reviews    │──► products, customers
   └────────────┘         └──────────────┘

테이블 한눈에

테이블행 수설명이 테이블로 배우는 것
categories17카테고리 (대분류 5 + 소분류 12)자기참조, 재귀 CTE
customers30고객 (등급/도시/포인트)GROUP BY, 세그먼트 분석
products40상품 (가격/재고/JSON 속성)JSON, 집계
orders600주문 (2024-01-02 ~ 2025-12-30)JOIN, 윈도우 함수, 시계열
order_items1,200주문 상세 (주문당 1~3개)다대다 해소, 집계
payments540결제 (PENDING 주문엔 없음)LEFT JOIN, 안티 조인
reviews80후기 (일부 배송완료 주문만)평점 집계, 상관 서브쿼리
employees18사원 (4단계 조직도)SELF JOIN, 재귀 CTE
tally10,000숫자 1~10000데이터 생성, 구간 채우기
access_logs1,000,000접근 로그 (인덱스 없음)인덱스, EXPLAIN, 파티셔닝

데이터를 이렇게 설계한 이유 (중요)

이 데이터에는 일부러 심어둔 함정들이 있습니다. 나중에 각 스텝에서 재료로 씁니다.

심어둔 것어디서 쓰나
customers.phoneNULL인 고객 3명 (윤대현, 남규리, 심준호)Step 05 — NULL 3값 논리, NOT IN 함정
PENDING 주문 60건엔 결제가 없음Step 07/08 — LEFT JOIN ... IS NULL, NOT EXISTS
후기가 하나도 없는 상품 24개Step 07/08 — 안티 조인, NOT EXISTS
products.attrsNULL인 상품 2개 (USB-C 허브, 프리미엄 라면)Step 18 — JSON 함수의 NULL 처리
products.statusSOLD_OUT 2개, HIDDEN 1개Step 06 — 필터링이 집계에 미치는 영향
orders.status가 5종류로 비율까지 고정Step 06 — GROUP BY, 조건부 집계
access_logs인덱스가 하나도 없음Step 15/16 — 인덱스를 직접 붙이며 효과 실측
카테고리·사원의 자기참조 계층Step 09 — 재귀 CTE

반대로, 모든 고객은 최소 1건 이상 주문했습니다. (customer_id = 1 + (n * 17) % 30 이 30명을 고르게 순환하므로) 그래서 "주문이 없는 고객 찾기" 안티 조인은 0건을 반환합니다 — 이것도 좋은 학습 재료입니다. 안티 조인이 빈 결과를 냈을 때 "쿼리가 틀렸나?"가 아니라 "정말 없구나"를 구분할 수 있어야 하니까요.


3-3. 데이터 둘러보기

USE shop;

SELECT * FROM orders ORDER BY order_id LIMIT 5;

결과

+----------+-------------+---------------------+-----------+--------------+---------------+
| order_id | customer_id | order_date          | status    | total_amount | shipping_city |
+----------+-------------+---------------------+-----------+--------------+---------------+
|        1 |          18 | 2024-02-07 13:07:00 | DELIVERED |   1836000.00 | 인천          |
|        2 |           5 | 2024-03-15 02:14:00 | DELIVERED |   6663900.00 | 인천          |
|        3 |          22 | 2024-04-21 15:21:00 | DELIVERED |    658000.00 | 서울          |
|        4 |           9 | 2024-05-28 04:28:00 | SHIPPED   |    837000.00 | 부산          |
|        5 |          26 | 2024-07-04 17:35:00 | CANCELLED |   1194000.00 | 인천          |
+----------+-------------+---------------------+-----------+--------------+---------------+

주문 1건이 상세 여러 줄로 갈라집니다.

SELECT * FROM order_items ORDER BY order_item_id LIMIT 5;

결과

+---------------+----------+------------+----------+------------+
| order_item_id | order_id | product_id | quantity | unit_price |
+---------------+----------+------------+----------+------------+
|             1 |        1 |         21 |        3 |  459000.00 |
|             2 |        1 |         34 |        1 |  459000.00 |   ← order 1 은 상품 2개
|             3 |        2 |         28 |        1 |   15900.00 |
|             4 |        2 |          1 |        2 |   39000.00 |
|             5 |        2 |         14 |        3 | 2190000.00 |   ← order 2 는 상품 3개
+---------------+----------+------------+----------+------------+

💡 unit_price가 따로 있나? products.price를 조인해서 쓰면 될 것 같지만 안 됩니다. 상품 가격은 바뀝니다. 작년에 39,000원에 팔린 셔츠가 오늘 29,000원이라고 해서 작년 매출이 줄어들면 안 되죠. 그래서 주문 시점의 가격을 스냅샷으로 박아둡니다. 실무 주문 테이블은 상품명까지 복사해 두는 경우가 많습니다. 이것은 "중복"이 아니라 의도된 반정규화입니다(Step 13).


3-4. 처음 보는 DB의 구조를 파악하는 법

실무에서 남이 만든 DB를 인계받았을 때 쓰는 순서입니다.

① 테이블 목록과 크기

SELECT table_name,
       table_rows AS approx_rows,
       ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
       table_comment
FROM information_schema.tables
WHERE table_schema = 'shop' AND table_type = 'BASE TABLE'
ORDER BY (data_length + index_length) DESC;

큰 테이블부터 보세요. 데이터가 몰려 있는 곳이 그 서비스의 핵심입니다.

② 외래키 관계 — ER 다이어그램을 SQL로 뽑기

SELECT
  table_name           AS `자식 테이블`,
  column_name          AS `자식 컬럼`,
  referenced_table_name  AS `부모 테이블`,
  referenced_column_name AS `부모 컬럼`
FROM information_schema.key_column_usage
WHERE table_schema = 'shop'
  AND referenced_table_name IS NOT NULL
ORDER BY table_name;

결과

+-------------+-------------+------------+-------------+
| 자식 테이블 | 자식 컬럼   | 부모 테이블| 부모 컬럼   |
+-------------+-------------+------------+-------------+
| categories  | parent_id   | categories | category_id |   ← 자기참조
| employees   | manager_id  | employees  | employee_id |   ← 자기참조
| order_items | order_id    | orders     | order_id    |
| order_items | product_id  | products   | product_id  |
| orders      | customer_id | customers  | customer_id |
| payments    | order_id    | orders     | order_id    |
| products    | category_id | categories | category_id |
| reviews     | customer_id | customers  | customer_id |
| reviews     | product_id  | products   | product_id  |
+-------------+-------------+------------+-------------+

이 쿼리 하나로 ER 다이어그램이 나옵니다. 처음 보는 DB에서 가장 먼저 실행하세요.

⚠️ 함정: 외래키를 선언하지 않은 프로젝트가 실무에 흔합니다(성능 우려나 ORM 관행 때문). 그러면 이 쿼리는 아무것도 반환하지 않고, 관계는 컬럼 이름(xxx_id)으로 추측하는 수밖에 없습니다. 그게 얼마나 괴로운지 겪어보면 FK를 선언하게 됩니다.

③ 각 테이블이 실제로 어떤 값을 담고 있나

-- 주문 상태 분포
SELECT status, COUNT(*) AS cnt,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM orders GROUP BY status ORDER BY cnt DESC;

결과

+-----------+-----+------+
| status    | cnt | pct  |
+-----------+-----+------+
| DELIVERED | 240 | 40.0 |
| SHIPPED   | 120 | 20.0 |
| PAID      | 120 | 20.0 |
| CANCELLED |  60 | 10.0 |
| PENDING   |  60 | 10.0 |
+-----------+-----+------+

(OVER ()는 윈도우 함수입니다. Step 17에서 배웁니다. 지금은 "전체 합계로 나눠 비율을 냈다"고만 이해하세요.)

-- 데이터가 언제부터 언제까지 있나
SELECT MIN(order_date) AS first_order, MAX(order_date) AS last_order FROM orders;

결과

+---------------------+---------------------+
| first_order         | last_order          |
+---------------------+---------------------+
| 2024-01-02 21:51:00 | 2025-12-30 13:19:00 |
+---------------------+---------------------+

💡 실무 팁: 새 DB를 받으면 주요 테이블의 시간 범위를 꼭 확인하세요. "데이터가 3개월 전에 멈춰 있다"는 사실을 리포트 다 만들고 나서 발견하는 일이 정말 많습니다.


3-5. 데이터 정합성 검증

시드가 제대로 들어갔는지 확인하는 쿼리들입니다. 동시에 좋은 검증 쿼리가 어떤 모양인지 배우는 예제이기도 합니다.

-- 1) orders.total_amount 가 order_items 합계와 일치하는가?
SELECT COUNT(*) AS mismatched_orders
FROM orders o
WHERE o.total_amount <> (
    SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)
    FROM order_items oi WHERE oi.order_id = o.order_id
);

결과

+-------------------+
| mismatched_orders |
+-------------------+
|                 0 |
+-------------------+

0이어야 합니다. 0이 아니면 데이터가 깨진 것입니다.

-- 2) 결제가 없는 주문은 정말 PENDING 뿐인가?
SELECT o.status, COUNT(*) AS orders_without_payment
FROM orders o
LEFT JOIN payments p ON p.order_id = o.order_id
WHERE p.payment_id IS NULL
GROUP BY o.status;

결과

+---------+------------------------+
| status  | orders_without_payment  |
+---------+------------------------+
| PENDING |                     60 |
+---------+------------------------+

의도한 대로 PENDING 60건만 결제가 없습니다. 이 LEFT JOIN ... IS NULL 패턴이 안티 조인이고, Step 07의 핵심입니다.


3-6. 되돌리기 / 정리

상황명령
데이터만 초기화cd mysql8/sql && ./install.sh
대용량 테이블까지./install.sh --big
컨테이너째 초기화cd mysql8/docker && docker compose down -v && docker compose up -d 후 위 실행
내가 만든 실습 테이블만 삭제아래 쿼리로 목록을 뽑아 DROP
-- 각 스텝의 실습 테이블은 s02_, s11_ 처럼 접두사가 붙습니다. 찾아서 지우기:
SELECT CONCAT('DROP TABLE IF EXISTS ', table_name, ';') AS cleanup_sql
FROM information_schema.tables
WHERE table_schema = 'shop' AND table_name REGEXP '^s[0-9]{2}_';

이렇게 SQL로 SQL을 생성하는 기법도 실무에서 자주 씁니다. 결과를 복사해서 실행하면 됩니다.


정리

하고 싶은 것방법
예제 DB 설치/초기화./install.sh
테이블 목록과 크기information_schema.tables
ER 관계 파악information_schema.key_column_usage (FK 조회)
컬럼 상세information_schema.columns / DESC t
데이터 정합성 검증상관 서브쿼리 비교, LEFT JOIN ... IS NULL
실습 흔적 청소s??_ 접두사 테이블 DROP

연습문제

문제는 exercise.sql, 정답은 solution.sql 입니다. 두 파일의 전문은 아래 실습 파일 섹션에 있습니다.

  1. shop의 모든 테이블과 행 수를 정확하게(추정값 말고) 한 번에 출력하시오.
  2. 인덱스가 하나도 없는(PK 제외) 테이블을 찾으시오.
  3. 카테고리 계층을 "대분류 > 소분류" 형태로 출력하시오. (힌트: SELF JOIN)
  4. 주문이 한 건도 없는 고객이 있는가? 있다면 누구인가?
  5. 후기(reviews)가 하나도 없는 상품은 몇 개인가?
  6. products.attrs가 NULL인 상품을 찾으시오.
  7. 각 테이블의 PK 컬럼명을 information_schema로 조회하시오.

다음 단계

여기까지가 준비운동입니다. 이제 진짜 SQL을 씁니다.

Step 04 — SELECT 기본


실습 파일

이 스텝에는 SQL 파일 3개가 있습니다. ./install.shshop DB를 설치한 뒤, 먼저 practice.sql 을 통째로 실행해 3-3 ~ 3-6 절의 쿼리를 눈으로 확인하고, 그다음 exercise.sql 의 빈칸을 직접 채워 풀어본 뒤, 마지막에 solution.sql 로 답과 해설을 맞춰보는 순서입니다. 세 파일 모두 읽기 전용 SELECT 담고 있어서 몇 번을 돌려도 데이터가 망가지지 않습니다.

practice.sql

강의 본문 3-3절부터 3-6절까지의 쿼리를 순서대로 모아 둔 따라치기용 스크립트입니다. mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop < practice.sql 처럼 리다이렉션으로 한 번에 흘려보내도 되고, 클라이언트에 붙여 한 줄씩 실행해도 됩니다.

  • 맨 앞에서 USE shop; 을 하므로, 다른 DB에 접속해 있어도 자동으로 shop 으로 전환됩니다.
  • 앞부분의 SELECT * FROM orders ... LIMIT 5 / order_items ... LIMIT 5 는 3-3절의 "주문 1건이 상세 여러 줄로 갈라진다"를 눈으로 보여주는 쌍입니다. 이어지는 스칼라 서브쿼리 8개((SELECT COUNT(*) FROM categories) AS categories, ...)는 전체 행 수를 한 행으로 뽑아 3-2절의 표와 대조하기 위한 것입니다.
  • information_schema.key_column_usage 에서 referenced_table_name IS NOT NULL 로 거르는 쿼리(②)가 이 파일의 핵심입니다. 이 조건이 곧 "외래키인 행만 남겨라"는 뜻이고, 결과가 그대로 ER 다이어그램이 됩니다.
  • 뒤쪽 정합성 검증 블록에서 LEFT JOIN payments ... WHERE p.payment_id IS NULL (안티 조인)과 NOT EXISTS 두 가지 표현이 나란히 등장합니다. Step 07/08의 예고편이니 지금은 "같은 질문을 두 방식으로 물었다"는 것만 기억하면 됩니다.
  • 마지막 REGEXP '^s[0-9]{2}_' 쿼리는 DROP 문을 실행하지 않고 문자열로 출력만 합니다. 실제로 지우려면 출력된 결과를 복사해 따로 실행해야 합니다 — 안전장치입니다.
-- =====================================================================
-- Step 03 practice.sql — 예제 데이터베이스 탐색
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop < practice.sql
--
-- 이 파일은 전부 SELECT 입니다. 데이터를 바꾸지 않습니다.
-- =====================================================================
USE shop;

-- ---------------------------------------------------------------------
-- [3-3] 데이터 둘러보기
-- ---------------------------------------------------------------------
SELECT * FROM orders      ORDER BY order_id      LIMIT 5;
SELECT * FROM order_items ORDER BY order_item_id LIMIT 5;   -- 주문 1건이 여러 줄로 갈라짐
SELECT * FROM products    ORDER BY product_id    LIMIT 5;
SELECT * FROM customers   ORDER BY customer_id   LIMIT 5;

-- 전체 행 수 한눈에
SELECT
  (SELECT COUNT(*) FROM categories)  AS categories,
  (SELECT COUNT(*) FROM customers)   AS customers,
  (SELECT COUNT(*) FROM products)    AS products,
  (SELECT COUNT(*) FROM orders)      AS orders,
  (SELECT COUNT(*) FROM order_items) AS order_items,
  (SELECT COUNT(*) FROM payments)    AS payments,
  (SELECT COUNT(*) FROM reviews)     AS reviews,
  (SELECT COUNT(*) FROM employees)   AS employees;


-- ---------------------------------------------------------------------
-- [3-4] 처음 보는 DB 구조 파악하기
-- ---------------------------------------------------------------------

-- ① 테이블 목록과 크기 — 데이터가 몰린 곳이 서비스의 핵심
SELECT table_name,
       table_rows AS approx_rows,
       ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
       table_comment
FROM information_schema.tables
WHERE table_schema = 'shop' AND table_type = 'BASE TABLE'
ORDER BY (data_length + index_length) DESC;

-- ② 외래키 관계 = SQL 로 뽑는 ER 다이어그램  ★ 새 DB 만나면 이거부터
SELECT
  table_name             AS child_table,
  column_name            AS child_column,
  referenced_table_name  AS parent_table,
  referenced_column_name AS parent_column
FROM information_schema.key_column_usage
WHERE table_schema = 'shop'
  AND referenced_table_name IS NOT NULL
ORDER BY table_name;

-- ③ 값의 분포 확인
SELECT status, COUNT(*) AS cnt,
       ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct   -- 윈도우 함수 (Step 17)
FROM orders
GROUP BY status
ORDER BY cnt DESC;

SELECT grade, COUNT(*) AS cnt FROM customers GROUP BY grade;
SELECT city,  COUNT(*) AS cnt FROM customers GROUP BY city ORDER BY cnt DESC;

-- ④ 데이터의 시간 범위 ★ 리포트 만들기 전에 반드시 확인
SELECT MIN(order_date) AS first_order,
       MAX(order_date) AS last_order,
       DATEDIFF(MAX(order_date), MIN(order_date)) AS span_days
FROM orders;


-- ---------------------------------------------------------------------
-- [3-5] 데이터 정합성 검증
-- ---------------------------------------------------------------------

-- 1) orders.total_amount 가 order_items 합계와 일치하는가? → 0 이어야 정상
SELECT COUNT(*) AS mismatched_orders
FROM orders o
WHERE o.total_amount <> (
    SELECT COALESCE(SUM(oi.quantity * oi.unit_price), 0)
    FROM order_items oi
    WHERE oi.order_id = o.order_id
);

-- 2) 결제가 없는 주문은 정말 PENDING 뿐인가?  (안티 조인 패턴 → Step 07)
SELECT o.status, COUNT(*) AS orders_without_payment
FROM orders o
LEFT JOIN payments p ON p.order_id = o.order_id
WHERE p.payment_id IS NULL
GROUP BY o.status;

-- 3) 후기가 하나도 없는 상품은 몇 개인가?
SELECT COUNT(*) AS products_without_review
FROM products p
WHERE NOT EXISTS (SELECT 1 FROM reviews r WHERE r.product_id = p.product_id);

-- 4) 일부러 심어둔 NULL 들
SELECT customer_id, name, phone FROM customers WHERE phone IS NULL;
SELECT product_id, name        FROM products  WHERE attrs IS NULL;
SELECT category_id, name       FROM categories WHERE parent_id IS NULL;   -- 최상위 카테고리
SELECT employee_id, name       FROM employees WHERE manager_id IS NULL;   -- CEO


-- ---------------------------------------------------------------------
-- [3-6] 실습 흔적 청소용 SQL 을 SQL 로 생성하기
-- ---------------------------------------------------------------------
SELECT CONCAT('DROP TABLE IF EXISTS ', table_name, ';') AS cleanup_sql
FROM information_schema.tables
WHERE table_schema = 'shop' AND table_name REGEXP '^s[0-9]{2}_';

SELECT 'Step 03 practice 완료' AS msg;

exercise.sql

연습문제 8개가 주석으로만 들어 있는 빈 답안지입니다. 각 문제 아래 빈 줄에 직접 쿼리를 써 넣고 실행해 보세요. 본문 "연습문제" 절의 7문항에 심화 문제(문제 8)가 하나 더 붙어 있습니다.

  • 문제 1은 "information_schema.table_rows 는 추정값"이라는 함정을 노린 것입니다. 주석의 힌트대로 UNION ALL 로 각 테이블의 COUNT(*) 를 이어 붙여야 정확한 값이 나옵니다.
  • 문제 2의 힌트 index_name <> 'PRIMARY'information_schema.statistics 에서 PK를 제외하고 세라는 뜻입니다. 정답은 access_logstally 두 개입니다 — 하나만 찾고 끝내지 마세요. 이 중 access_logs 에 보조 인덱스가 없다는 사실이 Step 15(인덱스 실습)의 출발점이 됩니다. (tallyn 하나로만 조회하므로 PK만으로 충분해서 인덱스가 없는 것이고, access_logs일부러 비워 둔 것입니다.)
  • 문제 4는 "결과가 비어 있다면 그것도 답" 이라고 못박아 둔 것이 포인트입니다. 3-2절에서 설명한 "모든 고객이 최소 1건 주문"이라는 설계를 스스로 확인하는 문제입니다.
  • 문제 7의 힌트 GROUP_CONCAT 은 복합 PK를 한 줄로 합치기 위한 것입니다.
  • 답을 보기 전에 최소 한 번씩은 직접 써 보세요. 특히 문제 4·6은 "쿼리 버그"와 "정말 그런 데이터"를 구분하는 훈련입니다.
-- =====================================================================
-- Step 03 exercise.sql — 연습문제
-- 전부 SELECT 로 풀 수 있습니다. 정답은 solution.sql
-- =====================================================================
USE shop;

-- 문제 1. shop 의 모든 테이블과 행 수를 "정확하게" 한 번에 출력하시오.
--   (information_schema.table_rows 는 추정값이라 정확하지 않습니다.
--    UNION ALL 로 각 테이블의 COUNT(*) 를 붙이세요.)


-- 문제 2. 보조 인덱스(PK 제외)가 하나도 없는 테이블을 찾으시오.
--   힌트: information_schema.statistics 에서 index_name <> 'PRIMARY' 인 것을 세어 0 인 테이블


-- 문제 3. 카테고리 계층을 "대분류 > 소분류" 형태로 출력하시오. (SELF JOIN)
--   예: 패션 > 남성의류


-- 문제 4. 주문이 한 건도 없는 고객이 있는가? 있다면 누구인가?
--   결과가 비어 있다면 그것도 답입니다. "쿼리가 틀렸다"와 "정말 없다"를 구분하세요.


-- 문제 5. 후기(reviews)가 하나도 없는 상품은 몇 개인가? 그 중 5개만 이름과 함께 보이시오.


-- 문제 6. products.attrs 가 NULL 인 상품을 찾으시오. 왜 NULL 인지 생각해 보시오.


-- 문제 7. 각 테이블의 PK 컬럼명을 information_schema 로 조회하시오.
--   (복합 PK 라면 컬럼들을 콤마로 이어 붙이시오. 힌트: GROUP_CONCAT)


-- 문제 8. (심화) 주문 금액이 가장 큰 주문 1건의 상세 내역을 출력하시오.
--   주문번호, 고객명, 주문일, 상품명, 수량, 단가, 라인금액(수량*단가)

solution.sql

exercise.sql 8문항의 정답 쿼리와 주석 해설입니다. 쿼리만 있는 게 아니라 각 문제마다 결과값과 "왜 그런가"가 함께 적혀 있으니, 답을 맞힌 뒤에도 해설 주석까지 읽는 것을 권합니다.

  • 문제 3의 JOIN categories p ON p.category_id = c.parent_id 가 SELF JOIN의 전형입니다. 같은 테이블에 c(자식)와 p(부모) 별칭을 각각 붙였고, 계층이 2단계뿐이라 JOIN 한 번으로 끝났다는 점, 그리고 깊이를 모르는 계층에서는 재귀 CTE(Step 09)가 필요하다는 점을 짚어줍니다.
  • 문제 4의 해설에 있는 "COUNT(DISTINCT c.customer_id) 대신 COUNT(*) 를 쓰면 600이 나온다"는 대목이 이 파일에서 가장 값진 부분입니다. 조인은 행을 불린다 — 초보자가 가장 자주 저지르는 집계 실수를 미리 못박아 둡니다.
  • 문제 5는 NOT EXISTS / LEFT JOIN ... IS NULL / NOT IN 세 가지를 비교하며, NOT IN 은 서브쿼리에 NULL이 하나만 섞여도 결과가 통째로 비어버린다고 경고합니다(Step 08의 핵심 함정).
  • 문제 6의 해설은 attrs IS NULL(컬럼 자체가 빔)과 JSON_TYPE(attrs) = 'NULL'(JSON 문서 안에 null 이 들어 있음)이 다른 것임을 구분합니다. 이걸 헷갈리면 JSON 쿼리가 조용히 틀립니다(Step 18).
  • 문제 8은 WHERE o.order_id = (SELECT order_id FROM orders ORDER BY total_amount DESC LIMIT 1) 로 "최댓값 행"을 먼저 찾는 패턴입니다. MAX() 는 값만 알려줄 뿐 어느 행인지는 못 알려준다는 차이, 그리고 동점 처리는 RANK()(Step 17)가 필요하다는 점을 함께 설명합니다.
-- =====================================================================
-- Step 03 solution.sql — 연습문제 정답
-- =====================================================================
USE shop;

-- ── 문제 1. 모든 테이블의 정확한 행 수
SELECT 'categories'  AS table_name, COUNT(*) AS exact_rows FROM categories
UNION ALL SELECT 'customers',   COUNT(*) FROM customers
UNION ALL SELECT 'products',    COUNT(*) FROM products
UNION ALL SELECT 'orders',      COUNT(*) FROM orders
UNION ALL SELECT 'order_items', COUNT(*) FROM order_items
UNION ALL SELECT 'payments',    COUNT(*) FROM payments
UNION ALL SELECT 'reviews',     COUNT(*) FROM reviews
UNION ALL SELECT 'employees',   COUNT(*) FROM employees
UNION ALL SELECT 'tally',       COUNT(*) FROM tally
ORDER BY exact_rows DESC;
-- 결과: tally 10000, order_items 1200, orders 600, payments 540,
--       reviews 80, products 40, customers 30, employees 18, categories 17
-- 해설: information_schema.table_rows 는 InnoDB 의 "통계 샘플 추정값"입니다.
--       실제로 100만 행인 access_logs 가 996,151 로 나오는 걸 Step 02 에서 봤습니다.
--       정확한 수가 필요하면 반드시 COUNT(*) 를 쓰세요.
--       (그래서 대용량 테이블의 COUNT(*) 는 비싼 연산입니다 → Step 15)


-- ── 문제 2. 보조 인덱스가 없는 테이블
SELECT t.table_name,
       COUNT(DISTINCT CASE WHEN s.index_name <> 'PRIMARY' THEN s.index_name END) AS secondary_indexes
FROM information_schema.tables t
LEFT JOIN information_schema.statistics s
       ON s.table_schema = t.table_schema AND s.table_name = t.table_name
WHERE t.table_schema = 'shop'
  AND t.table_type = 'BASE TABLE'
  AND t.table_name NOT REGEXP '^s[0-9]{2}_'      -- 다른 스텝의 실습 테이블 제외
GROUP BY t.table_name
HAVING secondary_indexes = 0
ORDER BY t.table_name;
-- 결과: access_logs, tally
-- 해설: access_logs 에 인덱스가 없는 것은 의도한 것입니다.
--       Step 15 에서 여러분이 직접 인덱스를 설계해 붙이고, 100만 행 조회가
--       몇 초 → 몇 밀리초로 줄어드는 것을 실측합니다.
--       tally 는 PK(n) 만으로 충분합니다 (조회 용도가 n 뿐이므로).


-- ── 문제 3. 카테고리 계층 (SELF JOIN)
SELECT CONCAT(p.name, ' > ', c.name) AS category_path
FROM categories c
JOIN categories p ON p.category_id = c.parent_id     -- 같은 테이블을 두 번! 별칭이 필수
ORDER BY p.sort_order, c.sort_order;
-- 결과:
--   패션 > 남성의류 / 패션 > 여성의류 / 패션 > 신발
--   디지털 > 노트북 / 디지털 > 스마트폰 / 디지털 > 주변기기
--   식품 > 신선식품 / 식품 > 가공식품
--   리빙 > 주방용품 / 리빙 > 가구
--   도서 > IT/컴퓨터 / 도서 > 소설
-- 해설: SELF JOIN 은 같은 테이블에 서로 다른 별칭(c, p)을 붙여 조인하는 것입니다.
--       "자식 c 의 parent_id 와 같은 category_id 를 가진 부모 p" 를 붙였습니다.
--       계층이 2단계라서 JOIN 한 번으로 됐지만, 깊이가 정해지지 않은 계층은
--       JOIN 을 몇 번 해야 할지 알 수 없습니다. 그때 재귀 CTE 를 씁니다 → Step 09


-- ── 문제 4. 주문이 없는 고객
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
-- 결과: (0 rows) — 빈 결과
-- 해설: ★ 이 빈 결과가 정답입니다.
--       시드에서 customer_id = 1 + (n * 17) % 30 이 30명을 고르게 순환하므로
--       모든 고객이 최소 1건 이상 주문했습니다.
--       실무에서 안티 조인이 0건을 반환하면 "쿼리 버그"와 "정말 없음"을 구분해야 합니다.
--       구분법: LEFT JOIN 을 빼고 실행해서 행이 나오는지 확인하세요.
SELECT COUNT(DISTINCT c.customer_id) AS total_customers,
       COUNT(DISTINCT o.customer_id) AS customers_with_orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id;
-- → 30, 30  ... 전원이 주문 이력이 있음을 교차 검증
--
-- ★ 여기서 COUNT(DISTINCT c.customer_id) 대신 COUNT(*) 를 쓰면 어떻게 될까요?
--   답: 600 이 나옵니다. 고객 수가 아니라 "조인 결과의 행 수"(= 주문 수)를 세기 때문입니다.
--   조인 뒤에 COUNT(*) 를 쓰는 것은 초보자가 가장 자주 저지르는 집계 실수입니다.
--   조인은 행을 "불립니다". 무엇을 세고 있는지 항상 의식하세요 → Step 06, Step 07


-- ── 문제 5. 후기가 없는 상품
SELECT COUNT(*) AS products_without_review
FROM products p
WHERE NOT EXISTS (SELECT 1 FROM reviews r WHERE r.product_id = p.product_id);
-- 결과: 24

SELECT p.product_id, p.name, p.price
FROM products p
WHERE NOT EXISTS (SELECT 1 FROM reviews r WHERE r.product_id = p.product_id)
ORDER BY p.product_id
LIMIT 5;
-- 해설: NOT EXISTS 는 "일치하는 행이 하나도 없는가"를 묻습니다.
--       같은 뜻의 LEFT JOIN 버전:
--         SELECT p.* FROM products p
--         LEFT JOIN reviews r ON r.product_id = p.product_id
--         WHERE r.review_id IS NULL;
--       NOT IN 버전은 위험합니다. reviews.product_id 에 NULL 이 하나라도 있으면
--       결과가 통째로 비어버립니다 → Step 08 의 핵심 함정


-- ── 문제 6. attrs 가 NULL 인 상품
SELECT product_id, name, category_id
FROM products
WHERE attrs IS NULL;
-- 결과: 22번 'USB-C 허브 8in1', 29번 '프리미엄 라면 5입'
-- 해설: JSON 컬럼도 결국 컬럼이라 NULL 이 될 수 있습니다.
--       주의: JSON 의 NULL(문자열 'null')과 SQL 의 NULL 은 다릅니다!
--         - attrs IS NULL          → 컬럼 자체가 비어 있음
--         - JSON_TYPE(attrs)='NULL' → JSON 문서 안에 null 이 들어 있음
--       이 둘을 헷갈리면 JSON 쿼리가 조용히 틀립니다 → Step 18


-- ── 문제 7. 각 테이블의 PK 컬럼
SELECT t.table_name,
       GROUP_CONCAT(k.column_name ORDER BY k.ordinal_position) AS pk_columns
FROM information_schema.tables t
JOIN information_schema.key_column_usage k
  ON  k.table_schema   = t.table_schema
  AND k.table_name     = t.table_name
  AND k.constraint_name = 'PRIMARY'          -- PK 의 제약 이름은 항상 'PRIMARY'
WHERE t.table_schema = 'shop'
  AND t.table_type = 'BASE TABLE'
  AND t.table_name NOT REGEXP '^s[0-9]{2}_'
GROUP BY t.table_name
ORDER BY t.table_name;
-- 결과:
--   access_logs=log_id, categories=category_id, customers=customer_id,
--   employees=employee_id, order_items=order_item_id, orders=order_id,
--   payments=payment_id, products=product_id, reviews=review_id, tally=n
-- 해설: GROUP_CONCAT 은 여러 행을 한 문자열로 합칩니다 (복합 PK 대응).
--       ORDER BY 를 GROUP_CONCAT "안에" 쓴다는 점에 주목하세요 → Step 06


-- ── 문제 8. 최고 금액 주문의 상세 내역
SELECT
  o.order_id,
  c.name           AS customer,
  o.order_date,
  o.total_amount,
  p.name           AS product,
  oi.quantity,
  oi.unit_price,
  oi.quantity * oi.unit_price AS line_amount
FROM orders o
JOIN customers   c  ON c.customer_id = o.customer_id
JOIN order_items oi ON oi.order_id   = o.order_id
JOIN products    p  ON p.product_id  = oi.product_id
WHERE o.order_id = (SELECT order_id FROM orders ORDER BY total_amount DESC LIMIT 1)
ORDER BY line_amount DESC;
-- 해설: 서브쿼리로 "최고 금액 주문의 ID" 를 먼저 구한 뒤 그 주문의 상세를 조인했습니다.
--       ORDER BY total_amount DESC LIMIT 1 로 최댓값 행을 찾는 것은 흔한 패턴입니다.
--       (MAX(total_amount) 를 쓰면 "그 값이 몇인지"는 알지만 "어느 행인지"는 모릅니다)
--       동점이 있으면 LIMIT 1 은 그 중 하나만 고릅니다. 전부 원하면 Step 17 의 RANK() 를 쓰세요.

SELECT 'Step 03 solution 완료' AS msg;