백엔드 데이터 인프라 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;

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을 써야 합니다.

🎯 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 차이

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

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

답글 남기기

error: Content is protected !!