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

안동민 개발노트

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

다중 테이블 조인

회원–게시글–태그의 조인 경로와 별칭을 명확히 하며 필터와 집계의 행 단위를 지킵니다.

‘안동민이 데이터베이스 태그 글을 몇 번 조회받았는가’에 답하려면 회원 이름은 ch5_member_demo, 조회수는 ch5_post_demo, 태그 이름은 ch5_tag_demo에서 읽어야 합니다.

세 테이블을 한 번에 나열하는 것보다 어느 관계를 한 단계씩 통과하는지 읽는 것이 중요합니다.

ch5-1에서 재생성한 공용 fixture의 경로는 ch5_member_demo 1:N ch5_post_demo 1:N ch5_post_tag_demo N:1 ch5_tag_demo입니다.

이 문서부터 실행한다면 ch5-1의 태그 테이블과 올바른 INNER JOIN SQL 블록을 먼저 실행합니다.

게시글과 태그의 다대다 관계는 ch5_post_tag_demo가 연결합니다.

FROM 순서는 문장을 읽는 출발점이고, 결과 의미는 ON 관계와 WHERE 모집단이 결정합니다.


중간 관계를 생략한 조인

회원과 태그에는 직접 FK가 없는데 ID 값이 같다는 이유로 두 테이블을 바로 조인합니다.

회원 1이 태그 1과 관계가 있다고 보일 수 있지만 게시글과 연결 행을 거치지 않아 근거가 없습니다.

존재하지 않는 직접 관계
SELECT m.name, t.name
FROM ch5_member_demo AS m
JOIN ch5_tag_demo AS t ON t.id = m.id;

ID가 겹친 일부 행만 나오며 게시글이 전혀 없는 회원도 태그와 관계가 있는 것처럼 보일 수 있습니다.

오류 실행 결과
name   | tag_name
안동민 | 데이터베이스 -- ID 우연 일치
이하나 | Java         -- 실제 게시글 근거 없음

관계 경로를 생략하면 결과가 어떤 사건을 근거로 하는지 사라집니다.

회원이 태그가 붙은 글을 작성했다는 사실은 postspost_tags 행으로 존재합니다.

따라서 회원→게시글→연결 행→태그의 세 ON을 모두 거쳐야 합니다.

다중 조인의 순서는 연결 그래프로 먼저 그리고 SQL로 옮깁니다.


조인 그래프와 관계 경로

p.author_id = m.id는 게시글의 작성자, pt.post_id = p.id는 게시글의 태그 연결, t.id = pt.tag_id는 연결된 태그를 뜻합니다.

각 ON을 따로 설명할 수 있어야 합니다.

INNER JOIN끼리는 옵티마이저가 물리 순서를 바꿀 수 있지만 SQL 작성자는 관계와 결과 행 단위를 중심으로 읽습니다.

SELECT 별칭과 테이블 별칭을 짧되 일관되게 사용하고, 같은 이름 컬럼은 항상 한정합니다.

여러 INNER JOIN을 연속하는 문법은 표준 SQL입니다.

MySQL 옵티마이저의 실제 조인 순서는 통계와 비용에 따라 작성 순서와 다를 수 있습니다.

성능을 추측해 테이블 순서를 억지로 바꾸기보다 정확한 조건과 인덱스를 제공하고 7장에서 실행 계획을 확인합니다.


네 테이블 연결

게시된 글만 모집단으로 삼고 회원·태그 이름을 붙인 뒤, 회원과 태그별 총 조회수를 집계합니다.

한 게시글에 태그가 여러 개면 연결 단계에서 행이 늘어나는 것이 정상입니다.

회원–게시글–연결–태그 4테이블 조인
SELECT
  m.id AS member_id,
  m.name,
  t.id AS tag_id,
  t.name AS tag_name,
  COUNT(*) AS post_count,
  SUM(p.view_count) AS total_views
FROM ch5_post_demo AS p
JOIN ch5_member_demo AS m ON m.id = p.author_id
JOIN ch5_post_tag_demo AS pt ON pt.post_id = p.id
JOIN ch5_tag_demo AS t ON t.id = pt.tag_id
WHERE p.status = 'PUBLISHED'
GROUP BY m.id, m.name, t.id, t.name
ORDER BY m.id, total_views DESC, t.id;

결과 한 행은 회원과 태그 조합을 뜻하며 게시글 수와 총 조회수가 그 조합에 집계됩니다.

개선 실행 결과
member_id | name   | tag_id | tag_name     | post_count | total_views
1         | 안동민 | 1      | 데이터베이스 | 3          | 165
1         | 안동민 | 2      | Java         | 1          | 40
2         | 이하나 | 1      | 데이터베이스 | 2          | 90

GROUP BY에 이름만 쓰면 동명이인이나 이름 변경 정책이 결과 정체성을 흐릴 수 있습니다.

대리 키를 그룹 키에 포함하고 이름은 그 키에 종속된 표시값으로 함께 둡니다.

MySQL의 함수적 종속성 판단에만 기대기보다 읽는 사람에게 결과 키를 드러냅니다.


단계별 행 수 추적

게시글→회원은 필수 다대일이라 건수가 유지됩니다.

게시글→연결 행에서는 태그 수만큼 늘 수 있고 연결 행→태그에서는 다시 유지되어야 합니다.

다중 조인 단계별 대사
WITH published AS (
  SELECT * FROM ch5_post_demo WHERE status = 'PUBLISHED'
), with_member AS (
  SELECT p.* FROM published AS p
  JOIN ch5_member_demo AS m ON m.id = p.author_id
), with_relation AS (
  SELECT p.*, pt.tag_id FROM with_member AS p
  JOIN ch5_post_tag_demo AS pt ON pt.post_id = p.id
), with_tag AS (
  SELECT p.* FROM with_relation AS p
  JOIN ch5_tag_demo AS t ON t.id = p.tag_id
)
SELECT
  (SELECT COUNT(*) FROM published) AS post_rows,
  (SELECT COUNT(*) FROM with_member) AS member_join_rows,
  (SELECT COUNT(*) FROM with_relation) AS relation_rows,
  (SELECT COUNT(*) FROM with_tag) AS tag_join_rows;

첫 두 수는 같고 마지막 두 수도 같아야 합니다.

relation_rows는 게시글별 태그 수에 따라 post_rows보다 클 수 있습니다.

다중 조인에서 SELECT 열을 먼저 늘리면 어떤 테이블이 결과 행 수를 바꾸는지 놓치기 쉽습니다.

처음에는 각 테이블의 PK와 FK만 조회하고 관계가 맞는지 본 뒤 표시 열과 집계를 추가합니다.

디버깅용으로 p.id, p.author_id, m.id, pt.post_id, pt.tag_id, t.id를 나란히 보면 잘못된 간선을 빠르게 찾습니다.

같은 테이블을 여러 역할로 조인할 때는 역할 별칭을 사용합니다.

예를 들어 작성자와 검토자가 모두 회원을 참조하면 author, reviewer처럼 이름을 붙여야 합니다.

지금 쿼리에서도 m, p, pt, t를 일관되게 사용하고 의미 없는 a,b,c는 피합니다.

WHERE의 상태 조건을 ON으로 옮겨도 INNER JOIN에서는 같은 결과가 될 수 있지만 관계 조건과 모집단 필터를 구분해 읽기 위해 ON에는 키 관계, WHERE에는 업무 필터를 둡니다.

다중 조인 결과를 API DTO(Data Transfer Object, 데이터 전송 객체)로 바로 매핑할 때 동일한 회원 정보가 게시글마다 반복됩니다.

이것은 관계형 행 결과의 정상 모양입니다.

서버에서 계층 JSON으로 묶을지 평면 행을 스트리밍할지 소비자 요구에 따라 결정합니다.

반복 전송을 줄이려고 쿼리를 여러 번 나누면 N+1 요청이 생길 수 있으므로 한 조인, 두 단계 배치, 캐시의 비용을 실제 호출 수로 비교합니다.

한 게시글에 선택 관계가 추가되면 INNER JOIN이 전체 게시글을 누락시킬 수 있습니다.

필수와 선택 간선을 그래프에 표시하고 선택 관계만 LEFT JOIN으로 바꿉니다.

조인한 테이블의 상태 필터도 삭제·활성 정책에 따라 결과를 바꿉니다.

태그 비활성화가 과거 게시글을 숨겨야 하는지 보존해야 하는지 먼저 정한 뒤 WHERE를 작성합니다.

쿼리 편의로 과거 사실을 잃지 않습니다.

이 문서의 3테이블 경로는 이후 인덱스 장에서 실제 접근 순서를 확인하는 기준 쿼리가 됩니다.

posts(author_id, created_at)post_tags(tag_id, post_id) 같은 복합 인덱스는 조회 패턴과 선택도를 보고 결정합니다.

모델링 장에서는 같은 경로를 ERD의 두 관계로 표현합니다.

SQL, 실행 계획, ERD가 같은 FK 이름을 사용하면 문서 사이에서 개념이 끊기지 않습니다.

조회 결과를 태그별 카드로 묶을 때는 평면 조인 행을 애플리케이션에서 다시 그룹화할지 SQL에서 JSON으로 집계할지도 결정해야 합니다.

SQL JSON 집계는 왕복과 매핑을 줄일 수 있지만 결과 규칙이 특정 DBMS 함수에 결합되고 큰 배열이 한 행에 몰립니다.

평면 행은 중복 열이 있지만 스트리밍과 범용 매핑이 쉽습니다.

두 방식 모두 기준 연결 수와 총 조회수를 일치 검사해 표현 변경이 업무 결과를 바꾸지 않았음을 확인합니다.

연관을 ORM(Object-Relational Mapping, 객체 관계 매핑)으로 읽는 경우에도 게시글마다 회원·태그를 따로 조회하는 N+1과 단일 JOIN의 왕복 수를 로그로 비교하고, 가져오기 전략을 관계 정의와 동일시하지 않습니다.


다중 조인 검토 순서

판단 축확인할 질문
중심결과 한 행의 기준 테이블은 무엇인가?
간선각 ON을 한 문장 관계로 설명할 수 있는가?
행 수간선 추가마다 감소·증가 가능성을 예측했는가?
역할같은 테이블의 여러 역할에 다른 별칭을 썼는가?
필터관계 조건과 업무 조건이 구분되는가?

FROM/JOIN/ON만 남겨 조인 그래프를 먼저 검토하고, SELECT·집계·정렬은 그 뒤에 봅니다.

이 순서가 관계 오류를 표시 요구에서 분리합니다.


연습 문제

회원 이메일별로 태그 코드와 게시된 글의 최근 작성 시각을 조회하세요.

회원·게시글·연결·태그를 모두 조인하고 결과 한 행은 회원–태그 조합이어야 합니다.

총 조회수가 60회 이상인 조합만 남기세요.

해설과 예시 답안

상태는 WHERE, 회원–태그 그룹의 총 조회수는 HAVING입니다.

최근 시각은 MAX로 계산합니다.

SELECT
  m.id AS member_id,
  m.email,
  t.id AS tag_id,
  t.code,
  SUM(p.view_count) AS total_views,
  MAX(p.created_at) AS last_created_at
FROM ch5_post_demo AS p
JOIN ch5_member_demo AS m ON m.id = p.author_id
JOIN ch5_post_tag_demo AS pt ON pt.post_id = p.id
JOIN ch5_tag_demo AS t ON t.id = pt.tag_id
WHERE p.status = 'PUBLISHED'
GROUP BY m.id, m.email, t.id, t.code
HAVING SUM(p.view_count) >= 60
ORDER BY m.email, t.code;

한 회원의 같은 태그가 한 행으로 축약되는지, 미게시 글이 합계에서 빠지는지 확인합니다.


핵심 정리

  • 다중 조인은 직접 관계가 아니라 실제 FK 간선을 모두 통과합니다.
  • 중심 테이블의 행 단위를 먼저 정하고 부모 조인이 수를 유지하는지 봅니다.
  • ON에는 키 관계, WHERE에는 업무 모집단을 두어 의도를 분리합니다.
  • 단계별 건수로 누락과 행 증가가 시작된 간선을 찾습니다.

다음 문서에서는 짝이 없는 부모도 보존하는 OUTER JOIN과 필터 위치의 차이를 다룹니다.