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
└────────────┘ └──────────────┘
테이블 한눈에
| 테이블 | 행 수 | 설명 | 이 테이블로 배우는 것 |
|---|
categories | 17 | 카테고리 (대분류 5 + 소분류 12) | 자기참조, 재귀 CTE |
customers | 30 | 고객 (등급/도시/포인트) | GROUP BY, 세그먼트 분석 |
products | 40 | 상품 (가격/재고/JSON 속성) | JSON, 집계 |
orders | 600 | 주문 (2024-01-02 ~ 2025-12-30) | JOIN, 윈도우 함수, 시계열 |
order_items | 1,200 | 주문 상세 (주문당 1~3개) | 다대다 해소, 집계 |
payments | 540 | 결제 (PENDING 주문엔 없음) | LEFT JOIN, 안티 조인 |
reviews | 80 | 후기 (일부 배송완료 주문만) | 평점 집계, 상관 서브쿼리 |
employees | 18 | 사원 (4단계 조직도) | SELF JOIN, 재귀 CTE |
tally | 10,000 | 숫자 1~10000 | 데이터 생성, 구간 채우기 |
access_logs | 1,000,000 | 접근 로그 (인덱스 없음) | 인덱스, EXPLAIN, 파티셔닝 |
데이터를 이렇게 설계한 이유 (중요)
이 데이터에는 일부러 심어둔 함정들이 있습니다. 나중에 각 스텝에서 재료로 씁니다.
| 심어둔 것 | 어디서 쓰나 |
|---|
customers.phone이 NULL인 고객 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.attrs가 NULL인 상품 2개 (USB-C 허브, 프리미엄 라면) | Step 18 — JSON 함수의 NULL 처리 |
products.status에 SOLD_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 입니다. 두 파일의 전문은 아래 실습 파일 섹션에 있습니다.
shop의 모든 테이블과 행 수를 정확하게(추정값 말고) 한 번에 출력하시오.
- 인덱스가 하나도 없는(PK 제외) 테이블을 찾으시오.
- 카테고리 계층을 "대분류 > 소분류" 형태로 출력하시오. (힌트: SELF JOIN)
- 주문이 한 건도 없는 고객이 있는가? 있다면 누구인가?
- 후기(reviews)가 하나도 없는 상품은 몇 개인가?
products.attrs가 NULL인 상품을 찾으시오.
- 각 테이블의 PK 컬럼명을 information_schema로 조회하시오.
다음 단계
여기까지가 준비운동입니다. 이제 진짜 SQL을 씁니다.
→ Step 04 — SELECT 기본
실습 파일
이 스텝에는 SQL 파일 3개가 있습니다. ./install.sh 로 shop 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_logs 와 tally 두 개입니다 — 하나만 찾고 끝내지 마세요. 이 중 access_logs 에 보조 인덱스가 없다는 사실이 Step 15(인덱스 실습)의 출발점이 됩니다. (tally 는 n 하나로만 조회하므로 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;