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

안동민 개발노트

본문 시작
선택 심화 · 17장 : 상속 모델과 동적 속성

구체 테이블 상속

텍스트 게시글·이미지 게시글·링크 게시글을 독립 테이블로 저장할 때 다형 ID를 피하고 유형별 연결과 UNION 뷰로 공통 읽기를 구성합니다.

이 장은 서로 다른 하위 유형을 관계형 테이블에 매핑하거나 운영 중 선택 속성을 늘려야 할 때 읽는 선택 심화입니다. 관계형 DB에 객체 지향의 상속 문법이 따로 있는 것은 아닙니다. 공통·전용 속성을 어느 테이블에 둘지에 따라 구체 테이블, 단일 테이블, 조인 테이블이라는 세 매핑 전략을 비교합니다. 10~12장의 키·관계·정규화를 먼저 이해해야 합니다.

게시글이 텍스트, 이미지, 링크 유형으로 나뉘면 세 테이블을 각각 만드는 선택이 자연스럽습니다. 각 타입은 자신에게 필요한 열만 갖고 다른 타입의 NULL을 보지 않습니다. 하지만 회원이 게시글을 붙이는 순간 post_typepost_id 한 쌍을 쓰고 싶은 유혹이 생깁니다.

관계형 FK는 한 열 값에 따라 참조 테이블을 바꾸지 못합니다. 다형 ID는 404가 되는 게시글을 저장해도 DB가 거절하지 않습니다. 구체 테이블 방식 전략을 유지하려면 참조도 구체 유형별로 나누거나 교차 유형 관계 자체를 두지 않아야 합니다.

회원 게시판 게시글은 유형에 따라 본문 길이, 대체 텍스트와 이미지 너비, 링크 종류처럼 서로 다른 속성을 가집니다. 공통 목록은 UNION ALL 뷰로 제공하고 작성자 연결은 member_text_post, member_image_post, member_link_post 세 테이블로 분리합니다.

이 모델은 유형 수가 작고 서로 다른 수명주기를 가지며 공통 쓰기 규칙이 적을 때 적합합니다. 새로운 게시글 유형을 추가하면 테이블, 연결, 뷰 분기, API 처리기를 함께 배포해야 합니다.


다형 참조의 고아 행

범용 연결의 복합 PK는 같은 유형/ID 중복만 막습니다. 텍스트 게시글 9999가 실제로 존재하는지, 이미지 게시글 행을 텍스트 게시글로 잘못 표시했는지 확인하지 못합니다. 정리 쿼리도 유형마다 분기해야 합니다.

FK를 걸 수 없는 polymorphic member-post reference
DROP TABLE IF EXISTS member_post_reference_bad;
CREATE TABLE member_post_reference_bad(
 member_id BIGINT UNSIGNED NOT NULL,
 post_type VARCHAR(16) NOT NULL,
 post_id BIGINT UNSIGNED NOT NULL,
 PRIMARY KEY(member_id,post_type,post_id),
 CHECK(post_type IN('TEXT','IMAGE','LINK')),
 CONSTRAINT fk_bad_member FOREIGN KEY(member_id)
  REFERENCES members(id) ON DELETE CASCADE
) ENGINE=InnoDB;
SET @member_id = (SELECT id FROM members ORDER BY id LIMIT 1);
INSERT INTO member_post_reference_bad VALUES
 (@member_id,'TEXT',999999),
 (@member_id,'IMAGE',888888);
SELECT * FROM member_post_reference_bad;

유형 검사와 회원 FK는 통과하지만 두 게시글 ID 모두 고아입니다.

오류 실행 결과
inserted links=2
valid member reference=yes
valid text references=0
valid image references=0
database errors=0

post_id의 의미가 post_type 값에 따라 바뀌므로 하나의 FK 대상을 선언할 수 없습니다. 트리거로 분기하면 새 유형마다 트리거와 잠금 동작을 수정해야 하고 연쇄도 수동 구현이 됩니다.

구체 전략에서는 반복을 인정하는 대신 각 테이블의 무결성을 독립적으로 완성합니다. 텍스트 게시글 연결은 텍스트 게시글 PK만, 이미지 게시글 연결은 이미지 게시글 PK만 참조합니다. 공통 목록은 쓰기 원본이 아니라 UNION 뷰입니다.

같은 이름의 열도 의미가 같은지 확인하기

제목과 created_at을 세 테이블에 반복할 때 길이, 정렬 규칙, 시간대를 맞춰야 UNION 결과가 안정적입니다. 이름만 같고 수명이 다르면 억지로 공통 투영에 넣지 않습니다.


구체 테이블의 외래 키

구체 테이블 방식은 각 하위 유형 행이 완전한 엔티티입니다. 별도 부모 ID가 없으므로 text_post_id=1과 image_post_id=1은 전혀 다른 객체입니다. API 식별자에는 유형을 함께 포함하고 유형별 엔드포인트 또는 유형 태그가 있는 ID를 사용합니다.

세 연결 테이블은 열 수를 늘리지만 DB가 ON DELETE와 존재 검사를 수행합니다. 회원 상세 쿼리는 세 분기를 UNION ALL하고 post_type 리터럴을 붙여 하나의 목록으로 반환합니다.

UNION ALL을 쓰는 이유

서로 다른 구체 PK 공간이므로 중복 제거가 필요 없습니다. UNION은 정렬 또는 해시 비용을 추가합니다. 전역적으로 같은 게시글이라는 업무 규칙이 생긴다면 이 전략의 전제가 깨진 것이며 ch17-3 부모 모델을 검토합니다.

MySQL 8.4 메모: 뷰 열 유형은 UNION 분기의 공통 강제 변환 결과입니다. URL과 제목 길이를 동일하게 맞추고 숫자 속성은 공통 display_hint로 명시 변환합니다.


유형별 테이블과 통합 VIEW

DDL은 유형별 특화 검사와 회원 연쇄, 게시글 참조 제한 정책을 정의합니다. 게시글이 사용 중이면 삭제를 거절하고 보관 플래그로 숨기는 정책을 선택합니다.

text·image·link concrete schema와 공통 projection
DROP VIEW IF EXISTS concrete_post_feed_v;
DROP TABLE IF EXISTS member_link_post;
DROP TABLE IF EXISTS member_image_post;
DROP TABLE IF EXISTS member_text_post;
DROP TABLE IF EXISTS link_posts;
DROP TABLE IF EXISTS image_posts;
DROP TABLE IF EXISTS text_posts;

CREATE TABLE text_posts(
 text_post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,title VARCHAR(160) NOT NULL,
 canonical_url VARCHAR(700) NOT NULL,body_chars INT UNSIGNED NOT NULL,
 created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
 UNIQUE KEY uq_text_url(canonical_url),CHECK(body_chars BETWEEN 1 AND 1000000)
) ENGINE=InnoDB;
CREATE TABLE image_posts(
 image_post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,title VARCHAR(160) NOT NULL,
 canonical_url VARCHAR(700) NOT NULL,alt_text VARCHAR(255) NOT NULL,image_width_px INT UNSIGNED NOT NULL,
 created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
 UNIQUE KEY uq_image_url(canonical_url),CHECK(image_width_px BETWEEN 1 AND 20000)
) ENGINE=InnoDB;
CREATE TABLE link_posts(
 link_post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,title VARCHAR(160) NOT NULL,
 target_url VARCHAR(700) NOT NULL,link_kind VARCHAR(16) NOT NULL,
 created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
 UNIQUE KEY uq_link_target(target_url),CHECK(link_kind IN('EXTERNAL','INTERNAL','DOWNLOAD'))
) ENGINE=InnoDB;

CREATE TABLE member_text_post(
 member_id BIGINT UNSIGNED NOT NULL,text_post_id BIGINT UNSIGNED NOT NULL,
 PRIMARY KEY(member_id,text_post_id),
 FOREIGN KEY(member_id) REFERENCES members(id) ON DELETE CASCADE,
 FOREIGN KEY(text_post_id) REFERENCES text_posts(text_post_id) ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE TABLE member_image_post(
 member_id BIGINT UNSIGNED NOT NULL,image_post_id BIGINT UNSIGNED NOT NULL,
 PRIMARY KEY(member_id,image_post_id),
 FOREIGN KEY(member_id) REFERENCES members(id) ON DELETE CASCADE,
 FOREIGN KEY(image_post_id) REFERENCES image_posts(image_post_id) ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE TABLE member_link_post(
 member_id BIGINT UNSIGNED NOT NULL,link_post_id BIGINT UNSIGNED NOT NULL,
 PRIMARY KEY(member_id,link_post_id),
 FOREIGN KEY(member_id) REFERENCES members(id) ON DELETE CASCADE,
 FOREIGN KEY(link_post_id) REFERENCES link_posts(link_post_id) ON DELETE RESTRICT
) ENGINE=InnoDB;

CREATE VIEW concrete_post_feed_v AS
SELECT 'TEXT' post_type,text_post_id post_id,title,canonical_url target_url,
 body_chars display_hint,created_at FROM text_posts
UNION ALL
SELECT 'IMAGE',image_post_id,title,canonical_url,
 image_width_px,created_at FROM image_posts
UNION ALL
SELECT 'LINK',link_post_id,title,target_url,NULL,created_at FROM link_posts;

INSERT INTO text_posts(title,canonical_url,body_chars)
VALUES('InnoDB 인덱스 읽기','https://example.com/text/innodb',25);
SET @text_post_id=LAST_INSERT_ID();
INSERT INTO member_text_post VALUES(@member_id,@text_post_id);

텍스트 게시글 연결은 실제 텍스트 게시글 PK를 요구하고 공통 뷰는 유형 태그를 붙여 반환합니다.

개선 실행 결과
text posts=1
member text links=1
common view rows=1
orphan concrete links=0
invalid text id insert=ERROR 1452

게시글 삭제는 사용 중 연결이 있으면 RESTRICT됩니다. 회원 삭제는 연결 자체가 소유 관계이므로 CASCADE합니다. 두 방향의 수명을 같은 연쇄로 처리하지 않습니다.

공통 뷰의 post_id는 유형 없이 전역 ID가 아닙니다. 소비자는 (post_type, post_id)를 키로 사용합니다. 캐시 키를 ID 숫자만으로 만들면 텍스트 게시글 1과 이미지 게시글 1이 충돌합니다.

새 POLL 타입을 추가할 때 보이는 비용

투표 게시글 테이블과 member_poll_post FK, 뷰 분기를 추가해야 합니다. 이 명시적 변경이 너무 잦다면 구체 전략이 업무 변화 속도에 맞지 않는다는 신호입니다.


참조·유형·뷰 키 검증

각 연결의 고아 쿼리는 원칙상 FK 때문에 0행입니다. 잘못된 숫자 INSERT가 1452인지 확인하고 뷰에서 종류+ID 조합이 유일한지 검사합니다.

concrete 무결성과 UNION projection 감사
INSERT INTO member_text_post VALUES(@member_id,999999);
-- ERROR 1452
SELECT l.member_id,l.text_post_id FROM member_text_post l
LEFT JOIN text_posts r ON r.text_post_id=l.text_post_id WHERE r.text_post_id IS NULL;
SELECT l.member_id,l.image_post_id FROM member_image_post l
LEFT JOIN image_posts r ON r.image_post_id=l.image_post_id WHERE r.image_post_id IS NULL;
SELECT l.member_id,l.link_post_id FROM member_link_post l
LEFT JOIN link_posts r ON r.link_post_id=l.link_post_id WHERE r.link_post_id IS NULL;
SELECT post_type,post_id FROM concrete_post_feed_v
GROUP BY post_type,post_id HAVING COUNT(*)>1;

SELECT 'TEXT' kind,a.text_post_id id,a.title FROM member_text_post l
JOIN text_posts a ON a.text_post_id=l.text_post_id WHERE l.member_id=@member_id
UNION ALL
SELECT 'IMAGE',v.image_post_id,v.title FROM member_image_post l
JOIN image_posts v ON v.image_post_id=l.image_post_id WHERE l.member_id=@member_id
UNION ALL
SELECT 'LINK',b.link_post_id,b.title FROM member_link_post l
JOIN link_posts b ON b.link_post_id=l.link_post_id WHERE l.member_id=@member_id;

잘못된 텍스트 게시글 ID는 FK에서 거절됩니다. 세 고아 쿼리와 뷰 복합-키 중복은 0행입니다. 마지막 회원 게시글 쿼리는 연결된 텍스트 게시글 한 건을 반환합니다.

분기별 EXPLAIN으로 회원 선두 PK가 사용되는지 확인합니다. 유형 수가 늘면 쿼리 텍스트도 길어지므로 뷰나 저장소 계층에서 한 번만 관리합니다.

구체 테이블이 가장 읽기 쉬운 경우

타입별 열과 작업 흐름이 거의 겹치지 않고 교차 유형 관계가 드물다면 각 스키마가 작고 명확합니다. 텍스트 편집기와 링크 검증기가 서로 다른 팀에 속할 때 마이그레이션 소유권도 나누기 쉽습니다.

공통 속성 수정의 반복 비용

제목 길이를 160에서 240으로 바꾸려면 세 테이블 마이그레이션과 뷰 호환성을 함께 관리합니다. 배포가 자주 일어나는 공통 열이 많아지면 부모 테이블 또는 단일 테이블이 적합합니다.

검색 인덱스로 보내는 방법

UNION 뷰에서 종류, ID, 제목, URL을 추출해 검색 문서 ID를 종류:ID로 만듭니다. 구체 PK만 사용하면 다른 유형의 문서를 덮어씁니다.


구체 테이블 상속 선택 기준

판단 축확인할 질문
공통성타입 사이 공유 열과 변경 규칙이 적은가?
참조교차 유형 FK를 거의 만들지 않는가?
변화새 유형 추가가 드물고 명시적 마이그레이션을 감당하는가?
조회UNION 목록이 핵심 OLTP 병목이 아닌가?

독립 수명주기와 드문 통합 조회라면 구체 테이블이 가장 솔직합니다. 공통 관계가 늘어 다형 연결을 만들고 싶어지는 순간 부모 식별자를 도입할지 다시 판단합니다.


연습 문제

투표 게시글을 추가하세요. question_text UNIQUE와 option_count 검사를 정의하고 member_poll_post FK, 공통 뷰의 POLL 분기까지 완성해야 합니다.

해설과 예시 답안

기존 유형을 수정하지 않고 새 구체 테이블과 연결을 만듭니다. CREATE OR REPLACE 뷰로 네 분기를 같은 열 순서와 호환 타입에 맞춥니다.

CREATE TABLE poll_posts(
 poll_post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,title VARCHAR(160) NOT NULL,
 question_text VARCHAR(1000) NOT NULL,option_count SMALLINT UNSIGNED NOT NULL,
 created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
 UNIQUE KEY uq_poll_question(question_text),CHECK(option_count BETWEEN 2 AND 20)
) ENGINE=InnoDB;
CREATE TABLE member_poll_post(
 member_id BIGINT UNSIGNED NOT NULL,poll_post_id BIGINT UNSIGNED NOT NULL,
 PRIMARY KEY(member_id,poll_post_id),
 FOREIGN KEY(member_id) REFERENCES members(id) ON DELETE CASCADE,
 FOREIGN KEY(poll_post_id) REFERENCES poll_posts(poll_post_id) ON DELETE RESTRICT
) ENGINE=InnoDB;
CREATE OR REPLACE VIEW concrete_post_feed_v AS
SELECT 'TEXT' post_type,text_post_id post_id,title,canonical_url target_url,body_chars display_hint,created_at FROM text_posts
UNION ALL SELECT 'IMAGE',image_post_id,title,canonical_url,image_width_px,created_at FROM image_posts
UNION ALL SELECT 'LINK',link_post_id,title,target_url,NULL,created_at FROM link_posts
UNION ALL SELECT 'POLL',poll_post_id,title,question_text,option_count,created_at FROM poll_posts;

POLL 행을 넣고 작성자 연결을 만든 뒤 뷰에 종류와 ID가 나타납니다. 없는 poll_post_id는 FK 1452로 거절되고 복합 뷰 키 중복은 0행입니다.


핵심 정리

  • 구체 하위 유형 행은 부모 없이 완전한 엔티티로 존재합니다.
  • 범용 유형/ID 연결 대신 유형별 FK 테이블을 두어 참조 무결성을 보존합니다.
  • 공통 읽기는 UNION ALL 뷰이며 종류와 ID를 함께 식별자로 사용합니다.
  • 공통 속성·관계·새 유형 변화가 많아지면 다른 상속 전략을 검토합니다.

다음 문서에서는 하나의 게시글 테이블에 유형별 열을 모으고 검사로 허용 조합을 제한합니다.