본문 바로가기

다중 테이블 조인

회원, 게시글, 연결 테이블, 태그의 네 테이블을 실제 외래 키의 경로대로 잇고, 결과 한 행의 단위를 지키며 집계합니다. 중간 관계를 건너뛴 조인의 오류, 다대다 연결에서 생기는 행의 펼쳐짐과 이중 집계, 단계마다 행 수를 세어 문제가 시작된 관계를 찾는 방법을 다룹니다.

"안동민이 작성한 데이터베이스 태그 글의 총 조회수는 얼마인가"에 답하려면, 회원의 이름은 ch5_member_demo에서, 조회수는 ch5_post_demo에서, 태그의 이름은 ch5_tag_demo에서 읽어야 합니다. 세 테이블을 한 번에 나열하는 것보다, 어느 관계를 한 단계씩 지나가는지 읽는 것이 중요합니다.

앞 글에서 만든 공용 테이블의 경로는 회원 1:N 게시글 1:N 연결 N:1 태그이며, 게시글과 태그의 다대다 관계는 연결 테이블 ch5_post_tag_demo가 잇습니다. 이 글부터 실행한다면 앞 글의 "태그 테이블과 올바른 INNER JOIN" 블록을 먼저 실행합니다. FROM의 순서는 문장을 읽기 시작하는 곳일 뿐이고, 결과의 뜻은 ON의 관계와 WHERE의 모집단이 정합니다.

중간 관계를 건너뛴 조인

회원과 태그 사이에는 외래 키가 없는데, id 값이 같다는 이유로 두 테이블을 바로 이어 봅니다. 아래 쿼리의 ON t.id = m.id는 회원의 id와 태그의 id를 직접 비교하므로, 게시글과 연결 테이블을 한 번도 거치지 않습니다. 두 id는 각자의 테이블에서 따로 매긴 번호일 뿐 서로 아무 뜻도 공유하지 않습니다.

존재하지 않는 직접 관계
SELECT m.name AS member_name, t.name AS tag_name
FROM ch5_member_demo AS m
JOIN ch5_tag_demo AS t ON t.id = m.id
ORDER BY m.id;
member_name | tag_name
안동민      | 데이터베이스
이하나      | Java
박준        | 네트워크

회원과 태그의 id가 1, 2, 3에서 모두 겹쳐 세 행이 나왔습니다. 이하나는 Java 태그를 단 글을 쓴 적이 없고, 박준은 작성한 글 자체가 없는데도 태그와 관계가 있는 것처럼 보입니다.

관계의 경로를 건너뛰면 결과가 어떤 사실에 근거하는지가 사라집니다. 회원이 태그가 붙은 글을 썼다는 사실은 게시글과 연결 테이블의 행으로 존재하므로, 회원에서 게시글, 연결 행, 태그로 이어지는 세 개의 ON을 모두 거쳐야 합니다. 여러 테이블을 잇는 조인은 먼저 연결의 그래프를 그린 뒤 SQL로 옮깁니다.

조인 그래프와 관계의 경로

다음 그림의 선은 저마다 자식의 외래 키가 부모의 id를 가리키는 관계이며, 회원과 태그 사이에는 직접 이어진 선이 없습니다.

JOIN · FOLLOW THREE FOREIGN KEYS

회원과 태그는 연결 테이블을 거쳐 만난다

회원 ID와 태그 ID를 직접 비교하지 않습니다. 게시글의 작성자와 연결 행의 두 FK가 실제 관계를 이어 줍니다.

회원과 태그는 게시글과 연결 행을 거쳐 관계를 가진다회원 한 명은 게시글 0개 이상, 게시글 하나는 태그 연결 0개 이상, 태그 하나는 태그 연결 0개 이상을 갖습니다. p.author_id=m.id, pt.post_id=p.id, pt.tag_id=t.id가 세 관계이며 각 자식의 부모는 정확히 하나입니다.회원 m# id게시글 p# id→ author_id태그 연결 pt# → post_id# → tag_id태그 t# id10..N10..N10..N
회원과 태그는 게시글과 연결 행을 거쳐 관계를 가진다회원 한 명은 게시글 0개 이상, 게시글 하나는 태그 연결 0개 이상, 태그 하나는 태그 연결 0개 이상을 갖습니다. p.author_id=m.id, pt.post_id=p.id, pt.tag_id=t.id가 세 관계이며 각 자식의 부모는 정확히 하나입니다.회원 m# id게시글 p# id→ author_id태그 연결 pt# → post_id# → tag_id태그 t# id10..N10..N0..N1

#는 PK, →는 FK입니다. 0..N은 부모에 연결된 자식이 없을 수도 있다는 뜻이며, 선은 조회의 물리 실행 순서를 나타내지 않습니다.

작성자 연결

p.author_id = m.id

태그 연결의 두 조건

pt.post_id = p.id

pt.tag_id = t.id

고정 fixture의 공개 글 5행은 태그 연결을 붙이면 6행입니다. 게시글 1에 태그 두 개가 있으므로 행 증가가 곧 오류라는 뜻은 아닙니다.

INNER JOIN끼리는 옵티마이저가 물리적인 순서를 바꿀 수 있으므로, SQL을 쓰는 사람은 관계와 결과 한 행의 단위를 중심으로 읽습니다. SELECT의 별칭과 테이블 별칭은 짧되 일관되게 쓰고, 이름이 같은 열은 언제나 한정합니다. INNER JOIN을 여러 번 잇는 문법은 표준 SQL이며, MySQL이 실제로 고르는 조인의 순서는 통계와 비용에 따라 쓴 순서와 다를 수 있습니다. 성능을 짐작해 테이블의 순서를 억지로 바꾸기보다, 정확한 조건과 인덱스를 주고 7장 3편에서 실행 계획을 확인합니다.

더 깊이: 옵티마이저가 조인 순서를 고르는 비용

조인할 테이블이 늘면, 옵티마이저가 따져 볼 수 있는 실행 계획의 수는 테이블 수에 따라 지수적으로 늘어납니다. MySQL 8.4 매뉴얼은 테이블이 보통 7개에서 10개보다 적으면 문제가 되지 않지만, 그보다 큰 쿼리에서는 계획을 고르는 시간이 서버 성능의 주된 병목이 될 수 있다고 설명합니다. 그래서 옵티마이저는 optimizer_prune_level(기본값 1)이 켜져 있으면 테이블마다 읽을 행 수의 추정을 보고 가망 없는 계획을 미리 건너뜁니다. optimizer_search_depth는 아직 완성되지 않은 계획을 몇 단계 앞까지 내다보고 평가할지를 정하며, 0으로 두면 옵티마이저가 알맞은 깊이를 스스로 고릅니다.

이 글의 네 테이블 조인은 계획을 고르는 시간이 문제가 될 만큼 크지 않으므로, 순서는 옵티마이저가 통계와 비용으로 정하게 둡니다. 옵티마이저가 드물게 나쁜 순서를 고르는 경우를 위해 MySQL은 STRAIGHT_JOIN을 제공하는데, JOIN과 같지만 왼쪽 테이블을 언제나 먼저 읽게 합니다. 다만 이렇게 고정한 순서는 데이터의 분포가 바뀌어도 그대로 남으므로, 실행 계획으로 나쁜 순서를 확인한 경우에만 이유와 함께 씁니다.

네 테이블을 잇는다

공개된 글만 모집단으로 삼아 회원과 태그의 이름을 붙인 뒤, 회원과 태그의 조합마다 게시글 수와 총 조회수를 집계합니다. 태그가 여러 개인 게시글은 연결 단계에서 행이 늘어나는 것이 정상입니다.

회원–게시글–연결–태그 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

결과 한 행은 회원과 태그의 조합을 뜻하고, 게시글 수와 총 조회수는 그 조합에 대해 집계되었습니다. 그런데 게시글 1의 조회수 40은 데이터베이스와 Java의 두 태그에 한 번씩 들어갔습니다. 그래서 세 그룹의 합계를 모두 더하면 165, 40, 90을 더한 295지만, 게시글 다섯 개의 원래 합계는 40, 55, 70, 40, 50을 더한 255이며, 차이 40은 게시글 1이 두 태그로 펼쳐진 결과입니다.

태그별 합계를 묻는 질문에는 이 결과가 맞지만, 이를 다시 더해 전체 게시글의 조회수라고 하면 이중으로 세게 됩니다. SUM(DISTINCT view_count)도 대안이 아닌데, 조회수가 똑같이 40인 서로 다른 게시글 1과 4를 하나로 줄여 버리기 때문입니다. 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;
post_rows | member_join_rows | relation_rows | tag_join_rows
5         | 5                | 6             | 6

앞의 두 수가 같고 뒤의 두 수도 같으며, 게시글 1의 태그 두 개 때문에 5행이 6행으로 늘었습니다. 다중 조인에서 SELECT의 열부터 늘리면 어느 테이블이 결과의 행 수를 바꾸는지 놓치기 쉽습니다. 처음에는 p.id, p.author_id, m.id, pt.post_id, pt.tag_id, t.id처럼 키만 나란히 조회해 관계가 맞는지 보고, 그다음에 표시할 열과 집계를 더합니다.

조인을 쓰는 쪽에서 생기는 문제

같은 테이블을 여러 역할로 조인할 때는 역할을 드러내는 별칭을 씁니다. 작성자와 검토자가 모두 회원을 가리킨다면 author와 reviewer처럼 이름을 붙이고, 뜻이 없는 a, b, c 같은 별칭은 피합니다. INNER JOIN에서는 WHERE의 상태 조건을 ON으로 옮겨도 결과가 같을 수 있지만, ON에는 키의 관계를, WHERE에는 업무의 필터를 두어 둘을 구별해 읽습니다.

다중 조인의 결과를 API의 데이터 전송 객체(Data Transfer Object, DTO)에 바로 담으면, 같은 회원의 정보가 게시글마다 되풀이됩니다. 이것은 관계형 결과의 정상적인 모양이므로, 서버에서 계층형 JSON으로 묶을지 평평한 행으로 흘려보낼지는 받는 쪽의 요구에 따라 정합니다. 반복 전송을 줄이려고 쿼리를 여러 번으로 나누면 게시글마다 조회가 하나씩 더 생기는 N+1 문제가 생길 수 있으므로, 조인 한 번과 두 단계의 일괄 조회, 캐시의 비용을 실제 호출 수로 비교합니다. 객체 관계 매핑(Object-Relational Mapping, ORM)으로 연관을 읽을 때도 이 호출 수를 로그로 확인하고, 데이터를 가져오는 전략을 관계의 정의와 같은 것으로 여기지 않습니다.

게시글에 선택적인 관계를 하나 더하면, INNER JOIN 때문에 그 관계가 없는 게시글이 결과에서 통째로 빠질 수 있습니다. 그래프에 필수와 선택의 관계를 표시하고, 선택인 관계만 LEFT JOIN으로 바꿉니다. 조인한 테이블의 상태 필터도 결과를 바꾸므로, 태그를 비활성으로 바꾸었을 때 지난 게시글을 숨겨야 하는지 보존해야 하는지를 먼저 정한 뒤 WHERE를 씁니다.

결과를 태그별 카드로 묶을 때는 평평한 행을 애플리케이션에서 다시 묶을지, SQL에서 JSON으로 집계할지도 정해야 합니다. SQL의 JSON 집계는 왕복과 매핑을 줄여 주지만 결과의 형식이 특정 DBMS의 함수에 묶이고 큰 배열이 한 행에 몰립니다. 어느 방식이든 연결의 수와 총 조회수가 원래의 기준과 같은지 확인해, 표현을 바꾼 것이 업무의 결과를 바꾸지 않았는지 봅니다.

이 글의 네 테이블 경로는 뒤의 인덱스 장에서 실제의 접근 순서를 확인하는 기준 쿼리가 됩니다. 게시글의 (author_id, created_at)이나 연결 테이블의 (tag_id, post_id) 같은 복합 인덱스는 조회의 패턴과 선택도를 보고 7장 4편에서 정하고, 같은 경로를 9장에서는 ERD의 관계로 나타냅니다.

따져 볼 것질문
중심결과 한 행의 기준이 되는 테이블은 무엇인가
관계각 ON을 한 문장의 관계로 설명할 수 있는가
행 수관계를 하나 더할 때마다 행이 줄거나 늘 가능성을 예측했는가
역할같은 테이블의 여러 역할에 다른 별칭을 썼는가
필터관계의 조건과 업무의 조건이 구별되는가

FROM과 JOIN, ON만 남겨 조인의 그래프를 먼저 검토하고, SELECT와 집계, 정렬은 그 뒤에 봅니다. 이 순서가 관계의 오류를 표시의 요구와 떼어 놓습니다.

정리

  • 다중 조인은 직접 이어진 것처럼 보이는 열이 아니라 실제 외래 키의 경로를 모두 지나갑니다.
  • 중심 테이블의 행 단위를 먼저 정하고, 부모로 가는 조인이 행 수를 유지하는지 봅니다.
  • 다대다 연결에서는 행이 펼쳐지므로, 태그별 합계를 다시 더해 전체 합계로 쓰지 않습니다.
  • ON에는 키의 관계를, WHERE에는 업무의 모집단을 두어 의도를 나눕니다.
  • 단계마다 행 수를 세어 누락과 증가가 시작된 관계를 찾습니다.

연습 문제

1. 조회수 60회 이상인 회원과 태그의 조합

회원의 이메일별로 태그 코드와 공개된 글의 가장 최근 작성 시각을 조회해 보세요. 회원, 게시글, 연결 테이블, 태그를 모두 조인하고 결과 한 행은 회원과 태그의 조합이어야 하며, 총 조회수가 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;
member_id | email            | tag_id | code     | total_views | last_created_at
2         | [email protected] | 1      | database | 90          | 2026-07-14 09:00:00.000000
1         | [email protected]  | 1      | database | 165         | 2026-07-12 09:00:00.000000

안동민의 Java 조합은 총 조회수가 40이어서 HAVING에서 빠졌습니다. 한 회원의 같은 태그가 한 행으로 줄었는지, 이메일의 사전 순서대로 hana가 min보다 앞에 왔는지 확인합니다.

2. 회원별 총 조회수의 이중 집계

본문의 네 테이블 조인을 그대로 두고 GROUP BY m.id, m.name으로만 묶어 회원별 총 조회수를 구하면 얼마가 나오나요? 올바른 회원별 총 조회수는 얼마이고, 어떻게 구해야 하나요?

해설 보기

태그 조인을 거친 채 회원으로만 묶으면 안동민은 205, 이하나는 90이 나옵니다. 안동민의 글 1, 2, 3의 조회수 165에, 태그가 둘인 게시글 1의 40이 한 번 더 들어갔기 때문입니다. 회원별 총 조회수는 태그와 관계없는 질문이므로 연결 테이블과 태그를 조인하지 않고 게시글과 회원만 이어 구하며, 그러면 안동민 165, 이하나 90이 나옵니다. 질문에 필요하지 않은 관계를 조인에 남겨 두면 그 관계가 행을 펼쳐 합계를 부풀립니다.

3. 상태 조건을 ON으로 옮기면

본문의 네 테이블 조인에서 WHERE p.status = 'PUBLISHED'를 지우고 그 조건을 게시글과 회원을 잇는 ON에 AND p.status = 'PUBLISHED'로 옮기면 결과가 달라지나요?

해설 보기

INNER JOIN만 쓰는 이 쿼리에서는 결과가 같습니다. ON의 조건이 거짓인 행의 쌍은 INNER JOIN에서 사라지고, WHERE의 조건이 거짓인 행도 결과에서 사라지므로, 어느 쪽에 두든 공개되지 않은 글은 빠집니다. 다만 같은 쿼리를 LEFT JOIN으로 바꾸면 두 위치의 결과가 달라지므로, 관계는 ON에, 모집단은 WHERE에 두는 습관을 지금부터 들입니다. 그 차이는 다음 글에서 직접 확인합니다.

다음 글 OUTER JOIN에서는 짝이 없는 부모 행도 남기는 OUTER JOIN과, 조건을 ON과 WHERE 가운데 어디에 두느냐에 따른 결과의 차이를 다룹니다.