본문 바로가기

구체 테이블 상속

텍스트와 이미지, 링크 게시글을 서로 독립된 테이블로 저장할 때, 외래 키를 걸 수 없는 다형 ID 대신 유형별 연결 테이블을 두고 UNION ALL 뷰로 공통 목록을 읽습니다. 새 유형을 더할 때 드는 비용도 확인합니다.

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

게시글이 텍스트와 이미지, 링크의 유형으로 나뉘면, 테이블을 유형마다 따로 만드는 구체 테이블 상속(concrete table inheritance)이 자연스럽습니다. 각 유형은 자신에게 필요한 열만 갖고 다른 유형의 NULL을 보지 않습니다. 하지만 회원에게 게시글을 연결하는 순간 post_type과 post_id의 한 쌍을 쓰고 싶어지는데, 관계형의 외래 키는 한 열의 값에 따라 참조할 테이블을 바꾸지 못합니다.

이런 범용 연결에는 게시글의 외래 키가 없으므로, 없는 대상의 ID도 회원의 외래 키 같은 다른 제약만 만족하면 저장됩니다. 구체 테이블 방식을 유지하려면 참조도 유형별로 나누거나, 유형을 넘나드는 관계를 두지 않아야 합니다.

회원 게시판의 게시글은 유형에 따라 본문의 길이, 대체 텍스트와 이미지의 너비, 링크의 종류처럼 서로 다른 속성을 가집니다. 공통 목록은 UNION ALL 뷰로 제공하고, 작성자 연결은 member_text_post, member_image_post, member_link_post의 세 테이블로 나눕니다. 이 모델은 유형의 수가 적고 수명이 서로 다르며 공통 쓰기 규칙이 적을 때 알맞고, 새 유형을 더하려면 테이블과 연결, 뷰의 분기, API 처리기를 함께 배포해야 합니다.

다형 참조의 고아 행

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

FK를 걸 수 없는 다형 회원·게시글 참조
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 ORDER BY post_type;
member_id | post_type | post_id
1         | IMAGE     | 888888
1         | TEXT      | 999999

두 행이 오류 없이 들어갔습니다. 회원 1은 실제로 있으므로 회원의 외래 키는 통과했지만, 텍스트 게시글 999999와 이미지 게시글 888888은 어디에도 없습니다. 유형의 CHECK와 회원의 외래 키는 대상 게시글의 존재까지 확인하지 않습니다.

post_id의 뜻이 post_type의 값에 따라 바뀌므로, 외래 키의 대상을 하나로 선언할 수 없습니다. 트리거로 분기하면 새 유형마다 트리거와 잠금의 동작을 고쳐야 하고, 연쇄 삭제도 손으로 구현해야 합니다. 구체 전략에서는 반복을 받아들이는 대신, 텍스트 게시글의 연결은 텍스트 게시글의 기본 키만, 이미지 게시글의 연결은 이미지 게시글의 기본 키만 참조하게 해 테이블마다 무결성을 따로 완성합니다. 공통 목록은 쓰기의 원본이 아니라 UNION 뷰입니다.

제목과 created_at처럼 같은 이름의 열을 세 테이블에 반복할 때는, 길이와 정렬 규칙, 시간을 저장하는 규칙을 맞춥니다. DATETIME은 세션의 시간대에 따라 값을 UTC로 바꾸어 주지 않으며, 이름만 같고 수명이 다른 열은 억지로 공통 목록에 넣지 않습니다.

CONCRETE TABLES · TYPED FK / COMPOSITE IDENTITY

공통 목록에서는 유형과 ID가 함께 게시글을 식별한다

각 유형은 독립된 PK를 가지며 연결 테이블의 FK는 실제 유형 테이블을 참조합니다. TEXT의 1과 IMAGE의 1은 서로 다른 게시글이고, type + id만 저장한 반례에는 대상 게시글 FK가 없습니다.

유형별 링크가 실제 회원과 구체 게시글을 참조한다일곱테이블노드여섯FK화살표. TEXT IMAGE LINK 각 링크는 members의 id와 해당 구체 테이블의 PK를 참조한다. 동일숫자 게시글ID도 다른테이블이면 별개다. type문자열+id를보관하는 일반참조와달리 세개의FK대상이실제존재한다. 모바일회원참조는왼쪽외곽으로돌아다른링크나게시글노드를지나지않는다. 삭제CASCADE/RESTRICT조건은원표와본문으로읽는다.members# idmember_text_post→ member_id→ text_post_idtext_posts# text_post_idmember_image_post→ member_id→ image_post_idimage_posts# image_post_idmember_link_post→ member_id→ link_post_idlink_posts# link_post_id각 링크의 게시글 FK는 유형별 테이블로
유형별 링크가 실제 회원과 구체 게시글을 참조한다일곱테이블노드여섯FK화살표. TEXT IMAGE LINK 각 링크는 members의 id와 해당 구체 테이블의 PK를 참조한다. 동일숫자 게시글ID도 다른테이블이면 별개다. type문자열+id를보관하는 일반참조와달리 세개의FK대상이실제존재한다. 모바일회원참조는왼쪽외곽으로돌아다른링크나게시글노드를지나지않는다. 삭제CASCADE/RESTRICT조건은원표와본문으로읽는다.members# idmember_text_post→ member_id→ text_post_idtext_posts# text_post_id게시글 FKmember_image_post→ member_id→ image_post_idimage_posts# image_post_id게시글 FKmember_link_post→ member_id→ link_post_idlink_posts# link_post_id게시글 FK
유형별 FK와 공통 목록의 식별자
유형연결과 실제 FK 대상공통 목록 키
텍스트 게시글member_text_post → text_posts.text_post_idTEXT + text_post_id
이미지 게시글member_image_post → image_posts.image_post_idIMAGE + image_post_id
링크 게시글member_link_post → link_posts.link_post_idLINK + link_post_id
텍스트 게시글
연결과 실제 FK 대상: member_text_post → text_posts.text_post_id
공통 목록 키: TEXT + text_post_id
이미지 게시글
연결과 실제 FK 대상: member_image_post → image_posts.image_post_id
공통 목록 키: IMAGE + image_post_id
링크 게시글
연결과 실제 FK 대상: member_link_post → link_posts.link_post_id
공통 목록 키: LINK + link_post_id

세 연결은 회원도 members.id로 참조합니다. text_post_id=1과 image_post_id=1은 같은 게시글이라는 뜻이 아닙니다.

존재하는 회원만으로는 존재하지 않는 게시글을 막지 못한다

반례: TEXT 999999 · IMAGE 888888 → 대상 없음에도 2행 저장

수정: member_text_post → text_posts → 999999 연결은 FK 오류 1452

본문은 텍스트 25자 게시글과 연결 1행으로 공통 목록을 확인합니다. 회원 삭제는 연결을 CASCADE로 지우고 게시글 삭제는 연결이 있으면 RESTRICT합니다. 공통 뷰는 유형별 값의 의미·타입을 맞추는 별도 계약이며 전역 PK를 만드는 제약은 아닙니다.

구체 테이블의 외래 키

구체 테이블 방식에서는 하위 유형의 행 하나하나가 완전한 엔티티입니다. API의 식별자에는 유형을 함께 넣어, 유형별 엔드포인트나 유형 태그가 붙은 ID를 씁니다. 세 연결 테이블은 테이블의 수를 늘리지만, 데이터베이스가 ON DELETE와 존재 검사를 대신 해 줍니다.

회원의 게시글 목록은 세 분기를 UNION ALL하고 post_type 리터럴을 붙여 하나의 목록으로 돌려줍니다. 서로 다른 기본 키 공간이므로 중복을 없앨 필요가 없고, UNION은 중복 제거의 작업이 더해지므로 각 유형의 행을 그대로 합치는 UNION ALL을 씁니다. 여러 유형이 사실은 같은 게시글이라는 업무 규칙이 생긴다면 이 전략의 전제가 깨진 것이므로, 17장 3편의 부모 테이블 모델을 검토합니다.

뷰의 열 타입은 UNION 분기들의 공통 타입으로 정해집니다. 아래 뷰의 display_hint는 텍스트의 길이와 이미지의 너비를 같은 열에 담고 LINK에는 NULL을 두며, 명시적인 CAST는 쓰지 않았습니다.

유형별 테이블과 통합 VIEW

DDL은 유형별 CHECK와 회원 쪽의 연쇄 삭제, 게시글 쪽의 참조 제한을 정의합니다. 작성자 연결이 남은 게시글의 삭제는 거부하며, 보관 플래그나 숨김 처리는 구현하지 않습니다.

텍스트·이미지·링크 구체 스키마와 공통 투영
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' AS post_type, text_post_id AS post_id, title,
       canonical_url AS target_url, body_chars AS 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);

SELECT post_type, post_id, title, display_hint
FROM concrete_post_feed_v;
post_type | post_id | title              | display_hint
TEXT      | 1       | InnoDB 인덱스 읽기 | 25

텍스트 게시글의 연결은 실제 텍스트 게시글의 기본 키를 요구하고, 공통 뷰는 유형 태그를 붙여 한 행을 돌려줍니다. URL 열 VARCHAR(700)의 UNIQUE는 utf8mb4의 문자 하나를 4바이트로 잡아도 2800바이트로, InnoDB의 인덱스 키 한도 3072바이트 안에 듭니다.

게시글의 삭제는 쓰고 있는 연결이 있으면 RESTRICT로 막히고, 회원의 삭제는 연결이 회원에게 속한 관계이므로 CASCADE로 함께 지웁니다. 두 방향의 수명을 같은 연쇄로 처리하지 않습니다.

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

참조와 유형, 뷰 키의 검증

각 연결의 고아 쿼리는 외래 키 덕분에 원칙상 빈 결과여야 합니다. 잘못된 숫자의 INSERT가 1452로 거부되는지 확인하고, 뷰에서 유형과 ID의 조합이 유일한지 검사합니다.

구체 테이블 무결성과 UNION 투영 감사
INSERT INTO member_text_post VALUES (@member_id, 999999);

SELECT l.member_id, l.text_post_id FROM member_text_post AS l
LEFT JOIN text_posts AS 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 AS l
LEFT JOIN image_posts AS 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 AS l
LEFT JOIN link_posts AS 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' AS kind, a.text_post_id AS id, a.title
FROM member_text_post AS l
JOIN text_posts AS 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 AS l
JOIN image_posts AS 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 AS l
JOIN link_posts AS b ON b.link_post_id = l.link_post_id
WHERE l.member_id = @member_id;
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`board_lab`.`member_text_post`, CONSTRAINT `member_text_post_ibfk_2` ...)

kind | id | title
TEXT | 1  | InnoDB 인덱스 읽기

없는 텍스트 게시글 ID는 두 번째로 선언한 외래 키 member_text_post_ibfk_2에서 거부됩니다. 세 고아 쿼리와 뷰의 복합 키 중복은 빈 결과이고, 마지막 회원의 게시글 쿼리는 연결된 텍스트 게시글 한 행을 돌려줍니다.

분기마다 EXPLAIN으로 member_id로 시작하는 기본 키를 쓰는지 확인합니다. 유형이 늘면 쿼리의 길이도 늘어나므로, 뷰나 저장소 계층에서 한 번만 관리합니다.

유형별 열과 작업의 흐름이 거의 겹치지 않고 유형을 넘나드는 관계가 드물다면, 구체 테이블은 각 스키마가 작고 분명합니다. 텍스트 편집기와 링크 검증기가 서로 다른 팀에 속할 때 마이그레이션의 담당을 나누기도 쉽습니다. 반대로 제목의 길이를 160에서 240으로 바꾸려면 세 테이블의 마이그레이션과 뷰의 호환성을 함께 관리해야 하므로, 자주 바뀌는 공통 열이 많아지면 부모 테이블이나 단일 테이블이 알맞습니다.

검색 인덱스로 보낼 때는 UNION 뷰에서 유형과 ID, 제목, URL을 꺼내 검색 문서의 ID를 유형:ID로 만듭니다. 구체 테이블의 기본 키만 쓰면 다른 유형의 문서를 덮어씁니다.

따져 볼 것질문
공통성유형 사이에 공유하는 열과 변경 규칙이 적은가
참조유형을 넘나드는 외래 키를 거의 만들지 않는가
변화새 유형의 추가가 드물고 명시적인 마이그레이션을 감당할 수 있는가
조회UNION 목록이 핵심 OLTP의 병목이 아닌가

수명이 서로 다르고 통합 조회가 드물다면, 구체 테이블에서 유형마다의 규칙이 분명하게 드러납니다. 공통 관계가 늘어 다형 연결을 만들고 싶어지는 순간, 부모 식별자를 들일지 다시 판단합니다.

정리

  • 구체 테이블의 하위 유형 행은 부모 없이 완전한 엔티티로 존재합니다.
  • 범용의 유형과 ID 연결은 외래 키를 걸 수 없어, 없는 대상을 가리키는 행을 막지 못합니다.
  • 유형별 연결 테이블이 참조 무결성을 데이터베이스에서 지킵니다.
  • 공통 읽기는 UNION ALL 뷰이며, 유형과 ID를 함께 식별자로 씁니다.
  • 공통 속성과 관계, 새 유형의 변화가 많아지면 다른 상속 전략을 검토합니다.

연습 문제

1. 투표 게시글의 추가

투표 게시글을 더해 보세요. question_text는 최대 700자로 제한하고 UNIQUE와 option_count의 CHECK를 정의하며, member_poll_post의 외래 키와 공통 뷰의 POLL 분기도 완성해야 합니다.

해설 보기

기존 유형은 고치지 않고, 새 구체 테이블과 연결을 만듭니다. CREATE OR REPLACE VIEW로 네 분기를 같은 열 순서와 호환되는 타입에 맞춥니다. 질문 전체의 UNIQUE는 16KB 페이지와 DYNAMIC 행 형식에서 700자 × 4바이트 = 2800바이트가 3072바이트의 한도 안에 든다는 전제이며, 질문의 같음은 테이블의 정렬 규칙을 따릅니다.

DROP TABLE IF EXISTS member_poll_post;
DROP TABLE IF EXISTS poll_posts;
CREATE TABLE poll_posts (
  poll_post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(160) NOT NULL,
  question_text VARCHAR(700) 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 ROW_FORMAT=DYNAMIC DEFAULT CHARACTER SET utf8mb4;
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' AS post_type, text_post_id AS post_id, title,
       canonical_url AS target_url, body_chars AS 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;

INSERT INTO poll_posts (title, question_text, option_count)
VALUES ('인덱스 설문', '어떤 인덱스를 가장 자주 쓰나요?', 3);
INSERT INTO member_poll_post VALUES (@member_id, LAST_INSERT_ID());

SELECT post_type, post_id, title, target_url, display_hint
FROM concrete_post_feed_v
ORDER BY post_type, post_id;
post_type | post_id | title              | target_url                      | display_hint
POLL      | 1       | 인덱스 설문        | 어떤 인덱스를 가장 자주 쓰나요? | 3
TEXT      | 1       | InnoDB 인덱스 읽기 | https://example.com/text/innodb | 25

없는 poll_post_id는 외래 키가 거부합니다. 이 확장은 뷰의 target_url 열에 POLL의 질문 문자열을 넣으므로, 소비하는 쪽이 이 열을 모든 유형의 URL로 해석해서는 안 됩니다. 공통의 URL이 필요하다면 투표의 URL을 따로 모델링해야 하며, 이렇게 열의 뜻이 유형마다 달라지는 것도 구체 테이블을 하나의 목록으로 합칠 때의 비용입니다.

2. 숫자 ID만으로 만든 캐시 키

공통 뷰의 결과를 캐시에 넣으면서 키를 post_id 숫자만으로 만들었습니다. 위 연습까지 마친 상태에서 TEXT 1을 먼저 캐시하고 POLL 1을 나중에 캐시하면 무슨 일이 생기나요?

해설 보기

두 행의 post_id가 모두 1이므로 같은 키를 쓰고, 나중에 캐시한 POLL 1이 TEXT 1을 덮어씁니다. 그 뒤 텍스트 게시글 1을 요청한 화면에는 투표 게시글이 보입니다. 구체 테이블은 유형마다 기본 키의 공간이 따로이므로, 캐시와 검색 인덱스, API의 키에는 TEXT:1, POLL:1처럼 유형을 함께 넣어야 합니다.

다음 글 단일 테이블 상속에서는 게시글 하나의 테이블에 유형별 열을 모으고, CHECK로 허용하는 조합을 제한합니다.