API 응답 하나를 만들기 위해 여러 테이블의 데이터를 합쳐야 하는 일이 많습니다. JOIN을 대충 쓰면 중복 행이 폭발하거나 필요한 데이터가 빠집니다. INNER, LEFT, 다대다 JOIN의 차이를 이해해야 정확한 조회 쿼리를 만들 수 있습니다.
JOIN은 관계형 데이터베이스의 핵심 기능입니다. 각 JOIN 타입이 어떤 행을 포함하고 제외하는지 명확히 이해하면 복잡한 데이터 요구사항도 자신 있게 처리할 수 있습니다.
- 1INNER, LEFT, RIGHT, FULL OUTER JOIN의 차이점
- 2벤다이어그램으로 JOIN 시각화
- 3SELF JOIN으로 계층 구조 쿼리
- 4JOIN 성능과 인덱스의 관계
JOIN 완전 정복 — INNER, LEFT, RIGHT, FULL, SELF
단일 테이블 조회에서 벗어나 실제 비즈니스 데이터를 다루려면 JOIN이 필수입니다. 주문과 고객 정보를 함께 보거나, 상품과 카테고리를 연결하는 것 모두 JOIN 없이는 불가능합니다. 이 모듈에서는 JOIN의 모든 종류를 벤다이어그램으로 시각화하고 실제 SQL로 익힙니다.
JOIN 5종 — 벤다이어그램으로 이해하기
주문이 있는 사용자만 조회해야 합니다. INNER JOIN을 썼더니 주문이 없는 신규 회원이 목록에서 사라집니다. LEFT JOIN으로 바꿨더니 주문이 없는 사용자도 나오는데 주문 정보 컬럼이 NULL입니다. JOIN 타입마다 어떤 데이터가 포함되고 빠지는지 벤다이어그램으로 이해해야 원하는 결과를 정확하게 뽑을 수 있습니다.

예시 데이터 준비
JOIN을 설명하기 위한 간단한 예시 데이터입니다. customers(고객)와 orders(주문) 두 테이블을 사용합니다. 주문이 없는 고객(박민준, 최지은)과 고객 테이블에 존재하지 않는 customer_id=5의 주문을 의도적으로 포함시켜, 각 JOIN 타입이 이 불일치를 어떻게 처리하는지 확인합니다.
customers와 orders 테이블을 만들고 의도적으로 불일치 데이터를 포함시킵니다. 각 JOIN 타입이 이 불일치를 어떻게 처리하는지 다음 단계에서 확인합니다.
CREATE TABLE customers (
id INT PRIMARY KEY,
name VARCHAR(50)
);
INSERT INTO customers VALUES
(1, '김철수'), (2, '이영희'), (3, '박민준'), (4, '최지은');
CREATE TABLE orders (
id INT PRIMARY KEY,
customer_id INT,
amount INT
);
INSERT INTO orders VALUES
(101, 1, 50000),
(102, 1, 30000),
(103, 2, 75000),
(104, 5, 20000);
실행 완료 또는 조회 결과가 표시됩니다.
CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(50));- 행 개수—JOIN 후 결과 행이 예상보다 늘거나 줄지 않았는지 확인합니다.
- NULL 위치—LEFT JOIN에서 매칭되지 않은 쪽 컬럼이 NULL로 남는지 봅니다.
- 조인 조건—ON 절이 PK/FK 관계를 정확히 연결하는지 점검합니다.
JOIN 타입 한눈에 비교
어떤 JOIN을 써야 할지 막막할 때는 "왼쪽 테이블의 행을 전부 보고 싶은가, 아니면 양쪽이 일치하는 것만 보고 싶은가"를 먼저 판단합니다. 이 질문에 답하면 아래 표에서 선택이 명확해집니다.
| JOIN 종류 | 포함되는 행 | 대표 용도 |
|---|---|---|
| INNER JOIN | 양쪽 모두 매칭되는 행만 | 주문이 있는 고객만 조회 |
| LEFT JOIN | 왼쪽 전체 + 오른쪽 매칭 (없으면 NULL) | 주문 없는 고객도 포함 |
| RIGHT JOIN | 오른쪽 전체 + 왼쪽 매칭 (없으면 NULL) | LEFT JOIN과 방향만 반대 |
| FULL OUTER JOIN | 양쪽 모두 전체 (합집합) | 불일치 데이터 전수 감사 |
| CROSS JOIN | 양쪽 행의 모든 조합 (카테시안 곱) | 조합표 생성 |
INNER JOIN — 교집합
INNER JOIN은 두 테이블 모두에 매칭되는 행만 반환합니다. 위 예시 데이터에서 customer_id=5인 주문(104번)은 customers에 존재하지 않으므로 제외됩니다. 주문이 없는 박민준, 최지은도 orders에 매칭 행이 없으므로 제외됩니다.
INNER JOIN을 실행하고, 주문이 없는 고객(박민준, 최지은)과 customer_id=5 주문이 결과에서 제외되는지 확인합니다.
SELECT c.name, o.id AS order_id, o.amount
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;
SELECT c.name, o.id AS order_id, o.amount FROM customers c INNER JOIN orders o ON c.id = o.customer_id;- 결과 행 수가 3행(김철수×2 + 이영희×1)인지 확인합니다.
- 박민준, 최지은은 결과에 없는지 확인합니다 — 주문 없는 고객은 INNER JOIN에서 제외됩니다.
- customer_id=5인 주문(104번)도 결과에 없는지 확인합니다.
결과: 김철수(101, 50000), 김철수(102, 30000), 이영희(103, 75000) — 총 3행.
중복 행이 왜 생기는가 — 카디널리티 이해
위 결과에서 김철수가 두 번 나타납니다. 이것은 버그가 아니라 JOIN의 정상 동작입니다. 한 고객이 여러 주문을 가지면 1:N 관계에서 고객 행이 주문 수만큼 복제됩니다.
| 관계 유형 | 결과 행 수 | 주의점 |
|---|---|---|
| 1:1 | 양쪽 중 작은 쪽과 같음 | 중복 없음 |
| 1:N | N 쪽(많은 쪽)의 행 수 | 1쪽 행이 복제됨 |
| N:M | 교차 테이블 없이 JOIN 시 폭발적 증가 | 반드시 중간 테이블 사용 |
집계 쿼리를 작성할 때 중복 행을 인식하지 못하면 SUM()이나 COUNT()가 실제보다 과다 계산됩니다. JOIN 후 집계가 예상과 다르면 먼저 중복 행 여부를 확인하세요.
LEFT JOIN — 왼쪽 테이블 전체 보존
LEFT JOIN은 왼쪽 테이블(FROM 뒤)의 모든 행을 유지합니다. 오른쪽 테이블에 매칭되는 행이 없으면 오른쪽 컬럼 전체가 NULL로 채워집니다. "주문 없는 고객도 목록에 포함하고 싶다"는 요구사항이 LEFT JOIN의 전형적인 사용 사례입니다.
LEFT JOIN으로 주문이 없는 고객(박민준, 최지은)도 결과에 포함시킵니다. NULL 행만 골라 "미구매 고객" 목록도 추출해봅니다.
SELECT c.name, o.id AS order_id, o.amount
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id;
-- 한 번도 주문하지 않은 고객만 추출
SELECT c.name
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;
SELECT c.name, o.id AS order_id, o.amount FROM customers c LEFT JOIN orders o ON c.id = o.customer_id;- 결과가 총 5행(INNER JOIN의 3행 + 박민준 + 최지은)인지 확인합니다.
- 박민준, 최지은의 order_id와 amount가 NULL로 표시되는지 봅니다.
- WHERE o.id IS NULL 필터로 미구매 고객 2명만 나오는지 확인합니다.
결과: 김철수(101, 50000), 김철수(102, 30000), 이영희(103, 75000), 박민준(NULL, NULL), 최지은(NULL, NULL) — 총 5행. 박민준과 최지은은 order_id와 amount가 NULL입니다.
RIGHT JOIN — 오른쪽 테이블 전체 보존
RIGHT JOIN은 LEFT JOIN과 방향만 반대입니다. 실무에서는 테이블 순서를 바꾸고 LEFT JOIN을 사용하는 것이 더 읽기 쉬워 RIGHT JOIN은 거의 사용하지 않습니다. 아래 두 쿼리는 동일한 결과를 반환합니다.
SELECT c.name, o.id FROM customers c RIGHT JOIN orders o ON c.id = o.customer_id;
SELECT c.name, o.id FROM orders o LEFT JOIN customers c ON o.customer_id = c.id;
FULL OUTER JOIN — 합집합
FULL OUTER JOIN은 양쪽 테이블의 모든 행을 반환합니다. 어느 쪽에도 매칭이 없으면 NULL로 채워집니다. 데이터 마이그레이션 후 불일치 감사, 두 시스템의 레코드 대조 등에 유용합니다.
SELECT c.name, o.id AS order_id
FROM customers c
FULL OUTER JOIN orders o ON c.id = o.customer_id;
결과: 매칭된 행 3개 + 주문 없는 고객 2개(order_id=NULL) + 고객 없는 주문 1개(name=NULL) — 총 6행.
CROSS JOIN — 모든 조합 (카테시안 곱)
CROSS JOIN은 ON 조건 없이 두 테이블의 모든 행 조합을 생성합니다. 의류 쇼핑몰에서 사이즈×색상 전체 SKU를 만들거나, 날짜 범위와 지역의 모든 조합을 생성할 때 의도적으로 사용합니다. 실수로 ON 절을 빠뜨린 INNER JOIN도 CROSS JOIN처럼 동작하므로 주의하세요.
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;
ON vs USING
두 테이블에서 조인 컬럼 이름이 동일할 때 USING으로 표현을 간결하게 줄일 수 있습니다.
SELECT * FROM customers c
JOIN orders o ON c.id = o.customer_id;
SELECT * FROM customers c
JOIN orders o USING (customer_id);
LEFT JOIN으로 왼쪽 테이블을 전체 보존했더니 WHERE 절을 추가하자 NULL 행이 사라지는 문제입니다.
-- 의도: 주문 없는 고객도 포함하면서, 최근 주문만 보고 싶다
SELECT c.name, o.id, o.order_date
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.order_date >= '2024-01-01';
이 쿼리는 INNER JOIN과 동일한 결과를 냅니다. 주문이 없는 고객의 경우 o.order_date가 NULL이고, NULL >= '2024-01-01'은 UNKNOWN이므로 WHERE에서 제거됩니다.
해결 방법은 날짜 조건을 WHERE가 아닌 ON 절로 옮기는 것입니다.
SELECT c.name, o.id, o.order_date
FROM customers c
LEFT JOIN orders o
ON c.id = o.customer_id
AND o.order_date >= '2024-01-01';
ON 절에 조건을 두면 날짜 조건이 만족하지 않을 때 오른쪽이 NULL로 채워지며 왼쪽 행은 유지됩니다.
마케팅팀이 "이번 달 전체 고객 목록에 구매 횟수와 금액을 보여달라"고 요청합니다. 이 요구사항의 핵심은 구매 이력이 없는 고객도 0건으로 표시해야 한다는 점입니다. INNER JOIN을 쓰면 구매 이력 있는 고객만 나와 보고서가 불완전해집니다.
SELECT
c.name,
COUNT(o.id) AS 주문횟수,
COALESCE(SUM(o.amount), 0) AS 총구매금액
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
ORDER BY 총구매금액 DESC;
COALESCE(SUM(...), 0)은 주문이 0건인 고객의 합계가 NULL이 되는 것을 방지합니다. LEFT JOIN과 COALESCE의 조합은 "있으면 실제 값, 없으면 0" 패턴의 표준입니다.
SELF JOIN과 실전 JOIN 패턴 — 계층 구조와 통계
카테고리 테이블에 parent_id 컬럼이 있습니다. 대분류-중분류-소분류 계층을 쿼리로 가져와야 하는데 어떻게 해야 하는지 모릅니다. 같은 테이블을 자기 자신과 JOIN하면 계층 관계를 한 번에 조회할 수 있습니다. 이 패턴을 알면 댓글 트리, 조직도, 카테고리 같은 재귀 구조를 처리하는 쿼리를 작성할 수 있습니다.

SELF JOIN — 같은 테이블끼리 조인
SELF JOIN은 테이블이 자기 자신을 참조하는 구조에서 사용합니다. 대표적인 예가 직원-관리자 관계입니다. 같은 테이블을 두 번 쓰기 때문에 반드시 서로 다른 별칭을 붙여야 어느 인스턴스를 참조하는지 구분됩니다.
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(50),
manager_id INT REFERENCES employees(id)
);
INSERT INTO employees VALUES
(1, '박대표', NULL),
(2, '김팀장', 1),
(3, '이팀장', 1),
(4, '박사원', 2),
(5, '최사원', 2),
(6, '정사원', 3);
SELECT
e.name AS 직원,
m.name AS 관리자
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
결과: 박대표(관리자=NULL), 김팀장/이팀장(관리자=박대표), 박사원/최사원(관리자=김팀장), 정사원(관리자=이팀장).
최상위 직원(박대표)처럼 manager_id가 NULL인 경우도 보존하려면 INNER JOIN이 아닌 LEFT JOIN을 사용해야 합니다.
다중 JOIN 체이닝
실무에서는 3개 이상의 테이블을 JOIN하는 경우가 많습니다. 각 JOIN에 명확한 ON 조건을 붙이고, 불필요한 JOIN은 과감히 제거해야 성능을 지킬 수 있습니다.
SELECT
o.id AS 주문번호,
c.name AS 고객명,
p.name AS 상품명,
oi.quantity AS 수량,
oi.unit_price AS 단가,
oi.quantity * oi.unit_price AS 소계
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.created_at >= NOW() - INTERVAL '30 days'
ORDER BY o.id, p.name;
집계와 JOIN 결합
JOIN과 GROUP BY를 결합해 통계 쿼리를 작성합니다. LEFT JOIN을 사용하면 주문 건수가 0인 고객도 결과에 포함됩니다.
SELECT
c.name,
COUNT(o.id) AS 주문횟수,
COALESCE(SUM(o.amount), 0) AS 총구매금액
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
ORDER BY 총구매금액 DESC;
JOIN 성능과 인덱스
JOIN 성능의 첫 번째 점검 항목은 항상 인덱스입니다. 외래키(FK) 컬럼에 인덱스가 없으면 매칭을 위해 테이블 전체를 스캔하게 됩니다(O(N×M)). 인덱스가 있으면 Index Scan으로 O(log N)에 처리됩니다.
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
EXPLAIN ANALYZE
SELECT c.name, o.amount
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE c.id = 1;
불필요한 JOIN은 쿼리를 느리게 만듭니다. 실제로 SELECT에서 사용하지 않는 테이블이 JOIN에 포함되어 있다면 제거하세요.
SELECT c.name, COUNT(*)
FROM orders o
JOIN customers c ON o.customer_id = c.id
GROUP BY c.name;
다음 모듈에서는 서브쿼리와 CTE(WITH 문)를 활용해 복잡한 쿼리를 읽기 쉬운 구조로 분리하는 방법을 다룹니다.