INNER JOIN
게시글과 태그를 연결 테이블로 나누어 저장하고, 기본 키와 외래 키의 동등 조건으로 INNER JOIN해 다시 잇습니다. 관계없는 열을 조건으로 쓴 조인이 오류 없이 틀린 결과를 내는 과정과, 조인 전후의 행 수와 고아 행으로 결과를 확인하는 방법을 다룹니다.
게시글 테이블에 태그의 이름을 매번 복사하면 '데이터베이스', 'DB', '데이타베이스'처럼 같은 대상을 여러 표기로 저장하게 됩니다. 태그를 별도의 테이블에 한 번만 저장하고 연결 테이블이 게시글과 태그를 가리키게 하면, 이름의 수정과 분류의 규칙을 한곳에서 관리할 수 있습니다. JOIN은 이렇게 나뉘어 저장된 사실을 조회할 때 다시 가로로 잇습니다.
이번 장은 누적된 members와 posts의 행 수에 기대지 않고, ch5_로 시작하는 공용 실습 테이블을 씁니다. 한 태그에는 여러 게시글이, 한 게시글에도 여러 태그가 붙을 수 있습니다. 지금은 모델링의 이론보다, 외래 키 두 개의 조건이 결과 행을 어떻게 만드는지 살펴봅니다.
JOIN을 실행하기 전에 작은 데이터를 눈으로 먼저 이어 봅니다.
| id | name |
|---|---|
| 10 | 데이터베이스 |
| 20 | Java |
| post_id | tag_id |
|---|---|
| 101 | 10 |
| 102 | 10 |
| 103 | 20 |
연결 행의 tag_id가 태그의 기본 키와 같은 쌍만 이으면 결과는 세 행이고, 태그 10은 게시글 두 개와 이어집니다. 이처럼 연결 테이블 post_tags는 게시글과 태그의 다대다(N:M) 관계를 일대다 관계 두 개로 풉니다.
INNER JOIN은 두 테이블에서 ON 조건이 참인 행의 쌍만 결과 행 하나로 합칩니다. JOIN이 새로운 사실을 만드는 것이 아니라, 기본 키와 외래 키로 이미 저장해 둔 관계를 읽기 좋은 가로 모양으로 다시 짜 맞춘다는 점이 핵심입니다.
INNER JOIN · MATCH → RESULT
FK 값이 같은 세 쌍만 결과의 세 행이 된다
본문 첫 번째 작은 예제입니다. 연결 행의 tag_id는 태그 id를 찾습니다. 같은 태그 10을 두 연결 행이 참조해도 각각 별도 결과입니다.
세 화살표는101–10,102–10,103–20의 일치 쌍입니다. 결합 결과는 각각101·데이터베이스,102·데이터베이스,103·Java로 세 행이고, 같은 태그를 찾은 두 연결 행이 한 행으로 합쳐지지 않습니다.
JOIN은 저장된 관계를 가로로 재구성합니다. 게시글 ID를 태그 ID와 비교하면 업무 관계가 달라집니다. 뒤의 ch5 공용 fixture는 별도의 입력입니다.
관계없는 열로 조인하면
두 테이블에 정수 열이 있다는 이유만으로 게시글의 id와 태그의 tag_id를 조인 조건으로 써 봅니다. 아래의 게시글에는 실제로 붙은 태그를 actual_tag_id로 적어 두었습니다.
WITH post_fixture AS (
SELECT 1 AS id, 'SELECT 기초' AS title, 1 AS actual_tag_id
UNION ALL SELECT 2, 'Java 복습', 1
UNION ALL SELECT 3, 'JOIN 계획', 2
), tag_fixture AS (
SELECT 1 AS tag_id, '데이터베이스' AS name
UNION ALL SELECT 2, 'Java'
UNION ALL SELECT 3, '네트워크'
)
SELECT
s.id,
s.title,
t.name AS tag_name
FROM post_fixture AS s
JOIN tag_fixture AS t
ON t.tag_id = s.id
ORDER BY s.id;id | title | tag_name
1 | SELECT 기초 | 데이터베이스
2 | Java 복습 | Java
3 | JOIN 계획 | 네트워크게시글의 id 1, 2, 3이 태그 id와 모두 겹쳐 세 행이 나왔습니다. 1번은 우연히 맞았지만, 2번의 실제 태그는 데이터베이스이고 3번은 Java이므로 뒤의 두 행은 틀린 태그를 붙였습니다. DBMS는 적힌 ON 조건을 계산할 뿐, 같은 숫자가 같은 업무 대상을 뜻하는지는 판단하지 않습니다.
JOIN의 조건은 값이 같아 보이는 열이 아니라, 스키마가 선언한 관계를 따라야 합니다. 자식의 외래 키가 부모의 기본 키나 UNIQUE 후보 키를 가리키는지 확인하고, 테이블 별칭으로 열의 출처를 드러냅니다. 잘못된 조인은 오류 없이 그럴듯한 행을 만들기 때문에, 외래 키 제약과 결과의 행 수 확인이 중요합니다.
INNER JOIN이 만드는 결과
논리적으로 DBMS는 왼쪽 행과 오른쪽 행의 모든 조합 가운데 ON 조건이 참인 쌍을 결과로 만듭니다. 기본 키와 외래 키의 관계에서는 자식 행 하나가 많아야 부모 행 하나와 이어지므로, 자식을 기준으로 한 결과의 행 수는 늘지 않습니다. 부모가 없는 자식은 외래 키가 저장을 막고, NULL을 허용하는 외래 키라면 그 자식은 INNER JOIN의 결과에서 빠집니다.
SELECT에는 두 테이블의 열을 함께 둘 수 있지만, 같은 이름의 열은 테이블 별칭으로 한정합니다. INNER JOIN ... ON은 표준 SQL이며, INNER는 생략할 수 있지만 이 장의 앞부분에서는 관계의 종류를 드러내려고 적습니다. MySQL은 ON이 없는 JOIN을 모든 조합을 만드는 데카르트 곱(Cartesian product), 곧 이산수학의 곱집합으로 받아들이므로, ON을 빠뜨린 것을 문법 오류가 막아 주리라 기대하지 않습니다.
더 깊이: MySQL이 조인을 실제로 실행하는 방법
모든 조합을 만든 뒤 조건에 맞는 쌍을 고른다는 설명은 결과의 뜻을 정하는 모델이고, MySQL이 실제로 모든 조합을 만들지는 않습니다. 기본이 되는 방법은 중첩 루프 조인(nested-loop join)으로, 바깥 테이블에서 행을 하나씩 읽을 때마다 안쪽 테이블에서 짝이 되는 행을 찾습니다. 안쪽 테이블의 조인 열에 인덱스가 있으면 바깥 행마다 인덱스로 짝을 바로 찾고, 없으면 바깥 행마다 안쪽 테이블을 다시 훑어야 합니다. 외래 키 열에는 InnoDB가 인덱스를 만들어 두므로, 외래 키를 따라가는 조인에는 쓸 수 있는 인덱스가 있습니다.
조인 조건에 쓸 수 있는 인덱스가 없으면, MySQL 8.0.18부터는 해시 조인(hash join)을 씁니다. 한쪽 입력의 행으로 조인 열의 해시 테이블을 만든 뒤, 다른 쪽 입력의 행을 하나씩 그 해시 테이블과 맞춰 보는 방식입니다. 8.0.20부터는 예전의 블록 중첩 루프(block nested loop) 알고리즘이 없어지고 그 자리를 해시 조인이 대신하며, 같다는 조건이 없는 조인과 외부 조인, 세미조인과 안티조인에도 해시 조인을 씁니다. 해시 테이블이 쓰는 메모리는 join_buffer_size를 넘지 않고, 그보다 많이 필요하면 디스크의 파일을 씁니다(MySQL 8.4의 해시 조인).
실행 계획에서 해시 조인은 EXPLAIN FORMAT=TREE의 Inner hash join이나, 기본 형식 Extra 열의 Using join buffer (hash join)으로 드러납니다. 기본 키와 외래 키로 잇는 이 글의 조인에서 이 표시가 보인다면, 조인 조건이 엉뚱한 열을 가리키거나 그 열의 인덱스가 빠졌을 수 있습니다. 실행 계획을 읽는 방법은 7장 3편에서 다룹니다.
태그 관계를 만든다
태그는 게시글의 필수 속성이 아니므로 게시글 테이블을 바꾸지 않습니다. 대신 외래 키 두 개를 묶어 복합 기본 키로 삼은 ch5_post_tag_demo가 실제의 연결을 저장합니다. 5장 2편과 3편도 이 공용 테이블을 같은 키와 관계로 쓰므로, 그 글로 바로 이동했다면 이 블록부터 실행합니다.
DROP TABLE IF EXISTS ch5_post_tag_demo;
DROP TABLE IF EXISTS ch5_tag_demo;
DROP TABLE IF EXISTS ch5_post_demo;
DROP TABLE IF EXISTS ch5_member_demo;
CREATE TABLE ch5_member_demo (
id BIGINT UNSIGNED NOT NULL,
email VARCHAR(255) NOT NULL,
name VARCHAR(80) NOT NULL,
status VARCHAR(16) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_ch5_member_email (email)
) ENGINE = InnoDB;
CREATE TABLE ch5_post_demo (
id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(80) NOT NULL,
status VARCHAR(16) NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_ch5_post_author
FOREIGN KEY (author_id) REFERENCES ch5_member_demo (id)
) ENGINE = InnoDB;
CREATE TABLE ch5_tag_demo (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(40) NOT NULL,
name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (id),
UNIQUE KEY uq_ch5_tags_code (code)
) ENGINE = InnoDB;
CREATE TABLE ch5_post_tag_demo (
post_id BIGINT UNSIGNED NOT NULL,
tag_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (post_id, tag_id),
KEY ix_ch5_post_tags_tag (tag_id, post_id),
CONSTRAINT fk_ch5_post_tags_post
FOREIGN KEY (post_id) REFERENCES ch5_post_demo (id),
CONSTRAINT fk_ch5_post_tags_tag
FOREIGN KEY (tag_id) REFERENCES ch5_tag_demo (id)
) ENGINE = InnoDB;
INSERT INTO ch5_member_demo (id, email, name, status)
VALUES
(1, '[email protected]', '안동민', 'ACTIVE'),
(2, '[email protected]', '이하나', 'ACTIVE'),
(3, '[email protected]', '박준', 'ACTIVE');
INSERT INTO ch5_post_demo
(id, author_id, title, status, view_count, created_at)
VALUES
(1, 1, 'SELECT 기초', 'PUBLISHED', 40, '2026-07-10 09:00:00'),
(2, 1, 'WHERE 조건', 'PUBLISHED', 55, '2026-07-11 09:00:00'),
(3, 1, 'JOIN 계획', 'PUBLISHED', 70, '2026-07-12 09:00:00'),
(4, 2, 'NULL 복습', 'PUBLISHED', 40, '2026-07-13 09:00:00'),
(5, 2, 'OUTER JOIN', 'PUBLISHED', 50, '2026-07-14 09:00:00');
INSERT INTO ch5_tag_demo (id, code, name)
VALUES
(1, 'database', '데이터베이스'),
(2, 'java', 'Java'),
(3, 'network', '네트워크');
INSERT INTO ch5_post_tag_demo (post_id, tag_id)
VALUES
(1, 1), (1, 2),
(2, 1), (3, 1),
(4, 1), (5, 1);
SELECT p.id, p.title, t.name AS tag_name
FROM ch5_post_demo AS p
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
ORDER BY p.id, t.id;id | title | tag_name
1 | SELECT 기초 | 데이터베이스
1 | SELECT 기초 | Java
2 | WHERE 조건 | 데이터베이스
3 | JOIN 계획 | 데이터베이스
4 | NULL 복습 | 데이터베이스
5 | OUTER JOIN | 데이터베이스게시글은 연결 테이블의 외래 키 두 개를 거쳐 태그를 읽고, 태그의 이름은 한곳에만 저장됩니다. 태그가 둘인 1번은 두 행으로 나오고, 어느 글에도 붙지 않은 '네트워크' 태그는 이 결과에 나타나지 않습니다. 짝이 없는 행을 남기는 방법은 5장 3편에서 다룹니다.
이 예제는 작은 데이터로 순서를 익히기 위한 것입니다. 운영 중인 테이블에 이런 관계를 더하는 ALTER와 기존 데이터의 백필은, 한 트랜잭션으로 묶어 모두 롤백할 수 있다고 가정하면 안 됩니다. MySQL의 DDL은 암묵적으로 커밋되고 잠금의 비용도 있으므로, 2장 1편처럼 배포의 단계와 온라인 DDL의 지원, 배치의 크기를 검토합니다.
조인 결과와 고아 행을 확인한다
연결 테이블의 행 수와, 연결 테이블을 거친 INNER JOIN의 결과 수는 같아야 합니다. 차이가 있다면 고아 데이터나 조건의 오류를 의심합니다.
SELECT
(SELECT COUNT(*) FROM ch5_post_tag_demo) AS relation_rows,
COUNT(*) AS joined_rows
FROM ch5_post_tag_demo AS pt
JOIN ch5_post_demo AS p ON p.id = pt.post_id
JOIN ch5_tag_demo AS t ON t.id = pt.tag_id;
SELECT pt.post_id, pt.tag_id
FROM ch5_post_tag_demo AS pt
LEFT JOIN ch5_post_demo AS p ON p.id = pt.post_id
LEFT JOIN ch5_tag_demo AS t ON t.id = pt.tag_id
WHERE p.id IS NULL OR t.id IS NULL;relation_rows | joined_rows
6 | 6두 수가 같고, 두 번째 쿼리는 빈 결과를 돌려줍니다. 외래 키 검사가 켜져 있으면 고아 행은 처음부터 들어오지 못하지만, 잘못된 ON 조건은 이런 행 수의 확인으로 찾아야 합니다.
JOIN은 저장된 중복을 없애 주지 않습니다. 부모 쪽의 키가 유일하지 않으면 자식 한 행이 여러 부모와 이어져 행이 늘어나므로, 참조하는 대상은 기본 키나 UNIQUE로 유일성이 보장되어야 합니다. 문자열 자연 키로 조인하면 정렬 규칙과 값의 변경 규칙까지 함께 따라오므로, 회원 게시판은 숫자 대리 키로 참조를 안정시키고 태그 코드의 유일성은 별도의 제약으로 지킵니다. 조인하는 열의 타입과 부호가 맞는지도 DDL에서 확인합니다(1장 3편).
조인 대신 복사하고 싶을 때
API가 태그의 이름을 필요로 한다고 게시글 테이블에 이름을 복사하지 않습니다. JOIN의 비용이 실제 병목으로 확인되기 전에는 정규화된 원래 데이터를 기준으로 둡니다. 태그의 이름을 바꿀 때 예전 표기를 남겨야 한다면, 단순한 복사가 아니라 이력의 요구로 모델링해야 합니다.
캐시나 검색 문서에 복사한 값은 데이터베이스의 외래 키가 지켜 주지 않으므로, 갱신 이벤트와 다시 색인하는 절차가 필요합니다. 관계 조회를 줄이려는 최적화는 이런 동기화의 부담까지 포함해 평가합니다.
연결 행이 없는 게시글이 INNER JOIN에서 빠지는 것이 의도인지도 확인합니다. 분류되지 않은 글도 보여야 한다면 LEFT JOIN과 대체 표기를 씁니다. 지우는 규칙도 정해야 하는데, 쓰이고 있는 태그를 지우지 못하게 할지 연결 행만 함께 지울지는 데이터의 수명에 따라 다릅니다. 게시글의 보존이 중요하므로 기본은 RESTRICT로 두고, 이름의 변경과 삭제를 같은 문제로 다루지 않습니다.
| 따져 볼 것 | 질문 |
|---|---|
| 관계 | ON이 실제의 외래 키와 부모의 후보 키를 잇는가 |
| 카디널리티 | 자식 한 행이 많아야 몇 개의 부모 행과 이어지는가 |
| 필수 여부 | 짝이 없는 행이 결과에서 빠져도 되는가 |
| 행 수 | 조인 전후의 예상 행 수를 설명할 수 있는가 |
| 이름 | 별칭으로 열의 출처가 분명한가 |
조인 SQL을 리뷰할 때는 SELECT 목록보다 FROM과 ON을 먼저 읽고 관계를 문장으로 말해 봅니다. "연결 행의 post_id와 tag_id가 각각 게시글과 태그의 id를 가리킨다"는 문장이 바로 나오지 않으면 조건을 다시 확인합니다.
정리
- INNER JOIN은 ON 조건이 참인 행의 쌍만 결과에 남깁니다.
- 조인의 조건은 타입이 같은 열이 아니라, 선언된 기본 키와 외래 키의 관계를 씁니다.
- 필수인 다대일 조인은 자식을 기준으로 한 행 수가 그대로인지 확인합니다.
- 다대다 관계는 연결 테이블의 외래 키 두 개와 복합 기본 키로 나타냅니다.
연습 문제
1. 공개 글의 태그 목록
공개된 글의 제목과 태그 코드, 태그 이름을 조회해 보세요. 게시글을 기준으로 연결 테이블과 태그를 INNER JOIN하고, 태그 코드와 작성 시각의 순서로 정렬합니다. 조인 결과의 행 수가 공개된 글의 태그 연결 수와 같은지도 따로 확인합니다.
해설 보기
외래 키 두 개가 유효하다면, 공개 상태의 필터를 지난 연결 행은 저마다 게시글과 태그를 하나씩 찾습니다. 행 수를 비교할 때는 같은 WHERE를 양쪽에 적용합니다.
SELECT p.title, t.code, t.name, p.created_at
FROM ch5_post_demo AS p
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'
ORDER BY t.code, p.created_at, p.id;
SELECT COUNT(*) AS published_tag_rows
FROM ch5_post_demo AS p
JOIN ch5_post_tag_demo AS pt ON pt.post_id = p.id
WHERE p.status = 'PUBLISHED';title | code | name | created_at
SELECT 기초 | database | 데이터베이스 | 2026-07-10 09:00:00.000000
WHERE 조건 | database | 데이터베이스 | 2026-07-11 09:00:00.000000
JOIN 계획 | database | 데이터베이스 | 2026-07-12 09:00:00.000000
NULL 복습 | database | 데이터베이스 | 2026-07-13 09:00:00.000000
OUTER JOIN | database | 데이터베이스 | 2026-07-14 09:00:00.000000
SELECT 기초 | java | Java | 2026-07-10 09:00:00.000000
published_tag_rows
6조회한 행이 6개이고 published_tag_rows도 6이므로 의도하지 않은 중복은 없습니다. 'SELECT 기초'가 두 번 나오는 것은 태그가 둘이기 때문이며, 이런 중복이 예상과 다르게 보이면 연결 테이블의 기본 키와 ON 조건을 점검합니다.
2. 세 가지 ON 조건의 행 수
연결 테이블 ch5_post_tag_demo와 태그 테이블을 (가) 조건 없이, (나) ON t.id = pt.post_id로, (다) ON t.id = pt.tag_id로 이으면 결과는 각각 몇 행인가요?
해설 보기
SELECT COUNT(*) FROM ch5_post_tag_demo AS pt JOIN ch5_tag_demo AS t;
SELECT COUNT(*) FROM ch5_post_tag_demo AS pt JOIN ch5_tag_demo AS t ON t.id = pt.post_id;
SELECT COUNT(*) FROM ch5_post_tag_demo AS pt JOIN ch5_tag_demo AS t ON t.id = pt.tag_id;(가)는 연결 6행과 태그 3행의 모든 조합이어서 18행입니다. (나)는 게시글의 id를 태그의 id와 비교하므로, post_id가 1인 두 행과 2, 3인 각 한 행이 우연히 태그 1, 2, 3과 맞아 4행이 나오고, 4번과 5번 게시글의 연결은 짝이 없어 빠집니다. (다)만 실제의 관계를 따라 6행입니다. 세 쿼리 모두 오류 없이 실행된다는 점이, ON 조건을 행 수로 확인해야 하는 이유입니다.
3. 태그별 게시글 수
태그마다 연결된 게시글의 수를 INNER JOIN과 GROUP BY로 세어 보세요. '네트워크' 태그는 결과에 나오나요?
해설 보기
SELECT t.code, COUNT(*) AS post_count
FROM ch5_tag_demo AS t
JOIN ch5_post_tag_demo AS pt ON pt.tag_id = t.id
GROUP BY t.id, t.code
ORDER BY t.code;code | post_count
database | 5
java | 1'네트워크' 태그는 연결 행이 없어 INNER JOIN에서 짝을 찾지 못하므로 결과에 나오지 않습니다. 쓰이지 않는 태그를 0건으로 보여 주려면 태그를 기준으로 한 LEFT JOIN과 COUNT(pt.post_id)가 필요하며, 이 방법은 5장 3편에서 다룹니다.
다음 글 다중 테이블 조인에서는 회원, 게시글, 연결 테이블, 태그의 네 테이블을 한 경로로 잇고, 조건을 어디에 두는지에 따라 결과가 어떻게 달라지는지 읽습니다.