백엔드 데이터 인프라 12편. JOIN으로 여러 테이블 합치기 — INNER·LEFT·RIGHT·FULL JOIN과 LATERAL JOIN까지 풀어쓴 학습 노트.
이 글은 백엔드 데이터 인프라 시리즈 70편 중 12편이에요. 7편 관계형 모델 에서 "외래 키로 행 사이 관계 표현" 을 다뤘죠. 이번 12편은 그 관계를 쿼리로 활용 — 여러 테이블 합치기.
JOIN의 직관
관계형 DB의 핵심 가치 = "한 사실은 한 곳에만 박고, 필요할 때 합쳐 본다". 사용자 정보 = users, 주문 정보 = orders. "누가 무엇을 주문했는가" = 둘을 JOIN.
users orders
+----+--------+ +----+---------+--------+
| id | name | | id | user_id | amount |
+----+--------+ +----+---------+--------+
| 1 | Alice | | 1 | 1 | 10000 |
| 2 | Bob | | 2 | 1 | 5000 |
| 3 | Carol | | 3 | 2 | 3000 |
+----+--------+ +----+---------+--------+
JOIN 결과
+--------+---------+--------+
| name | id | amount |
+--------+---------+--------+
| Alice | 1 | 10000 |
| Alice | 2 | 5000 |
| Bob | 3 | 3000 |
+--------+---------+--------+
INNER JOIN — 기본
SELECT u.name, o.id, o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
또는 단축 (INNER 생략):
SELECT u.name, o.id, o.amount
FROM users u
JOIN orders o ON u.id = o.user_id;
INNER JOIN = "양쪽 다 매칭되는 행만". 주문 없는 사용자(Carol)·사용자 없는 주문은 결과에 없음.
별칭(alias) 표준
FROM users u JOIN orders o ON u.id = o.user_id
u·o 같은 짧은 별칭 = SQL 표준 표기. 컬럼 명시 = u.name·o.amount.
LEFT JOIN — 왼쪽 보존
SELECT u.name, o.id, o.amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
+--------+------+--------+
| name | id | amount |
+--------+------+--------+
| Alice | 1 | 10000 |
| Alice | 2 | 5000 |
| Bob | 3 | 3000 |
| Carol | NULL | NULL | ← 주문 없는 사용자도 표시
+--------+------+--------+
왼쪽 테이블의 모든 행 보존. 오른쪽 매칭 없으면 NULL. "사용자 + 주문 (없어도 OK)" 표현.
한국 회사 백엔드에서 가장 자주 — "X 목록 + 각 X의 부속 정보 (있을 수도)".
RIGHT JOIN — 오른쪽 보존
FROM users u RIGHT JOIN orders o ON u.id = o.user_id
LEFT JOIN의 거울. 거의 안 씀 — 테이블 순서를 바꿔 LEFT JOIN으로 표현하는 게 일반.
FULL OUTER JOIN — 양쪽 보존
FROM users u FULL OUTER JOIN orders o ON u.id = o.user_id
매칭 안 되는 양쪽 행도 NULL로 채움. "양쪽 다 봐야 하는" 흔치 않은 시나리오.
CROSS JOIN — 데카르트 곱
FROM colors CROSS JOIN sizes
조건 없이 모든 조합. 결과 = m × n. 색상 5 × 사이즈 3 = 15행. 가끔 "모든 조합 생성" 에 활용 (재고 매트릭스 등).
SELF JOIN — 같은 테이블 두 번
-- 매니저 정보 + 부하 직원 이름
SELECT
e.name AS employee,
m.name AS manager
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
같은 테이블에 두 별칭 박아서 관계 표현. 트리·계층 구조에 흔함.
USING — 같은 이름 컬럼
SELECT *
FROM users
JOIN orders USING (user_id); -- ON u.user_id = o.user_id 의 단축
양쪽에 user_id 같은 이름 컬럼이면 — USING (컬럼) 단축. 결과에 한 번만 나옴.
NATURAL JOIN — 같은 이름 모두 매칭
SELECT * FROM users NATURAL JOIN orders;
거의 안 권장 — 같은 이름 컬럼이 또 생기면 의도와 다르게 동작.
3개+ 테이블 JOIN
SELECT
u.name,
o.id AS order_id,
p.name AS product
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN products p ON o.product_id = p.id
WHERE u.created_at >= '2026-01-01'
ORDER BY o.created_at DESC;
여러 JOIN을 차례로. PG는 자동으로 "가장 효율적인 JOIN 순서" 를 계획.
LATERAL JOIN — PG 강점
SELECT
u.name,
recent_orders.*
FROM users u
LEFT JOIN LATERAL (
SELECT id, amount, created_at
FROM orders
WHERE user_id = u.id
ORDER BY created_at DESC
LIMIT 3
) recent_orders ON true;
서브쿼리 안에서 "외부 테이블 컬럼(u.id) 참조 가능". "각 사용자별 최근 3개 주문" 같은 "Top-N per group" 시나리오.
표준 SQL에도 LATERAL 있지만 — PG가 가장 깔끔하게 지원.
JOIN과 WHERE — 미묘한 차이
-- LEFT JOIN + ON 조건
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'PAID';
-- vs LEFT JOIN + WHERE
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'PAID';
차이:
- ON 조건 = 매칭 자체에 영향, 주문 없는 사용자는 여전히 결과에 (o.* NULL)
- WHERE 조건 = 모든 JOIN 후 필터, o.status IS NULL 인 행은 제거 → INNER JOIN과 같아짐
LEFT JOIN의 함정 = WHERE가 "INNER JOIN으로 둔갑". 의도 정확히.
성능
큰 테이블 JOIN = 비싼 연산. PG 계획자 3가지 방법:
| 방법 | 의미 |
|---|---|
| Nested Loop | 한쪽 행마다 다른 쪽 스캔 (작은 테이블) |
| Hash Join | 한쪽으로 해시 테이블, 다른 쪽 룩업 (중간) |
| Merge Join | 양쪽 정렬 후 병합 (정렬 인덱스 있을 때) |
40편 EXPLAIN으로 어느 방법인지 확인. 인덱스 (36편) 가 결정적.
Spring JPA 관점
자바 백엔드 입문 46편 JPA 연관관계 의 @OneToMany·@ManyToOne 이 자동 JOIN 생성. 단 — N+1 문제 (자바 백엔드 입문 47편)가 LEFT JOIN을 안 박았을 때 폭발.
// JPA fetch join
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id")
Optional<User> findByIdWithOrders(@Param("id") Long id);
→ 자동 생성 SQL:
SELECT u.*, o.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id = ?;
JOIN 직접 이해 = JPA 최적화의 토대.
함정 5가지
(1) LEFT JOIN + WHERE 함정
위에서 다뤘듯이 — WHERE에 오른쪽 컬럼 조건 박으면 INNER JOIN처럼 동작.
(2) JOIN 조건 누락
FROM users JOIN orders -- ❌ 조건 없으면 CROSS JOIN (데카르트 곱)
ON 또는 USING 필수.
(3) 같은 이름 컬럼 모호
SELECT id FROM users JOIN orders ON users.id = orders.user_id;
-- ❌ ERROR: column reference "id" is ambiguous
별칭 + 명시 — users.id 또는 u.id.
(4) JOIN 너무 많이
5개+ 테이블 JOIN = 계획자 부담, 결과 정확성 의심. 큰 쿼리는 CTE (WITH) 또는 서브쿼리로 분해.
(5) NULL 비교
LEFT JOIN 결과의 NULL을 = NULL 로 비교. IS NULL 사용.
매칭 행만 = INNER. 왼쪽 보존 (한국 백엔드 가장 흔함) = LEFT. 모든 조합 = CROSS. 각 그룹 Top-N = LATERAL. 80% 시나리오는 LEFT JOIN이 정답.
한 줄 정리 — JOIN 4종 = INNER·LEFT·RIGHT·FULL. 한국 백엔드 80% = LEFT JOIN. CROSS·SELF·LATERAL은 특수. ON vs WHERE 차이 주의 — LEFT JOIN + WHERE 오른쪽 조건은 INNER JOIN으로 둔갑. JPA는 fetch join 으로 N+1 회피.
시험 직전 한 번 더 — JOIN 입문자가 매번 헷갈리는 것
- INNER JOIN = 양쪽 매칭만
- LEFT JOIN = 왼쪽 보존, 오른쪽 없으면 NULL
- RIGHT JOIN = 거의 안 씀 (테이블 순서 바꿔 LEFT로)
- FULL OUTER = 양쪽 보존
- CROSS JOIN = 데카르트 곱 (조건 없음)
- SELF JOIN = 같은 테이블 두 별칭 (계층)
- USING (col) = ON의 단축 (같은 이름)
- NATURAL JOIN = 안 권장
- LATERAL = 서브쿼리에서 외부 컬럼 참조 (Top-N per group)
- ON 조건 = JOIN 자체 영향
- WHERE 조건 = JOIN 후 필터 — LEFT JOIN에서 INNER로 둔갑 주의
- 별칭 표준 =
users u·orders o - 3개+ 테이블 JOIN = 차례로 추가
- PG 계획자 = Nested Loop·Hash·Merge 자동 선택
- 인덱스가 JOIN 성능 결정
- N+1 문제 = JPA Lazy Loading 함정
JOIN FETCH(JPQL) = LEFT JOIN으로 미리 가져옴- 컬럼 모호 = 별칭 + 명시
- JOIN 너무 많이 = CTE·서브쿼리 분해
- NULL 비교 =
IS NULL - 한국 백엔드 80% = LEFT JOIN
- 면접 단골 질문 = INNER vs LEFT 차이
시리즈 다른 편
- Part 1 PostgreSQL 튜토리얼: 1편 · 9편 CREATE TABLE · 10편 INSERT · 11편 SELECT · 12편 (현재 글)
시리즈 다음 글
다음 글(13편)에서는 UPDATE 데이터 수정 — SET·FROM·RETURNING 패턴.
공식 문서: PostgreSQL 18 — Tutorial: Joins Between Tables에서 더 자세한 사양을 확인할 수 있어요.