LabHub
배우기 러닝패스 코스

SQL実戦

結合 — どちら側を残すのか

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

조인은 두 집합의 행을 조건으로 짝짓는 연산이며, 실무 버그의 대부분은 짝이 없는 행을 어떻게 처리할지 잘못 정한 데서 나온다.

概念マップ: INNER JOIN・LEFT JOIN・안티 조인・CROSS JOIN

왜 이게 필요했나

정규화의 대가가 조인이다. 고객 정보와 주문 정보를 나눠 두었으니 함께 보려면 다시 붙여야 한다. 문제는 "짝이 없는 행"을 어떻게 할지다. 주문이 없는 고객을 결과에 남길 것인가 뺄 것인가. 이 결정이 곧 조인 종류다.

어떻게 동작하나

여기서 가장 중요한 함정을 짚어야 한다. LEFT JOIN 뒤의 WHERE 에 오른쪽 테이블 조건을 쓰면 그 순간 INNER JOIN 이 된다.

-- 모든 고객 + 그중 결제 완료 주문 (고객 400명 유지)
SELECT c.id, count(o.id)
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id AND o.status = 'paid'
GROUP BY c.id;

-- 결제 완료 주문이 있는 고객만 (고객 수가 줄어든다)
SELECT c.id, count(o.id)
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'paid'
GROUP BY c.id;

짝이 없는 행은 오른쪽 컬럼이 NULL 이 되는데, o.status = 'paid' 는 NULL 에 대해 미지를 돌려주므로 그 행들이 통째로 걸러진다. 왼쪽 외부 조인의 필터는 ON 절에, 왼쪽 테이블의 필터는 WHERE 절에 두는 것이 규칙이다.

집계와 결합될 때의 함정도 하나 더 있다. LEFT JOIN 뒤에 count(*) 를 쓰면 짝이 없는 행도 1로 세어 버린다. count(o.id) 처럼 조인된 쪽 컬럼을 세야 0 이 나온다. 집계 함수가 NULL 을 무시한다는 성질을 이용하는 것이다.

현장에서 만나는 모습

조인이 행을 늘린다는 사실도 자주 잊힌다. 주문 하나에 상세가 셋이면 조인 결과는 세 행이 되고, 그 상태에서 sum(o.total_amount) 를 하면 주문 금액이 세 배가 된다. 이런 팬아웃은 리포트 숫자를 조용히 부풀리는 대표적 원인이다. 해법은 집계를 먼저 하고 조인하거나, sum(DISTINCT ...) 가 아니라 서브쿼리로 미리 접는 것이다.

성능 면에서는 조인 알고리즘 세 가지를 알아 둘 만하다. 바깥쪽 행이 적고 안쪽에 인덱스가 있으면 중첩 루프, 등호 조인이고 한쪽을 메모리에 올릴 수 있으면 해시 조인, 양쪽이 이미 정렬되어 있으면 머지 조인 이 유리하다. 옵티마이저가 고르는 일이지만, 계획에서 중첩 루프의 반복 횟수가 수만 회를 넘는다면 바깥쪽 행 수 추정이 틀렸다는 신호다.

조인 종류를 그림으로 정리하면

A (주문)          B (고객)
 ┌───┐            ┌───┐
 │ 1 │────────────│ 1 │      INNER   : 양쪽에 다 있는 것만
 │ 2 │────────────│ 2 │      LEFT    : A 는 전부, 짝 없으면 B 쪽이 NULL
 │ 3 │            │   │      RIGHT   : B 는 전부
 │   │            │ 4 │      FULL    : 양쪽 전부
 └───┘            └───┘

실무에서 90% 는 INNERLEFT 입니다. RIGHTLEFT 로 뒤집어 쓰는 편이 읽기 쉽고, FULL 은 데이터 대조(어느 쪽에만 있는 것 찾기)에 씁니다.

LEFT JOIN 에서 조건을 어디에 쓰는지가 결과를 바꿉니다. 이것이 가장 흔한 실수 입니다.

-- 취소되지 않은 결제가 있는 주문 + 결제가 아예 없는 주문
select o.*, p.amount
from orders o
left join payments p on p.order_id = o.id and p.status <> 'canceled';
                                            ↑ ON 절 — 짝을 찾는 조건

-- 사실상 INNER JOIN 이 된다. 짝이 없는 행은 p.status 가 NULL 이라 걸러진다
from orders o
left join payments p on p.order_id = o.id
where p.status <> 'canceled';
      ↑ WHERE 절 — 조인 결과를 거르는 조건

LEFT JOIN 뒤에 오른쪽 표의 조건을 WHERE 에 쓰면 LEFT 의 의미가 사라집니다. 오른쪽 표 조건은 ON 에, 왼쪽 표 조건은 WHERE 에 두는 것이 규칙입니다.

행이 불어나는 것을 알아채기

조인은 짝이 여럿이면 행을 곱합니다. 주문 하나에 결제가 3건이면 그 주문이 3줄이 되고, 그 상태에서 sum(o.amount) 를 하면 금액이 3배 가 됩니다.

-- ❌ 주문 금액이 결제 건수만큼 부풀려진다
select sum(o.amount) from orders o join payments p on p.order_id = o.id;

-- ✅ 미리 접어서 조인한다
select sum(o.amount)
from orders o
join (select order_id from payments group by order_id) p on p.order_id = o.id;

-- ✅ 또는 존재 여부만 물을 때는 EXISTS
select sum(o.amount) from orders o
where exists (select 1 from payments p where p.order_id = o.id);

EXISTS 는 첫 짝을 찾으면 멈추므로 행을 불리지 않고 대개 더 빠릅니다. "있는지만 알면 되는" 경우에는 조인보다 이쪽이 맞습니다.

NOT IN 의 NULL 함정

-- 서브쿼리 결과에 NULL 이 하나라도 있으면 전체가 빈 결과가 된다
select * from orders where customer_id not in (select id from vip_customers);

-- 안전하다
select * from orders o
where not exists (select 1 from vip_customers v where v.id = o.customer_id);

x NOT IN (1, 2, NULL)x <> 1 and x <> 2 and x <> NULL 인데, 마지막이 UNKNOWN 이라 전체가 참이 되지 않습니다. NOT IN 대신 NOT EXISTS 를 쓰는 것이 안전합니다.

다음 실습에서 할 것

내부 조인, 외부 조인, 안티 조인, 자기 조인을 차례로 쓰고, ON 절과 WHERE 절에 같은 조건을 두었을 때 결과가 어떻게 갈리는지 직접 비교한다.