백엔드 데이터 인프라 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;
LATERAL(서브쿼리에서 바깥 테이블 컬럼 참조 허용) JOIN은 서브쿼리 안에서 "외부 테이블 컬럼(u.id) 참조" 가 가능합니다. "각 사용자별 최근 3개 주문" 같은 "Top-N per group(그룹별 상위 N건)" 시나리오에 딱이에요.
표준 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 계획자(쿼리 실행 경로 자동 선택기)는 세 가지 방법을 골라 씁니다.
| 방법 | 의미 |
|---|---|
| Nested Loop | 한쪽 행마다 다른 쪽 스캔 (작은 테이블) |
| Hash Join | 한쪽으로 해시 테이블, 다른 쪽 룩업 (중간) |
| Merge Join | 양쪽 정렬 후 병합 (정렬 인덱스 있을 때) |
어느 방법이 골랐는지는 40편 EXPLAIN(쿼리 실행 계획 출력 명령)으로 확인하면 되고, 결정적인 변수는 인덱스(36편)예요.
Spring JPA 관점
자바 백엔드 입문 46편 JPA 연관관계 에서 본 JPA(자바 ORM 표준)의 @OneToMany·@ManyToOne이 알아서 JOIN을 생성합니다. 다만 LEFT JOIN을 안 박았을 때 N+1 문제(쿼리 1건이 N건으로 폭증)가 터져요(자바 백엔드 입문 47편).
// 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 차이
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 7편 — PostgreSQL 관계형 모델 핵심 개념
- 8편 — SQL 기초 5가지 동사
- 9편 — CREATE TABLE 첫 테이블 만들기
- 10편 — INSERT 데이터 입력 표준 패턴
- 11편 — SELECT 데이터 조회 표준 패턴
다음 글: