OUTER JOIN
짝이 없는 행을 남기는 LEFT JOIN으로 모든 회원과 공개 글의 수를 구합니다. 오른쪽 테이블의 조건을 WHERE에 두면 LEFT JOIN이 INNER JOIN처럼 변하는 이유, COUNT(*)가 글이 없는 회원을 1로 세는 함정, 짝이 없는 행을 찾는 안티 조인과 여러 자식 조인에서 행이 곱해지는 문제를 다룹니다.
아직 글을 쓰지 않은 신규 회원과 한 번도 쓰이지 않은 태그는 INNER JOIN의 결과에서 사라집니다. "활동한 회원"을 묻는다면 맞지만, "모든 회원과 각자의 활동 수"를 묻는다면 틀린 결과입니다. OUTER JOIN은 짝이 없는 기준 행을 남기고, 상대 테이블의 열을 NULL로 채웁니다.
이 글에서는 모든 회원을 왼쪽의 기준으로 두고, 7월에 작성되었으며 지금 공개 상태인 글을 붙입니다. 자식의 조건을 WHERE에 두었을 때 LEFT JOIN이 사실상 INNER JOIN으로 변하는 오류와, COUNT(*)가 글이 없는 회원을 1로 세는 함정을 함께 해결합니다. 결과는 5장 1편에서 만든 공용 테이블을 기준으로 하며, 이 글부터 실행한다면 그 글의 블록을 먼저 실행합니다.
WHERE가 바꾸는 OUTER JOIN
모든 회원을 남기려고 LEFT JOIN을 썼지만, WHERE에서 p.status = 'PUBLISHED'를 요구해 봅니다. 아래 쿼리는 회원을 왼쪽에 두고 게시글을 LEFT JOIN한 뒤, 공개 글이라는 자식의 조건을 ON이 아니라 WHERE에 적었습니다. 회원별로 묶어 COUNT(*)로 세므로, 의도대로라면 글이 없는 박준도 한 행으로 나와야 합니다.
SELECT m.id, m.name, COUNT(*) AS published_count
FROM ch5_member_demo AS m
LEFT JOIN ch5_post_demo AS p ON p.author_id = m.id
WHERE p.status = 'PUBLISHED'
GROUP BY m.id, m.name
ORDER BY m.id;id | name | published_count
1 | 안동민 | 3
2 | 이하나 | 2글이 없는 회원 3 박준이 사라졌습니다. LEFT JOIN은 박준에게 게시글의 열이 모두 NULL인 행을 만들어 주었지만, 그 행의 p.status는 NULL이어서 WHERE의 비교가 알 수 없음이 되었고 결과에서 빠졌습니다. "모든 회원"이라는 요구를 만족하지 못한 것입니다.
LEFT JOIN이 NULL로 채워 만든 행도 WHERE를 통과해야 최종 결과에 남습니다. 그래서 오른쪽 자식 가운데 어떤 행을 짝의 후보로 인정할지 정하는 조건은 ON에 두고, 결과 전체에서 빼야 할 왼쪽 부모의 조건은 WHERE에 둡니다. INNER JOIN에서는 같았던 두 위치가 OUTER JOIN에서는 결과의 뜻을 바꿉니다.
LEFT JOIN이 행을 남기는 방식
LEFT JOIN은 왼쪽의 행마다 ON을 만족하는 오른쪽 행이 있으면 모두 잇고, 하나도 없으면 오른쪽 열이 NULL인 결과 행 하나를 만듭니다. 그래서 일대다의 자식이 셋이면 부모 행은 세 번 되풀이되고, 자식이 없으면 한 번 나옵니다.
이 때문에 자식이 0개인 부모를 셀 때는 COUNT(p.id)처럼 자식의 키를 세야 합니다. COUNT(*)는 NULL로 채운 결과 행도 한 행으로 세어 1을 돌려줍니다. 다음 쿼리는 조건 없이 회원과 게시글을 LEFT JOIN한 뒤, 회원마다 결과의 행 수와 실제 자식의 수를 나란히 셉니다.
SELECT
m.id,
COUNT(*) AS joined_rows,
COUNT(p.id) AS child_count,
SUM(p.id IS NULL) AS null_extended_rows
FROM ch5_member_demo AS m
LEFT JOIN ch5_post_demo AS p ON p.author_id = m.id
GROUP BY m.id
ORDER BY m.id;id | joined_rows | child_count | null_extended_rows
1 | 3 | 3 | 0
2 | 2 | 2 | 0
3 | 1 | 0 | 1박준은 결과 행이 하나 있지만 실제 자식은 0개이고, 그 한 행이 NULL로 채운 행입니다. 다음 도표는 이 세 수가 회원마다 어떻게 갈리는지 보여 줍니다.
LEFT JOIN · ROWS / REAL CHILDREN
게시글 0개여도 보존된 회원 행은 1개다
LEFT JOIN은 게시글 없는 박준도 결과에 남깁니다. COUNT(*)는 이 보존 행을 세고 COUNT(p.id)는 NULL인 게시글 ID를 세지 않습니다.
| 회원 | COUNT(*) | COUNT(p.id) |
|---|---|---|
| 안동민 | 3 | 3 |
| 이하나 | 2 | 2 |
| 박준 | 1 | 0 |
- 안동민
- COUNT(*): 3COUNT(p.id): 3
- 이하나
- COUNT(*): 2COUNT(p.id): 2
- 박준
- COUNT(*): 1COUNT(p.id): 0
고정 fixture와 LEFT JOIN 규칙에서 계산한 값입니다. 박준의 한 행은 실제 게시글이 아니라 오른쪽 열이 NULL인 보존 행입니다.
박준의 조인 결과 · p.id = NULL
보존 행 1개 → COUNT(*) = 1
실제 게시글 0개 → COUNT(p.id) = 0
NULL 확장은 게시글을 새로 만든 것이 아닙니다. 자식 수는 NOT NULL인 자식 PK로 셉니다.
LEFT OUTER JOIN은 표준 SQL이며 OUTER는 생략할 수 있습니다. RIGHT JOIN은 테이블의 순서를 바꾼 LEFT JOIN으로 쓸 수 있으므로, 이 교재는 남길 기준을 언제나 왼쪽에 두는 방식을 권합니다. 양쪽의 짝 없는 행을 모두 남기는 FULL OUTER JOIN은 MySQL이 직접 지원하지 않으므로, 꼭 필요하다면 LEFT JOIN과 반대 방향의 안티 조인을 UNION ALL로 합치는 방법을 검토하되 정말 그런 요구인지부터 확인합니다.
ON과 WHERE에 둘 조건
7월에 작성되었고 지금 PUBLISHED인 글만 오른쪽 짝의 후보로 제한하고, 활성 회원은 모두 남깁니다. 자식의 기본 키를 세어 짝이 없으면 0을 얻습니다.
SELECT
m.id,
m.name,
COUNT(p.id) AS published_count,
COALESCE(SUM(p.view_count), 0) AS total_views
FROM ch5_member_demo AS m
LEFT JOIN ch5_post_demo AS p
ON p.author_id = m.id
AND p.status = 'PUBLISHED'
AND p.created_at >= '2026-07-01'
AND p.created_at < '2026-08-01'
WHERE m.status = 'ACTIVE'
GROUP BY m.id, m.name
ORDER BY published_count DESC, m.id;id | name | published_count | total_views
1 | 안동민 | 3 | 165
2 | 이하나 | 2 | 90
3 | 박준 | 0 | 0활성 회원은 모두 한 행씩 나오고, 글이 없는 박준은 건수와 합계가 0입니다. SUM은 오른쪽 값이 모두 NULL이면 NULL이므로, 화면이나 통계의 규칙에서 0이 맞을 때만 COALESCE로 바꿉니다. "데이터가 없음"과 실제 합계 0을 분석에서 구별해야 한다면 원래의 NULL과 건수를 함께 내줍니다. 남길 부모 쪽의 조건인 m.status = 'ACTIVE'는 WHERE에 있어도 의도대로 활성 회원만 남깁니다.
짝이 없는 행만 찾고 싶을 때는 같은 LEFT JOIN에 WHERE p.id IS NULL을 붙입니다. 이를 안티 조인(anti join)이라 하며, 아래 쿼리는 공개 글이 하나도 없는 회원을 찾습니다.
SELECT m.id, m.name
FROM ch5_member_demo AS m
LEFT JOIN ch5_post_demo AS p
ON p.author_id = m.id
AND p.status = 'PUBLISHED'
WHERE p.id IS NULL;id | name
3 | 박준여기서 NULL인지 검사하는 열은 자식의 기본 키여야 합니다. NULL을 허용하는 다른 열로 검사하면 실제로 있는 자식 가운데 그 열이 NULL인 행까지 짝이 없는 것처럼 섞입니다. 자식에 조건을 더할 때는 "그 조건에 맞는 자식이 없음"을 묻는지 "자식 자체가 없음"을 묻는지도 분명히 합니다.
더 깊이: 옵티마이저가 OUTER JOIN을 다시 쓰는 경우
첫 절의 잘못된 쿼리는 옵티마이저가 보기에도 INNER JOIN입니다. MySQL은 외부 조인이 NULL로 채워 만든 행에서 언제나 거짓이나 알 수 없음이 되는 조건을 널 거부(null-rejected) 조건이라고 부르고, WHERE에 이런 조건이 있으면 그 외부 조인을 내부 조인으로 바꿉니다. p.status = 'PUBLISHED'는 p.status가 NULL인 행에서 알 수 없음이므로 널 거부 조건이고, 그래서 박준의 행이 빠집니다. 반대로 p.id IS NULL처럼 NULL로 채운 행에서 참이 되는 조건이나, m.status = 'ACTIVE' OR p.status = 'PUBLISHED'처럼 한 갈래라도 널 거부가 아닌 OR는 외부 조인을 그대로 둡니다.
이 변환의 목적은 결과를 바꾸는 것이 아니라 실행 순서를 자유롭게 하는 것입니다. 외부 조인을 그대로 실행하면 옵티마이저는 남길 쪽 테이블을 먼저 읽는 순서만 고를 수 있지만, 내부 조인으로 바뀌면 조건이 더 좁은 테이블을 먼저 읽는 계획도 따져 볼 수 있습니다. RIGHT JOIN은 이보다 앞선 파싱 단계에서 같은 뜻의 LEFT JOIN으로 바뀝니다(외부 조인의 단순화).
안티 조인에도 전용 최적화가 있습니다. LEFT JOIN ... WHERE p.id IS NULL에서 p.id가 NOT NULL인 열이라면, EXPLAIN의 Extra에 Not exists가 나올 수 있습니다. 짝이 되는 자식 행을 하나 찾는 순간 그 부모는 결과에 들어갈 수 없다는 것을 알 수 있으므로, 같은 부모의 나머지 자식은 더 읽지 않는 방식입니다. NULL 검사에 자식의 기본 키를 쓰면 결과도 정확하고, 이 최적화의 조건도 함께 맞출 수 있습니다.
여러 자식을 함께 붙이면
여러 일대다 자식을 한꺼번에 조인하면 자식의 수가 곱해질 수 있습니다. 한 회원에게 게시글 3개와 권한 2개가 있을 때 둘을 같은 쿼리에서 붙이면 6행이 되어, 게시글의 합계와 권한의 수가 모두 부풀어 오릅니다. 자식마다 먼저 부모의 키로 집계한 뒤 그 요약을 조인하거나, 목적별로 쿼리를 나눕니다. DISTINCT로 숫자를 억지로 줄이면 같은 값을 가진 실제의 행까지 지워질 수 있습니다.
INNER JOIN과 LEFT JOIN을 같은 데이터에서 비교해, 어느 집합이 남는지 표시해 봅니다. 글이 없는 회원, 초안만 있는 회원, 공개 글이 있는 회원을 하나씩 준비하고, 상태 조건을 ON과 WHERE로 옮겨 가며 결과의 차이를 확인합니다.
목록 화면에서 0건인 부모를 보여 줄지 숨길지는 제품이 정할 일이지, INNER JOIN이 더 짧다는 이유로 SQL을 쓰는 사람이 정할 일이 아닙니다. 통계 카드의 0과 NULL도 사용자에게 서로 다른 뜻을 전합니다. 권한 테이블을 LEFT JOIN한 뒤 NULL을 허용하면 권한이 없는 행이 통과하는 보안상의 실수가 생길 수 있으므로, 권한의 판단에 쓰는 조인은 권한을 확인하지 못하면 거부하도록 설계합니다.
ON에 오른쪽 테이블의 필터를 두면 왼쪽 행은 남지만 SELECT에 나오는 오른쪽의 상태는 NULL이 됩니다. 이를 상태가 NULL인 실제 자식과 헷갈리지 않도록, 자식의 기본 키가 NULL인지를 봅니다. NULL을 허용하는 외래 키가 있는 모델에서는 부모에서 시작한 LEFT JOIN과 자식에서 시작한 LEFT JOIN이 남기는 집합이 다르므로, 어느 테이블의 전체 목록이 필요한지 먼저 말합니다.
0건을 만들어야 하는 통계
OUTER JOIN이 행을 남기는 규칙은 뒤의 삭제 수명 주기와 통계에서 되풀이됩니다. 소프트 삭제된 자식을 ON에서 빼면, 부모는 남고 활성 자식의 수만 0이 될 수 있습니다. 통계를 다시 계산할 때 0건인 항목까지 만들려면, 기준이 되는 차원 테이블에서 시작해 LEFT JOIN해야 합니다.
달력의 날짜나 허용되는 상태처럼 원래 데이터에 행이 없는 차원까지 보여 주려면, 그 차원을 먼저 만든 뒤 사실 테이블을 LEFT JOIN합니다. 반대로 사실 테이블에서 시작하면 0건인 날짜는 처음부터 행이 없어 만들 수 없습니다. 대시보드에 전체 회원 수와 활동한 회원 수, 글을 쓴 회원 수를 함께 보여 줄 때도, 지표마다 남겨야 할 집합을 먼저 적고 같은 조인 결과를 무리하게 다시 쓰지 않습니다.
권한과 테넌트의 조건은 행을 남길지보다 앞서는 안전장치이므로, 왼쪽의 부모와 오른쪽의 자식 양쪽에 빠짐없이 적용합니다. OUTER JOIN이 만든 NULL을 COALESCE로 0으로 바꾸기 전에, 원래의 자식 수와 남은 부모의 수를 함께 기록해 두면 통계의 누락을 추적할 수 있습니다.
| 따져 볼 것 | 질문 |
|---|---|
| 보존 | 짝이 없어도 남아야 하는 테이블은 어느 쪽인가 |
| 후보 | 오른쪽의 어떤 행을 짝으로 인정하는가 |
| 부모 | 결과 전체에서 뺄 왼쪽의 조건은 무엇인가 |
| 0건 | COUNT(*) 대신 자식의 기본 키를 세었는가 |
| 행의 곱 | 여러 자식을 함께 조인해 행이 곱해지지 않는가 |
요구 사항에서 "모든"이라는 말을 찾으면, 남겨야 할 집합부터 표시합니다. ON은 관계와 오른쪽의 후보를, WHERE는 최종적으로 남길 왼쪽의 범위를 정한다는 기본 구조로 리뷰합니다.
정리
- LEFT JOIN은 짝이 없는 왼쪽 행을 오른쪽 열이 NULL인 행으로 남깁니다.
- 오른쪽 자식의 후보 조건을 WHERE에 두면, NULL로 채운 행이 빠져 남기려던 행을 잃습니다.
- 0건의 집계는
COUNT(*)가 아니라 자식의 기본 키를 세어 구합니다. - 짝이 없는 행은 LEFT JOIN과 자식 기본 키의
IS NULL로 찾습니다. - 여러 일대다 자식을 함께 조인할 때는 행이 곱해지는지 점검합니다.
연습 문제
1. 모든 활성 태그와 공개 글의 수
모든 활성 태그마다 7월에 작성된 공개 글의 수와 총 조회수를 보여 주세요. 쓰이지 않은 태그도 0으로 나와야 하고, 비활성 태그는 뺍니다. 태그 이름의 순서로 정렬합니다.
해설 보기
태그가 왼쪽의 남길 대상이고, 공개 상태와 날짜는 오른쪽 게시글의 ON 조건입니다. 실제 행의 수는 게시글의 id를 세어 구합니다.
SELECT
t.id AS tag_id,
t.name,
COUNT(p.id) AS published_count,
COALESCE(SUM(p.view_count), 0) AS total_views
FROM ch5_tag_demo AS t
LEFT JOIN ch5_post_tag_demo AS pt ON pt.tag_id = t.id
LEFT JOIN ch5_post_demo AS p
ON p.id = pt.post_id
AND p.status = 'PUBLISHED'
AND p.created_at >= '2026-07-01'
AND p.created_at < '2026-08-01'
WHERE t.is_active = TRUE
GROUP BY t.id, t.name
ORDER BY t.name, t.id;tag_id | name | published_count | total_views
2 | Java | 1 | 40
3 | 네트워크 | 0 | 0
1 | 데이터베이스 | 5 | 255쓰이지 않은 '네트워크'가 0으로 남았습니다. 이 데이터의 태그는 모두 활성이므로 WHERE가 빼는 행은 없지만, is_active를 FALSE로 바꾼 태그는 이 조건에서 빠집니다. 정렬은 utf8mb4_0900_ai_ci의 규칙을 따라 라틴 문자로 시작하는 'Java'가 한글보다 앞에 왔습니다.
2. NULL도 허용하면 모두 남을까
다른 데이터에서 회원 A는 DRAFT 글 하나만, B는 글이 없고, C는 PUBLISHED 글 하나를 가진다고 해 봅시다. ON에서는 작성자의 키만 비교하고, WHERE를 p.status = 'PUBLISHED' OR p.id IS NULL로 바꾸면 A, B, C가 모두 남을까요?
해설 보기
A는 빠지고 B와 C만 남습니다. A는 ON 단계에서 실제의 DRAFT 글과 짝이 생겼으므로 NULL로 채운 행이 따로 만들어지지 않고, 그 실제 글은 WHERE의 두 조건을 모두 통과하지 못합니다. 짝이 없는 B만 p.id IS NULL을 통과합니다. 모든 회원과 공개 글의 수가 목적이라면 공개 상태를 ON의 후보 조건으로 옮겨야, A도 "공개 글 0건"으로 남습니다.
3. COUNT(*)로 세면
본문의 "0건 회원을 포함한 게시글 수" 쿼리에서 COUNT(p.id)를 COUNT(*)로 바꾸면 박준의 published_count는 얼마가 되나요? total_views는 어떻게 되나요?
해설 보기
박준의 published_count는 1이 됩니다. 짝이 없는 박준에게도 NULL로 채운 결과 행이 하나 있고, COUNT(*)는 그 행을 세기 때문입니다. 반면 total_views는 SUM이 NULL만 받아 NULL이 되고 COALESCE가 0으로 바꾸므로 여전히 0입니다. 건수는 1인데 합계는 0인 어긋난 결과가 나오므로, 0건이 있을 수 있는 집계에서는 언제나 자식의 기본 키를 셉니다.
다음 글 SELF JOIN과 CROSS JOIN에서는 같은 테이블을 서로 다른 역할로 잇는 자기 참조 조인과, 모든 조합을 만드는 CROSS JOIN을 다룹니다.