Step 07 — 조인(JOIN)
학습 목표
INNER / LEFT / RIGHT / CROSS / SELF JOIN 을 구분해서 쓴다
- 여러 테이블을 잇는 다중 조인을 작성한다
- LEFT JOIN 에서 필터를
ON 에 두느냐 WHERE 에 두느냐가 결과를 바꾸는 함정을 이해한다
- NULL 확장을 이용한 안티조인(
LEFT JOIN ... IS NULL)을 쓴다
- MySQL 엔
FULL OUTER JOIN 이 없다는 것을 알고 UNION 으로 우회한다
선행 스텝: Step 06 — 집계함수와 GROUP BY
예상 소요: 70분
조인은 관계형 DB 의 심장입니다. 스키마 관계도는 실습 환경의 sql/01_schema.sql 상단 주석을 참고하세요.
7-1. INNER JOIN — 양쪽에 다 있는 것만
주문(orders)에는 customer_id 만 있고 고객 이름은 없습니다. 이름을 붙이려면 customers 와 이어야 합니다. 양쪽에 짝이 있는 행만 남기는 것이 INNER JOIN 입니다.
SELECT
o.order_id,
o.order_date,
c.name AS 고객명,
c.grade AS 등급,
o.total_amount
FROM orders o
INNER JOIN customers c ON c.customer_id = o.customer_id
ORDER BY o.order_id
LIMIT 5;
결과
+----------+---------------------+-----------+--------+--------------+
| order_id | order_date | 고객명 | 등급 | total_amount |
+----------+---------------------+-----------+--------+--------------+
| 1 | 2024-02-07 13:07:00 | 류하나 | GOLD | 1836000.00 |
| 2 | 2024-03-15 02:14:00 | 정 훈 | GOLD | 6663900.00 |
| 3 | 2024-04-21 15:21:00 | 안지수 | GOLD | 658000.00 |
| 4 | 2024-05-28 04:28:00 | 한지호 | BRONZE | 837000.00 |
| 5 | 2024-07-04 17:35:00 | 배채영 | GOLD | 1194000.00 |
+----------+---------------------+-----------+--------+--------------+
ON c.customer_id = o.customer_id 가 조인 조건입니다. "orders 의 customer_id 와 customers 의 customer_id 가 같은 행끼리 짝지어라". INNER 는 생략할 수 있습니다 — 그냥 JOIN 은 INNER JOIN 입니다.
💡 실무 팁: 조인이 등장하면 모든 테이블에 별칭을 붙이고 모든 컬럼에 별칭을 접두사로 다세요(c.name, o.order_id). 두 테이블에 같은 이름의 컬럼(name 등)이 있을 때 접두사가 없으면 ERROR 1052: Column 'name' in field list is ambiguous 가 납니다. 접두사는 "이 컬럼이 어디서 왔는지" 를 읽는 사람에게도 알려줍니다.
7-2. 다중 조인 — 여러 테이블을 잇기
"어떤 고객이 어떤 상품을 어느 카테고리에서 몇 개 샀나" 를 보려면 4개 테이블을 이어야 합니다. orders → order_items → products → categories, 그리고 고객 이름을 위해 customers 까지.
SELECT
o.order_id,
c.name AS 고객,
p.name AS 상품,
cat.name AS 카테고리,
oi.quantity AS 수량,
oi.unit_price AS 단가
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
JOIN categories cat ON cat.category_id = p.category_id
ORDER BY o.order_id, p.product_id
LIMIT 8;
결과
+----------+-----------+-------------------------------+--------------+--------+------------+
| order_id | 고객 | 상품 | 카테고리 | 수량 | 단가 |
+----------+-----------+-------------------------------+--------------+--------+------------+
| 1 | 류하나 | 27인치 4K 모니터 | 주변기기 | 3 | 459000.00 |
| 1 | 류하나 | 원목 4인 식탁 | 가구 | 1 | 459000.00 |
| 2 | 정 훈 | 베이직 옥스퍼드 셔츠 | 남성의류 | 2 | 39000.00 |
| 2 | 정 훈 | 게이밍 노트북 RTX4060 | 노트북 | 3 | 2190000.00 |
| 2 | 정 훈 | 콜드브루 원액 1L | 가공식품 | 1 | 15900.00 |
| 3 | 안지수 | 인체공학 사무용 의자 | 가구 | 2 | 329000.00 |
| 4 | 한지호 | 슬림핏 치노 팬츠 | 남성의류 | 3 | 49000.00 |
| 4 | 한지호 | 보급형 노트북 15 | 노트북 | 1 | 690000.00 |
+----------+-----------+-------------------------------+--------------+--------+------------+
order_id = 1 이 두 줄인 것에 주목하세요. 주문 1건에 상품이 2개(1:N)라서, 조인 결과에서 주문 헤더 정보(고객명 등)가 상품 수만큼 반복됩니다. 이게 다음 함정으로 이어집니다.
⚠️ 함정 (행 뻥튀기, fan-out): 1:N 조인은 "왼쪽 행 × 매칭되는 오른쪽 행 수" 만큼 행을 만듭니다. 여기에 SUM(o.total_amount) 를 걸면 주문 금액이 상품 개수만큼 중복 합산됩니다. 주문 1의 total_amount 가 2번 더해지는 식이죠. 집계 전에 "지금 한 행이 무엇의 단위인가?" 를 항상 자문하세요. 방어법은 7-11 에서 다룹니다.
7-3. LEFT JOIN — 왼쪽은 모두 남긴다
INNER JOIN 은 짝이 없으면 버립니다. 하지만 "상품이 하나도 없는 카테고리" 처럼 짝이 없는 쪽도 보고 싶을 때가 많습니다. LEFT JOIN 은 왼쪽 테이블의 행을 전부 남기고, 오른쪽에 짝이 없으면 그 자리를 NULL 로 채웁니다(NULL 확장).
우리 카테고리는 2단계입니다. 대분류 5개(패션, 디지털 ...)에는 상품이 직접 매달려 있지 않습니다(상품은 소분류에만).
SELECT
cat.category_id,
cat.name AS 카테고리,
p.product_id,
p.name AS 상품명
FROM categories cat
LEFT JOIN products p ON p.category_id = cat.category_id
WHERE cat.parent_id IS NULL -- 대분류만
ORDER BY cat.category_id;
결과
+-------------+--------------+------------+-----------+
| category_id | 카테고리 | product_id | 상품명 |
+-------------+--------------+------------+-----------+
| 1 | 패션 | NULL | NULL |
| 2 | 디지털 | NULL | NULL |
| 3 | 식품 | NULL | NULL |
| 4 | 리빙 | NULL | NULL |
| 5 | 도서 | NULL | NULL |
+-------------+--------------+------------+-----------+
카테고리는 남았고, 짝이 없는 상품 컬럼은 NULL 이 되었습니다. INNER JOIN 이었다면 이 5줄은 통째로 사라졌을 겁니다.
COUNT 의 함정 — LEFT JOIN 에서는 COUNT(*) 를 쓰지 마라
"카테고리별 상품 수"를 셀 때, 상품이 0개인 카테고리도 0 으로 보이길 원합니다.
SELECT
cat.category_id,
cat.name AS 카테고리,
COUNT(p.product_id) AS 상품수,
COUNT(*) AS `COUNT(*)_함정`
FROM categories cat
LEFT JOIN products p ON p.category_id = cat.category_id
GROUP BY cat.category_id, cat.name
ORDER BY 상품수, cat.category_id
LIMIT 8;
결과
+-------------+--------------+-----------+-----------------+
| category_id | 카테고리 | 상품수 | COUNT(*)_함정 |
+-------------+--------------+-----------+-----------------+
| 1 | 패션 | 0 | 1 |
| 2 | 디지털 | 0 | 1 |
| 3 | 식품 | 0 | 1 |
| 4 | 리빙 | 0 | 1 |
| 5 | 도서 | 0 | 1 |
| 52 | 소설 | 1 | 1 |
| 13 | 신발 | 3 | 3 |
| 22 | 스마트폰 | 3 | 3 |
+-------------+--------------+-----------+-----------------+
⚠️ 함정: 패션 카테고리의 상품 수는 0 이어야 하는데 COUNT(*) 는 1 을 반환합니다. NULL 확장으로 "상품이 전부 NULL 인 행" 이 1줄 생겼고, COUNT(*) 는 그 행도 세기 때문입니다. COUNT(오른쪽 테이블의 컬럼) 을 쓰면 그 컬럼이 NULL 인 행은 안 세므로 올바른 0 이 나옵니다. LEFT JOIN 뒤의 COUNT 는 반드시 오른쪽 테이블 컬럼을 대상으로 하세요.
7-4. ON vs WHERE — LEFT JOIN 필터 위치의 함정 (이 스텝의 핵심)
이건 실무에서 가장 많이 틀리는 조인 함정입니다.
"고객별 배송완료(DELIVERED) 주문" 을 보려고 합니다. 조건 status = 'DELIVERED' 를 ON 에 두는 것과 WHERE 에 두는 것은 결과가 다릅니다.
-- (A) 조건을 ON 에: 조인 짝을 만들 때만 적용. 왼쪽(고객)은 전부 보존된다.
SELECT COUNT(*) AS 조건이_ON
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'DELIVERED';
결과
+--------------+
| 조건이_ON |
+--------------+
| 258 |
+--------------+
-- (B) 조건을 WHERE 에: 조인 "후" 적용. NULL 확장된 행이 탈락 → 사실상 INNER JOIN.
SELECT COUNT(*) AS 조건이_WHERE
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.status = 'DELIVERED';
결과
+-----------------+
| 조건이_WHERE |
+-----------------+
| 240 |
+-----------------+
258 vs 240. 18 행이 차이납니다. 무슨 일이 벌어진 걸까요?
- (A)
ON 에 두면: status='DELIVERED' 는 오른쪽 행을 매칭할지 말지 결정하는 데만 쓰입니다. 배송완료 주문이 없는 고객도 LEFT JOIN 규칙에 따라 NULL 확장으로 한 줄 남습니다. 그래서 240(배송완료 주문) + 18(배송완료 없는 고객) = 258.
- (B)
WHERE 에 두면: 조인이 다 끝난 뒤 status='DELIVERED' 로 거릅니다. NULL 확장된 행의 status 는 NULL 이고 NULL = 'DELIVERED' 는 UNKNOWN 이라 탈락합니다. 결국 배송완료 주문만 남아 240 — LEFT JOIN 이 INNER JOIN 으로 퇴화했습니다.
배송완료 주문이 없는 고객(예: 주문이 전부 취소된 강소라, customer_id=6)을 직접 봅시다.
SELECT c.customer_id, c.name, o.order_id, o.status
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'DELIVERED'
WHERE c.customer_id = 6;
결과
+-------------+-----------+----------+--------+
| customer_id | name | order_id | status |
+-------------+-----------+----------+--------+
| 6 | 강소라 | NULL | NULL |
+-------------+-----------+----------+--------+
강소라는 배송완료 주문이 없지만 NULL 확장으로 한 줄 남았습니다. 만약 이 조건을 WHERE 로 옮기면 강소라는 결과에서 사라집니다.
⚠️ 핵심 규칙: LEFT JOIN 에서 오른쪽 테이블 조건은 ON 에, 왼쪽 테이블 조건은 WHERE 에.
- "모든 고객 + 그들의 배송완료 주문(없으면 NULL)" 을 원하면 → 조건을
ON 에.
- "배송완료 주문이 있는 고객만" 을 원하면 → 조건을
WHERE 에 (또는 그냥 INNER JOIN).
실수의 전형: LEFT JOIN 을 써놓고 오른쪽 테이블 조건을 WHERE 에 걸어서, 자기도 모르게 INNER JOIN 을 만들어 놓고 "왜 LEFT JOIN 인데 행이 안 남지?" 라고 헤매는 것. 예외: WHERE o.order_id IS NULL 처럼 NULL 확장을 일부러 노리는 경우는 정상입니다(다음 절).
7-5. 안티조인 — LEFT JOIN ... IS NULL
방금 본 NULL 확장을 거꾸로 이용하면 "짝이 없는 행" 만 골라낼 수 있습니다. LEFT JOIN 후 WHERE 오른쪽PK IS NULL. 이것을 안티조인(anti-join) 이라 합니다.
우리 데이터에서 PENDING 주문은 결제(payments)가 없습니다. "결제가 없는 주문" 을 안티조인으로 찾아봅시다.
SELECT o.order_id, o.status, o.total_amount
FROM orders o
LEFT JOIN payments pay ON pay.order_id = o.order_id
WHERE pay.payment_id IS NULL -- 짝이 없었던 행만
ORDER BY o.order_id
LIMIT 8;
결과
+----------+---------+--------------+
| order_id | status | total_amount |
+----------+---------+--------------+
| 7 | PENDING | 1116000.00 |
| 17 | PENDING | 1942800.00 |
| 27 | PENDING | 318000.00 |
| 37 | PENDING | 276000.00 |
| 47 | PENDING | 854000.00 |
| 57 | PENDING | 3580000.00 |
| 67 | PENDING | 1627000.00 |
| 77 | PENDING | 694000.00 |
+----------+---------+--------------+
전부 PENDING 입니다. 개수가 실제 PENDING 주문 수와 일치하는지 검산합니다.
SELECT
(SELECT COUNT(*) FROM orders o
LEFT JOIN payments pay ON pay.order_id = o.order_id
WHERE pay.payment_id IS NULL) AS 안티조인_결과,
(SELECT COUNT(*) FROM orders WHERE status = 'PENDING') AS PENDING_주문수;
결과
+---------------------+-------------------+
| 안티조인_결과 | PENDING_주문수 |
+---------------------+-------------------+
| 60 | 60 |
+---------------------+-------------------+
60 = 60. 정확합니다. Step 05 에서 배운 NOT EXISTS, NOT IN 과 함께 안티조인의 세 가지 표현이 완성되었습니다.
-- 후기를 한 번도 안 남긴 고객 (Step 05 의 NOT EXISTS 를 조인 버전으로)
SELECT c.customer_id, c.name, c.grade
FROM customers c
LEFT JOIN reviews r ON r.customer_id = c.customer_id
WHERE r.review_id IS NULL
ORDER BY c.customer_id
LIMIT 8;
결과
+-------------+-----------+--------+
| customer_id | name | grade |
+-------------+-----------+--------+
| 2 | 이지은 | GOLD |
| 3 | 박철수 | SILVER |
| 4 | 최영희 | BRONZE |
| 5 | 정 훈 | GOLD |
| 6 | 강소라 | VIP |
| 7 | 윤대현 | BRONZE |
| 8 | 임수진 | SILVER |
| 9 | 한지호 | BRONZE |
+-------------+-----------+--------+
... (총 26행)
💡 실무 팁 — 안티조인 3형제 중 무엇을 쓰나:
NOT EXISTS : 기본값으로 추천. NULL 에 안전하고 옵티마이저가 잘 처리.
LEFT JOIN ... IS NULL : 짝이 없는 쪽의 컬럼도 함께 SELECT 해야 할 때 자연스러움. 단, IS NULL 대상은 반드시 NOT NULL 컬럼(대개 PK) 이어야 함 — NULL 이 가능한 컬럼을 쓰면 "짝은 있는데 그 컬럼이 NULL 인 행" 까지 딸려온다.
NOT IN (서브쿼리) : ⚠️ NULL 하나면 전체가 빈 결과. 상수 리스트에만.
세 방법의 성능은 MySQL 8 에서 대체로 비슷하지만, 의미가 가장 명확한 NOT EXISTS 를 기본으로 삼으세요.
7-6. SELF JOIN — 같은 테이블을 두 번
employees 는 manager_id → employee_id 로 자기 자신을 참조합니다. 사원과 그의 관리자를 나란히 보려면 같은 테이블을 두 번, 다른 별칭으로 조인합니다.
SELECT
e.employee_id,
e.name AS 사원,
e.position AS 직급,
m.name AS 관리자,
m.position AS 관리자_직급
FROM employees e
LEFT JOIN employees m ON m.employee_id = e.manager_id
ORDER BY e.employee_id
LIMIT 10;
결과
+-------------+-----------+-----------+-----------+------------------+
| employee_id | 사원 | 직급 | 관리자 | 관리자_직급 |
+-------------+-----------+-----------+-----------+------------------+
| 1 | 정한별 | CEO | NULL | NULL |
| 2 | 김코드 | 본부장 | 정한별 | CEO |
| 3 | 이세일 | 본부장 | 정한별 | CEO |
| 4 | 오지원 | 본부장 | 정한별 | CEO |
| 5 | 박서버 | 팀장 | 김코드 | 본부장 |
| 6 | 최화면 | 팀장 | 김코드 | 본부장 |
| 7 | 강매출 | 팀장 | 이세일 | 본부장 |
| 8 | 윤사람 | 팀장 | 오지원 | 본부장 |
| 9 | 한백엔 | 시니어 | 박서버 | 팀장 |
| 10 | 임쿼리 | 주니어 | 박서버 | 팀장 |
+-------------+-----------+-----------+-----------+------------------+
CEO(정한별)는 관리자가 없어서 NULL 입니다. LEFT JOIN 을 썼기에 CEO 도 남았습니다. INNER JOIN 이었다면 CEO 가 사라졌을 겁니다.
SELF JOIN 은 "같은 그룹 안에서 서로 비교" 할 때도 씁니다. "나보다 같은 부서에서 급여가 높은 사람이 몇 명인가" (= 부서 내 급여 순위 - 1).
SELECT
e.name AS 사원,
e.dept AS 부서,
e.salary AS 급여,
COUNT(h.employee_id) AS 나보다_높은_사람수
FROM employees e
LEFT JOIN employees h
ON h.dept = e.dept
AND h.salary > e.salary
GROUP BY e.employee_id, e.name, e.dept, e.salary
ORDER BY e.dept, e.salary DESC
LIMIT 10;
결과
+-----------+--------------+------------+----------------------------+
| 사원 | 부서 | 급여 | 나보다_높은_사람수 |
+-----------+--------------+------------+----------------------------+
| 김코드 | 개발본부 | 9500000.00 | 0 |
| 박서버 | 개발본부 | 7200000.00 | 1 |
| 최화면 | 개발본부 | 7000000.00 | 2 |
| 한백엔 | 개발본부 | 5800000.00 | 3 |
| 조리액 | 개발본부 | 5600000.00 | 4 |
| 임쿼리 | 개발본부 | 4200000.00 | 5 |
| 서인덱 | 개발본부 | 4000000.00 | 6 |
| 남뷰어 | 개발본부 | 3900000.00 | 7 |
| 오지원 | 경영지원 | 8800000.00 | 0 |
| 윤사람 | 경영지원 | 6500000.00 | 1 |
+-----------+--------------+------------+----------------------------+
"나보다 높은 사람 수 + 1" 이 곧 부서 내 급여 순위입니다. 여기서도 LEFT JOIN 이 필수입니다 — 부서 1등은 "나보다 높은 사람" 이 없어서 매칭이 0건인데, INNER JOIN 이면 1등이 결과에서 사라집니다.
💡 참고: 이런 "그룹 내 순위" 는 MySQL 8 의 윈도우 함수(RANK() OVER (PARTITION BY dept ORDER BY salary DESC))로 훨씬 간결하게 쓸 수 있습니다. Step 12 에서 다룹니다. SELF JOIN 은 윈도우 함수가 없던 시절의 정석이었고, 지금도 원리를 이해하는 데 좋습니다.
7-7. CROSS JOIN — 모든 조합
CROSS JOIN 은 조건 없이 두 테이블의 모든 조합(곱집합) 을 만듭니다. A 가 m행, B 가 n행이면 결과는 m×n 행입니다.
SELECT g.grade, ct.city
FROM (SELECT DISTINCT grade FROM customers) g
CROSS JOIN (SELECT DISTINCT city FROM customers WHERE city IN ('서울','부산','인천')) ct
ORDER BY g.grade, ct.city;
결과 (4등급 × 3도시 = 12행)
+--------+--------+
| grade | city |
+--------+--------+
| BRONZE | 부산 |
| BRONZE | 서울 |
| BRONZE | 인천 |
| SILVER | 부산 |
| SILVER | 서울 |
| SILVER | 인천 |
| GOLD | 부산 |
| ... | ... |
+--------+--------+
... (총 12행)
실전에서 CROSS JOIN 이 빛나는 곳은 "빈 조합 채우기" 입니다. 리포트에서 "데이터가 0인 칸"도 0으로 보여야 할 때, 모든 조합을 먼저 만들고 실제 데이터를 LEFT JOIN 합니다.
SELECT
g.grade,
ct.city,
COUNT(c.customer_id) AS 고객수
FROM (SELECT DISTINCT grade FROM customers) g
CROSS JOIN (SELECT DISTINCT city FROM customers) ct
LEFT JOIN customers c ON c.grade = g.grade AND c.city = ct.city
GROUP BY g.grade, ct.city
HAVING COUNT(c.customer_id) > 0
ORDER BY g.grade, 고객수 DESC
LIMIT 10;
결과 (일부)
+--------+--------+-----------+
| grade | city | 고객수 |
+--------+--------+-----------+
| BRONZE | 울산 | 2 |
| BRONZE | 수원 | 2 |
| BRONZE | 부산 | 2 |
| BRONZE | 서울 | 1 |
| ... | ... | ... |
+--------+--------+-----------+
(위에선 HAVING > 0 으로 걸렀지만, 이 조건을 빼면 "GOLD 등급 울산 고객 0명" 같은 빈 조합도 0으로 나옵니다 — 달력/매트릭스 리포트의 핵심 기법.)
⚠️ 함정: CROSS JOIN 을 실수로 만드는 것이 진짜 위험합니다. FROM a, b 로 콤마 조인을 쓰고 WHERE 에 조인 조건을 빠뜨리면 곱집합이 터집니다. 100만 × 100만 = 1조 행. 서버가 멈춥니다. 콤마 조인 대신 항상 명시적 JOIN ... ON 을 쓰세요. ON 을 빼먹으면 문법 에러라도 나서 사고를 막아줍니다 (CROSS JOIN 만 ON 없이 허용).
7-8. USING — 조인 컬럼 이름이 같을 때
양쪽 조인 컬럼의 이름이 완전히 같으면 ON a.x = b.x 대신 USING (x) 로 줄일 수 있습니다.
SELECT customer_id, o.order_id, o.total_amount
FROM orders o
JOIN customers c USING (customer_id)
ORDER BY o.order_id
LIMIT 5;
결과
+-------------+----------+--------------+
| customer_id | order_id | total_amount |
+-------------+----------+--------------+
| 18 | 1 | 1836000.00 |
| 5 | 2 | 6663900.00 |
| 22 | 3 | 658000.00 |
| 9 | 4 | 837000.00 |
| 26 | 5 | 1194000.00 |
+-------------+----------+--------------+
주목할 점: USING 으로 조인한 컬럼은 결과에 한 번만 나옵니다. 그래서 위에서 customer_id 를 접두사 없이 그냥 썼습니다(o.customer_id 도 c.customer_id 도 아닌 통합된 하나). SELECT * 를 하면 그 컬럼이 맨 앞에 한 번만 옵니다.
💡 실무 팁: USING 은 깔끔하지만, 조인 컬럼 이름이 양쪽 정확히 같을 때만 됩니다. 우리 스키마는 orders.customer_id / customers.customer_id 처럼 일관되게 이름을 맞췄기에 USING 이 잘 먹힙니다. 이름이 다르면(user_id vs id) ON 을 써야 합니다. 팀 스타일에 따라 ON 으로 통일하는 곳도 많습니다(명시성 선호).
7-9. RIGHT JOIN — 방향만 바꾼 LEFT
RIGHT JOIN 은 오른쪽 테이블을 전부 남깁니다. LEFT 의 거울상일 뿐입니다.
SELECT
p.product_id,
p.name,
COUNT(oi.order_item_id) AS 판매_횟수
FROM order_items oi
RIGHT JOIN products p ON p.product_id = oi.product_id
GROUP BY p.product_id, p.name
ORDER BY 판매_횟수 ASC, p.product_id
LIMIT 5;
결과
+------------+-------------------------------+---------------+
| product_id | name | 판매_횟수 |
+------------+-------------------------------+---------------+
| 1 | 베이직 옥스퍼드 셔츠 | 30 |
| 2 | 슬림핏 치노 팬츠 | 30 |
| 3 | 라이트 다운 재킷 | 30 |
| 4 | 울 니트 스웨터 | 30 |
| 5 | 플리츠 롱스커트 | 30 |
+------------+-------------------------------+---------------+
이 쿼리는 "판매된 적 없는 상품(판매 횟수 0)도 보이게" 하려고 products 를 다 남긴 것입니다(우리 데이터에선 40개 상품이 모두 팔려서 0 은 없습니다).
💡 실무 팁: RIGHT JOIN 은 거의 쓰지 마세요. 위 쿼리는 FROM products p LEFT JOIN order_items oi ... 로 쓰는 게 훨씬 자연스럽습니다. 사람은 "왼쪽에서 오른쪽으로" 읽으므로, "전부 남길 테이블을 FROM 에 놓고 LEFT JOIN" 하는 흐름이 이해하기 쉽습니다. RIGHT JOIN 은 코드를 거꾸로 읽게 만듭니다. 실무 코드베이스에서 RIGHT JOIN 을 보면 대개 LEFT 로 리팩터링할 후보입니다.
7-10. FULL OUTER JOIN 이 없다 — UNION 으로 우회
FULL OUTER JOIN 은 "양쪽 다 남기고, 짝 없으면 NULL" — LEFT 와 RIGHT 를 합친 것입니다. PostgreSQL/Oracle 엔 있지만 MySQL 에는 없습니다. 쓰려고 하면 문법 에러입니다.
SELECT * FROM a FULL OUTER JOIN b ON ...; -- ERROR 1064 (문법 에러)
대신 (LEFT JOIN) UNION (RIGHT JOIN) 으로 만듭니다. UNION 이 중복(양쪽 다 매칭된 행)을 자동으로 제거해 줍니다.
"2024년 1분기에 주문한 고객" 과 "2025년 1분기에 주문한 고객" 을 비교합시다. 한쪽에만 있는 고객까지 전부 보려면 FULL OUTER JOIN 이 필요합니다.
WITH
q2024 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'),
q2025 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2025-01-01' AND order_date < '2025-04-01')
SELECT a.customer_id AS `2024_1분기`, b.customer_id AS `2025_1분기`
FROM q2024 a
LEFT JOIN q2025 b ON b.customer_id = a.customer_id
UNION
SELECT a.customer_id, b.customer_id
FROM q2024 a
RIGHT JOIN q2025 b ON b.customer_id = a.customer_id
ORDER BY `2024_1분기`, `2025_1분기`
LIMIT 6;
결과
+--------------+--------------+
| 2024_1분기 | 2025_1분기 |
+--------------+--------------+
| NULL | 25 | ← 2025 1분기에만 주문 (25번 고객)
| 1 | 1 |
| 2 | 2 |
| 3 | 3 |
| 4 | 4 |
| 5 | 5 |
+--------------+--------------+
첫 행의 2024_1분기 가 NULL 입니다 — 25번 고객은 2025 1분기에만 주문했고 2024 1분기엔 없었다는 뜻입니다. "한쪽에만 있는 고객" (대칭 차집합)만 뽑으면 이렇게 됩니다.
WITH
q2024 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'),
q2025 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2025-01-01' AND order_date < '2025-04-01')
SELECT a.customer_id AS `2024만`, b.customer_id AS `2025만`
FROM q2024 a LEFT JOIN q2025 b ON b.customer_id = a.customer_id
WHERE b.customer_id IS NULL
UNION
SELECT a.customer_id, b.customer_id
FROM q2024 a RIGHT JOIN q2025 b ON b.customer_id = a.customer_id
WHERE a.customer_id IS NULL;
결과
+---------+---------+
| 2024만 | 2025만 |
+---------+---------+
| 15 | NULL | ← 15번은 2024 1분기에만
| NULL | 25 | ← 25번은 2025 1분기에만
+---------+---------+
💡 실무 팁: UNION 은 중복 제거를 위해 정렬/해시 작업을 합니다. 두 쿼리 결과에 중복이 없다고 확신하면 UNION ALL 이 더 빠릅니다. 하지만 FULL OUTER JOIN 우회에서는 "양쪽 매칭된 행" 이 LEFT 결과와 RIGHT 결과 양쪽에 나타나므로 반드시 UNION(중복 제거)을 써야 합니다. UNION ALL 을 쓰면 매칭된 행이 두 번씩 나옵니다.
7-11. 종합 — LEFT JOIN 여러 개 + fan-out 방어
"고객별 주문/결제/후기 요약" 을 한 번에 뽑아봅시다. 1:N 관계를 세 개나 동시에 LEFT JOIN 하면 행이 곱해집니다. COUNT(DISTINCT) 로 방어합니다.
SELECT
c.customer_id,
c.name AS 고객,
c.grade AS 등급,
COUNT(DISTINCT o.order_id) AS 주문수,
COUNT(DISTINCT pay.payment_id) AS 결제수,
COUNT(DISTINCT r.review_id) AS 후기수
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
LEFT JOIN payments pay ON pay.order_id = o.order_id
LEFT JOIN reviews r ON r.customer_id = c.customer_id
GROUP BY c.customer_id, c.name, c.grade
ORDER BY 후기수 DESC, 주문수 DESC
LIMIT 8;
결과
+-------------+-----------+--------+-----------+-----------+-----------+
| customer_id | 고객 | 등급 | 주문수 | 결제수 | 후기수 |
+-------------+-----------+--------+-----------+-----------+-----------+
| 1 | 김민수 | VIP | 20 | 20 | 20 |
| 28 | 심준호 | SILVER | 20 | 20 | 20 |
| 25 | 양현우 | VIP | 20 | 20 | 20 |
| 22 | 안지수 | GOLD | 20 | 20 | 20 |
| 2 | 이지은 | GOLD | 20 | 20 | 0 |
| 3 | 박철수 | SILVER | 20 | 20 | 0 |
| 30 | 하준서 | GOLD | 20 | 0 | 0 |
| 5 | 정 훈 | GOLD | 20 | 20 | 0 |
+-------------+-----------+--------+-----------+-----------+-----------+
⚠️ 함정 (fan-out 재확인): 만약 위에서 COUNT(o.order_id) (DISTINCT 없이)를 썼다면, payments 와 reviews 조인으로 orders 행이 여러 번 복제되어 주문 수가 부풀려집니다. 여러 1:N 을 한 쿼리에서 집계할 때는 (1) COUNT(DISTINCT ...) 로 방어하거나, (2) 각 집계를 서브쿼리로 따로 계산해 붙이는 게 안전합니다. 특히 SUM 은 DISTINCT 로도 완전히 못 고칩니다(같은 값이 여러 주문에 있으면 DISTINCT 가 지워버림) — 이럴 땐 반드시 서브쿼리 분리나 상관 서브쿼리를 쓰세요. 자세한 건 Step 11(서브쿼리)에서.
💡 하준서(30번)의 결제수가 0 인 게 보이나요? 하준서의 주문은 전부 PENDING 이라 결제가 없습니다. LEFT JOIN 이라 주문수는 20 으로 남으면서 결제수만 0 이 되었습니다 — INNER JOIN 이었다면 하준서가 통째로 빠졌을 겁니다.
정리
| 조인 | 남기는 것 | 대표 용도 |
|---|
INNER JOIN (= JOIN) | 양쪽에 짝이 있는 행만 | 관련 데이터 결합 |
LEFT JOIN | 왼쪽 전부 + 오른쪽(없으면 NULL) | "없는 것도 0으로", 안티조인 |
RIGHT JOIN | 오른쪽 전부 + 왼쪽 | 거의 안 씀 (LEFT 로 뒤집기) |
CROSS JOIN | 모든 조합 (m×n) | 빈 조합 채우기, 매트릭스 |
SELF JOIN | 같은 테이블 두 번 | 계층(관리자), 그룹 내 비교 |
LEFT JOIN ... IS NULL | 짝이 없는 왼쪽 행 | 안티조인 |
(LEFT) UNION (RIGHT) | 양쪽 전부 | FULL OUTER JOIN 우회 |
핵심 함정 3가지
- ON vs WHERE: LEFT JOIN 에서 오른쪽 조건을
WHERE 에 걸면 INNER JOIN 으로 퇴화한다. 오른쪽 조건은 ON 에, 왼쪽 조건은 WHERE 에.
- LEFT JOIN + COUNT:
COUNT(*) 는 NULL 확장 행도 세어 0을 1로 만든다. COUNT(오른쪽컬럼) 을 써라.
- fan-out: 여러 1:N 을 조인하면 행이 곱해진다.
COUNT(DISTINCT) 또는 서브쿼리 분리로 방어.
MySQL 특이사항: FULL OUTER JOIN 없음 → UNION 우회. USING 컬럼은 결과에 한 번만.
연습문제
orders 와 customers 를 INNER JOIN 해서, 서울에 사는 고객의 주문 중 금액 상위 5건을 조회하세요. (주문번호, 고객명, 금액)
products 를 기준으로 categories 를 LEFT JOIN 해서, 각 상품의 카테고리명과 상위 카테고리명을 함께 보이세요. (힌트: categories 를 두 번 조인 — 소분류용, 대분류용)
- 주문을 한 번도 안 한 고객이 있는지 안티조인(
LEFT JOIN ... IS NULL)으로 확인하세요. (우리 데이터에선 몇 명일까요?)
employees 에서 부하 직원이 없는 사원(말단) 을 SELF JOIN 안티조인으로 찾으세요. (힌트: 자신의 employee_id 가 누군가의 manager_id 로 쓰이지 않는 사원)
orders o LEFT JOIN payments pay 에서, 환불(REFUNDED)된 결제가 있는 주문 과 결제가 아예 없는 주문(PENDING) 을 구분해서, 상태별 주문 수를 세세요. (힌트: pay.status 를 GROUP BY, NULL 도 한 그룹)
- 카테고리별 매출 리포트를 만드세요.
categories(대분류만 아님, 소분류 전체) 를 LEFT JOIN 기준으로 삼아 매출이 0인 카테고리도 0으로 보이게 하세요. (category_id, 카테고리명, 매출 — 취소 주문 제외)
- (ON vs WHERE) 다음 두 쿼리의 결과 행 수가 왜 다른지 실행해서 확인하고 주석으로 설명하세요.
-- (A)
SELECT COUNT(*) FROM customers c
LEFT JOIN orders o ON o.customer_id=c.customer_id AND o.total_amount > 5000000;
-- (B)
SELECT COUNT(*) FROM customers c
LEFT JOIN orders o ON o.customer_id=c.customer_id WHERE o.total_amount > 5000000;
- (FULL OUTER 우회)
products 의 카테고리 집합과, reviews 가 달린 상품들의 카테고리 집합을 비교하세요. "상품은 있지만 후기가 하나도 없는 카테고리" 를 FULL OUTER JOIN 우회 또는 안티조인으로 찾으세요.
문제만 담긴 파일은 exercise.sql, 정답과 해설은 solution.sql 입니다. 두 파일 모두 아래 실습 파일 섹션에 전문이 실려 있습니다.
다음 단계
→ Step 08 — 집합 연산과 서브쿼리 기초 (이후 스텝에서 계속됩니다)
실습 파일
이 스텝은 SQL 파일 3개로 구성됩니다. 본문(7-1 ~ 7-11)의 예제를 순서대로 담은 practice.sql 을 먼저 실행해 결과를 눈으로 확인하고, 그다음 exercise.sql 의 8개 문제를 직접 풀어본 뒤, 마지막으로 solution.sql 로 채점하고 해설을 읽는 흐름입니다. 세 파일 모두 맨 앞에 USE shop; 이 있어 shop 데이터베이스를 대상으로 동작하며, Step 01 에서 띄운 컨테이너(mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < 파일명)에 그대로 흘려 넣으면 됩니다. -t 옵션이 있어야 본문에 나온 것과 같은 ASCII 표 형태로 결과가 출력됩니다.
practice.sql
본문 강의를 따라가며 손으로 쳐볼 예제를 [7-1] ~ [7-11] 주석 번호로 묶어 놓은 파일입니다. 절 번호가 본문 소제목과 1:1 로 대응하므로, 본문을 읽다가 막히면 같은 번호의 블록을 찾아 실행해 보면 됩니다.
[7-1] 은 INNER JOIN customers c ON c.customer_id = o.customer_id 로 시작해, 바로 아래에 INNER 를 뺀 JOIN 버전을 나란히 두어 둘이 같은 것임을 눈으로 확인시킵니다.
[7-3] 의 두 번째 쿼리는 COUNT(p.product_id) 와 COUNT(*) 를 한 SELECT 안에 나란히 놓습니다. 대분류 5개에서 앞은 0, 뒤는 1 이 나오는 것이 이 절의 핵심 학습 포인트입니다.
[7-4] 는 이 스텝의 심장입니다. AND o.status = 'DELIVERED' 를 ON 절에 붙인 쿼리(258)와 WHERE 로 내린 쿼리(240)를 연달아 실행해 18행 차이를 만들고, 이어서 WHERE c.customer_id = 6 으로 강소라 한 명만 뽑아 NULL 확장 행을 직접 보여줍니다.
[7-10] 의 FULL OUTER JOIN 예시는 주석으로만 남겨두었습니다(-- SELECT * FROM a FULL OUTER JOIN b ... → ERROR 1064). 주석을 풀면 파일 실행이 그 지점에서 에러로 멈추니, 주석을 지우지 말고 그대로 두세요.
[7-11] 의 마지막 컬럼 FORMAT(COALESCE(SUM(DISTINCT o.total_amount), 0), 0) AS 주문금액참고 는 이름 그대로 참고용입니다. SUM(DISTINCT ...) 는 fan-out 을 완전히 해결하지 못합니다(서로 다른 주문의 금액이 우연히 같으면 한 번만 더해집니다). 본문 7-11 의 경고와 짝지어 읽으세요.
-- =====================================================================
-- Step 07 — 조인(JOIN) : practice.sql
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < practice.sql
-- =====================================================================
USE shop;
-- [7-1] INNER JOIN — 양쪽에 다 있는 것만
SELECT
o.order_id,
o.order_date,
c.name AS 고객명,
c.grade AS 등급,
o.total_amount
FROM orders o
INNER JOIN customers c ON c.customer_id = o.customer_id
ORDER BY o.order_id
LIMIT 5;
-- [7-1] INNER 는 생략 가능 (JOIN = INNER JOIN)
SELECT o.order_id, c.name
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
LIMIT 3;
-- [7-2] 다중 조인 — 주문 → 상세 → 상품 → 카테고리
SELECT
o.order_id,
c.name AS 고객,
p.name AS 상품,
cat.name AS 카테고리,
oi.quantity AS 수량,
oi.unit_price AS 단가
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
JOIN categories cat ON cat.category_id = p.category_id
ORDER BY o.order_id, p.product_id
LIMIT 8;
-- [7-3] LEFT JOIN — 왼쪽은 전부 남기고, 오른쪽은 없으면 NULL
-- categories 5개 대분류는 직접 매달린 상품이 없다 → 상품 컬럼이 NULL
SELECT
cat.category_id,
cat.name AS 카테고리,
p.product_id,
p.name AS 상품명
FROM categories cat
LEFT JOIN products p ON p.category_id = cat.category_id
WHERE cat.parent_id IS NULL
ORDER BY cat.category_id;
-- [7-3] LEFT JOIN + COUNT — 상품이 0개인 카테고리도 보인다
-- ⚠️ COUNT(*) 가 아니라 COUNT(오른쪽컬럼) 을 써야 0 이 나온다!
SELECT
cat.category_id,
cat.name AS 카테고리,
COUNT(p.product_id) AS 상품수,
COUNT(*) AS `COUNT(*)_함정`
FROM categories cat
LEFT JOIN products p ON p.category_id = cat.category_id
GROUP BY cat.category_id, cat.name
ORDER BY 상품수, cat.category_id
LIMIT 8;
-- [7-4] ON vs WHERE — LEFT JOIN 에서 필터 위치가 결과를 바꾼다 (핵심)
-- 조건을 ON 에 두면: 조인 짝을 만들 때만 적용 → 왼쪽 행은 전부 보존
SELECT COUNT(*) AS 조건이_ON
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'DELIVERED';
-- 조건을 WHERE 에 두면: 조인 "후" 적용 → NULL 확장된 행이 탈락 → INNER JOIN 이 됨
SELECT COUNT(*) AS 조건이_WHERE
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.status = 'DELIVERED';
-- [7-4] 눈으로 확인 — 배송완료 주문이 하나도 없는 고객
-- (예: 주문이 전부 CANCELLED 인 강소라)
SELECT
c.customer_id,
c.name,
o.order_id,
o.status
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'DELIVERED'
WHERE c.customer_id = 6;
-- [7-5] 안티조인 ① — LEFT JOIN ... IS NULL
-- "결제가 없는 주문" = PENDING 주문 60건
SELECT
o.order_id,
o.status,
o.total_amount
FROM orders o
LEFT JOIN payments pay ON pay.order_id = o.order_id
WHERE pay.payment_id IS NULL
ORDER BY o.order_id
LIMIT 8;
-- [7-5] 개수로 확인 — LEFT JOIN IS NULL vs 순수 조건
SELECT
(SELECT COUNT(*) FROM orders o
LEFT JOIN payments pay ON pay.order_id = o.order_id
WHERE pay.payment_id IS NULL) AS 안티조인_결과,
(SELECT COUNT(*) FROM orders
WHERE status = 'PENDING') AS PENDING_주문수;
-- [7-5] 안티조인 ② — 후기를 한 번도 안 남긴 고객 (Step 05 의 NOT EXISTS 를 조인으로)
SELECT
c.customer_id,
c.name,
c.grade
FROM customers c
LEFT JOIN reviews r ON r.customer_id = c.customer_id
WHERE r.review_id IS NULL
ORDER BY c.customer_id
LIMIT 8;
-- [7-6] SELF JOIN — 같은 테이블을 두 번 (사원 ↔ 관리자)
SELECT
e.employee_id,
e.name AS 사원,
e.position AS 직급,
m.name AS 관리자,
m.position AS 관리자_직급
FROM employees e
LEFT JOIN employees m ON m.employee_id = e.manager_id
ORDER BY e.employee_id
LIMIT 10;
-- [7-6] SELF JOIN 으로 "나보다 월급 많은 같은 부서 사람" 세기
SELECT
e.name AS 사원,
e.dept AS 부서,
e.salary AS 급여,
COUNT(h.employee_id) AS 나보다_높은_사람수
FROM employees e
LEFT JOIN employees h
ON h.dept = e.dept
AND h.salary > e.salary
GROUP BY e.employee_id, e.name, e.dept, e.salary
ORDER BY e.dept, e.salary DESC
LIMIT 10;
-- [7-7] CROSS JOIN — 모든 조합 (곱집합)
-- 등급 4종 × 도시 3종 = 12행. 조건 없이 곱한다.
SELECT g.grade, ct.city
FROM (SELECT DISTINCT grade FROM customers) g
CROSS JOIN (SELECT DISTINCT city FROM customers WHERE city IN ('서울','부산','인천')) ct
ORDER BY g.grade, ct.city;
-- [7-7] CROSS JOIN 의 실용 예: 등급×도시 조합별 고객 수 (없는 조합도 0으로)
SELECT
g.grade,
ct.city,
COUNT(c.customer_id) AS 고객수
FROM (SELECT DISTINCT grade FROM customers) g
CROSS JOIN (SELECT DISTINCT city FROM customers) ct
LEFT JOIN customers c ON c.grade = g.grade AND c.city = ct.city
GROUP BY g.grade, ct.city
HAVING COUNT(c.customer_id) > 0
ORDER BY g.grade, 고객수 DESC
LIMIT 10;
-- [7-8] USING — 조인 컬럼 이름이 양쪽 같을 때 축약
-- ON a.customer_id = b.customer_id → USING (customer_id)
-- USING 컬럼은 결과에 한 번만 나온다 (SELECT * 시 차이)
SELECT customer_id, o.order_id, o.total_amount
FROM orders o
JOIN customers c USING (customer_id)
ORDER BY o.order_id
LIMIT 5;
-- [7-9] RIGHT JOIN — LEFT 의 방향만 바꾼 것 (실무에선 잘 안 씀)
-- products 를 전부 남기고 order_items 를 매칭
SELECT
p.product_id,
p.name,
COUNT(oi.order_item_id) AS 판매_횟수
FROM order_items oi
RIGHT JOIN products p ON p.product_id = oi.product_id
GROUP BY p.product_id, p.name
ORDER BY 판매_횟수 ASC, p.product_id
LIMIT 5;
-- [7-10] MySQL 엔 FULL OUTER JOIN 이 없다 → UNION 으로 우회
-- 먼저 실패를 확인: FULL JOIN 은 문법 에러
-- SELECT * FROM a FULL OUTER JOIN b ON ...; ← ERROR 1064
--
-- 질문: "2024년 1분기 주문 고객" 과 "2025년 1분기 주문 고객" 을 비교하고 싶다.
-- 한쪽에만 있는 고객까지 전부 보려면 FULL OUTER JOIN 이 필요하다.
-- 우회: (LEFT JOIN) UNION (RIGHT JOIN). UNION 이 중복(양쪽 매칭 행)을 제거한다.
WITH
q2024 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'),
q2025 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2025-01-01' AND order_date < '2025-04-01')
SELECT a.customer_id AS `2024_1분기`, b.customer_id AS `2025_1분기`
FROM q2024 a
LEFT JOIN q2025 b ON b.customer_id = a.customer_id
UNION
SELECT a.customer_id, b.customer_id
FROM q2024 a
RIGHT JOIN q2025 b ON b.customer_id = a.customer_id
ORDER BY `2024_1분기`, `2025_1분기`
LIMIT 6;
-- [7-10] 한쪽에만 있는 고객만 추리기 (FULL OUTER + 대칭차집합)
-- 2024 1분기에만 주문(15번) / 2025 1분기에만 주문(25번)
WITH
q2024 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'),
q2025 AS (SELECT DISTINCT customer_id FROM orders
WHERE order_date >= '2025-01-01' AND order_date < '2025-04-01')
SELECT a.customer_id AS `2024만`, b.customer_id AS `2025만`
FROM q2024 a LEFT JOIN q2025 b ON b.customer_id = a.customer_id
WHERE b.customer_id IS NULL
UNION
SELECT a.customer_id, b.customer_id
FROM q2024 a RIGHT JOIN q2025 b ON b.customer_id = a.customer_id
WHERE a.customer_id IS NULL;
-- [7-11] 종합: 고객별 주문/결제/후기 요약 (LEFT JOIN 여러 개 + 집계)
-- ⚠️ 여러 1:N 을 한꺼번에 조인하면 행이 곱해진다 → COUNT(DISTINCT) 로 방어
SELECT
c.customer_id,
c.name AS 고객,
c.grade AS 등급,
COUNT(DISTINCT o.order_id) AS 주문수,
COUNT(DISTINCT pay.payment_id) AS 결제수,
COUNT(DISTINCT r.review_id) AS 후기수,
FORMAT(COALESCE(SUM(DISTINCT o.total_amount), 0), 0) AS 주문금액참고
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.customer_id
LEFT JOIN payments pay ON pay.order_id = o.order_id
LEFT JOIN reviews r ON r.customer_id = c.customer_id
GROUP BY c.customer_id, c.name, c.grade
ORDER BY 후기수 DESC, 주문수 DESC
LIMIT 8;
exercise.sql
본문 "연습문제" 8개를 그대로 옮겨 담은 빈칸 채우기용 파일입니다. 각 문제는 [문제 N] 주석 블록으로 구분되어 있고 그 아래가 비어 있으니, 거기에 직접 쿼리를 써 넣고 파일을 통째로 실행해 검증하면 됩니다.
[문제 7] 만 예외적으로 쿼리 (A)/(B) 가 이미 작성되어 있습니다. 여러분이 할 일은 쿼리를 쓰는 게 아니라 두 COUNT 를 실제로 실행해 비교하고, 파일 끝의 -- → 왜 다른가요? (여기에 설명) 자리에 이유를 주석으로 적는 것입니다.
[문제 5] 는 - NULL 그룹은 '(무결제)' 로 표시하세요. (힌트: COALESCE) 라고 힌트를 줍니다. PENDING 주문이 payments 에 짝이 없어 pay.status 가 NULL 이 되는 성질(7-5 절)을 이용하는 문제입니다.
[문제 6] 의 힌트 취소 제외는 ON 이 아니라 SUM(CASE WHEN ...) 로 해야 정확합니다. 왜일까요? 가 이 파일에서 가장 어려운 대목입니다. 답을 보기 전에 두 방식을 모두 돌려 숫자가 왜 갈리는지 먼저 관찰해 보세요.
- 파일을 그대로 실행하면 문제 7 의 두 쿼리만 결과가 나오고 나머지는 아무것도 출력되지 않습니다. 정상입니다.
-- =====================================================================
-- Step 07 — 조인(JOIN) : exercise.sql (문제)
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < exercise.sql
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [문제 1] INNER JOIN 기본
-- orders 와 customers 를 조인해서, 서울에 사는 고객의 주문 중
-- 금액(total_amount) 상위 5건을 조회하세요.
-- (order_id, 고객명, total_amount)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 2] 카테고리 두 번 조인 (소분류 + 대분류)
-- products 기준으로, 각 상품의 카테고리명(소분류)과 상위 카테고리명(대분류)을
-- 함께 보이세요. categories 를 두 번 조인해야 합니다.
-- (product_id, 상품명, 소분류명, 대분류명) — product_id 순, 상위 10개.
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 3] 주문 없는 고객 (안티조인)
-- 주문을 한 번도 안 한 고객이 있는지 LEFT JOIN ... IS NULL 로 확인하세요.
-- 몇 명인가요?
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 4] 부하가 없는 말단 사원 (SELF JOIN 안티조인)
-- employees 에서 자신의 employee_id 가 누구의 manager_id 로도 쓰이지 않는
-- 사원(= 부하 직원이 없는 말단)을 찾으세요.
-- (employee_id, name) — employee_id 순.
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 5] 결제 상태별 주문 수
-- orders LEFT JOIN payments 로, 결제 상태(pay.status)별 주문 수를 세세요.
-- - DONE(정상결제) / REFUNDED(환불) / 결제 없음(NULL) 세 그룹이 나와야 합니다.
-- - NULL 그룹은 '(무결제)' 로 표시하세요. (힌트: COALESCE)
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 6] 카테고리별 매출 (0도 보이게)
-- 전체 카테고리(17개)를 기준으로 매출 리포트를 만드세요.
-- - 취소(CANCELLED) 주문은 매출에서 제외
-- - 매출이 0인 카테고리(상품이 없는 대분류 등)도 0으로 보이게
-- (category_id, 카테고리명, 매출) — 매출 내림차순.
-- 힌트: 취소 제외는 ON 이 아니라 SUM(CASE WHEN ...) 로 해야 정확합니다. 왜일까요?
-- ---------------------------------------------------------------------
-- ---------------------------------------------------------------------
-- [문제 7] ON vs WHERE 차이 확인
-- 아래 두 쿼리를 각각 실행해 COUNT 를 비교하고, 왜 다른지 주석으로 쓰세요.
-- (A) LEFT JOIN ... ON ... AND o.total_amount > 5000000
-- (B) LEFT JOIN ... ON ... WHERE o.total_amount > 5000000
-- ---------------------------------------------------------------------
-- (A)
SELECT COUNT(*) AS A
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.total_amount > 5000000;
-- (B)
SELECT COUNT(*) AS B
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.total_amount > 5000000;
-- → 왜 다른가요? (여기에 설명)
-- ---------------------------------------------------------------------
-- [문제 8] 후기가 하나도 없는 카테고리
-- "상품은 있지만 후기(reviews)가 하나도 달리지 않은 카테고리" 를 찾으세요.
-- 안티조인 또는 NOT EXISTS 로 작성하세요.
-- ---------------------------------------------------------------------
solution.sql
8문제의 정답 쿼리와 해설 주석을 담은 파일입니다. exercise.sql 을 스스로 풀어본 뒤에 열어보세요. 각 정답 위 주석에 기대 결과값까지 적혀 있어 채점표로 바로 쓸 수 있습니다.
[정답 3] 의 결과는 0명입니다. 시드 데이터가 고객 30명 전원에게 주문 20건씩을 배정했기 때문입니다. "아무것도 안 나오는 것" 이 정답인 문제로, 데이터 검증에서 안티조인이 어떻게 쓰이는지 보여줍니다.
[정답 4] 는 LEFT JOIN employees s ON s.manager_id = e.employee_id 로 부하 방향의 SELF JOIN 을 건 뒤 WHERE s.employee_id IS NULL 로 걸러냅니다. 7-6 절의 m.employee_id = e.manager_id(관리자 방향)와 조인 조건의 방향이 반대라는 점이 포인트입니다. 결과는 9~18번 사원 10명입니다.
[정답 6] 이 이 파일의 하이라이트입니다. ON o.status <> 'CANCELLED' 로 취소를 제외하면 틀립니다. 그 조건은 orders 를 매칭할지 말지만 정할 뿐, 이미 조인된 order_items 의 quantity * unit_price 값은 그대로 남아 매출에 섞입니다. 그래서 SUM(CASE WHEN o.status <> 'CANCELLED' THEN oi.quantity * oi.unit_price END) 로 합산 여부 자체를 제어해야 합니다. 결과 1위는 노트북 327,900,000 이고, 상품이 없는 대분류 5개는 COALESCE(..., 0) 덕분에 0 으로 나옵니다.
[정답 7] 의 숫자는 (A) 46, (B) 20 입니다. 46 = 500만원 초과 주문 20건 + 그런 주문이 하나도 없는 고객 26명. 본문 7-4 의 258/240 과 같은 원리를 다른 조건으로 한 번 더 겪게 하는 문제입니다.
[정답 8] 은 EXISTS (products) 로 "상품이 있는 카테고리" 로 먼저 좁힌 뒤 NOT EXISTS (products JOIN reviews) 로 후기 없는 곳만 남깁니다. 정답은 소설(52) 카테고리 하나입니다.
-- =====================================================================
-- Step 07 — 조인(JOIN) : solution.sql (정답 + 해설)
-- 실행: mysql -h127.0.0.1 -P3307 -ulearner -plearn1234 shop -t < solution.sql
-- =====================================================================
USE shop;
-- ---------------------------------------------------------------------
-- [정답 1] INNER JOIN 기본
-- 서울 고객 조건은 customers(왼쪽 개념) 조건이므로 WHERE 에 둡니다.
-- 결과: 김민수의 4,380,000원 주문 5건이 상위를 차지합니다
-- (동점이라 order_id 로 tie-break 하면 더 안정적).
-- ---------------------------------------------------------------------
SELECT o.order_id, c.name AS 고객명, o.total_amount
FROM orders o
JOIN customers c ON c.customer_id = o.customer_id
WHERE c.city = '서울'
ORDER BY o.total_amount DESC, o.order_id
LIMIT 5;
-- ---------------------------------------------------------------------
-- [정답 2] 카테고리 두 번 조인 (소분류 + 대분류)
-- sub = 상품이 직접 속한 소분류 (products.category_id)
-- main = 그 소분류의 상위 (sub.parent_id)
-- 같은 테이블(categories)을 서로 다른 별칭으로 두 번 조인합니다.
-- ---------------------------------------------------------------------
SELECT
p.product_id,
p.name AS 상품명,
sub.name AS 소분류,
main.name AS 대분류
FROM products p
JOIN categories sub ON sub.category_id = p.category_id
JOIN categories main ON main.category_id = sub.parent_id
ORDER BY p.product_id
LIMIT 10;
-- ---------------------------------------------------------------------
-- [정답 3] 주문 없는 고객 (안티조인)
-- 결과: 0명. 30명 모두 주문이 있습니다(시드가 전원에게 20건씩 배정).
-- "0명"을 확인하는 것도 훌륭한 데이터 검증입니다.
-- ---------------------------------------------------------------------
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;
-- ---------------------------------------------------------------------
-- [정답 4] 부하가 없는 말단 사원 (SELF JOIN 안티조인)
-- e 를 기준으로, e.employee_id 를 manager_id 로 가진 부하(s)를 LEFT JOIN.
-- 부하가 없으면 s 가 NULL 확장 → WHERE s.employee_id IS NULL 로 걸러냄.
-- 결과: 9~18번 (시니어/주니어 실무자 10명). 1~8번은 누군가의 관리자.
-- ---------------------------------------------------------------------
SELECT e.employee_id, e.name
FROM employees e
LEFT JOIN employees s ON s.manager_id = e.employee_id
WHERE s.employee_id IS NULL
ORDER BY e.employee_id;
-- ---------------------------------------------------------------------
-- [정답 5] 결제 상태별 주문 수
-- PENDING 주문은 payments 에 짝이 없어 pay.status 가 NULL 이 됩니다.
-- COALESCE 로 NULL 을 '(무결제)' 로 표시. GROUP BY 는 NULL 도 한 그룹으로 묶습니다.
-- 결과: DONE 480 / REFUNDED 60 / (무결제) 60
-- ---------------------------------------------------------------------
SELECT
COALESCE(pay.status, '(무결제)') AS 결제상태,
COUNT(DISTINCT o.order_id) AS 주문수
FROM orders o
LEFT JOIN payments pay ON pay.order_id = o.order_id
GROUP BY pay.status
ORDER BY 주문수 DESC;
-- ---------------------------------------------------------------------
-- [정답 6] 카테고리별 매출 (0도 보이게)
-- 핵심: 취소 제외를 "ON o.status <> 'CANCELLED'" 로 하면 틀립니다!
-- 그 조건은 orders 를 매칭할지만 정할 뿐, order_items(oi)의 값은 그대로
-- 남아 NULL 확장된 취소 주문의 상세가 매출에 섞여 들어갑니다.
-- → SUM(CASE WHEN o.status <> 'CANCELLED' THEN ... END) 로 "합산 여부"를
-- 제어해야 정확합니다.
-- 결과: 노트북 327,900,000 이 1위. 상품 없는 대분류 5개는 매출 0.
-- ---------------------------------------------------------------------
SELECT
cat.category_id,
cat.name AS 카테고리,
FORMAT(
COALESCE(SUM(CASE WHEN o.status <> 'CANCELLED'
THEN oi.quantity * oi.unit_price END), 0), 0
) AS 매출
FROM categories cat
LEFT JOIN products p ON p.category_id = cat.category_id
LEFT JOIN order_items oi ON oi.product_id = p.product_id
LEFT JOIN orders o ON o.order_id = oi.order_id
GROUP BY cat.category_id, cat.name
ORDER BY SUM(CASE WHEN o.status <> 'CANCELLED'
THEN oi.quantity * oi.unit_price END) DESC
LIMIT 12;
-- ---------------------------------------------------------------------
-- [정답 7] ON vs WHERE 차이 확인
-- (A) = 46, (B) = 20.
-- (A) 조건이 ON: 500만원 초과 주문만 매칭하되, 그런 주문이 없는 고객도
-- NULL 확장으로 남습니다. 500만 초과 주문 20건 + 그런 주문이 하나도
-- 없는 고객 26명 = 46.
-- (B) 조건이 WHERE: 조인 후 필터. NULL 확장 행은 o.total_amount 가 NULL 이라
-- NULL > 5000000 = UNKNOWN 으로 탈락 → 사실상 INNER JOIN → 20.
-- 교훈: LEFT JOIN 의 오른쪽 조건을 WHERE 에 두면 LEFT 가 INNER 로 퇴화한다.
-- ---------------------------------------------------------------------
SELECT COUNT(*) AS A
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.total_amount > 5000000;
SELECT COUNT(*) AS B
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.total_amount > 5000000;
-- ---------------------------------------------------------------------
-- [정답 8] 후기가 하나도 없는 카테고리
-- "상품은 있으나 그 카테고리의 어떤 상품에도 후기가 없는" 카테고리.
-- 먼저 상품이 있는 카테고리로 좁히고(EXISTS products),
-- 그 카테고리 상품에 후기가 하나도 없음(NOT EXISTS reviews)을 확인합니다.
-- 결과: 소설(52). 소설 카테고리의 상품(여름의 끝에서)에는 후기가 없습니다.
-- ---------------------------------------------------------------------
SELECT cat.category_id, cat.name
FROM categories cat
WHERE EXISTS (
SELECT 1 FROM products p WHERE p.category_id = cat.category_id
)
AND NOT EXISTS (
SELECT 1
FROM products p
JOIN reviews r ON r.product_id = p.product_id
WHERE p.category_id = cat.category_id
)
ORDER BY cat.category_id;