結合 — どちら側を残すのか
한국어 원문으로 표시합니다.
한 줄 요약
조인은 두 집합의 행을 조건으로 짝짓는 연산이며, 실무 버그의 대부분은 짝이 없는 행을 어떻게 처리할지 잘못 정한 데서 나온다.
왜 이게 필요했나
정규화의 대가가 조인이다. 고객 정보와 주문 정보를 나눠 두었으니 함께 보려면 다시 붙여야 한다. 문제는 "짝이 없는 행"을 어떻게 할지다. 주문이 없는 고객을 결과에 남길 것인가 뺄 것인가. 이 결정이 곧 조인 종류다.
어떻게 동작하나
- INNER JOIN — 양쪽 모두에 짝이 있는 행만 남긴다.
- LEFT JOIN — 왼쪽은 모두 남기고, 짝이 없으면 오른쪽 컬럼을 NULL 로 채운다.
- 안티 조인 — 짝이 없는 행만 남긴다.
NOT EXISTS로 쓰는 것이 가장 명확하다. - 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% 는 INNER 와 LEFT 입니다. RIGHT 는 LEFT 로 뒤집어 쓰는 편이
읽기 쉽고, 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 절에 같은 조건을 두었을 때 결과가 어떻게 갈리는지 직접 비교한다.