조인 테이블 상속
공통 게시글과 텍스트·이미지·링크 하위 테이블을 분리하고 생성 프로시저와 일치 검사로 정확히 한 유형을 보장하며 투표 유형으로 확장합니다.
공통 post_id와 깨끗한 하위 유형 테이블을 모두 원하면 조인 테이블 상속이 후보입니다.
부모는 제목, URL, 생성 시각과 판별자를 소유하고 자식은 각 타입의 전용 속성만 저장합니다.
작성자 연결은 부모 하나를 참조합니다.
관계형 FK는 자식이 부모 없이 생기는 것을 막지만 반대 방향, 즉 부모가 반드시 자식 하나를 가져야 한다는 규칙은 자동으로 보장하지 못합니다. 또한 같은 부모 ID가 텍스트 게시글과 이미지 게시글 자식에 동시에 들어가는 것도 각각의 FK만 보면 합법입니다. 쓰기 프로시저와 완전성 감사가 필요합니다.
posts_joined는 전역 식별자와 공통 수명주기를 맡습니다.
text_post_detail, image_post_detail, link_post_detail은 공유 기본 키로 부모를 참조합니다.
자식 ID는 새로 생성하지 않고 post_id를 그대로 사용합니다.
애플리케이션 역할은 부모와 자식에 직접 INSERT하지 않고 유형별 프로시저를 호출합니다.
프로시저가 부모 ID를 만든 뒤 올바른 자식을 넣고 is_complete를 TRUE로 바꿉니다.
읽기 주체는 완료 부모만 노출합니다.
부모와 하위 행의 불완전성
부모→자식 FK는 선언할 수 없고 자식 테이블끼리 배타성도 없습니다. 가져오기 스크립트가 부모만 넣거나 텍스트 게시글과 이미지 게시글에 같은 ID를 넣으면 공통 목록에는 행이 보이지만 하위 유형 전용 속성을 읽을 수 없거나 두 개가 됩니다.
DROP TABLE IF EXISTS joined_text_bad;
DROP TABLE IF EXISTS joined_image_bad;
DROP TABLE IF EXISTS joined_post_bad;
CREATE TABLE joined_post_bad(
post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_type VARCHAR(16) NOT NULL,title VARCHAR(160) NOT NULL
) ENGINE=InnoDB;
CREATE TABLE joined_text_bad(
post_id BIGINT UNSIGNED PRIMARY KEY,body_chars INT NOT NULL,
FOREIGN KEY(post_id) REFERENCES joined_post_bad(post_id)
) ENGINE=InnoDB;
CREATE TABLE joined_image_bad(
post_id BIGINT UNSIGNED PRIMARY KEY,image_width_px INT NOT NULL,
FOREIGN KEY(post_id) REFERENCES joined_post_bad(post_id)
) ENGINE=InnoDB;
INSERT INTO joined_post_bad(post_type,title) VALUES('TEXT','child 없는 parent');
SET @bad_parent=LAST_INSERT_ID();
INSERT INTO joined_post_bad(post_type,title) VALUES('TEXT','child 두 개인 parent');
SET @double_parent=LAST_INSERT_ID();
INSERT INTO joined_text_bad VALUES(@double_parent,20);
INSERT INTO joined_image_bad VALUES(@double_parent,1200);모든 FK가 유효하지만 첫 부모는 자식 0개, 두 번째는 서로 다른 자식 2개입니다.
오류 실행 결과parent rows=2
parent with zero child=1
parent with two children=1
discriminator mismatch=1
foreign key violations=0FK는 자식이 존재하면 그 부모가 맞는지만 확인합니다. 부모 생성 트랜잭션의 종료 시점에 자식이 하나 있어야 한다는 교차-테이블 제약 선언은 MySQL DDL로 직접 표현되지 않습니다.
is_complete는 편의 플래그이지만 진실의 유일 근거가 아닙니다.
프로시저가 마지막에 TRUE로 바꾸고 읽기 주체가 필터하는 검증 단계이며, 정기 쿼리는 실제 자식 건수와 유형 일치를 재검증합니다.
공유 기본 키가 뜻하는 소유권
자식 행은 부모 없이 독립 식별자를 갖지 않습니다. ON DELETE CASCADE로 부모 수명에 종속됩니다. 반대로 작성자 연결은 부모를 RESTRICT해 사용 중 게시글을 실수로 지우지 못하게 합니다.
부모 관계와 하위 타입 완전성
제목 수정과 보관 같은 공통 작업은 부모 한 행에 수행합니다.
텍스트 게시글 body_chars 변경은 자식만 잠급니다.
공통 목록은 부모 스캔으로 빠르고, 상세 화면은 유형에 맞는 자식을 조인합니다.
CREATE 프로시저는 판별자를 매개변수로 받는 범용 JSON 파서보다 유형별 함수 시그니처가 읽기 쉽습니다.
텍스트 게시글 생성 프로시저는 body_chars를 필수 IN 매개변수로 갖고 이미지 게시글 필드를 받을 수 없습니다.
정확히 하나인 자식 일치 검사 공식
각 부모를 세 자식에 LEFT JOIN하고 자식 존재 여부를 0/1로 더합니다. 합이 1이 아니거나 판별자와 실제 자식이 다르면 위반입니다. 이 쿼리를 마이그레이션 전후와 백업 복구 테스트에 포함합니다.
MySQL 8.4 메모: 지연 적용 제약 조건이 없으므로 부모와 자식을 여러 구문으로 만들 때 트랜잭션과 최소 권한이 중요합니다. 일반 계정의 테이블 INSERT를 제거하고 프로시저 실행만 부여합니다.
유형별 생성 프로시저
스키마는 공통 부모, 세 상세, 작성자 연결을 의존성 순서로 생성합니다. 텍스트 게시글 프로시저에는 EXIT 처리기가 있어 자식 검사에서 오류가 발생하면 부모 ID도 롤백됩니다. 이미지 게시글과 링크 게시글 시드 데이터도 명시적 트랜잭션으로 같은 순서를 따릅니다.
DROP PROCEDURE IF EXISTS create_text_post;
DROP TABLE IF EXISTS member_post_joined;
DROP TABLE IF EXISTS link_post_detail;
DROP TABLE IF EXISTS image_post_detail;
DROP TABLE IF EXISTS text_post_detail;
DROP TABLE IF EXISTS posts_joined;
CREATE TABLE posts_joined(
post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_type VARCHAR(16) NOT NULL,title VARCHAR(160) NOT NULL,canonical_url VARCHAR(700) NOT NULL,
is_complete BOOLEAN NOT NULL DEFAULT FALSE,created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_joined_canonical_url(canonical_url),
CONSTRAINT chk_posts_joined_type CHECK(post_type IN('TEXT','IMAGE','LINK')),
CHECK(is_complete IN(0,1))
) ENGINE=InnoDB;
CREATE TABLE text_post_detail(
post_id BIGINT UNSIGNED PRIMARY KEY,body_chars INT UNSIGNED NOT NULL,
FOREIGN KEY(post_id) REFERENCES posts_joined(post_id) ON DELETE CASCADE,
CHECK(body_chars BETWEEN 1 AND 1000000)
) ENGINE=InnoDB;
CREATE TABLE image_post_detail(
post_id BIGINT UNSIGNED PRIMARY KEY,alt_text VARCHAR(255) NOT NULL,image_width_px INT UNSIGNED NOT NULL,
FOREIGN KEY(post_id) REFERENCES posts_joined(post_id) ON DELETE CASCADE,
CHECK(image_width_px BETWEEN 1 AND 20000)
) ENGINE=InnoDB;
CREATE TABLE link_post_detail(
post_id BIGINT UNSIGNED PRIMARY KEY,link_kind VARCHAR(16) NOT NULL,target_url VARCHAR(1000) NOT NULL,
FOREIGN KEY(post_id) REFERENCES posts_joined(post_id) ON DELETE CASCADE,
CHECK(link_kind IN('EXTERNAL','INTERNAL','DOWNLOAD'))
) ENGINE=InnoDB;
CREATE TABLE member_post_joined(
member_id BIGINT UNSIGNED NOT NULL,post_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY(member_id,post_id),
FOREIGN KEY(member_id) REFERENCES members(id) ON DELETE CASCADE,
FOREIGN KEY(post_id) REFERENCES posts_joined(post_id) ON DELETE RESTRICT
) ENGINE=InnoDB;
DELIMITER //
CREATE PROCEDURE create_text_post(
IN p_title VARCHAR(160),IN p_url VARCHAR(700),IN p_body_chars INT UNSIGNED,
OUT p_post_id BIGINT UNSIGNED)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
INSERT INTO posts_joined(post_type,title,canonical_url)
VALUES('TEXT',p_title,p_url);
SET p_post_id=LAST_INSERT_ID();
INSERT INTO text_post_detail VALUES(p_post_id,p_body_chars);
UPDATE posts_joined SET is_complete=TRUE WHERE post_id=p_post_id;
COMMIT;
END//
DELIMITER ;
SET @member_id = (SELECT id FROM members ORDER BY id LIMIT 1);
CALL create_text_post('joined 상속 읽기','https://example.com/joined',35,@joined_text);
INSERT INTO member_post_joined VALUES(@member_id,@joined_text);
START TRANSACTION;
INSERT INTO posts_joined(post_type,title,canonical_url)
VALUES('IMAGE','잠금 구조 이미지','https://example.com/lock-image');
SET @joined_image=LAST_INSERT_ID();
INSERT INTO image_post_detail VALUES(@joined_image,'잠금 범위 구조',2400);
UPDATE posts_joined SET is_complete=TRUE WHERE post_id=@joined_image;
COMMIT;완료 부모마다 판별자와 일치하는 자식이 정확히 하나 있습니다.
개선 실행 결과complete parents=2
text child=1
image child=1
zero/multiple child violations=0
type mismatch violations=0텍스트 게시글 자식 검사에서 오류가 발생하면 EXIT 처리기가 부모 INSERT도 취소합니다.
LAST_INSERT_ID 번호에 누락 구간이 생길 수 있지만 무결성 문제는 아닙니다.
ID 연속성을 업무 규칙으로 사용하지 않습니다.
is_complete=FALSE 행이 장시간 남으면 중단된 가져오기 또는 권한 우회입니다.
경과 시간 지표를 두고 안전하게 재처리하거나 삭제합니다.
완료를 수동 TRUE로 바꿀 권한은 일반 쓰기 주체에게 주지 않습니다.
상세 쿼리에서 모든 자식을 조인하지 않기
post_type을 먼저 읽고 맞는 상세 테이블만 조회하거나 유형별 뷰를 사용합니다.
목록마다 세 LEFT JOIN을 수행하면 행 폭과 옵티마이저 비용이 늘어 조인 모델의 장점이 줄어듭니다.
하위 행과 판별자 검증
일치 검사는 위반 부모의 실제 자식 건수를 보여 줍니다. 두 번째 쿼리는 유형과 자식 존재를 비교합니다. 미완료 경과 시간과 작성자 연결이 미완료 부모를 향하는지도 별도로 찾습니다.
SELECT r.post_id,r.post_type,
(a.post_id IS NOT NULL)+(v.post_id IS NOT NULL)+(l.post_id IS NOT NULL) child_count
FROM posts_joined r
LEFT JOIN text_post_detail a ON a.post_id=r.post_id
LEFT JOIN image_post_detail v ON v.post_id=r.post_id
LEFT JOIN link_post_detail l ON l.post_id=r.post_id
WHERE (a.post_id IS NOT NULL)+(v.post_id IS NOT NULL)+(l.post_id IS NOT NULL)<>1;
SELECT r.post_id,r.post_type FROM posts_joined r
LEFT JOIN text_post_detail a ON a.post_id=r.post_id
LEFT JOIN image_post_detail v ON v.post_id=r.post_id
LEFT JOIN link_post_detail l ON l.post_id=r.post_id
WHERE (r.post_type='TEXT')<>(a.post_id IS NOT NULL)
OR (r.post_type='IMAGE')<>(v.post_id IS NOT NULL)
OR (r.post_type='LINK')<>(l.post_id IS NOT NULL);
SELECT post_id FROM posts_joined
WHERE is_complete=FALSE AND created_at<CURRENT_TIMESTAMP-INTERVAL 10 MINUTE;
SELECT x.member_id,x.post_id FROM member_post_joined x
JOIN posts_joined r ON r.post_id=x.post_id WHERE r.is_complete=FALSE;정상 시드 데이터에서 네 쿼리는 모두 0행입니다. 부모만 직접 넣은 예제 데이터는 첫 번째와 미완료 경과 시간 쿼리에 나타납니다. 텍스트 게시글 부모에 이미지 게시글 자식을 넣으면 건수는 1이어도 판별자 쿼리가 잡습니다.
배포 검증 단계는 이 보고서 0행 뒤에만 읽기 주체가 is_complete 필터를 제거하도록 허용합니다.
실무에서는 필터를 유지해 부분 가져오기를 방어하는 편이 안전합니다.
공통 부모의 경합 지점
모든 유형 생성이 부모 AUTO_INCREMENT와 공통 유일 제약 URL 인덱스를 갱신합니다.
쓰기 처리량이 매우 높다면 순서 경합과 인덱스 페이지를 측정합니다.
일반적인 회원 게시판에서는 무결성과 참조 단순성이 더 큰 이득입니다.
하위 유형 간 이동
잘못 분류한 텍스트 게시글을 이미지 게시글로 바꿀 때 판별자 UPDATE와 자식 교체를 한 트랜잭션에 두고 외부 캐시를 무효화합니다. 식별자 유지가 필수가 아니라면 새 게시글을 만들고 연결을 옮기는 편이 감사하기 쉽습니다.
스키마 마이그레이션 순서
자식 열 추가는 해당 유형만 영향을 받습니다. 공통 제목 변경은 부모 하나만 바뀝니다. 이 분리가 단일 테이블보다 릴리스 소유권을 나누기 좋지만 상세 조회에는 조인이 추가됩니다.
조인 테이블 상속 선택 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 식별자 | 모든 하위 유형이 하나의 전역 post_id로 참조되어야 하는가? |
| 공통 | 제목·URL·보관처럼 공유 수명주기가 충분한가? |
| 전용 속성 | 전용 열이 많아 단일 테이블 희소 행이 부담인가? |
| 쓰기 | 부모와 자식을 생성자 트랜잭션으로 강제할 수 있는가? |
| 감사 | 정확히 하나인 자식 일치 검사를 운영할 준비가 있는가? |
공통 관계와 전용 속성이 모두 크다면 조인 테이블 상속이 균형을 줍니다. 생성자와 감사 없이 직접 DML을 허용하면 0/다중 자식이 생기므로 조직의 권한 운영도 선택 조건입니다.
연습 문제
POLL 하위 유형과 생성자 프로시저를 추가하세요.
option_count는 2 이상 20 이하이고 부모 판별자는 POLL이어야 합니다.
생성 중 검사가 실패하면 부모 행도 남지 않아야 합니다.
기존 정확히 하나인 자식 감사에는 투표 상세 테이블을 포함해야 합니다.
해설과 예시 답안
부모 판별자 검사에 POLL을 추가하고 공유 PK를 사용하는 poll_post_detail을 만듭니다.
생성자는 부모, 투표 상세, 완료 플래그를 한 트랜잭션으로 커밋합니다.
ALTER TABLE posts_joined
DROP CHECK chk_posts_joined_type,
ADD CONSTRAINT chk_posts_joined_type
CHECK(post_type IN('TEXT','IMAGE','LINK','POLL'));
CREATE TABLE poll_post_detail(
post_id BIGINT UNSIGNED PRIMARY KEY,
option_count SMALLINT UNSIGNED NOT NULL,
FOREIGN KEY(post_id) REFERENCES posts_joined(post_id) ON DELETE CASCADE,
CHECK(option_count BETWEEN 2 AND 20)
) ENGINE=InnoDB;
DROP PROCEDURE IF EXISTS create_poll_post;
DELIMITER //
CREATE PROCEDURE create_poll_post(
IN p_title VARCHAR(160),IN p_url VARCHAR(700),
IN p_option_count SMALLINT UNSIGNED,OUT p_post_id BIGINT UNSIGNED)
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
INSERT INTO posts_joined(post_type,title,canonical_url)
VALUES('POLL',p_title,p_url);
SET p_post_id=LAST_INSERT_ID();
INSERT INTO poll_post_detail VALUES(p_post_id,p_option_count);
UPDATE posts_joined SET is_complete=TRUE WHERE post_id=p_post_id;
COMMIT;
END//
DELIMITER ;
CALL create_poll_post(
'다음 글의 난이도는?','https://example.com/poll/difficulty',4,@joined_poll);정상 호출은 POLL 부모와 투표 상세 한 건을 남깁니다.
선택지 수가 1인 호출은 검사 오류로 롤백되어 같은 URL의 부모 행도 남지 않습니다.
감사 쿼리의 자식 수 합과 판별자 비교에 poll_post_detail 분기를 추가하면 네 유형을 함께 검증할 수 있습니다.
핵심 정리
- 조인 테이블 상속은 공통 식별자를 부모에, 하위 유형 전용 속성을 공유-PK 자식에 둡니다.
- FK는 자식→부모만 보장하므로 생성자 트랜잭션이 부모+자식을 함께 발행합니다.
- 정확히 하나인 자식과 판별자 일치는 명시적 감사 쿼리로 검증하고 새 유형을 추가할 때 감사 분기도 함께 확장합니다.
- 공통 변경 집중과 상세 조인 비용을 워크로드에 맞춰 평가합니다.
다음 문서에서는 스키마가 자주 바뀌는 선택적 메타데이터만 타입 명시 EAV로 제한하는 방법을 다룹니다.