infra
Platform

모듈 맵

[Database] INNER, LEFT, RIGHT, FULL JOIN의 최적화 실행 조건

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 12 / 37

[Database] INNER, LEFT, RIGHT, FULL JOIN의 최적화 실행 조건

여러 테이블의 데이터를 결합하는 JOIN의 모든 종류와 동작 원리를 실습으로 익힙니다

🚨INCIDENT ALERT
HIGH

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 5종 — 벤다이어그램으로 이해하기

예시 데이터 준비

JOIN을 설명하기 위한 간단한 예시 데이터입니다. customers(고객)와 orders(주문) 두 테이블을 사용합니다. 주문이 없는 고객(박민준, 최지은)과 고객 테이블에 존재하지 않는 customer_id=5의 주문을 의도적으로 포함시켜, 각 JOIN 타입이 이 불일치를 어떻게 처리하는지 확인합니다.

1JOIN 실습용 예시 데이터 준비

customers와 orders 테이블을 만들고 의도적으로 불일치 데이터를 포함시킵니다. 각 JOIN 타입이 이 불일치를 어떻게 처리하는지 다음 단계에서 확인합니다.

SQL
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);
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
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에 매칭 행이 없으므로 제외됩니다.

2INNER JOIN — 양쪽 모두 매칭되는 행만

INNER JOIN을 실행하고, 주문이 없는 고객(박민준, 최지은)과 customer_id=5 주문이 결과에서 제외되는지 확인합니다.

SQL
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:NN 쪽(많은 쪽)의 행 수1쪽 행이 복제됨
N:M교차 테이블 없이 JOIN 시 폭발적 증가반드시 중간 테이블 사용

집계 쿼리를 작성할 때 중복 행을 인식하지 못하면 SUM()이나 COUNT()가 실제보다 과다 계산됩니다. JOIN 후 집계가 예상과 다르면 먼저 중복 행 여부를 확인하세요.

LEFT JOIN — 왼쪽 테이블 전체 보존

LEFT JOIN은 왼쪽 테이블(FROM 뒤)의 모든 행을 유지합니다. 오른쪽 테이블에 매칭되는 행이 없으면 오른쪽 컬럼 전체가 NULL로 채워집니다. "주문 없는 고객도 목록에 포함하고 싶다"는 요구사항이 LEFT JOIN의 전형적인 사용 사례입니다.

3LEFT JOIN — 주문 없는 고객도 포함

LEFT JOIN으로 주문이 없는 고객(박민준, 최지은)도 결과에 포함시킵니다. NULL 행만 골라 "미구매 고객" 목록도 추출해봅니다.

SQL
SELECT c.name, o.id AS order_id, o.amount
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id;
SQL
-- 한 번도 주문하지 않은 고객만 추출
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_idamount가 NULL입니다.

RIGHT JOIN — 오른쪽 테이블 전체 보존

RIGHT JOIN은 LEFT JOIN과 방향만 반대입니다. 실무에서는 테이블 순서를 바꾸고 LEFT JOIN을 사용하는 것이 더 읽기 쉬워 RIGHT JOIN은 거의 사용하지 않습니다. 아래 두 쿼리는 동일한 결과를 반환합니다.

SQL
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로 채워집니다. 데이터 마이그레이션 후 불일치 감사, 두 시스템의 레코드 대조 등에 유용합니다.

SQL
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처럼 동작하므로 주의하세요.

SQL
SELECT s.size, c.color
FROM sizes s
CROSS JOIN colors c;

ON vs USING

두 테이블에서 조인 컬럼 이름이 동일할 때 USING으로 표현을 간결하게 줄일 수 있습니다.

SQL
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 행이 사라지는 문제입니다.

SQL
-- 의도: 주문 없는 고객도 포함하면서, 최근 주문만 보고 싶다
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 절로 옮기는 것입니다.

SQL
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을 쓰면 구매 이력 있는 고객만 나와 보고서가 불완전해집니다.

SQL
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과 실전 JOIN 패턴 — 계층 구조와 통계

SELF JOIN — 같은 테이블끼리 조인

SELF JOIN은 테이블이 자기 자신을 참조하는 구조에서 사용합니다. 대표적인 예가 직원-관리자 관계입니다. 같은 테이블을 두 번 쓰기 때문에 반드시 서로 다른 별칭을 붙여야 어느 인스턴스를 참조하는지 구분됩니다.

SQL
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);
SQL
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은 과감히 제거해야 성능을 지킬 수 있습니다.

SQL
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인 고객도 결과에 포함됩니다.

SQL
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)에 처리됩니다.

SQL
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에 포함되어 있다면 제거하세요.

SQL
SELECT c.name, COUNT(*)
FROM orders o
JOIN customers c ON o.customer_id = c.id
GROUP BY c.name;

다음 모듈에서는 서브쿼리와 CTE(WITH 문)를 활용해 복잡한 쿼리를 읽기 쉬운 구조로 분리하는 방법을 다룹니다.

지식 확인

퀴즈 — 5문제

Q1

전체 사용자 목록과 각 사용자의 최근 주문 날짜를 함께 조회해야 합니다. 아직 주문이 없는 신규 사용자도 결과에 포함되어야 합니다. 어떤 JOIN을 사용해야 합니까?

Q2

INNER JOIN과 LEFT JOIN의 차이는?

Q3

SELF JOIN은 어떤 상황에서 유용합니까?

Q4

JOIN에서 ON 절 대신 USING을 사용할 수 있는 조건은?

Q5

아래 쿼리를 실행했더니 예상보다 훨씬 많은 행이 반환됐습니다. orders 3만 건, products 500건인데 결과가 1,500만 건입니다. 원인은? SELECT * FROM orders, products WHERE orders.created_at > '2024-01-01';

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Ubuntu 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

40📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

database중급 · 50
[Database] N+1 문제, SELECT *, 인덱스 무력화 안티패턴 방지
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점