본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
5장 : 관계를 읽는 JOIN

OUTER JOIN

LEFT JOIN의 보존 쪽과 ON·WHERE 필터 차이를 이해하고 0건 집계를 정확히 만듭니다.

아직 게시글이 없는 신규 회원과 한 번도 사용되지 않은 태그는 INNER JOIN 결과에서 사라집니다.

‘활동한 회원’에는 맞지만 ‘모든 회원과 활동 수’에는 틀립니다.

OUTER JOIN은 짝이 없는 기준 행을 보존하고 상대 열을 NULL로 채웁니다.

회원 게시판의 모든 회원을 왼쪽 기준으로 두고 7월에 게시된 글을 붙입니다.

자식 조건을 WHERE에 둘 때 LEFT JOIN이 사실상 INNER JOIN으로 변하는 오류와, COUNT(*)가 0건 부모를 1로 세는 함정을 함께 해결합니다.

이 문서의 실행 결과는 ch5-1에서 재생성한 ch5_*_demo 공용 fixture를 기준으로 하며, 이 문서부터 실행한다면 해당 SQL 블록을 먼저 실행합니다.


WHERE가 바꾸는 OUTER JOIN

모든 회원을 보존하려고 LEFT JOIN을 썼지만 WHERE에서 p.status = 'PUBLISHED'를 요구합니다.

짝 없는 회원의 p.status는 NULL이고 비교 결과가 알 수 없음이어서 결과에서 빠집니다.

선택 관계를 다시 제거하는 WHERE
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;

게시글이 없는 신규 회원과 게시된 글이 없는 회원이 모두 누락되어 ‘모든 회원’ 요구를 만족하지 못합니다.

오류 실행 결과
id | name   | published_count
1  | 안동민 | 3
2  | 이하나 | 2
-- members 3 신규 회원이 사라짐

LEFT JOIN이 만든 NULL 확장 행도 WHERE를 통과해야 최종 보존됩니다.

자식 중 어떤 행을 ‘짝 후보’로 인정할지 정하는 조건은 ON에 둡니다.

결과 전체에서 제거할 부모 조건은 WHERE에 둡니다.

이 위치 차이는 OUTER JOIN에서 결과 의미를 바꿉니다.


LEFT JOIN의 행 보존

각 왼쪽 행에 ON을 만족하는 오른쪽 행이 있으면 모두 연결하고, 하나도 없으면 오른쪽 컬럼이 NULL인 결과 한 행을 만듭니다.

따라서 일대다 자식이 세 개면 부모 행은 세 번 반복될 수 있습니다.

0건 자식 수는 COUNT(p.id)로 세야 합니다.

COUNT(*)는 NULL 확장 결과 행 자체를 세어 1이 될 수 있습니다.

LEFT OUTER JOIN은 표준 SQL이며 OUTER는 생략할 수 있습니다.

RIGHT JOIN은 테이블 순서를 바꾼 LEFT JOIN으로 표현할 수 있어 교재는 보존 기준을 왼쪽에 두는 스타일을 권장합니다.

MySQL은 완전 외부 JOIN을 직접 지원하지 않으므로 필요할 때 UNION 기반 대안을 검토하되 요구를 먼저 확인합니다.


ON과 WHERE의 역할

7월에 게시된 글만 오른쪽 짝 후보로 제한하고 모든 활성 회원은 보존합니다.

자식 PK를 세어 없는 경우 0을 얻습니다.

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;

활성 회원은 모두 한 행 이상 나오고 게시글이 없으면 건수와 합계가 0입니다.

개선 실행 결과
id | name   | published_count | total_views
1  | 안동민 | 3               | 165
2  | 이하나 | 2               | 90
3  | 박준   | 0               | 0

SUM은 오른쪽 값이 전부 NULL이면 NULL이므로 표시·통계 규칙에서 0이 맞을 때만 COALESCE합니다.

‘데이터가 없음’과 실제 합계 0을 분석에서 구분해야 한다면 원본 NULL과 건수를 함께 제공합니다.

보존 대상 부모의 상태 조건은 WHERE에 있어도 의도대로 활성 부모만 남깁니다.


일대다 행 증가 확인

LEFT JOIN 결과에서 부모가 몇 번 반복되는지 원본 자식 수와 일치하는지 확인합니다.

부모별 joined_rowschild_count가 다른지 확인합니다.

NULL 확장 행과 실제 자식 수 비교
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;

자식이 있는 부모는 joined_rowschild_count가 같고, 자식이 없는 부모는 각각 1, 0, 1이 됩니다.

이 차이가 COUNT(*) 함정을 눈으로 보여 줍니다.

여러 일대다 자식을 한꺼번에 조인하면 자식 수가 곱해질 수 있습니다.

게시글 3개와 권한 2개를 같은 회원에 붙이면 6행이 되어 두 합계가 모두 부풀 수 있습니다.

각 자식을 먼저 부모 키로 집계한 뒤 요약 결과를 조인하거나 목적별 쿼리를 나눕니다.

DISTINCT로 숫자를 억지로 줄이면 같은 값의 실제 중복까지 제거할 수 있습니다.

INNER와 LEFT를 같은 데이터에서 비교해 보존되는 집합을 표시합니다.

게시글 없는 회원, 초안만 있는 회원, 게시된 글이 있는 회원을 각각 하나씩 준비합니다.

상태 조건을 ON과 WHERE로 옮겨 결과 차이를 확인하고, WHERE p.id IS NULL로 ‘게시된 글이 없는 회원’을 찾는 안티 조인 패턴도 실행합니다.

단, 자식에 다른 조건을 추가할 때 ‘해당 조건의 자식이 없음’인지 ‘자식 자체가 없음’인지 질문을 명확히 합니다.

목록 화면에서 0건 부모를 보여 줄지 숨길지는 제품 결정입니다.

SQL 개발자가 INNER가 더 짧다는 이유로 숨겨서는 안 됩니다.

통계 카드의 0과 NULL 표현도 사용자에게 다른 메시지를 줍니다.

권한 테이블을 LEFT JOIN한 뒤 NULL을 허용하면 권한 없는 행이 통과할 수 있는 보안 실수가 생길 수 있으므로 보존 JOIN을 권한 판단에 사용할 때는 미통과-닫힌 조건을 설계합니다.

ON 조건에 오른쪽 테이블 필터를 둘 때 왼쪽 행은 보존되지만, SELECT의 오른쪽 상태는 NULL이 됩니다.

이를 ‘상태가 NULL인 실제 자식’과 혼동하지 않도록 자식 PK의 NULL 여부를 봅니다.

NULL 허용 FK 자식이 있는 모델에서는 부모에서 시작한 LEFT JOIN과 자식에서 시작한 LEFT JOIN이 보존 집합이 다릅니다.

항상 어떤 테이블의 전체 목록이 요구인지 먼저 말합니다.

OUTER JOIN의 보존 규칙은 뒤의 삭제 수명주기와 통계에서 반복됩니다.

논리 삭제된 자식을 ON에서 제외하면 부모는 남고 활성 자식 수만 0이 될 수 있습니다.

통계 재계산에서도 0건 차원을 생성하려면 기준 차원 테이블에서 LEFT JOIN해야 합니다.

지금 만든 모든 회원–게시글 쿼리는 그 패턴의 원형이며, 건수 대상과 필터 위치를 문서화해 이후 집계 테이블과 결과가 일치하는지 확인합니다.

달력 날짜나 허용 상태처럼 원본 행이 없는 차원까지 보여 주려면 그 차원을 먼저 만든 뒤 사실 테이블을 LEFT JOIN합니다.

반대로 사실 테이블에서 시작하면 ‘0건 날짜’는 애초에 행이 없어 만들 수 없습니다.

대시보드에서 전체 회원 수, 활동 회원 수, 게시글 작성 회원 수를 함께 제공할 때도 각 지표의 보존 집합을 먼저 적고 같은 조인 결과를 무리하게 재사용하지 않습니다.

권한·테넌트 조건식은 보존 여부보다 앞선 안전 경계이므로 왼쪽 부모와 오른쪽 자식 양쪽에 누락 없이 적용합니다.

외부 조인이 만든 NULL을 COALESCE로 0으로 바꾸기 전 원본 child_count와 보존된 parent_count를 함께 기록하면 통계 누락을 추적할 수 있습니다.


OUTER JOIN 조건 기준

판단 축확인할 질문
보존짝이 없어도 남아야 하는 테이블은 어느 쪽인가?
후보오른쪽의 어떤 행을 짝으로 인정하는가?
부모전체 결과에서 제외할 왼쪽 조건은 무엇인가?
0건건수(*) 대신 자식 PK를 세었는가?
행 증가여러 자식 조인으로 행이 곱해지지 않는가?

자연어 요구에서 ‘모든’을 찾으면 보존 집합을 먼저 표시합니다.

ON은 관계와 오른쪽 후보, WHERE는 최종 왼쪽 범위라는 기본 구조로 리뷰합니다.


연습 문제

모든 활성 태그와 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;

게시글 없는 활성 태그가 0으로 남고, 비활성 태그는 WHERE에서 빠지는지 확인합니다.


핵심 정리

  • LEFT JOIN은 짝이 없는 왼쪽 행을 NULL 확장 행으로 보존합니다.
  • 오른쪽 자식 후보 조건을 WHERE에 두면 보존 효과를 잃을 수 있습니다.
  • 0건 집계는 건수(*)가 아니라 자식의 비NULL 키를 셉니다.
  • 여러 일대다 자식을 동시에 붙일 때 행 증가 곱을 점검합니다.

다음 문서에서는 같은 테이블의 역할을 나누는 자기 참조 JOIN과 모든 조합을 만드는 CROSS JOIN을 다룹니다.