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 테이블만 보면 이렇게 생겼습니다.

SQL
SELECT * FROM orders LIMIT 3;
CODE
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: 양쪽에 다 있는 것만

가장 기본이자 많이 쓰는 조인입니다. 두 표를 잇되, 연결이 성사된 행만 남깁니다.

SQL
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;

출력:

CODE
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_idcustomersc로 잇되, 잇는 조건(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을 쓸 땐 거의 항상 별칭을 붙이세요.

JOININNER JOIN의 줄임말입니다. 둘은 완전히 같습니다.

5.3 ON: 무엇을 기준으로 이을까

ON 뒤의 조건이 조인의 핵심입니다. 보통 외래키 = 기본키를 적습니다(o.customer_id = c.id). 이 짝이 맞는 행끼리 합쳐집니다.

만약 ON 조건을 잘못 주거나 빼먹으면 어떻게 될까요? 조건 없이 두 표를 합치면, 한 표의 모든 행이 다른 표의 모든 행과 짝지어지는 카티전 곱(cartesian product) 이 생깁니다. 4명 × 6권 = 24줄 같은, 의미 없는 거대한 결과죠.

SQL
-- ⚠️ 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로 채웁니다.

SQL
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를 다 남기고 ordersLEFT JOIN한 뒤, 짝이 없어 NULL이 된 행만 거릅니다.

SQL
SELECT b.title
FROM books b
LEFT JOIN orders o ON o.book_id = b.id
WHERE o.id IS NULL;

출력:

CODE
title
----------
1984
고서 필사본

'1984'와 '고서 필사본'은 주문 테이블에 짝이 없어 o.idNULL이 되었고, 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 하나로 대부분 해결합니다. 입문에선 INNERLEFT 둘에 집중하세요.

5.5 자기 자신과의 조인 (self join) — 맛보기

가끔 한 테이블을 자기 자신과 잇기도 합니다. 예를 들어 customers에 "추천인(referrer_id)" 칸이 있어 고객이 다른 고객을 추천하는 구조라면, 같은 표를 두 번 등장시켜 "추천인의 이름"을 함께 봅니다.

SQL
-- (referrer_id 칸이 있다고 가정한 예시)
SELECT c.name AS 회원, r.name AS 추천인
FROM customers c
LEFT JOIN customers r ON r.id = c.referrer_id;

같은 customersc(회원)와 r(추천인) 두 별칭으로 부르는 게 핵심입니다. 지금 데이터엔 referrer_id가 없으니 개념만 알아 두세요. 별칭으로 같은 표를 둘로 다룬다는 발상이 포인트입니다.

5.6 흔한 함정

① 별칭 빠뜨려 칸 충돌. 여러 표에 같은 이름 칸이 있을 때 id라고만 쓰면 "어느 표의 id냐"는 모호함 오류가 납니다. 항상 o.id처럼 한정하세요.

ON 조건 실수로 행이 폭증. 잘못된 ON이나 누락은 카티전 곱을 만들어 결과가 비정상적으로 많아집니다. 결과 행 수가 예상보다 훨씬 많으면 ON을 의심하세요.

INNER인데 LEFT가 필요한 경우. "전체 고객별 주문 수"를 보는데 주문 없는 고객이 빠졌다면, INNER JOINLEFT 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가 그 일을 합니다.

직접 해 보기

  1. 모든 주문에 대해 "고객 이름 · 책 제목 · 수량"을 보여 주세요. (세 테이블 조인)
  2. '김하나'가 주문한 책들의 제목을 모두 찾아보세요. (WHERE c.name = '김하나')
  3. 한 번도 주문되지 않은 책을 LEFT JOIN 패턴으로 찾아보세요. (위 5.4 응용)
  4. 각 주문에 대해 "책 제목 · 가격 · 수량 · (가격×수량) 합계"를 보여 주세요. 합계 열엔 AS subtotal 별칭을 주세요.
  5. (생각해 보기) "전체 고객과 각자의 주문 수"를 볼 때 INNER JOIN이 아니라 LEFT JOIN을 써야 하는 이유는?
← 4부 필터링 심화 · 목차 · 다음: 집계와 GROUP BY →