SQL 조인 정리: 행이 사라지거나 늘어나는 이유
들어가며
글 목록에 댓글 수를 붙이려고 LEFT JOIN을 썼는데 댓글 없는 글이 사라진 적이 있으신가요? 학생별 신청 수와 점수 합계를 한 쿼리로 냈더니 점수만 두 배가 되는 일도 흔합니다.
두 문제 모두 조인 문법보다 어떤 행이 남고 몇 행이 되는가를 보면 풀립니다. 이번 글에서는 조인 종류의 차이, ON과 WHERE의 차이, 행이 늘어나는 이유, DB가 조인을 실행하는 방식을 정리합니다.
1. 조인 종류는 짝 없는 행을 정한다
조인은 두 테이블의 행을 조건에 맞춰 이어 붙이는 연산입니다. 왼쪽 행마다 오른쪽에서 짝을 찾는다고 보면 이해가 쉽습니다.
예를 들어 학생은 민수, 지아, 유나 세 명이고, 동아리 신청서는 지아 1장, 유나 2장, 명단에 없는 하늘 1장이라고 해 보겠습니다. 짝이 있는 행은 3개이고, 왼쪽에만 있는 민수와 오른쪽에만 있는 하늘을 남길지가 조인 종류로 갈립니다.
조인은 반 명단과 동아리 신청서를 맞춰 보는 일이에요. LEFT는 명단의 학생을 전부 남겨요.
| 조인 종류 | 짝 있음 | 왼쪽만 | 오른쪽만 | 결과 행 |
|---|---|---|---|---|
| INNER JOIN짝만 | 3행 | |||
| LEFT JOIN자주 씀 | 민수 · 신청 NULL | 4행 | ||
| RIGHT JOINLEFT의 거울 | 하늘 · 학생 NULL | 4행 | ||
| FULL OUTERMySQL 미지원 | 민수 | 하늘 | 5행 |
SELECT s.name, a.club
FROM students s
LEFT JOIN apply a ON a.name = s.name;
-- 민수 | NULL 짝이 없어도 남는다
-- 지아 | 밴드
-- 유나 | 밴드
-- 유나 | 독서 짝이 둘이면 2행RIGHT JOIN은 테이블 순서를 바꾼 LEFT JOIN과 같아서 실무에서는 보통 LEFT로 통일합니다. MySQL에는 FULL OUTER JOIN이 없으므로, LEFT JOIN 결과와 오른쪽에만 있는 행을 UNION ALL로 합칩니다.
2. ON과 WHERE
INNER JOIN에서는 조건을 ON에 쓰든 WHERE에 쓰든 결과가 같습니다. LEFT JOIN에서는 다릅니다. ON은 짝을 찾는 조건이고, WHERE는 조인이 끝난 결과를 거르는 조건이기 때문입니다.
숨긴 댓글을 빼고 글마다 댓글 수를 센다고 해 보겠습니다. 글 1에는 보이는 댓글이 2개, 글 2에는 댓글이 없고, 글 3에는 숨긴 댓글만 1개 있습니다.
ON은 짝을 맞출 때의 조건, WHERE는 다 맞춘 뒤 명단에서 지우는 조건이에요.
- 글 1 · 댓글 2
- 글 2 · 댓글 없음hidden 이 NULL
- 글 3 · 숨김만hidden 이 true
- 글 1 · 댓글 2
- 글 2 · 댓글 0
- 글 3 · 댓글 0
-- 나쁨: 글 2의 c.hidden 은 NULL 이라 WHERE 에서 탈락
SELECT p.id, COUNT(c.id)
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
WHERE c.hidden = false
GROUP BY p.id;
-- 좋음: 짝을 찾을 때 거르면 왼쪽 행은 남는다
SELECT p.id, COUNT(c.id)
FROM posts p
LEFT JOIN comments c ON c.post_id = p.id
AND c.hidden = false
GROUP BY p.id;COUNT(c.id) 는 NULL을 세지 않으므로 짝이 없는 글은 0이 됩니다. COUNT(*) 로 쓰면 NULL로 채운 행까지 세어 1이 되니 주의합니다.
3. 짝이 여럿이면 행이 늘어난다
조인 종류와 관계없이, 한 행에 짝이 여럿이면 결과도 그만큼 늘어납니다. 유나의 점수가 90이고 신청서가 2장이면, 조인 결과에 유나가 2행으로 나오면서 점수를 더했을 때 180이 됩니다.
-- 나쁨: 유나 점수가 90 + 90 = 180
SELECT s.name, SUM(s.score), COUNT(a.club)
FROM students s
LEFT JOIN apply a ON a.name = s.name
GROUP BY s.name;
-- 좋음: 신청서를 먼저 집계해 1:1 로 잇는다
WITH applied AS (
SELECT name, COUNT(*) AS cnt
FROM apply
GROUP BY name
)
SELECT s.name, s.score, COALESCE(a.cnt, 0) AS cnt
FROM students s
LEFT JOIN applied a ON a.name = s.name;신청서를 학생별로 한 줄씩 요약한 뒤 명단에 옮겨 적는 일이에요.
4. DB가 조인을 실행하는 방식
SQL에는 결과만 적고, 어떤 알고리즘으로 실행할지는 옵티마이저가 통계를 보고 고릅니다. PostgreSQL은 아래 세 방식을 모두 쓰고, MySQL은 중첩 루프와 8.0.18부터 지원한 해시 조인을 씁니다.
| 중첩 루프 | 해시 조인 | 머지 조인 | |
|---|---|---|---|
| 동작 | 바깥 행마다 안쪽을 찾음 | 작은 쪽으로 해시 테이블을 만듦 | 정렬된 양쪽을 나란히 읽음 |
| 잘 맞는 곳 | 바깥 행이 적고 안쪽에 인덱스가 있을 때 | 큰 테이블끼리 같음 조건으로 이을 때 | 이미 정렬돼 있을 때 |
| 약점 | 바깥 행이 많으면 느림 | 메모리 사용, 범위 조건 불가 | 정렬 비용 |
조인이 느리면 EXPLAIN 으로 어떤 방식이 선택됐는지와 추정 행 수를 봅니다. 추정이 크게 틀렸다면 통계 갱신이나 조인 키의 인덱스를 먼저 확인합니다.
헷갈리기 쉬운 점
- LEFT JOIN은 왼쪽 행 수를 그대로 지킨다는 오해. 왼쪽 행이 사라지지 않을 뿐, 오른쪽 짝이 여럿이면 행은 늘어납니다.
- NULL끼리는 짝이 된다는 오해.
NULL = NULL은 참이 아니므로 조인 키가 NULL인 행은 서로 이어지지 않습니다. - 없는 행을 NOT IN으로 찾아도 안전하다는 오해. 서브쿼리 결과에 NULL이 하나라도 있으면 결과가 비어 버립니다. NOT EXISTS가 더 안전합니다.
면접에서 자주 받는 질문
신청서가 없는 학생은 어떻게 찾나요?
LEFT JOIN 뒤에 오른쪽 키가 IS NULL 인 행만 남기거나 NOT EXISTS를 씁니다. NOT IN은 서브쿼리에 NULL이 섞이면 결과가 비므로 피합니다.
그룹별 최신 한 건은 어떻게 조회하나요?
그룹별 최댓값을 구해 self-join하거나, ROW_NUMBER() 로 그룹 안에서 순번을 매겨 1번만 고릅니다. 윈도우 함수를 쓰면 의도가 더 잘 드러나고, 최댓값이 같은 행이 있어도 한 건만 남습니다.
조인과 서브쿼리 중 무엇이 더 빠른가요?
쓰는 모양만으로 정해지지 않습니다. 옵티마이저가 서브쿼리를 조인으로 바꿔 실행하는 경우도 많으므로, 실행 계획으로 비교합니다. 그다음에는 읽기 쉬운 쪽을 고릅니다.
정리
- 조인 종류는 짝 없는 행을 남길지를 정합니다. 왼쪽을 지키려면 LEFT JOIN을 씁니다.
- LEFT JOIN에서 오른쪽 조건은 ON에 둡니다. WHERE에 두면 NULL 행이 사라져 INNER JOIN처럼 동작합니다.
- 짝이 여럿이면 행이 늘어나 합계가 부풀므로 먼저 집계한 뒤 잇습니다.
조인 결과가 이상하면 쿼리를 고치기 전에, 어떤 행이 남고 각 행의 짝이 몇 개인지부터 세어 봅니다.