AI 시대 DB 모델링 & SQL 완벽 마스터3-7 JOIN 뻥튀기 함정 ⭐
테크타니 LEARN — 섹션 03 · SQL 핵심 문법 — 한방 압축

매출이 두 배로 잡히는 보고서,
JOIN 뻥튀기 함정

JOIN을 한 줄 늘렸을 뿐인데 김철수의 주문 수가 6에서 10으로 — AI가 가장 자주 틀리고, 사람 개발자도 평생 틀리는 SQL 패턴입니다. 왜 행이 불어나는지, 그리고 그걸 한 줄로 잡아내는 검산 습관까지. 섹션 3의 화룡점정입니다.

난이도보통 · 중요도 최상소요10분준비물shop DB + 3-4 · 3-6 완료오늘의 무기COUNT(*) 검산 · COUNT(DISTINCT)

이번 섹션의 여정

3-1 ~ 3-3

완료 ✓

3-4 ~ 3-6

완료 ✓

3-7 JOIN 뻥튀기 함정 ⭐

지금 여기

3-8 CASE / 서브쿼리

섹션 3 마무리

섹션 4 — DB 모델링

강의 메인

멀쩡하던 쿼리가 틀어지는 순간

이번 강의는 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;
DBeaver — SQL Editor (Ctrl + Enter)
member_name주문수
김철수6
이영희4
박민수3
황태웅0
20 rows fetched — 회원 20명 전원, 주문 없는 회원은 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;
DBeaver — SQL Editor (Ctrl + Enter)
member_name주문수
김철수10 ← 6이었는데?
이영희6 ← 4였는데?
박민수5
16 rows fetched — 같은 COUNT인데 숫자가 전부 부풀었습니다

김철수의 주문 수가 6에서 10으로 늘었습니다. 주문을 더 한 것도 아닌데 — 4건은 어디서 왔을까요?

✕ order_items까지 JOIN

주문 1건 = 담긴 상품 수만큼 N행

김철수의 주문 6건이 주문상세 10행으로 불어난 채 COUNT — 주문수 10. 에러는 없습니다. 조용히 틀린 숫자가 나올 뿐.

✓ orders까지만 JOIN

주문 1건 = 정확히 1행

COUNT(o.order_id)가 주문 건수를 그대로 셉니다 — 주문수 6. 질문이 주문 수라면 JOIN은 orders에서 멈춰야 해요.

원인 — 1:N 관계가 행을 곱한다

천천히 봅시다. 김철수의 주문은 6건이고, 그중 한 주문 — order 14 에는 책이 3권 담겨 있습니다.

주문 1건의 실제 구조 — order 14
order 14 ── 김철수의 주문 1건
├─ 상품 22 · SQL 입문서
├─ 상품 23 · 데이터 모델링 실전
└─ 상품 24 · AI 시대 개발자
 
orders 와 order_items 를 JOIN 하면 —
 
order_id │ product_id
─────────┼───────────
14 │ 22
14 │ 23
14 │ 24
주문 1건이 3행으로 — order_items 행 수만큼 뻥튀기

이 상태에서 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 행 수와 일치, 정상)
검산 — 행 수가 말해줍니다
SELECT
COUNT(*)
FROM
members m
JOIN orders o
ON m.member_id = o.member_id;
→ 40 (orders 의 행 수 = 40)
 
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 의 행 수 = 64)
 
총 행 수 = 가장 자식 테이블의 행 수 → 정상
판정 기준은 하나

JOIN 결과의 총 행 수가 가장 자식 테이블의 행 수와 일치하면 정상. 그보다 크면 어딘가에 의도치 않은 N:M 매칭이나 카테시안 곱이 끼어든 것 — 일단 의심하세요.

이 습관 하나가 70%를 잡습니다

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;
DBeaver — SQL Editor (Ctrl + Enter)
member_name주문수
김철수6
이영희4
박민수3
16 rows fetched — 김철수 6으로 정상화

order_id 14가 3행으로 늘어나 있어도, DISTINCT가 같은 값을 하나로 접어서 셉니다. 다만 이건 어디까지나 수습이에요 — 그리고 INNER JOIN이라 주문 없는 회원 4명은 결과에서 빠져 있죠(16행). 더 근본적인 해결이 있습니다.

해결 ② — 필요한 만큼만 JOIN ⭐

가장 깔끔한 해결은 — 그 JOIN 자체를 하지 않는 겁니다. 회원별 주문 수만 필요하면 order_items 까지 내려갈 이유가 없어요. 질문이 무엇이냐에 따라 JOIN의 깊이가 정해집니다.

알고 싶은 것필요한 테이블JOIN 깊이
회원별 주문 수members + orders2개
회원별 총 주문 수량members + orders + order_items3개
회원별 총 매출액members + orders + order_items + products4개
-- 회원별 주문 수 → 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;
총 주문 수량 — 이건 order_items 까지 가는 게 맞습니다
member_name총수량
김철수11
이영희6
박민수7
16 rows fetched — quantity 는 order_items 의 값이라 뻥튀기 없음

매출액처럼 상품 가격이 필요하면 그때 비로소 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;
회원별 총 매출 TOP — VIP 랭킹
member_name총매출
조성민600000
김철수498000
박민수356000
16 rows fetched — 단위가 주문상세라 SUM 이 정확
외워둘 원칙 ⭐

필요한 데이터가 어느 테이블까지 가야 있는지 — 딱 그만큼만 JOIN. JOIN은 많이 할수록 좋은 게 아니라, 한 단계 내려갈 때마다 행의 단위가 바뀌는 위험한 연산입니다.

AI 검증 체크리스트 — 딱 3가지

AI가 만들어준 JOIN + 집계 쿼리를 받았을 때, 실행하기 전에 이 3가지만 체크하세요.

#체크 항목
1JOIN 직후 COUNT(*) 한 번 찍기 — 행 수가 가장 자식 테이블의 행 수와 같은가?
2GROUP BY 결과의 한 행이 — 의도한 단위(회원별 · 일별)와 일치하는가?
3COUNT · SUM 결과를 다른 방법으로 검산 — 예: 회원 다섯 명만 골라 직접 세보기
AI의 한계 — 그래서 사람이 봅니다

AI는 SQL 문법은 정말 잘 짭니다. 근데 그 쿼리의 의미가 맞는지 — 행의 단위가 의도와 일치하는지 — 는 못 봐요. 그건 데이터의 맥락을 아는 사람의 일입니다.

예고 — 이 패턴, 섹션 6에서 다시 만납니다

오늘 잡은 뻥튀기 패턴은 섹션 6(AI 결과 검증)의 6-2에서 심화 버전으로 다시 나옵니다. 여기서 검산 습관을 들여두면 그때는 복습이 돼요.

✓ JOIN + 집계 = 일단 의심

“JOIN 결과의 행 수가 의심스러우면 먼저 점검한다” — 이 한 가지가 AI 시대의 진짜 SQL 실력입니다. 20년 짠 사람과 1년 짠 사람의 차이가 바로 여기서 갈려요.

자주 막히는 지점

① LEFT JOIN 으로 바꾸면 뻥튀기가 안 일어나나요?

뻥튀기의 원인은 INNER냐 LEFT냐가 아니라, 한 행이 여러 행과 매칭되는 관계 그 자체입니다. LEFT JOIN은 “매칭 안 되는 쪽을 살릴지” 만 정할 뿐이에요.

FIX ▸JOIN 종류와 무관합니다 — 1:N 매칭이면 INNER 든 LEFT 든 N 만큼 늘어나요. 해결은 DISTINCT 또는 JOIN 줄이기.
② COUNT 만 조심하면 되나요? SUM 도 뻥튀기되나요?

예를 들어 orders에 배송비 컬럼이 있었다면 — order_items까지 JOIN한 뒤 배송비를 SUM하는 순간, 주문마다 담긴 상품 수만큼 배송비가 곱해집니다.

FIX ▸합치려는 값이 어느 테이블 소속인지 보세요. 가장 자식 테이블의 값(oi.quantity)은 안전 — 부모 쪽 값을 자식까지 JOIN 한 채 SUM 하면 부풀어요.
③ COUNT(*) 검산이 자식 테이블 행 수보다 커요

MySQL은 ON 없이도 에러 없이 그냥 돌아갑니다. 회원 20 × 주문 40 = 800행처럼 행 수가 폭발했다면 거의 확실히 이 경우예요.

FIX ▸ON 조건 누락(카테시안 곱) 또는 의도치 않은 N:M 매칭 의심 — 3-6 의 함정 ①을 다시 확인하세요.

뻥튀기를 직접 만들어보고, 잡아보고, 고쳐봐야 검증 본능이 몸에 붙습니다 — 연습 문제로 손에 새기고 가세요. 다음 강의는 섹션 3의 마지막, CASE와 서브쿼리를 빠르게 훑고 드디어 강의의 메인 — 섹션 4 DB 모델링으로 넘어갑니다.

NEXT LESSON3-7 연습 문제

뻥튀기를 직접 일으키고 — COUNT 검산과 DISTINCT 로 직접 잡아봅니다.

GO ▸ 연습 문제 풀기