SELF JOIN과 CROSS JOIN
같은 댓글 테이블의 부모·답글 관계를 역할 별칭으로 읽고 CROSS JOIN의 조합 수를 안전하게 제한합니다.
댓글에 답글을 달 수 있다면 댓글 테이블은 자기 자신을 참조합니다.
게시 상태와 정렬 방식의 모든 조합을 만들 때는 서로 관계 조건이 없는 두 작은 집합의 데카르트 곱이 필요합니다.
자기 참조와 CROSS는 특별한 저장 방식이 아니라 JOIN을 사용하는 두 목적입니다.
회원 게시판의 댓글 구조를 ch5_comment_demo에 재현하고 같은 테이블을 답글과 부모 댓글 역할로 두 번 읽습니다.
이어서 작은 상태·정렬 집합을 CROSS JOIN해 필터 후보를 만들고, 입력 크기 곱을 먼저 계산하는 안전 기준을 세웁니다.
SELF JOIN의 최상위 행 누락
부모가 없는 최상위 댓글도 목록에 보여야 하지만 댓글을 자기 자신과 INNER JOIN합니다.
parent_id가 NULL인 행은 짝이 없어 결과에서 사라집니다.
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;부모가 지정된 답글 두 개만 나오고 최상위 댓글은 목록에서 누락됩니다.
오류 실행 결과reply | parent
첫 답글 | 첫 댓글
두 번째 답글 | 첫 댓글
-- 첫 댓글(parent_id NULL) 누락자기 참조 JOIN이라는 새 문법이 있는 것이 아니라 댓글을 서로 다른 역할 별칭으로 두 번 사용한 것입니다.
모든 댓글을 보존하려면 왼쪽에 댓글을 두고 부모를 LEFT JOIN합니다.
NULL은 ‘부모 없음’이라는 최상위 관계를 표현합니다.
별칭과 조합 수의 의미
자기 참조 JOIN에서는 같은 컬럼 집합이 두 번 나타나므로 역할 이름이 필수입니다.
계층의 한 단계만 읽을 때 적합하며 여러 깊이를 순회하는 방법은 14장에서 재귀 CTE로 다룹니다.
CROSS JOIN은 ON 없이 왼쪽 M행과 오른쪽 N행의 모든 M×N 조합을 만듭니다.
조합 생성이 목적일 때만 명시적으로 사용하고 실행 전 입력 크기와 필터 시점을 계산합니다.
자기 참조 JOIN은 일반 JOIN의 사용 패턴이고 CROSS JOIN은 표준 SQL입니다.
MySQL에서 JOIN 조건을 빠뜨려 우연히 데카르트 곱이 되는 것과 의도적으로 CROSS JOIN을 쓰는 것을 코드 리뷰에서 구분합니다.
자기 참조 FK도 InnoDB가 같은 테이블의 PK를 참조하도록 선언할 수 있습니다.
댓글 관계와 작은 집합 조합
부모 FK를 NULL 허용으로 두어 최상위 댓글과 답글을 함께 저장합니다.
조회는 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 AUTO_INCREMENT,
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;최상위 댓글도 남고 parent_content만 NULL로 표시됩니다.
id | content | parent_content
1 | 좋은 글입니다. | NULL
2 | 저도 도움이 됐습니다. | 좋은 글입니다.
3 | 예제를 더 보고 싶습니다. | 좋은 글입니다.자기 자신을 부모로 지정하면 FK만으로는 막지 못합니다.
parent_id <> id 검사로 직접 순환은 막을 수 있지만 A→B→A 같은 다단계 순환은 단일 행 제약으로 어렵습니다.
애플리케이션 또는 계층 모델에서 별도 검증합니다.
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;결과는 정확히 4×3=12행입니다.
입력이 10만×10만이면 100억 후보가 되므로 CROSS JOIN 뒤 LIMIT로 막겠다는 생각은 안전하지 않습니다.
입력을 먼저 제한합니다.
자기 참조 JOIN으로 답글의 부모를 고정 깊이로 붙일 수 있지만 깊이가 바뀔 때 JOIN 수를 수정해야 합니다.
계층 탐색이 핵심 요구라면 인접 목록과 재귀 CTE를 사용합니다.
반면 현재 부모 댓글 한 개를 표시하는 화면은 단일 자기 참조 JOIN이 가장 단순합니다.
문제 깊이에 맞는 도구를 선택합니다.
역할 별칭을 바꿔 보며 문장도 바뀌는지 확인합니다.
답글에서 부모를 찾는 조회와 부모에서 모든 답글을 찾는 조회는 같은 FK를 반대 방향으로 읽습니다.
CROSS JOIN에서는 먼저 SELECT COUNT(*)로 각 입력 크기를 확인하고 곱을 계산합니다.
조합을 테이블에 INSERT할 때 UNIQUE 제약과 재실행 중복 정책도 필요합니다.
단순 미리보기와 영구 생성 작업을 같은 SQL로 다루지 않습니다.
필터 후보 생성, 가격표×지역, 날짜×시간 슬롯처럼 데카르트 곱이 합법적인 업무도 많습니다.
그러나 권한 범위가 적용되기 전에 사용자×리소스를 모두 만들면 정보 노출과 부하가 커집니다.
각 입력을 CTE나 파생 테이블에서 먼저 필터링하고 예상 곱에 상한을 둡니다.
배치 작업은 분할 단위와 재시작 지점을 설계하며, 결과 행 수 경고를 로그로 남깁니다.
자기 참조 JOIN에서 내용이 같은 두 댓글은 ID로 구별해야 합니다.
부모 댓글을 삭제했을 때 FK 동작을 RESTRICT, SET NULL 중 무엇으로 할지 수명 규칙을 정합니다.
CROSS JOIN 결과를 ORDER BY하지 않으면 조합 순서는 정의되지 않습니다.
CTE의 UNION ALL 입력에도 중복 요일이 있으면 조합이 배로 늘 수 있으므로 차원 집합의 유일성을 확인합니다.
댓글 자기 참조는 14장 계층 설계의 가장 작은 사례가 됩니다.
지금은 한 단계 관계와 선택 참여를 익히고, 나중에는 깊이·경로·순환·읽기 비용을 비교합니다.
CROSS JOIN의 크기 계산은 7장 실행 계획에서도 중요합니다.
옵티마이저가 잘 실행해 줄 것이라는 기대보다 논리 결과 크기를 먼저 제한하는 것이 안전합니다.
공용 회원 게시판 모델에는 댓글의 부모 FK만 반영하고 상태·정렬 CTE는 실습용으로 영구 스키마에 추가하지 않습니다.
SELF·CROSS JOIN 선택 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 역할 | 같은 테이블의 두 인스턴스 이름이 업무 역할을 드러내는가? |
| 보존 | 참조가 NULL인 최상위 행도 남아야 하는가? |
| 깊이 | 한 단계 자기 참조 JOIN이면 충분한가, 재귀가 필요한가? |
| 곱 | CROSS 입력 M과 N, 결과 M×N을 계산했는가? |
| 영속 | 후보 조회인지 실제 INSERT인지 중복 정책이 있는가? |
자기 참조는 자기 참조 관계, CROSS는 모든 조합이라는 목적을 SQL 이름에서 드러냅니다.
단지 ON을 쓰기 귀찮아서 CROSS를 사용하거나 계층 전체를 고정 자기 참조 JOIN으로 늘리지 않습니다.
연습 문제
댓글별 답글 수를 모든 댓글에 대해 보여 주세요.
답글이 없는 댓글도 0이어야 합니다.
이어서 활성 태그와 DRAFT, PUBLISHED, HIDDEN, DELETED 네 상태의 모든 필터 조합 수를 구하세요.
해설과 예시 답안
부모 후보를 보존 왼쪽으로 두고 답글을 자기 FK로 LEFT JOIN합니다.
조합은 활성 태그와 4행 상태 CTE를 CROSS JOIN합니다.
두 번째 쿼리의 ch5_tag_demo는 ch5-1의 공용 fixture를 사용합니다.
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;답글 없는 댓글이 0으로 남는지, combination_count가 활성 태그 수×4인지 확인합니다.
핵심 정리
- 자기 참조 JOIN은 같은 테이블을 역할 별칭으로 두 번 읽는 일반 JOIN입니다.
- 선택 자기 참조는 LEFT JOIN으로 참조 없는 행을 보존합니다.
- CROSS JOIN은 모든 M×N 조합이 목적일 때만 명시적으로 사용합니다.
- 다단계 순환과 큰 데카르트 곱은 기본 FK·LIMIT만으로 해결되지 않습니다.
다음 장에서는 한 쿼리의 결과를 다른 쿼리의 조건·값·테이블로 사용하는 서브쿼리로 이동합니다.