백엔드 데이터 인프라 12편 — JOIN 여러 테이블 합치기

2026-05-17백엔드 데이터 인프라

백엔드 데이터 인프라 12편. JOIN으로 여러 테이블 합치기 — INNER·LEFT·RIGHT·FULL JOIN과 LATERAL JOIN까지 풀어쓴 학습 노트.

📚 백엔드 데이터 인프라 · 12편 — 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 사용.

🎯 JOIN 4가지 선택 룰

매칭 행만 = 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 차이

시리즈 다른 편

시리즈 다음 글

다음 글(13편)에서는 UPDATE 데이터 수정 — SET·FROM·RETURNING 패턴.

공식 문서: PostgreSQL 18 — Tutorial: Joins Between Tables에서 더 자세한 사양을 확인할 수 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!