INNER, LEFT, RIGHT, FULL OUTER, CROSS, SELF 등 분석가가 알아야 할 6가지 SQL JOIN을 실용적인 예제와 흔한 실수와 함께 배웁니다.

SQL JOIN은 보통 ID 같은 관련 열을 기준으로 두 개(혹은 그 이상)의 테이블에서 행을 결합해 하나의 결과로 만드는 기능입니다.
실제 데이터베이스는 같은 정보를 반복하지 않도록 의도적으로 여러 테이블로 나뉘어 있습니다. 예를 들어, 고객 이름은 customers 테이블에 있고 구매 내역은 orders 테이블에 있습니다. JOIN은 필요할 때 이 조각들을 다시 연결하는 방법입니다.
그래서 JOIN은 보고서와 분석 전반에 자주 등장합니다:
JOIN이 없으면 별도 쿼리를 실행해 수동으로 결과를 결합해야 하므로 느리고 오류가 많고 재현하기 어렵습니다.
제품(대시보드, 관리자 패널, 내부 도구, 고객 포털)을 관계형 데이터베이스 위에 구축할 때도 JOIN은 필수입니다. 예를 들어 Koder.ai 같은 플랫폼은 React + Go + PostgreSQL 앱을 빠르게 생성하지만, 정확한 목록 페이지나 보고서, 대조 화면을 만들려면 여전히 튼튼한 JOIN 개념이 필요합니다. 개발이 빨라져도 데이터베이스 논리는 사라지지 않습니다.
이 가이드는 일상적인 SQL 작업의 대부분을 다루는 여섯 가지 JOIN에 중점을 둡니다:
JOIN 문법은 PostgreSQL, MySQL, SQL Server, SQLite 등 대부분의 SQL DB에서 매우 유사합니다. FULL OUTER JOIN 지원 여부 등 일부 차이가 있지만 개념과 핵심 패턴은 대부분 통용됩니다.
조인 예제를 단순하게 유지하기 위해, 고객이 주문을 하고 주문에 결제가 있을 수도 있고 없을 수도 있는 현실적인 세 테이블을 사용합니다.
시작 전 작은 메모: 아래 샘플 테이블은 일부 열만 보여주지만, 이후 쿼리에서 order_date, created_at, status, paid_at 같은 추가 필드를 참조합니다. 그런 열들은 실제 스키마에서 흔히 있는 필드라고 생각하세요.
기본 키: customer_id
| customer_id | name |
|---|---|
| 1 | Ava |
| 2 | Ben |
| 3 | Chen |
| 4 | Dia |
기본 키: order_id
외래 키: customer_id → customers.customer_id
| order_id | customer_id | order_total |
|---|---|---|
| 101 | 1 | 50 |
| 102 | 1 | 120 |
| 103 | 2 | 35 |
| 104 | 5 | 70 |
주목할 점: order_id = 104는 customer_id = 5를 참조하는데, customers에 해당 고객이 없습니다. 이러한 “매칭 없음” 사례는 LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN이 어떻게 동작하는지 보기 좋습니다.
기본 키: payment_id
외래 키: order_id → orders.order_id
| payment_id | order_id | amount |
|---|---|---|
| 9001 | 101 | 50 |
| 9002 | 102 | 60 |
| 9003 | 102 | 60 |
| 9004 | 999 | 25 |
두 가지 교육용 포인트:
order_id = 102는 두 개의 결제 행을 가집니다(분할 결제). orders를 payments에 조인하면 해당 주문이 두 번 나타납니다—여기서 중복이 사람들을 깜짝 놀라게 합니다.payment_id = 9004는 order_id = 999를 참조하는데 orders에 존재하지 않습니다. 이것도 또 하나의 “매칭 없음” 사례입니다.orders를 payments와 조인하면 주문 102는 결제가 두 건이라 결과에 반복됩니다.INNER JOIN은 두 테이블 모두에서 매칭되는 행만 반환합니다. 고객에게 주문이 없으면 결과에 나타나지 않습니다. 주문이 존재하지 않는 고객을 참조하면(잘못된 데이터), 그 주문도 결과에서 빠집니다.
왼쪽 테이블을 선택하고 오른쪽 테이블을 조인한 뒤 ON 절에서 연결 조건을 지정합니다.
SELECT
c.customer_id,
c.name,
o.order_id,
o.order_date
FROM customers c
INNER JOIN orders o
ON o.customer_id = c.customer_id;
핵심은 ON o.customer_id = c.customer_id입니다: 행이 어떻게 연결되는지 SQL에 알려줍니다.
실제로 주문을 한 고객(및 해당 주문 상세)만 목록으로 뽑고 싶다면 INNER JOIN이 자연스럽습니다:
SELECT
c.name,
o.order_id,
o.total_amount
FROM customers c
INNER JOIN orders o
ON o.customer_id = c.customer_id
ORDER BY o.order_id;
예: 주문 후속 이메일 발송이나 구매가 있는 고객에 대한 매출 계산 등에 유용합니다.
조인을 작성하면서 ON 조건을 잊거나 잘못된 열로 조인하면 카테시안 곱(모든 고객 × 모든 주문) 같은 결과가 나오거나 미묘하게 잘못된 매칭이 생길 수 있습니다.
나쁜 예(절대 하지 말 것):
SELECT c.name, o.order_id
FROM customers c
JOIN orders o;
항상 명확한 ON(또는 특정 상황에서 USING) 조건을 포함하세요.
LEFT JOIN은 왼쪽 테이블의 모든 행을 반환하고, 오른쪽에서 매칭되는 데이터가 있으면 붙입니다. 매칭이 없으면 오른쪽 컬럼은 NULL이 됩니다.
기본(주) 테이블의 전체 목록과 선택적 관련 데이터를 함께 보여주고 싶을 때 LEFT JOIN을 사용합니다.
예: "모든 고객을 보여주고, 주문이 있으면 주문도 포함"하려면:
SELECT
c.customer_id,
c.name,
o.order_id,
o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
ORDER BY c.customer_id;
o.order_id 등은 NULL입니다.LEFT JOIN을 사용하는 매우 흔한 이유는 다른 테이블에 관련 레코드가 없는 항목을 찾기 위해서입니다.
예: "한 번도 주문하지 않은 고객은 누구인가?"
SELECT
c.customer_id,
c.name
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.order_id IS NULL;
WHERE ... IS NULL은 조인이 매칭을 찾지 못한 왼쪽 테이블 행만 남깁니다.
LEFT JOIN은 오른쪽에 여러 매칭이 있을 때 왼쪽 행을 여러 번 보여줄 수 있습니다.
한 고객이 주문 3건을 가지고 있으면 그 고객은 주문별로 3번 나타납니다. 카운트를 할 때 놀랄 수 있으니 주의하세요.
예: 다음 쿼리는 고객 수가 아니라 주문 수를 셉니다:
SELECT COUNT(*)
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id;
고객 수를 세려면 보통 COUNT(DISTINCT c.customer_id)처럼 고객 키를 기준으로 집계합니다(측정 목적에 따라 다름).
RIGHT JOIN은 오른쪽 테이블의 모든 행을 유지하고 왼쪽에서 매칭되는 행만 붙입니다. 매칭이 없으면 왼쪽 컬럼은 NULL이 됩니다. 이는 LEFT JOIN의 거울입니다.
우리 예제에서 모든 결제를 나열하되, 해당 결제가 주문에 연결되지 않더라도 포함하려면:
SELECT
o.order_id,
o.customer_id,
p.payment_id,
p.amount,
p.paid_at
FROM orders o
RIGHT JOIN payments p
ON o.order_id = p.order_id;
결과:
payments이므로).o.order_id와 o.customer_id는 NULL입니다.대부분의 경우 테이블 순서를 바꿔 LEFT JOIN으로 다시 작성할 수 있습니다:
SELECT
o.order_id,
o.customer_id,
p.payment_id,
p.amount,
p.paid_at
FROM payments p
LEFT JOIN orders o
ON o.order_id = p.order_id;
동일한 결과를 반환하지만, 많은 사람들은 "주 테이블을 먼저 쓰고 추가 데이터를 LEFT JOIN으로 붙인다"는 패턴을 선호해 읽기 쉽다고 느낍니다.
많은 SQL 스타일 가이드가 RIGHT JOIN을 권장하지 않습니다. 그 이유는 독자가 패턴을 거꾸로 읽어야 하기 때문입니다.
항상 LEFT JOIN으로 통일하면 쿼리를 더 빠르게 훑어볼 수 있습니다.
긴 쿼리에서 현재 오른쪽에 있는 테이블이 반드시 유지해야 하는 테이블이라면, 전체 쿼리를 뒤집지 않고 해당 조인을 RIGHT JOIN으로 바꾸는 것이 빠르고 낮은 위험일 수 있습니다.
FULL OUTER JOIN은 두 테이블의 모든 행을 반환합니다.
NULL입니다.NULL입니다.주문과 결제 대조 같은 상황에서 유용합니다:
예:
SELECT
o.order_id,
o.customer_id,
p.payment_id,
p.amount
FROM orders o
FULL OUTER JOIN payments p
ON p.order_id = o.order_id;
FULL OUTER JOIN은 PostgreSQL, SQL Server, Oracle에서 지원됩니다.
MySQL과 SQLite에서는 직접 지원되지 않으므로 우회 방법이 필요합니다.
DB가 FULL OUTER JOIN을 지원하지 않을 때는 보통 다음을 결합해 흉내 냅니다:
orders의 모든 행(가능하면 결제를 붙임)payments 행한 가지 패턴:
SELECT o.order_id, o.customer_id, p.payment_id, p.amount
FROM orders o
LEFT JOIN payments p
ON p.order_id = o.order_id
UNION
SELECT o.order_id, o.customer_id, p.payment_id, p.amount
FROM orders o
RIGHT JOIN payments p
ON p.order_id = o.order_id;
팁: 한쪽에 NULL이 보이면 그 행이 다른 테이블에 없었다는 신호입니다—감사와 대조에 딱 맞는 정보입니다.
CROSS JOIN은 두 테이블의 모든 가능한 페어링을 반환합니다. 테이블 A에 3행, 테이블 B에 4행이 있으면 결과는 3 × 4 = 12행이 됩니다. 카테시안 곱이라고도 합니다.
위험하게 들리지만, 조합이 필요한 경우엔 실제로 유용합니다.
옵션을 별도 테이블로 관리한다고 가정:
sizes: S, M, Lcolors: Red, BlueCROSS JOIN으로 모든 변형을 생성할 수 있습니다(제품 SKU 생성, 카탈로그 사전 구축, 테스트에 유용):
SELECT
s.size,
c.color
FROM sizes AS s
CROSS JOIN colors AS c;
결과(3 × 2 = 6행):
행 수가 곱해지므로 CROSS JOIN은 순식간에 폭발할 수 있습니다:
이런 경우 쿼리가 느려지고 메모리를 압박하며, 쓸모없는 출력이 생성될 수 있습니다. 조합이 필요하면 입력 테이블을 작게 유지하고 제한이나 필터를 적절히 사용하세요.
SELF JOIN은 말 그대로 테이블을 자기 자신과 조인합니다. 한 테이블의 한 행이 같은 테이블의 다른 행과 관련 있을 때 유용합니다—흔히 직원과 관리자 같은 계층 구조입니다.
같은 테이블을 두 번 사용하므로 각 "복사본"에 다른 별칭을 주어야 합니다. 별칭은 가독성을 높이고 SQL에 어느 쪽을 가리키는지 알려줍니다.
일반적인 패턴:
ememployees 테이블이 다음 열을 가진다고 가정:
idnamemanager_id(다른 직원의 id를 가리킴)각 직원과 그 관리자 이름을 나열하려면:
SELECT
e.id,
e.name AS employee_name,
m.name AS manager_name
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.id;
manager_id가 NULL인 경우)위 쿼리는 LEFT JOIN을 사용합니다. CEO처럼 관리자가 없는 직원이 있을 수 있기 때문에 그렇습니다. manager_id가 보통 NULL이면 LEFT JOIN은 직원 행을 유지하고 manager_name은 NULL로 보여줍니다.
INNER JOIN을 사용하면 이런 최상위 직원이 결과에서 사라집니다(매칭되는 관리자가 없기 때문).
JOIN은 두 테이블이 어떻게 관련되는지 알아야 하므로 조인 조건을 꼭 지정해야 합니다. 이 관계는 JOIN 옆에 적는 것이 옳습니다—그 이유는 조인 조건이 "행이 어떻게 매칭되는지"를 설명하기 때문이지, 최종 결과를 어떻게 필터링할지 설명하는 것은 아니기 때문입니다.
ON: 가장 유연하고 일반적인 방법열 이름이 다르거나 다중 조건, 추가 규칙이 필요할 때 ON을 사용하세요.
SELECT
c.customer_id,
c.name,
o.order_id,
o.created_at
FROM customers AS c
INNER JOIN orders AS o
ON o.customer_id = c.customer_id;
ON은 두 열이 어떻게 매칭되는지 완전히 제어할 수 있게 해줍니다(예: 두 열로 매칭하거나 복잡한 조건을 추가할 때).
USING: 이름이 같은 열에 대한 간단한 축약PostgreSQL과 MySQL 같은 DB는 USING을 지원합니다. 양쪽 테이블에 같은 이름의 열이 있고 그 열로만 조인할 때 편리한 축약입니다.
SELECT
customer_id,
name,
order_id
FROM customers
JOIN orders
USING (customer_id);
USING의 좋은 점 중 하나는 출력에 customer_id가 한 번만 보이는 경우가 많다는 것입니다(두 번 복사되지 않음).
조인하면 id, created_at, status처럼 겹치는 이름이 많습니다. SELECT id라고 쓰면 DB가 "모호한 컬럼" 오류를 낼 수 있고, 더 나쁘게는 잘못된 id를 읽을 수도 있습니다.
명확성을 위해 테이블 접두어나 별칭을 사용하세요:
SELECT c.customer_id, o.order_id
FROM customers AS c
JOIN orders AS o
ON o.customer_id = c.customer_id;
SELECT * 피하기조인에서 SELECT *는 빠르게 지저분해집니다: 불필요한 컬럼을 가져오고, 이름이 겹쳐 문제가 생기며, 쿼리 목적을 파악하기 어렵게 만듭니다.
필요한 정확한 컬럼을 선택하세요. 결과가 더 깔끔하고 유지보수하기 쉬우며, 테이블이 넓을 때 더 효율적입니다.
조인할 때 WHERE와 ON은 둘 다 "필터" 역할을 하지만 적용 시점이 다릅니다.
이 시점 차이가 LEFT JOIN을 의도치 않게 INNER JOIN으로 바꿔버리는 원인입니다.
모든 고객을 유지하되 최근의 결제된 주문만 포함하려는 상황을 생각해봅시다.
SELECT c.customer_id, c.name, o.order_id, o.status, o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
WHERE o.status = 'PAID'
AND o.order_date \u003e= DATE '2025-01-01';
문제: 매칭되는 주문이 없는 고객의 o.status와 o.order_date는 NULL입니다. WHERE 조건이 그 행들을 걸러내므로, 매칭 없는 고객이 사라지고 LEFT JOIN이 사실상 INNER JOIN처럼 동작합니다.
SELECT c.customer_id, c.name, o.order_id, o.status, o.order_date
FROM customers c
LEFT JOIN orders o
ON o.customer_id = c.customer_id
AND o.status = 'PAID'
AND o.order_date \u003e= DATE '2025-01-01';
이제 조건을 만족하지 않는 고객은 NULL 주문 컬럼을 가진 채로 남아 있습니다(대개 이것이 LEFT JOIN을 사용하는 목적입니다).
WHERE o.order_id IS NOT NULL)을 사용하세요.조인은 단지 "열을 더"하는 것이 아니라 행을 곱할 수 있습니다. 이는 보통 올바른 동작이지만 합계가 갑자기 두 배로 늘어나면 놀랄 수 있습니다.
조인은 매칭되는 행 쌍마다 하나의 출력 행을 만듭니다.
payments), 또 다른 다수 테이블(order_items)과도 조인하면 payments × items처럼 곱셈 효과가 발생합니다.목표가 "고객당 한 행"이나 "주문당 한 행"이라면, ‘many’ 쪽을 먼저 요약한 뒤 조인하세요.
-- 결제에서 주문당 한 행 만들기
WITH payment_totals AS (
SELECT
order_id,
SUM(amount) AS total_paid,
COUNT(*) AS payment_count
FROM payments
GROUP BY order_id
)
SELECT
o.order_id,
o.customer_id,
COALESCE(pt.total_paid, 0) AS total_paid,
COALESCE(pt.payment_count, 0) AS payment_count
FROM orders o
LEFT JOIN payment_totals pt
ON pt.order_id = o.order_id;
이렇게 하면 조인의 결과 형태가 예측 가능해집니다: 주문당 한 행을 유지할 수 있습니다.
SELECT DISTINCT는 중복을 겉으로만 제거하지만 근본 원인을 숨길 수 있습니다:
중복이 우연이고 발생 원리를 정확히 이해했다면 제한적으로 사용하세요.
결과를 신뢰하기 전에 행 수를 비교해보세요:
JOIN이 느리다고 탓하기 전에, 실제 원인은 데이터가 얼마나 많이 결합되는지와 DB가 매칭 행을 얼마나 쉽게 찾을 수 있는지인 경우가 많습니다.
인덱스는 책의 목차와 같습니다. 인덱스가 없으면 DB는 매칭을 찾으려고 많은 행을 스캔해야 할 수 있습니다. 조인 키(예: customers.customer_id와 orders.customer_id)에 인덱스가 있으면 관련 행으로 빠르게 점프할 수 있습니다.
실무적으로는: 조인에 자주 사용되는 열은 인덱스 후보입니다.
가능하면 안정적이고 고유한 식별자로 조인하세요:
customers.customer_id = orders.customer_idcustomers.email = orders.email 또는 customers.name = orders.name이름은 바뀔 수 있고 중복될 수 있습니다. 이메일도 형식이나 대소문자 때문에 다를 수 있습니다. ID는 일관된 매칭을 위해 설계되었고 보통 인덱스가 있습니다.
두 가지 습관으로 JOIN이 더 빨라집니다:
SELECT *를 피하세요—불필요한 컬럼은 메모리와 네트워크 비용을 늘립니다.예: 주문을 먼저 제한한 다음 조인:
SELECT c.customer_id, c.name, o.order_id, o.created_at
FROM customers c
JOIN (
SELECT order_id, customer_id, created_at
FROM orders
WHERE created_at \u003e= DATE '2025-01-01'
) o
ON o.customer_id = c.customer_id;
앱 빌드 과정에서 이러한 쿼리를 반복적으로 작성할 때(예: PostgreSQL 기반 보고 페이지), Koder.ai 같은 도구가 스캐폴딩(스키마, 엔드포인트, UI)을 빠르게 만들어 주지만, 정확성을 결정하는 JOIN 논리는 여전히 직접 관리해야 합니다.
NULLNULLNULLSQL JOIN은 관련된 열(보통 기본 키와 외래 키 예: customers.customer_id = orders.customer_id)을 기준으로 두 개 이상의 테이블의 행을 하나의 결과 집합으로 결합하는 것입니다. 정규화된 테이블들을 보고서나 감사, 분석을 위해 ‘다시 연결’할 때 사용합니다.
관계가 양쪽 테이블에 모두 존재하는 행만 원할 때 INNER JOIN을 사용하세요.
구매를 실제로 한 고객만 나열할 때처럼 “확인된 관계”를 보고할 때 이상적입니다.
LEFT JOIN은 주 테이블(왼쪽)의 모든 행을 유지하고, 오른쪽에서 가능하면 관련 데이터를 붙입니다.
"매칭이 없는" 항목을 찾으려면 조인 후 오른쪽 컬럼이 NULL인 행을 필터하세요:
SELECT c.customer_id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id c.customer_id
o.order_id ;
RIGHT JOIN은 오른쪽 테이블의 모든 행을 유지하고, 왼쪽 테이블의 매칭이 없으면 왼쪽 컬럼을 NULL로 채웁니다. 많은 팀이 읽기 어렵다고 여겨 피하지만, 기존 쿼리를 조금만 수정할 때 편리할 수 있습니다.
대부분의 경우 테이블 순서를 바꿔 LEFT JOIN으로 같은 결과를 얻을 수 있습니다:
FROM payments p
LEFT JOIN orders o ON o.order_id = p.order_id
FULL OUTER JOIN은 두 테이블의 모든 행을 반환합니다.
NULL로 표시됩니다.NULL로 표시됩니다.주문과 결제의 대조(결제된 주문, 미결제 주문, 단독 결제 등)를 감사할 때 유용합니다.
일부 데이터베이스(MySQL, SQLite)는 FULL OUTER JOIN을 직접 지원하지 않습니다. 보통은 두 쿼리를 결합하는 방식으로 대체합니다:
orders LEFT JOIN payments일반적으로 UNION(또는 필요에 따라 UNION ALL)를 사용해 양쪽에만 존재하는 레코드를 모두 확보합니다.
CROSS JOIN은 두 테이블의 모든 가능한 조합(카테시안 곱)을 반환합니다. 시나리오 생성(예: 사이즈 × 색상)이나 캘린더 격자 생성 등에 유용합니다.
단, 입력 행 수가 곱해지므로 결과가 빠르게 폭발할 수 있습니다. 입력을 작게 유지하거나 제한을 두고 신중히 사용하세요.
SELF JOIN은 같은 테이블을 두 번 사용해 테이블 내 행들끼리 관계를 맺을 때 사용합니다(직원→관리자 같은 계층 구조).
같은 테이블을 두 번 참조하므로 별칭을 사용해 구분해야 합니다:
FROM employees e
LEFT JOIN employees m
ON e.manager_id = m.id
ON은 조인이 어떻게 매칭될지 정의하고, WHERE는 조인 결과에서 어떤 행을 유지할지 필터링합니다. 이 시점 차이 때문에 LEFT JOIN에 오른쪽 테이블 조건을 WHERE에 넣으면 의도치 않게 INNER JOIN처럼 동작할 수 있습니다.
오른쪽 테이블의 행만 제한하고 왼쪽 행은 모두 유지하려면 조건을 ON에 넣으세요.
조인은 일대다 또는 다대다 관계에서 결과 행을 곱하기 때문에 중복이 생깁니다. 예: 주문에 결제가 두 건이면 orders JOIN payments 결과에 그 주문이 두 번 나타납니다.
한 주문당 한 행을 원하면 ‘many’ 쪽을 미리 집계한 다음 조인하세요(예: SUM(amount)로 order_id별 그룹화). DISTINCT는 마지막 수단으로만 사용하세요—중복을 숨기고 합계/카운트가 틀어질 수 있습니다.