정규화로 쪼갠 테이블을 다시 엮어 하나의 결과로 만드는 연산.
종류와 남는 행
INNER JOIN 양쪽에 짝이 있는 행만
LEFT JOIN 왼쪽은 다 남기고, 짝이 없으면 오른쪽을 NULL 로 채운다
RIGHT JOIN 반대
FULL JOIN 양쪽 다 남긴다 (MySQL 은 지원하지 않아 UNION 으로 흉내 낸다)
CROSS JOIN 조건 없이 모든 조합 (곱집합)
-- 주문이 없는 회원까지 포함해 주문 수를 센다
SELECT m.id, m.name, count(o.id) AS order_count
FROM member m
LEFT JOIN orders o ON o.member_id = m.id
GROUP BY m.id, m.name;
-- count(o.id) 를 쓴다. count(*) 로 하면 주문 없는 회원도 1이 된다
마지막 주석이 실무에서 가장 자주 틀리는 지점이다 — LEFT JOIN 뒤의 count(*)는 NULL 행도 1로 센다.
LEFT JOIN을 INNER로 만들어 버리는 함정
-- ❌ WHERE 에 오른쪽 테이블 조건을 걸면 NULL 행이 걸러져 INNER 가 된다
SELECT * FROM member m
LEFT JOIN orders o ON o.member_id = m.id
WHERE o.status = 'PAID'; -- 주문 없는 회원의 o.status 는 NULL → 탈락
-- ✅ 조건을 ON 절로 옮긴다
SELECT * FROM member m
LEFT JOIN orders o ON o.member_id = m.id AND o.status = 'PAID';
ON은 조인할 때 쓰는 조건, WHERE는 조인 결과를 거르는 조건이다. INNER JOIN에서는 둘이 같지만 OUTER JOIN에서는 전혀 다르다.
행 수가 불어나는 경우
-- 1:N 조인은 왼쪽 행이 오른쪽 개수만큼 복제된다
SELECT m.id, sum(o.amount)
FROM member m JOIN orders o ON o.member_id = m.id
JOIN coupon c ON c.member_id = m.id -- 쿠폰이 3장이면 주문 금액이 3배로 합산된다
GROUP BY m.id;
두 개의 1:N을 동시에 조인하면 곱집합이 되어 집계가 부풀려진다. 각각 서브쿼리로 집계한 뒤 조인하는 것이 정석이다.
SELECT m.id, o.total, c.cnt
FROM member m
LEFT JOIN (SELECT member_id, sum(amount) total FROM orders GROUP BY 1) o ON o.member_id = m.id
LEFT JOIN (SELECT member_id, count(*) cnt FROM coupon GROUP BY 1) c ON c.member_id = m.id;
세미 조인 — 존재만 확인할 때
-- 주문한 적 있는 회원 — JOIN 을 쓰면 주문 수만큼 중복된다
SELECT DISTINCT m.* FROM member m JOIN orders o ON o.member_id = m.id; -- DISTINCT 비용
-- EXISTS 는 첫 일치에서 멈춘다. 중복도 안 생긴다
SELECT * FROM member m WHERE EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id);
면접 함정
- ❌ "조인은 느리니 나눠 조회한다" → N+1이 되어 훨씬 느려진다. 왕복이 비싸다.
- ❌ "LEFT JOIN이 INNER JOIN보다 항상 느리다" → 계획에 따라 다르다. 잘못된
WHERE로 사실상 INNER가 되고 있는 경우가 더 흔한 문제다.