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

안동민 개발노트

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

INNER JOIN

회원 게시판에 태그 관계를 추가하고 PK–FK 동등 조건으로 게시글과 태그를 정확히 연결합니다.

게시글 테이블에 태그 이름을 매번 복사하면 ‘데이터베이스’, ‘DB’, ‘데이터베이스’처럼 같은 대상을 여러 표기로 저장하게 됩니다.

태그를 별도 테이블에 한 번 저장하고 연결 테이블이 게시글과 태그를 가리키면 이름 수정과 분류 정책을 한곳에서 관리할 수 있습니다.

JOIN은 이 분리된 사실을 조회 시 다시 가로로 연결합니다.

이번 장은 운영 membersposts의 누적 행 수에 기대지 않고 ch5_*_demo 공용 fixture를 사용합니다.

한 태그에는 여러 게시글이 있고 한 게시글에도 여러 태그가 붙을 수 있습니다.

지금은 모델링 이론보다 두 FK 조건이 결과 행을 어떻게 만드는지 관찰합니다.

JOIN을 실행하기 전에 작은 데이터를 눈으로 연결합니다.

idname
10데이터베이스
20Java
post_idtag_id
10110
10210
10320

연결 행의 tag_id가 태그 PK와 같은 쌍만 이어 보면 결과는 세 행입니다.

태그 10은 게시글 두 개와 연결되고, post_tags는 게시글과 태그의 다대다(N:M) 관계를 두 개의 일대다 관계로 풉니다.

INNER JOIN은 두 테이블에서 ON 조건이 참인 행 쌍만 한 결과 행으로 결합합니다.

JOIN이 새 사실을 만드는 것이 아니라 PK와 FK로 이미 저장한 관계를 읽기 편한 가로 모양으로 재구성한다는 점이 핵심입니다.


관계없는 열의 조인

두 테이블에 BIGINT 컬럼이 있다는 이유로 id = tag_id를 조인 조건으로 사용합니다.

값이 우연히 같은 행은 나오지만 게시글에 실제로 붙은 태그와는 관계없는 결과입니다.

DBMS는 타입이 맞는지 확인할 뿐 업무 관계까지 추론하지 않습니다.

의미가 다른 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 1, 2처럼 숫자가 우연히 겹친 행만 연결되고, 실제 tag_id가 3인 게시글도 id에 따라 잘못된 이름을 받습니다.

오류 실행 결과
id | title       | tag_name
1  | SELECT 기초 | 데이터베이스 -- 우연히 일치
2  | Java 복습   | Java         -- 실제 태그는 데이터베이스
3  | JOIN 계획   | 네트워크     -- 실제 태그는 Java

JOIN 조건은 ‘값이 같아 보이는 컬럼’이 아니라 스키마가 선언한 관계를 따라야 합니다.

자식의 FK가 부모의 PK 또는 UNIQUE 후보 키를 참조하는지 확인하고, 별칭으로 출처를 드러냅니다.

잘못된 조인은 오류 없이 그럴듯한 행을 만들기 때문에 FK 제약과 결과 일치 검사가 중요합니다.


INNER JOIN의 결과 집합

논리적으로 DBMS는 왼쪽 후보 행과 오른쪽 후보 행의 조합에서 ON 조건이 참인 쌍을 결과로 만듭니다.

PK–FK 관계에서는 자식 행 하나가 최대 한 부모 행과 연결되므로 자식 기준 결과 수가 늘지 않습니다.

부모가 없으면 FK가 삽입을 막고, NULL 허용 FK라면 INNER JOIN 결과에서 그 자식은 빠집니다.

SELECT에는 두 테이블의 열을 함께 둘 수 있지만 같은 이름은 테이블 별칭으로 한정합니다.

INNER JOIN ... ON은 표준 SQL입니다.

INNER는 생략할 수 있지만 교재 초반에는 관계 종류를 명시합니다.

MySQL은 조건 없는 JOIN을 사실상 데카르트 곱으로 허용할 수 있으므로 ON 누락을 문법 오류가 막아 줄 것이라 기대하지 않습니다.

스키마에는 InnoDB FK와 자식 인덱스를 명시합니다.


태그 관계 추가

태그는 게시글의 필수 속성이 아니므로 핵심 posts 테이블을 바꾸지 않습니다.

대신 두 FK를 복합 PK로 묶은 post_tags가 실제 연결 사실을 저장합니다.

태그 테이블과 올바른 INNER JOIN
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, 'min@example.com', '안동민', 'ACTIVE'),
  (2, 'hana@example.com', '이하나', 'ACTIVE'),
  (3, 'jun@example.com', '박준', '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;

연결된 게시글은 실제 FK 두 개를 거쳐 태그를 읽고 태그 이름은 한곳에만 저장됩니다.

개선 실행 결과
id | title       | tag_name
1  | SELECT 기초 | 데이터베이스
1  | SELECT 기초 | Java
2  | WHERE 조건  | 데이터베이스
3  | JOIN 계획   | 데이터베이스
4  | NULL 복습   | 데이터베이스
5  | OUTER JOIN  | 데이터베이스

운영 테이블의 ALTER와 백필은 한 트랜잭션으로 전부 롤백된다고 가정하면 안 됩니다.

MySQL DDL은 암묵적 커밋과 잠금 비용이 있으므로 배포 단계, 온라인 DDL 지원, 배치 크기를 검토합니다.

이 예제는 소량의 게시판 데이터에서 순서를 이해하기 위한 것입니다.


조인 결과와 고아 행 확인

연결 행 수와 post_tags를 거친 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;

첫 두 수가 같고 두 번째 쿼리가 0행이어야 합니다.

FK가 이미 ENFORCED라면 고아 입력은 차단되지만, 잘못된 ON 조건은 여전히 결과 일치 검사에서 발견해야 합니다.

JOIN은 저장된 중복을 없애는 마법이 아닙니다.

부모 후보 키가 유일하지 않으면 자식 한 행이 여러 부모와 연결되어 행이 늘어납니다.

참조 대상은 PK나 UNIQUE로 유일성이 보장되어야 하며, 문자열 자연 키 조인은 정렬 규칙과 변경 정책까지 공유합니다.

회원 게시판은 숫자 대리 키로 참조를 안정시키고 코드 유일성은 별도 제약으로 지킵니다.

실습에서는 ON을 하나씩 바꿔 행 수와 내용을 비교합니다.

조건을 제거한 데카르트 곱, 잘못된 ID 조건, 올바른 FK 조건을 실행해 연결 6행과 태그 3행이 각각 18행, 우연 일치 몇 행, 정확한 6행으로 변하는지 기록합니다.

같은 이름 컬럼을 SELECT tag_id처럼 한정하지 않으면 오류 1052 모호한 열이 날 수 있으므로 pt.tag_idt.id를 구분합니다.

조인 컬럼 타입과 부호 있음/부호 없음이 맞는지도 DDL에서 확인합니다.

API가 태그 이름을 필요로 한다고 게시글 테이블에 이름을 복제하지 않습니다.

JOIN 비용이 실제 병목으로 확인되기 전에는 정규화된 원본을 기준으로 둡니다.

이름 변경 시 과거 표기를 보존해야 한다면 단순 복제가 아니라 이력 요구로 모델링해야 합니다.

캐시나 검색 문서에 복제한 값은 데이터베이스 FK가 지켜 주지 않으므로 갱신 이벤트와 재색인 절차가 필요합니다.

관계 조회를 줄이려는 최적화는 새로운 동기화 책임을 포함해 평가합니다.

post_tags 행이 없다면 INNER JOIN에서 미분류 게시글이 빠지는 것이 의도인지 확인합니다.

미분류도 보여야 하면 LEFT JOIN과 대체 표기를 사용합니다.

삭제 규칙도 결정해야 합니다.

참조 중인 태그 삭제를 RESTRICT할지, 연결 행만 함께 삭제할지는 데이터 수명에 따라 다릅니다.

게시글 보존이 중요하므로 기본은 RESTRICT이며, 이름 변경과 삭제를 같은 문제로 취급하지 않습니다.

이 장에서 만든 태그와 FK는 뒤의 모델링 장에서 논리 관계로 다시 설명합니다.

지금은 먼저 쿼리 결과를 통해 ‘연결 행 여러 개가 태그 하나를 참조한다’는 사실을 경험합니다.

ch5-2와 ch5-3은 이 공용 fixture의 같은 키와 관계를 사용합니다.

해당 문서로 바로 이동했다면 이 블록을 먼저 실행해 입력과 예상 결과를 같은 상태로 맞춥니다.


INNER JOIN 검토 기준

판단 축확인할 질문
관계ON이 실제 FK와 부모 후보 키를 연결하는가?
카디널리티한 자식이 최대 몇 부모 행과 연결되는가?
필수짝 없는 행이 결과에서 빠져도 되는가?
행 수조인 전후 예상 행 수를 설명할 수 있는가?
이름별칭으로 컬럼 출처가 분명한가?

조인 SQL을 리뷰할 때 SELECT 목록보다 먼저 FROM과 ON만 읽고 관계 문장을 말합니다.

‘연결 행의 post_idtag_id가 각각 게시글과 태그의 id를 참조한다’가 바로 나오지 않으면 조건을 다시 확인합니다.


연습 문제

게시된 글의 제목, 태그 코드, 태그 이름을 조회하세요.

posts를 기준으로 post_tags, tags와 INNER JOIN하고, 태그 코드와 작성 시각 순으로 정렬하세요.

조인 결과 행 수가 게시된 글의 태그 연결 수와 같은지도 별도 쿼리로 확인하세요.

해설과 예시 답안

두 FK가 유효하다면 게시 상태 필터를 적용한 연결 행 각각이 게시글과 태그를 하나씩 찾습니다.

건수 일치 검사는 같은 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';

조회 행 수와 published_tag_rows가 같은지 확인하고, 의도하지 않은 중복이 보이면 연결 PK와 ON 조건을 점검합니다.


핵심 정리

  • INNER JOIN은 ON이 TRUE인 행 쌍만 결과에 남깁니다.
  • 동일 타입이 아니라 선언된 PK–FK 관계를 조건으로 사용합니다.
  • 필수 다대일 조인은 자식 기준 행 수가 유지되는지 일치 여부를 확인합니다.
  • 다대다 관계는 연결 테이블의 두 FK와 복합 PK로 표현합니다.

다음 문서에서는 회원–게시글–연결–태그 네 테이블을 한 경로로 연결하고 조건 위치를 읽습니다.