SELF JOIN과 CROSS JOIN
답글이 부모 댓글을 가리키는 댓글 테이블을 역할 별칭으로 두 번 읽는 자기 참조 조인과, 두 작은 집합의 모든 조합을 만드는 CROSS JOIN을 다룹니다. 최상위 댓글을 잃지 않는 LEFT JOIN, 자기 참조 외래 키와 CHECK가 보장하는 범위, 조합의 크기를 먼저 계산하는 기준을 확인합니다.
댓글에 답글을 달 수 있다면, 댓글 테이블은 자기 자신을 가리킵니다. 게시 상태와 정렬 방식의 모든 조합을 만들 때는 서로 관계의 조건이 없는 두 작은 집합의 데카르트 곱이 필요합니다. 자기 참조 조인(self join)과 CROSS JOIN은 특별한 저장 방식이 아니라, JOIN을 쓰는 두 가지 목적입니다.
이 글에서는 회원 게시판의 댓글 구조를 ch5_comment_demo에 재현하고, 같은 테이블을 답글과 부모 댓글이라는 두 역할로 읽습니다. 이어서 작은 상태와 정렬의 집합을 CROSS JOIN해 필터의 후보를 만들고, 입력의 크기를 곱해 결과의 크기를 먼저 계산하는 기준을 세웁니다.
자기 참조 INNER JOIN이 잃는 행
부모가 없는 최상위 댓글도 목록에 보여야 하는데, 댓글을 자기 자신과 INNER JOIN해 봅니다. 아래 쿼리는 테이블을 만들지 않고 WITH로 댓글 세 개를 즉석에서 만듭니다. 1번은 parent_id가 NULL인 최상위 댓글이고 2번과 3번은 1번의 답글이며, 같은 comment_fixture를 reply와 parent라는 두 별칭으로 읽어 답글마다 부모 댓글을 붙입니다.
WITH comment_fixture AS (
SELECT 1 AS id, '첫 댓글' AS content,
CAST(NULL AS UNSIGNED) AS parent_id
UNION ALL SELECT 2, '첫 답글', 1
UNION ALL SELECT 3, '두 번째 답글', 1
)
SELECT reply.content, parent.content AS parent_content
FROM comment_fixture AS reply
JOIN comment_fixture AS parent
ON parent.id = reply.parent_id
ORDER BY reply.id;content | parent_content
첫 답글 | 첫 댓글
두 번째 답글 | 첫 댓글부모가 있는 답글 두 개만 나오고, parent_id가 NULL인 최상위 댓글은 짝이 없어 목록에서 빠졌습니다. 자기 참조 조인이라는 새 문법이 있는 것이 아니라, 같은 테이블을 서로 다른 역할의 별칭으로 두 번 쓴 것입니다. 모든 댓글을 남기려면 댓글을 왼쪽에 두고 부모를 LEFT JOIN하며, 이때 NULL은 "부모 없음", 곧 최상위라는 관계를 나타냅니다.
별칭과 조합의 크기
자기 참조 조인에서는 같은 열의 집합이 두 번 나타나므로 역할의 이름이 꼭 필요합니다. 이 방식은 계층의 한 단계만 읽을 때 알맞으며, 여러 깊이를 따라 내려가는 방법은 14장 2편에서 재귀 CTE로 다룹니다. InnoDB에서도 자기 참조 외래 키로 같은 테이블의 기본 키를 가리키게 선언할 수 있습니다.
CROSS JOIN은 ON 없이 왼쪽 M행과 오른쪽 N행의 모든 조합, 곧 M×N행을 만듭니다. 조합을 만드는 것이 목적일 때만 명시적으로 쓰고, 실행하기 전에 입력의 크기와 거르는 시점을 계산합니다. CROSS JOIN은 표준 SQL이며, MySQL에서 JOIN의 조건을 빠뜨려 우연히 데카르트 곱이 된 것과 일부러 CROSS JOIN을 쓴 것은 코드 리뷰에서 구별합니다.
더 깊이: MySQL의 JOIN 키워드와 쉼표의 우선순위
표준 SQL에서 INNER JOIN은 ON과 함께, CROSS JOIN은 ON 없이 쓰는 서로 다른 연산입니다. 그런데 MySQL 8.4 매뉴얼에 따르면 MySQL에서는 JOIN, CROSS JOIN, INNER JOIN이 문법상 같아서 서로 바꿔 쓸 수 있습니다. 그래서 MySQL은 ON이 붙은 CROSS JOIN도, ON이 없는 INNER JOIN도 받아들이며, 키워드만 보고는 데카르트 곱인지 판단할 수 없습니다. 이 교재가 조합을 만들 때 CROSS JOIN을, 관계를 이을 때 JOIN ... ON을 쓰는 것은 문법이 요구해서가 아니라 의도를 드러내려는 약속입니다.
FROM a, b처럼 쉼표로 테이블을 나열해도 조인 조건이 없으면 같은 데카르트 곱이 만들어지지만, 쉼표 연산자의 우선순위는 JOIN, LEFT JOIN 같은 키워드보다 낮습니다. 그래서 FROM t1, t2 JOIN t3 ON (t1.i1 = t3.i3)에서는 t2 JOIN t3가 먼저 묶이고, ON이 볼 수 있는 테이블은 t2와 t3뿐이어서 Unknown column 't1.i1' in 'on clause' 오류가 납니다. 쉼표와 JOIN 키워드를 섞어야 한다면 (t1, t2) JOIN t3처럼 괄호로 묶어야 하므로, 처음부터 쉼표 대신 JOIN 키워드로만 적는 편이 안전합니다.
댓글의 관계와 자기 참조 LEFT JOIN
부모를 가리키는 외래 키를 NULL 허용으로 두어, 최상위 댓글과 답글을 한 테이블에 함께 저장합니다. 이 실습 데이터는 id를 직접 정하는데, MySQL은 CHECK에서 AUTO_INCREMENT 열을 참조할 수 없으므로 자기 자신을 부모로 삼지 못하게 하는 CHECK를 두려고 자동 증가를 쓰지 않았습니다. 조회는 자기 참조 LEFT JOIN으로 모든 댓글을 남깁니다.
DROP TABLE IF EXISTS ch5_comment_demo;
DROP TABLE IF EXISTS ch5_comment_post_demo;
DROP TABLE IF EXISTS ch5_comment_member_demo;
CREATE TABLE ch5_comment_member_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
name VARCHAR(80) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE ch5_comment_post_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
title VARCHAR(80) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE ch5_comment_demo (
id BIGINT UNSIGNED NOT NULL,
post_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
parent_id BIGINT UNSIGNED NULL,
content VARCHAR(1000) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'VISIBLE',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY ix_comments_post_created (post_id, created_at, id),
KEY ix_comments_parent (parent_id, id),
CONSTRAINT fk_ch5_comments_post FOREIGN KEY (post_id) REFERENCES ch5_comment_post_demo (id),
CONSTRAINT fk_ch5_comments_author FOREIGN KEY (author_id) REFERENCES ch5_comment_member_demo (id),
CONSTRAINT fk_ch5_comments_parent FOREIGN KEY (parent_id) REFERENCES ch5_comment_demo (id),
CONSTRAINT ck_ch5_comments_parent CHECK (parent_id IS NULL OR parent_id <> id),
CONSTRAINT ck_ch5_comments_status CHECK (status IN ('VISIBLE', 'HIDDEN', 'DELETED'))
) ENGINE = InnoDB;
INSERT INTO ch5_comment_member_demo (id, name)
VALUES (1, '안동민'), (2, '이하나'), (3, '박준');
INSERT INTO ch5_comment_post_demo (id, title)
VALUES (1, 'JOIN 관계');
INSERT INTO ch5_comment_demo (id, post_id, author_id, parent_id, content, status)
VALUES
(1, 1, 1, NULL, '좋은 글입니다.', 'VISIBLE'),
(2, 1, 2, 1, '저도 도움이 됐습니다.', 'VISIBLE'),
(3, 1, 3, 1, '예제를 더 보고 싶습니다.', 'VISIBLE');
SELECT
reply.id,
reply.content,
parent.content AS parent_content
FROM ch5_comment_demo AS reply
LEFT JOIN ch5_comment_demo AS parent ON parent.id = reply.parent_id
ORDER BY reply.id;id | content | parent_content
1 | 좋은 글입니다. | NULL
2 | 저도 도움이 됐습니다. | 좋은 글입니다.
3 | 예제를 더 보고 싶습니다. | 좋은 글입니다.최상위 댓글도 남고, 그 행의 parent_content만 NULL입니다.
SELF JOIN · TWO ROLES / ONE TABLE
부모가 없는 첫 댓글도 LEFT JOIN으로 남긴다
같은 댓글 테이블을 reply와 parent라는 역할로 읽습니다. parent.id = reply.parent_id가 두 답글을 댓글 1에 연결하고, 댓글 1 자체는 부모 없이 보존합니다.
화살표는 reply의 parent_id가 parent 역할의 댓글을 찾는 방향입니다. 댓글1 내용은 ‘좋은 글입니다.’, 댓글2는 ‘저도 도움이 됐습니다.’, 댓글3은 ‘예제를 더 보고 싶습니다.’입니다. 두 답글이 같은 부모 내용을 읽어도 댓글 테이블을 두 개 만드는 것은 아닙니다.
이 JOIN은 한 단계 부모를 읽습니다. parent_id ≠ id 검사는 자기 자신만 막으며 A→B→A 다단계 순환까지 막지 않습니다.
이 DDL이 보장하는 것과 보장하지 않는 것
부모를 가리키는 외래 키는 parent_id가 가리키는 댓글이 있는지를 검사하고, CHECK인 parent_id <> id는 자기 자신을 부모로 지정하지 못하게 합니다. 그러나 A의 부모가 B이고 B의 부모가 A인 것처럼 여러 단계를 도는 순환은, 행 하나만 보는 제약으로 막기 어렵습니다. 이런 순환은 애플리케이션이나 계층 모델에서 따로 검증하며, 14장 1편에서 다룹니다.
부모와 답글의 post_id가 같은지도 검사하지 않습니다. 게시글 1의 댓글을 게시글 2의 답글이 부모로 가리켜도, 게시글과 작성자의 행이 있고 나머지 값이 정상이라면 두 제약을 모두 통과합니다. "답글은 같은 게시글의 댓글에만 달 수 있다"는 규칙이 필요하다면, 부모 쪽에 (post_id, id)의 UNIQUE 인덱스를 두고 (post_id, parent_id)가 그것을 가리키는 복합 외래 키를 걸거나, 쓰는 트랜잭션에서 검사합니다. 부모가 있다는 것, 같은 게시글이라는 것, 순환이 없다는 것은 서로 다른 보장입니다.
부모 댓글을 지울 때 외래 키를 RESTRICT로 둘지 SET NULL로 둘지도 수명의 규칙으로 정합니다. 다만 MySQL 8.4 문서에 따르면 CHECK에서 쓰는 열에는 ON DELETE나 ON UPDATE의 참조 동작을 걸 수 없으므로, 이 테이블처럼 parent_id를 CHECK에서 쓰고 있다면 SET NULL은 함께 쓸 수 없습니다. 두 규칙은 처음부터 함께 설계합니다.
CROSS JOIN의 크기를 먼저 계산한다
게시글의 상태 4개와 정렬 방식 3개를 조합하면 12행이므로 안전합니다. 큰 테이블 대신 작은 CTE의 집합으로 의도를 드러내고, 결과의 수를 확인합니다.
WITH statuses AS (
SELECT 'DRAFT' AS status UNION ALL
SELECT 'PUBLISHED' UNION ALL
SELECT 'HIDDEN' UNION ALL
SELECT 'DELETED'
), sort_orders AS (
SELECT 'RECENT' AS sort_order UNION ALL
SELECT 'MOST_VIEWED' UNION ALL
SELECT 'OLDEST'
)
SELECT s.status, o.sort_order
FROM statuses AS s
CROSS JOIN sort_orders AS o
ORDER BY s.status, o.sort_order;status | sort_order
DELETED | MOST_VIEWED
DELETED | OLDEST
DELETED | RECENT
DRAFT | MOST_VIEWED
DRAFT | OLDEST
DRAFT | RECENT
HIDDEN | MOST_VIEWED
HIDDEN | OLDEST
HIDDEN | RECENT
PUBLISHED | MOST_VIEWED
PUBLISHED | OLDEST
PUBLISHED | RECENT결과는 정확히 4×3=12행입니다. ORDER BY가 없으면 조합의 순서는 정해지지 않으므로 정렬을 적었습니다. 입력이 10만 행과 10만 행이라면 100억 개의 후보가 생기므로, CROSS JOIN 뒤에 LIMIT을 붙여 막겠다는 생각은 안전하지 않습니다. 입력을 먼저 줄입니다.
CROSS JOIN을 쓰기 전에는 SELECT COUNT(*)로 입력마다 크기를 확인하고 곱을 계산합니다. CTE의 UNION ALL 입력에 같은 값이 두 번 들어 있으면 조합도 그만큼 늘어나므로, 조합에 쓰는 차원 집합이 유일한지도 확인합니다. 만든 조합을 테이블에 INSERT한다면 UNIQUE 제약과 다시 실행했을 때의 중복 규칙도 필요하므로, 미리보기와 영구적인 생성 작업을 같은 SQL로 다루지 않습니다.
도구를 고르는 법
필터 후보의 생성, 가격표와 지역, 날짜와 시간대의 칸처럼 데카르트 곱이 정당한 업무도 많습니다. 그러나 권한의 범위를 적용하기 전에 사용자와 리소스의 조합을 모두 만들면 정보의 노출과 부하가 커지므로, 입력마다 CTE나 파생 테이블에서 먼저 거르고 예상되는 곱에 상한을 둡니다. 큰 조합을 만드는 배치 작업은 나누는 단위와 다시 시작할 지점을 설계하고, 결과의 행 수를 로그로 남깁니다.
자기 참조 조인으로 답글의 부모를 정해진 깊이까지 붙일 수는 있지만, 깊이가 바뀌면 JOIN의 수도 고쳐야 합니다. 계층을 따라가는 것이 핵심 요구라면 인접 리스트와 재귀 CTE를 쓰고, 지금의 부모 댓글 하나만 보여 주는 화면이라면 자기 참조 조인 하나가 가장 단순합니다. 답글에서 부모를 찾는 조회와 부모에서 모든 답글을 찾는 조회는 같은 외래 키를 반대 방향으로 읽는 것이며, 내용이 같은 두 댓글은 id로 구별합니다.
공용 회원 게시판 모델에는 댓글의 부모 외래 키만 반영하고, 상태와 정렬의 CTE는 실습에만 쓸 뿐 영구 스키마에 더하지 않습니다.
| 따져 볼 것 | 질문 |
|---|---|
| 역할 | 같은 테이블의 두 별칭이 업무의 역할을 드러내는가 |
| 보존 | 참조가 NULL인 최상위 행도 남아야 하는가 |
| 깊이 | 한 단계의 자기 참조 조인으로 충분한가, 재귀가 필요한가 |
| 곱 | CROSS JOIN의 입력 M과 N, 결과 M×N을 계산했는가 |
| 저장 | 후보를 조회만 하는가, 실제로 INSERT하며 중복의 규칙이 있는가 |
자기 참조 조인은 자기 참조 관계를, CROSS JOIN은 모든 조합이라는 목적을 SQL에서 드러냅니다. ON을 쓰기 귀찮아서 CROSS JOIN을 쓰거나, 계층 전체를 정해진 수의 자기 참조 조인으로 늘리지 않습니다.
정리
- 자기 참조 조인은 같은 테이블을 역할 별칭으로 두 번 읽는 평범한 JOIN입니다.
- 참조가 선택인 자기 참조는 LEFT JOIN으로 참조가 없는 행을 남깁니다.
- 자기 참조 외래 키는 부모의 존재만,
parent_id <> id는 직접적인 자기 참조만 막으며, 같은 게시글과 다단계 순환은 따로 지킵니다. - CROSS JOIN은 모든 M×N 조합이 목적일 때만 명시적으로 쓰고, 입력을 먼저 줄입니다.
연습 문제
1. 댓글별 답글 수와 필터 조합 수
모든 댓글에 대해 댓글마다 달린 답글의 수를 보여 주세요. 답글이 없는 댓글도 0이어야 합니다. 이어서 활성 태그와 DRAFT, PUBLISHED, HIDDEN, DELETED의 네 상태로 만들 수 있는 필터 조합의 수를 구합니다.
해설 보기
부모가 될 댓글을 왼쪽의 남길 대상으로 두고, 답글을 자기 참조 외래 키로 LEFT JOIN합니다. 조합은 활성 태그와 네 상태의 CTE를 CROSS JOIN하며, ch5_tag_demo는 5장 1편의 공용 테이블입니다.
SELECT
parent.id,
parent.content,
COUNT(reply.id) AS reply_count
FROM ch5_comment_demo AS parent
LEFT JOIN ch5_comment_demo AS reply ON reply.parent_id = parent.id
GROUP BY parent.id, parent.content
ORDER BY reply_count DESC, parent.id;
WITH statuses AS (
SELECT 'DRAFT' AS status UNION ALL
SELECT 'PUBLISHED' UNION ALL
SELECT 'HIDDEN' UNION ALL
SELECT 'DELETED'
)
SELECT COUNT(*) AS combination_count
FROM ch5_tag_demo AS t
CROSS JOIN statuses
WHERE t.is_active = TRUE;id | content | reply_count
1 | 좋은 글입니다. | 2
2 | 저도 도움이 됐습니다. | 0
3 | 예제를 더 보고 싶습니다. | 0
combination_count
12답글이 없는 두 댓글이 0으로 남았고, 활성 태그 3개에 상태 4개를 곱한 12개의 조합이 나왔습니다.
2. 역할을 바꿔 읽기
본문의 자기 참조 LEFT JOIN은 "답글마다 부모"를 읽었습니다. 같은 외래 키로 "부모 댓글마다 답글"을 읽으려면 별칭과 ON을 어떻게 바꾸어야 하나요? 그 결과의 행 수는 몇인가요?
해설 보기
부모를 왼쪽에 두고 LEFT JOIN ch5_comment_demo AS reply ON reply.parent_id = parent.id로 잇습니다. 연습 문제 1의 첫 쿼리에서 GROUP BY를 뺀 모양입니다. 댓글 1은 답글 두 개와 이어져 두 행이 되고, 답글이 없는 댓글 2와 3은 NULL로 채운 행으로 하나씩 남으므로 모두 네 행입니다. 같은 외래 키라도 어느 쪽을 기준으로 남기느냐에 따라 결과 한 행의 뜻과 행 수가 달라집니다.
3. 다른 게시글의 댓글에 단 답글
본문의 DDL이 "같은 게시글" 규칙을 지켜 주지 않는다는 것을 확인해 보세요. 트랜잭션 안에서 게시글 2를 만들고, 그 게시글에 댓글 1을 부모로 삼는 답글을 넣은 뒤, 부모와 게시글이 다른 답글을 찾고 ROLLBACK합니다.
해설 보기
START TRANSACTION;
INSERT INTO ch5_comment_post_demo (id, title) VALUES (2, '다른 글');
INSERT INTO ch5_comment_demo (id, post_id, author_id, parent_id, content)
VALUES (4, 2, 2, 1, '다른 글의 댓글에 단 답글');
SELECT reply.id, reply.post_id, parent.post_id AS parent_post_id
FROM ch5_comment_demo AS reply
JOIN ch5_comment_demo AS parent ON parent.id = reply.parent_id
WHERE reply.post_id <> parent.post_id;
ROLLBACK;id | post_id | parent_post_id
4 | 2 | 1두 INSERT는 모두 성공합니다. 부모 댓글 1이 있으므로 외래 키를 통과하고, 4와 1은 다르므로 CHECK도 통과하기 때문입니다. 이 규칙을 데이터베이스에서 지키려면 본문에서 설명한 복합 외래 키를 걸고, 그렇지 않다면 답글을 쓰는 트랜잭션에서 부모의 post_id를 확인해야 합니다.
다음 글 서브쿼리 연산자에서는 한 쿼리의 결과를 다른 쿼리의 조건과 값, 테이블로 쓰는 서브쿼리를 다룹니다.