매출이 두 배로 잡히는 보고서,
JOIN 뻥튀기 함정
JOIN을 한 줄 늘렸을 뿐인데 김철수의 주문 수가 6에서 10으로 — AI가 가장 자주 틀리고, 사람 개발자도 평생 틀리는 SQL 패턴입니다. 왜 행이 불어나는지, 그리고 그걸 한 줄로 잡아내는 검산 습관까지. 섹션 3의 화룡점정입니다.
이번 섹션의 여정
완료 ✓
완료 ✓
지금 여기
섹션 3 마무리
강의 메인
멀쩡하던 쿼리가 틀어지는 순간
이번 강의는 AI가 가장 자주 틀리는 SQL 패턴이자, 사람 개발자도 평생 틀리는 패턴 — JOIN으로 데이터가 뻥튀기되는 함정입니다.
이걸 모르면 “매출이 두 배로 잡히는 보고서” 가 회사에 그대로 나갈 수 있습니다. 문법 에러가 없으니 아무도 눈치 못 채요 — 조용히 틀린 숫자가 의사결정에 쓰이는 게 이 함정의 무서운 점입니다.
3-4의 “회원별 주문 수” 기억나시죠. 거기에 3-6의 LEFT JOIN으로 이름까지 붙인, 우리가 이미 검증한 깔끔한 쿼리입니다.
-- ✅ 정확 — 3-4의 회원별 주문 수 + 3-6의 LEFT JOIN
SELECT
m.member_name,
COUNT(o.order_id) AS 주문수
FROM
members m
LEFT JOIN orders o
ON m.member_id = o.member_id
GROUP BY
m.member_id,
m.member_name;| member_name | 주문수 |
|---|---|
| 김철수 | 6 |
| 이영희 | 4 |
| 박민수 | 3 |
| ⋮ | |
| 황태웅 | 0 |
깔끔합니다. 김철수 6번, 이영희 4번 — 정확해요. 근데 여기서 “회원별 총 주문 수량도 알고 싶다” 며 주문상세(order_items)까지 JOIN을 한 줄 추가했다고 칩시다. 같은 패턴인 줄 알고요.
-- ❌ 함정 — 같은 패턴인 줄 알고 JOIN만 추가
SELECT
m.member_name,
COUNT(o.order_id) AS 주문수
FROM
members m
JOIN orders o
ON m.member_id = o.member_id
JOIN order_items oi
ON o.order_id = oi.order_id
GROUP BY
m.member_id,
m.member_name;| member_name | 주문수 |
|---|---|
| 김철수 | 10 ← 6이었는데? |
| 이영희 | 6 ← 4였는데? |
| 박민수 | 5 |
| ⋮ | |
김철수의 주문 수가 6에서 10으로 늘었습니다. 주문을 더 한 것도 아닌데 — 4건은 어디서 왔을까요?
주문 1건 = 담긴 상품 수만큼 N행
김철수의 주문 6건이 주문상세 10행으로 불어난 채 COUNT — 주문수 10. 에러는 없습니다. 조용히 틀린 숫자가 나올 뿐.
주문 1건 = 정확히 1행
COUNT(o.order_id)가 주문 건수를 그대로 셉니다 — 주문수 6. 질문이 주문 수라면 JOIN은 orders에서 멈춰야 해요.
원인 — 1:N 관계가 행을 곱한다
천천히 봅시다. 김철수의 주문은 6건이고, 그중 한 주문 — order 14 에는 책이 3권 담겨 있습니다.
이 상태에서 COUNT(o.order_id) 를 하면? 3으로 셉니다. “order_id 14가 3번 나타났으니까.” SQL 입장에선 시킨 대로 정직하게 센 거예요 — 우리가 의도한 단위(주문 건)와 실제 행의 단위(주문상세)가 어긋났을 뿐.
1:N 관계의 N 쪽으로 JOIN해 내려갈수록, 부모의 행이 자식의 행 수만큼 곱해집니다.
그래서 그 위에서 돌린 COUNT · SUM · AVG가 전부 실제보다 부풀어요. 이게 JOIN 뻥튀기 함정의 전부입니다.
검산 습관 — JOIN 직후 COUNT부터 찍기 ⭐
이런 함정을 잡는 가장 좋은 습관 — JOIN을 만들었으면, 집계하기 전에 행 수부터 찍어봅니다. 이 한 줄이 평생의 SQL 사고를 막아줘요.
-- 검산 ①: 회원-주문 JOIN의 행 수
SELECT
COUNT(*)
FROM
members m
JOIN orders o
ON m.member_id = o.member_id;
-- → 40 (= orders 행 수와 일치, 정상)
-- 검산 ②: 회원-주문-주문상세 JOIN의 행 수
SELECT
COUNT(*)
FROM
members m
JOIN orders o
ON m.member_id = o.member_id
JOIN order_items oi
ON o.order_id = oi.order_id;
-- → 64 (= order_items 행 수와 일치, 정상)JOIN 결과의 총 행 수가 가장 자식 테이블의 행 수와 일치하면 정상. 그보다 크면 어딘가에 의도치 않은 N:M 매칭이나 카테시안 곱이 끼어든 것 — 일단 의심하세요.
JOIN 직후 행 수 검산 — 이거 하나만 몸에 배어 있어도 AI가 만든 SQL의 함정 중 70% 정도는 잡아낼 수 있습니다. 이 강의가 만들고 싶은 게 바로 이 검증 본능이에요.
해결 ① — COUNT(DISTINCT)로 중복 제거
뻥튀기된 상태에서도 주문 수를 정확히 세는 방법이 있습니다. “중복된 order_id는 한 번만 세라” — COUNT(DISTINCT ...) 입니다.
-- 회원별 주문 수 — DISTINCT 로 중복 제거
SELECT
m.member_name,
COUNT(DISTINCT o.order_id) AS 주문수
FROM
members m
JOIN orders o
ON m.member_id = o.member_id
JOIN order_items oi
ON o.order_id = oi.order_id
GROUP BY
m.member_id,
m.member_name;| member_name | 주문수 |
|---|---|
| 김철수 | 6 |
| 이영희 | 4 |
| 박민수 | 3 |
| ⋮ | |
order_id 14가 3행으로 늘어나 있어도, DISTINCT가 같은 값을 하나로 접어서 셉니다. 다만 이건 어디까지나 수습이에요 — 그리고 INNER JOIN이라 주문 없는 회원 4명은 결과에서 빠져 있죠(16행). 더 근본적인 해결이 있습니다.
해결 ② — 필요한 만큼만 JOIN ⭐
가장 깔끔한 해결은 — 그 JOIN 자체를 하지 않는 겁니다. 회원별 주문 수만 필요하면 order_items 까지 내려갈 이유가 없어요. 질문이 무엇이냐에 따라 JOIN의 깊이가 정해집니다.
| 알고 싶은 것 | 필요한 테이블 | JOIN 깊이 |
|---|---|---|
| 회원별 주문 수 | members + orders | 2개 |
| 회원별 총 주문 수량 | members + orders + order_items | 3개 |
| 회원별 총 매출액 | members + orders + order_items + products | 4개 |
-- 회원별 주문 수 → orders 까지만
SELECT
m.member_name,
COUNT(o.order_id) AS 주문수
FROM
members m
LEFT JOIN orders o
ON m.member_id = o.member_id
GROUP BY
m.member_id,
m.member_name;-- 회원별 총 주문 수량 → order_items 까지 + SUM
SELECT
m.member_name,
SUM(oi.quantity) AS 총수량
FROM
members m
JOIN orders o
ON m.member_id = o.member_id
JOIN order_items oi
ON o.order_id = oi.order_id
GROUP BY
m.member_id,
m.member_name;| member_name | 총수량 |
|---|---|
| 김철수 | 11 |
| 이영희 | 6 |
| 박민수 | 7 |
| ⋮ | |
매출액처럼 상품 가격이 필요하면 그때 비로소 products 까지 — 4테이블 JOIN이 정당해집니다. 3-6 마지막에 만든 그 체인이에요.
-- 회원별 총 매출액 → products 까지 + SUM(가격 × 수량)
SELECT
m.member_name,
SUM(p.price * oi.quantity) AS 총매출
FROM
members m
JOIN orders o
ON m.member_id = o.member_id
JOIN order_items oi
ON o.order_id = oi.order_id
JOIN products p
ON oi.product_id = p.product_id
GROUP BY
m.member_id,
m.member_name
ORDER BY
총매출 DESC;| member_name | 총매출 |
|---|---|
| 조성민 | 600000 |
| 김철수 | 498000 |
| 박민수 | 356000 |
| ⋮ | |
필요한 데이터가 어느 테이블까지 가야 있는지 — 딱 그만큼만 JOIN. JOIN은 많이 할수록 좋은 게 아니라, 한 단계 내려갈 때마다 행의 단위가 바뀌는 위험한 연산입니다.
AI 검증 체크리스트 — 딱 3가지
AI가 만들어준 JOIN + 집계 쿼리를 받았을 때, 실행하기 전에 이 3가지만 체크하세요.
| # | 체크 항목 |
|---|---|
| 1 | JOIN 직후 COUNT(*) 한 번 찍기 — 행 수가 가장 자식 테이블의 행 수와 같은가? |
| 2 | GROUP BY 결과의 한 행이 — 의도한 단위(회원별 · 일별)와 일치하는가? |
| 3 | COUNT · SUM 결과를 다른 방법으로 검산 — 예: 회원 다섯 명만 골라 직접 세보기 |
AI는 SQL 문법은 정말 잘 짭니다. 근데 그 쿼리의 의미가 맞는지 — 행의 단위가 의도와 일치하는지 — 는 못 봐요. 그건 데이터의 맥락을 아는 사람의 일입니다.
오늘 잡은 뻥튀기 패턴은 섹션 6(AI 결과 검증)의 6-2에서 심화 버전으로 다시 나옵니다. 여기서 검산 습관을 들여두면 그때는 복습이 돼요.
“JOIN 결과의 행 수가 의심스러우면 먼저 점검한다” — 이 한 가지가 AI 시대의 진짜 SQL 실력입니다. 20년 짠 사람과 1년 짠 사람의 차이가 바로 여기서 갈려요.
자주 막히는 지점
뻥튀기의 원인은 INNER냐 LEFT냐가 아니라, 한 행이 여러 행과 매칭되는 관계 그 자체입니다. LEFT JOIN은 “매칭 안 되는 쪽을 살릴지” 만 정할 뿐이에요.
예를 들어 orders에 배송비 컬럼이 있었다면 — order_items까지 JOIN한 뒤 배송비를 SUM하는 순간, 주문마다 담긴 상품 수만큼 배송비가 곱해집니다.
oi.quantity)은 안전 — 부모 쪽 값을 자식까지 JOIN 한 채 SUM 하면 부풀어요.MySQL은 ON 없이도 에러 없이 그냥 돌아갑니다. 회원 20 × 주문 40 = 800행처럼 행 수가 폭발했다면 거의 확실히 이 경우예요.
뻥튀기를 직접 만들어보고, 잡아보고, 고쳐봐야 검증 본능이 몸에 붙습니다 — 연습 문제로 손에 새기고 가세요. 다음 강의는 섹션 3의 마지막, CASE와 서브쿼리를 빠르게 훑고 드디어 강의의 메인 — 섹션 4 DB 모델링으로 넘어갑니다.