5부 · 여러 테이블을 잇고 요약하기 (1) JOIN
← 4부 필터링 심화 · 목차 · 다음: 집계와 GROUP BY →
2부에서 우리는 데이터를 books·customers·orders로 쪼갰습니다. 덕분에 중복은 사라졌지만, 이제 "누가 무엇을 샀나"를 보려면 흩어진 표를 다시 이어야 합니다. 그 도구가 JOIN 입니다. 쪼개기(정규화)와 잇기(JOIN)는 한 쌍입니다.
flowchart LR
A[테이블 구조] --> C[행 추가]
C --> E[고르기 · 거르기]
E --> F["합치기<br/>JOIN"]
classDef here fill:#d5f2e0,stroke:#27ae60,color:#000,stroke-width:3px
classDef dim fill:#eee,stroke:#bbb,color:#888
class F here
class A,C,E dim
예제는 공용 데이터를 씁니다. 출력은 실제 실행 결과입니다.
5.1 JOIN이 푸는 문제
orders 테이블만 보면 이렇게 생겼습니다.
SELECT * FROM orders LIMIT 3;
id customer_id book_id order_date quantity -- ----------- ------- ---------- -------- 1 1 1 2026-01-03 1 2 2 1 2026-01-05 1 3 1 2 2026-01-05 2
여기엔 번호만 있습니다. "고객 1번이 누구고, 책 1번이 뭔지"는 customers·books에 있죠. 사람이 읽으려면 세 표를 머릿속으로 맞춰 봐야 합니다. JOIN은 이 맞춰 보기를 데이터베이스가 대신 해 주는 것입니다. 한 표의 외래키와 다른 표의 기본키를 짝지어 한 줄로 합칩니다.
flowchart LR
o["orders<br/>customer_id=1, book_id=1"]
c["customers<br/>id=1 → 김하나"]
b["books<br/>id=1 → 모비딕"]
o -->|customer_id = customers.id| c
o -->|book_id = books.id| b
o --> result["결과 한 줄:<br/>김하나 · 모비딕 · 2026-01-03"]
classDef t fill:#e8d5f2,stroke:#9b59b6,color:#000
classDef r fill:#d5f2e0,stroke:#27ae60,color:#000
class o,c,b t
class result r
5.2 INNER JOIN: 양쪽에 다 있는 것만
가장 기본이자 많이 쓰는 조인입니다. 두 표를 잇되, 연결이 성사된 행만 남깁니다.
SELECT c.name, b.title, o.order_date FROM orders o JOIN customers c ON c.id = o.customer_id JOIN books b ON b.id = o.book_id ORDER BY o.order_date;
출력:
name title order_date ------ ---------- ---------- 김하나 모비딕 2026-01-03 이두리 모비딕 2026-01-05 김하나 데미안 2026-01-05 박세찬 노인과 바다 2026-02-01 김하나 이방인 2026-02-10 이두리 데미안 2026-02-11 박세찬 모비딕 2026-03-02 최넷별 노인과 바다 2026-03-15
번호만 있던 주문이, 사람이 읽을 수 있는 "누가·무엇을·언제"로 바뀌었습니다. 구조를 뜯어봅니다.
FROM orders o— 기준 테이블orders에 별칭o를 줍니다.JOIN customers c ON c.id = o.customer_id—customers를c로 잇되, 잇는 조건(ON)은 "고객의id와 주문의customer_id가 같을 때".JOIN books b ON b.id = o.book_id— 같은 방식으로books도 이음.c.name,b.title,o.order_date— 어느 표의 칸인지 별칭.칸 으로 분명히 지정.
별칭(alias)이 왜 필수가 되는가. 여러 표를 합치면 같은 이름의 칸이 충돌할 수 있습니다(books.id,customers.id,orders.id가 모두id).c.id처럼 별칭을 붙이면 어느 표의id인지 분명해집니다. 4부에서 "한 표만 쓸 땐 군더더기"라던 테이블 별칭이, 여기서 제 역할을 합니다.JOIN을 쓸 땐 거의 항상 별칭을 붙이세요.
JOIN은 INNER JOIN의 줄임말입니다. 둘은 완전히 같습니다.
5.3 ON: 무엇을 기준으로 이을까
ON 뒤의 조건이 조인의 핵심입니다. 보통 외래키 = 기본키를 적습니다(o.customer_id = c.id). 이 짝이 맞는 행끼리 합쳐집니다.
만약 ON 조건을 잘못 주거나 빼먹으면 어떻게 될까요? 조건 없이 두 표를 합치면, 한 표의 모든 행이 다른 표의 모든 행과 짝지어지는 카티전 곱(cartesian product) 이 생깁니다. 4명 × 6권 = 24줄 같은, 의미 없는 거대한 결과죠.
-- ⚠️ ON을 빼면(또는 CROSS JOIN) 모든 조합이 나옴 — 보통 실수 SELECT c.name, b.title FROM customers c CROSS JOIN books b; -- 4×6 = 24줄
대부분의 경우 이건 버그입니다. 조인엔 거의 항상 ON이 필요하다고 기억하세요.
5.4 LEFT JOIN: 한쪽은 다 남기고
INNER JOIN은 "양쪽에 다 있는 것"만 남깁니다. 그래서 한 번도 주문 안 한 고객은 위 결과에 안 나옵니다(최넷별은 주문이 있어 나왔지만, 주문 없는 고객이 있었다면 빠졌을 것). 그런데 "주문을 한 번도 안 한 고객까지 포함해 전체 고객을 보고 싶다"면?
이때 LEFT JOIN 을 씁니다. 왼쪽(FROM에 적은) 테이블의 행은 전부 남기고, 오른쪽에 짝이 없으면 그 칸을 NULL로 채웁니다.
SELECT c.name, o.id AS order_id FROM customers c LEFT JOIN orders o ON o.customer_id = c.id ORDER BY c.id;
(공용 데이터에선 네 고객 모두 주문이 있어 NULL이 안 보이지만, 주문 없는 고객을 추가하면 그 사람의 order_id가 비어서 나옵니다.) LEFT JOIN의 진가는 다음 패턴에서 드러납니다.
"한 번도 안 일어난 것" 찾기 — 매우 흔한 패턴
"한 번도 안 팔린 책"을 찾고 싶습니다. books를 다 남기고 orders를 LEFT JOIN한 뒤, 짝이 없어 NULL이 된 행만 거릅니다.
SELECT b.title FROM books b LEFT JOIN orders o ON o.book_id = b.id WHERE o.id IS NULL;
출력:
title ---------- 1984 고서 필사본
'1984'와 '고서 필사본'은 주문 테이블에 짝이 없어 o.id가 NULL이 되었고, WHERE o.id IS NULL이 바로 그 "안 팔린 책"만 남긴 것입니다.
flowchart LR
b["books 전부 남김<br/>(LEFT)"] --> j{"orders에 짝이 있나?"}
j -->|있음| sold["팔린 책<br/>(o.id 채워짐)"]
j -->|없음| unsold["안 팔린 책<br/>(o.id = NULL)"]
unsold --> filter["WHERE o.id IS NULL<br/>→ 이것만 남김"]
classDef q fill:#fff3a0,stroke:#f1c40f,color:#000
classDef good fill:#d5f2e0,stroke:#27ae60,color:#000
class j q
class b,sold,unsold,filter good
"주문 없는 고객", "댓글 없는 게시글", "재고 없는 카테고리" 등 "~가 없는 것"을 찾는 질문은 거의 이 LEFT JOIN ... WHERE 오른쪽.키 IS NULL 패턴으로 풀립니다. 꼭 익혀 두세요.
INNER vs LEFT 한눈에
flowchart TB
subgraph inner["INNER JOIN"]
i["양쪽 모두 짝이 있는 행만"]
end
subgraph left["LEFT JOIN"]
l["왼쪽은 전부 + 오른쪽은 짝 있으면 채우고<br/>없으면 NULL"]
end
classDef a fill:#d5e8f2,stroke:#3498db,color:#000
classDef b fill:#d5f2e0,stroke:#27ae60,color:#000
class i a
class l b
RIGHT JOIN과 FULL JOIN은? 다른 데이터베이스엔 오른쪽을 다 남기는RIGHT JOIN, 양쪽을 다 남기는FULL OUTER JOIN도 있습니다. SQLite도 근래 버전(3.39+)부터 둘을 지원합니다. 다만RIGHT JOIN은 표 순서만 바꾸면LEFT JOIN으로 똑같이 표현되므로, 실무에서도LEFT JOIN하나로 대부분 해결합니다. 입문에선INNER와LEFT둘에 집중하세요.
5.5 자기 자신과의 조인 (self join) — 맛보기
가끔 한 테이블을 자기 자신과 잇기도 합니다. 예를 들어 customers에 "추천인(referrer_id)" 칸이 있어 고객이 다른 고객을 추천하는 구조라면, 같은 표를 두 번 등장시켜 "추천인의 이름"을 함께 봅니다.
-- (referrer_id 칸이 있다고 가정한 예시) SELECT c.name AS 회원, r.name AS 추천인 FROM customers c LEFT JOIN customers r ON r.id = c.referrer_id;
같은 customers를 c(회원)와 r(추천인) 두 별칭으로 부르는 게 핵심입니다. 지금 데이터엔 referrer_id가 없으니 개념만 알아 두세요. 별칭으로 같은 표를 둘로 다룬다는 발상이 포인트입니다.
5.6 흔한 함정
① 별칭 빠뜨려 칸 충돌. 여러 표에 같은 이름 칸이 있을 때 id라고만 쓰면 "어느 표의 id냐"는 모호함 오류가 납니다. 항상 o.id처럼 한정하세요.
② ON 조건 실수로 행이 폭증. 잘못된 ON이나 누락은 카티전 곱을 만들어 결과가 비정상적으로 많아집니다. 결과 행 수가 예상보다 훨씬 많으면 ON을 의심하세요.
③ INNER인데 LEFT가 필요한 경우. "전체 고객별 주문 수"를 보는데 주문 없는 고객이 빠졌다면, INNER JOIN을 LEFT JOIN으로 바꿔야 합니다. "빠지면 안 되는 쪽"을 왼쪽에 두세요.
④ LEFT JOIN 후 오른쪽 칸으로 WHERE 필터. LEFT JOIN 해 놓고 WHERE o.quantity > 1 같은 조건을 걸면, NULL인 행이 탈락해 사실상 INNER JOIN처럼 되어 버립니다. "짝 없는 행도 남기려던" 의도가 무너지죠. 이럴 땐 조건을 ON으로 옮기거나 IS NULL을 함께 고려해야 합니다.
5.7 정리
- 쪼갠 테이블을 다시 잇는 도구가
JOIN. 보통 외래키 = 기본키를ON으로 짝지어 합친다. INNER JOIN(=JOIN)은 양쪽에 짝이 있는 행만 남긴다. 여러 표를 합칠 땐 테이블 별칭(o,c,b)이 사실상 필수.LEFT JOIN은 왼쪽 테이블을 전부 남기고 오른쪽은 짝 없으면NULL. "~가 없는 것 찾기" 는LEFT JOIN ... WHERE 오른쪽.키 IS NULL패턴.ON을 빼면 카티전 곱(모든 조합)이 생긴다 — 대개 버그. 행이 폭증하면ON을 의심.- 같은 표를 두 별칭으로 잇는 self join도 가능하다.
이제 표를 자유롭게 이었습니다. 마지막 한 걸음은 요약입니다. "고객별 주문 건수", "책별 총 판매량", "월별 매출" 같은 질문은 흩어진 행을 묶어 합산해야 합니다. 다음 챕터의 GROUP BY가 그 일을 합니다.
직접 해 보기
- 모든 주문에 대해 "고객 이름 · 책 제목 · 수량"을 보여 주세요. (세 테이블 조인)
- '김하나'가 주문한 책들의 제목을 모두 찾아보세요. (
WHERE c.name = '김하나') - 한 번도 주문되지 않은 책을
LEFT JOIN패턴으로 찾아보세요. (위 5.4 응용) - 각 주문에 대해 "책 제목 · 가격 · 수량 · (가격×수량) 합계"를 보여 주세요. 합계 열엔
AS subtotal별칭을 주세요. - (생각해 보기) "전체 고객과 각자의 주문 수"를 볼 때
INNER JOIN이 아니라LEFT JOIN을 써야 하는 이유는?
← 4부 필터링 심화 · 목차 · 다음: 집계와 GROUP BY →