본문 바로가기

단일 테이블 상속

텍스트와 이미지, 링크 게시글을 하나의 테이블과 공통 기본 키로 저장하면서, 판별자만 검사하면 숨어 들어오는 잘못된 NULL 조합을 재현합니다. 유형별 필수와 금지 열을 한 CHECK의 진리표로 제약하고, 트리거로 유형의 변경을 막습니다.

하나의 member_post_single 외래 키와 최신 게시글 목록이 핵심이라면, 모든 유형을 한 테이블에 두는 단일 테이블 상속(single table inheritance)이 매력적입니다. post_id는 전역으로 유일하고 제목과 URL, created_at도 한 번만 정의합니다. 대가로 하위 유형의 전용 열이 NULL을 허용하게 되고, 잘못된 조합이 들어올 여지가 생깁니다.

단일 테이블 상속의 품질은 판별자(discriminator) 하나가 아니라, 판별자와 NULL을 허용하는 열 사이의 논리식에서 결정됩니다. 이미지 게시글인데 image_width_px가 없거나, 텍스트 게시글인데 link_kind가 채워진 행을 CHECK가 거부해야 합니다.

posts_single은 텍스트와 이미지, 링크 게시글을 함께 보관합니다. canonical_url은 모든 유형에 필요하고, body_chars와 alt_text·image_width_px, link_kind는 각각 한 하위 유형만 쓰며, 작성자 연결은 post_id 하나를 외래 키로 참조합니다. 게시글 유형의 변경은 허용하지 않으므로, 텍스트 게시글을 이미지 게시글로 바꾸려면 새 게시글을 만들고 연결을 옮깁니다. 한 행의 정체성을 바꾸면 하위 유형의 열과 바깥의 캐시가 동시에 흔들리기 때문입니다.

SINGLE TABLE · REQUIRED / FORBIDDEN PAYLOAD

유형마다 필수값과 다른 유형의 NULL을 함께 검사한다

한 행에 여러 유형의 열을 두더라도 선택한 유형의 값만 남아야 합니다. 범위·목록 비교는 NULL에서 UNKNOWN이 되므로 필수 열에 IS NOT NULL을 명시합니다.

한 행의 판별자가 필요한 열과 금지할 열을 고른다한posts_single행의공통ID/판별자노드와세조건분기노드,세화살표. TEXT IMAGE LINK는동시에세행을만든다는뜻이아니라한행의타입에따라선택되는대안검사다. 세조건은본문의정확한열NULL/NOTNULL과범위를함께적용한다. 한테이블영역이모바일에서도유지된다. 유형변경금지는별도트리거이며범위CHECK와구분한다.한 posts_single 행post_type · 공통 post_idTEXTbody_chars 필수나머지 전용 열 NULLIMAGEalt_text · 너비 필수body/link_kind NULLLINKlink_kind 필수body/alt/너비 NULL유형에 맞는 한 분기의 필수·금지 조합을 검사
한 행의 판별자가 필요한 열과 금지할 열을 고른다한posts_single행의공통ID/판별자노드와세조건분기노드,세화살표. TEXT IMAGE LINK는동시에세행을만든다는뜻이아니라한행의타입에따라선택되는대안검사다. 세조건은본문의정확한열NULL/NOTNULL과범위를함께적용한다. 한테이블영역이모바일에서도유지된다. 유형변경금지는별도트리거이며범위CHECK와구분한다.한 posts_single 행공통 post_idpost_typeTEXTbody_chars 필수그 외 전용 열 NULLIMAGEalt_text · 너비 필수body/link_kind NULLLINKlink_kind 필수body/alt/너비 NULL세 표시는 대안 분기서로 다른 세 행 아님
단일 행의 유형별 필수값과 금지값
유형필수 전용 값NULL이어야 할 값
TEXTbody_chars: NULL 아님, 1~1,000,000alt_text · image_width_px · link_kind
IMAGEalt_text: NULL 아님 / image_width_px: NULL 아님, 1~20,000body_chars · link_kind
LINKlink_kind: NULL 아님, EXTERNAL·INTERNAL·DOWNLOADbody_chars · alt_text · image_width_px
TEXT
필수 전용 값: body_chars: NULL 아님, 1~1,000,000
NULL이어야 할 값: alt_text · image_width_px · link_kind
IMAGE
필수 전용 값: alt_text: NULL 아님 / image_width_px: NULL 아님, 1~20,000
NULL이어야 할 값: body_chars · link_kind
LINK
필수 전용 값: link_kind: NULL 아님, EXTERNAL·INTERNAL·DOWNLOAD
NULL이어야 할 값: body_chars · alt_text · image_width_px

CHECK는 UNKNOWN도 통과시킵니다. 범위·목록 비교만 쓰지 않고 각 필수 열에 IS NOT NULL을 함께 둡니다.

현재 행의 모양과 유형 변경 이력은 서로 다른 검사

TEXT + body_chars 10 + link_kind DOWNLOAD → CHECK 3819

기존 TEXT를 유효한 IMAGE 값으로 바꾸어도 → 변경 금지 트리거 1644

CHECK는 현재 값 조합을 검증하며 과거 유형의 불변성을 보장하지 않습니다. IMAGE의 alt_text는 NULL 금지이지 빈 문자열·설명 품질 검사까지 포함하지 않습니다. POLL 확장은 기존 세 분기에도 새 열의 NULL 조건을 추가해야 합니다.

판별자만 검사한 잘못된 조합

post_type의 IN 목록은 오타만 막을 뿐, 전용 열의 필수와 금지의 조합은 별도의 검사가 없으면 모두 허용됩니다. 읽는 쪽이 유형마다 NULL을 가정하고 있다면, 실행 중의 오류나 잘못된 화면이 생깁니다.

유효한 유형 문자열 아래 숨은 잘못된 NULL 패턴
DROP TABLE IF EXISTS posts_single_bad;
CREATE TABLE posts_single_bad (
  post_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  post_type VARCHAR(16) NOT NULL,
  title VARCHAR(160) NOT NULL,
  body_chars INT UNSIGNED NULL,
  image_width_px INT UNSIGNED NULL,
  link_kind VARCHAR(16) NULL,
  CHECK (post_type IN ('TEXT', 'IMAGE', 'LINK'))
) ENGINE=InnoDB;

INSERT INTO posts_single_bad
  (post_type, title, body_chars, image_width_px, link_kind)
VALUES
  ('IMAGE', '너비 없는 이미지', NULL, NULL, 'DOWNLOAD'),
  ('TEXT', '필드 없는 글', NULL, NULL, NULL),
  ('LINK', '이미지 너비가 있는 링크', NULL, 3600, 'EXTERNAL');

SELECT * FROM posts_single_bad ORDER BY post_id;
post_id | post_type | title                   | body_chars | image_width_px | link_kind
1       | IMAGE     | 너비 없는 이미지        | NULL       | NULL           | DOWNLOAD
2       | TEXT      | 필드 없는 글            | NULL       | NULL           | NULL
3       | LINK      | 이미지 너비가 있는 링크 | NULL       | 3600           | EXTERNAL

세 행 모두 판별자는 맞지만, 하위 유형의 업무 규칙을 하나씩 어깁니다. 이미지에는 너비가 없고 링크의 종류가 붙었으며, 글에는 본문 길이가 없고, 링크에는 이미지의 너비가 들어갔는데도 제약의 오류는 하나도 없었습니다.

여기서 NULL을 허용한다는 것은 "모든 유형에서 선택적"이라는 뜻이 아니라, "다른 하위 유형에서는 있을 수 없다"는 뜻일 수 있습니다. 이 차이를 DDL에 쓰지 않으면, ORM(Object-Relational Mapping, 객체 관계 매핑)의 검증을 우회한 가져오기나 운영 SQL이 오염된 행을 만듭니다. 그래서 하위 유형의 분기마다, 필요한 열의 IS NOT NULL과 금지된 열의 IS NULL을 모두 적습니다. 필수 조건만 쓰면 이미지 게시글 행에 link_kind가 함께 들어오는 오류를 놓칩니다.

조건이 길어지면 chk_post_single_payload처럼 제약에 이름을 붙여, 오류가 어디서 났는지 찾을 수 있게 합니다. 유형이 늘 때마다 기존 분기를 건드려야 하므로, 마이그레이션을 검토할 때 전체 조합을 다시 실행합니다.

판별자와 하위 유형의 규칙

텍스트 게시글의 분기는 body_chars만 채우고 alt_text와 image_width_px, link_kind를 비웁니다. 이미지 게시글의 분기는 alt_text와 image_width_px만 요구하고, 링크 게시글의 분기는 link_kind만 허용하며, OR로 이은 세 분기 밖의 상태는 모두 거부됩니다.

공통 기본 키 덕분에 member_post_single은 일반적인 외래 키 하나를 가지며, 댓글과 즐겨찾기, 검색 색인도 같은 post_id를 참조할 수 있습니다. 이 이점이 듬성듬성한 열과 넓은 행의 비용보다 중요한지는 작업의 양상으로 판단합니다.

MySQL의 CHECK는 이미 정해진 유형이 바뀌는 것을 직접 금지하지 못합니다. 애플리케이션의 역할에 post_type의 UPDATE 권한을 주지 않거나, BEFORE UPDATE 트리거에서 이전 값과 새 값을 비교하며, 이 예제는 변경을 막는 트리거를 둡니다. 또 CHECK는 UNKNOWN을 통과시키므로, 분기마다 필수 열의 IS NOT NULL을 명시하고, 범위의 비교만 쓰지 않습니다(연습 문제 2).

공통 열과 진리표 제약

DDL은 게시글의 행과 작성자 연결을 만들고, 유형의 변경을 막는 트리거를 정의합니다. 정상적인 세 유형의 데이터를 넣은 뒤, 뒤의 진단에서 잘못된 텍스트 게시글의 INSERT로 제약의 이름을 확인합니다.

단일 게시글 테이블과 유형별 입력 값 CHECK
DROP TRIGGER IF EXISTS prevent_post_type_change;
DROP TABLE IF EXISTS member_post_single;
DROP TABLE IF EXISTS posts_single;

CREATE TABLE posts_single (
  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,
  body_chars INT UNSIGNED NULL,
  alt_text VARCHAR(255) NULL,
  image_width_px INT UNSIGNED NULL,
  link_kind VARCHAR(16) NULL,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  CONSTRAINT uq_post_single_url UNIQUE (canonical_url),
  CONSTRAINT chk_post_single_type CHECK (post_type IN ('TEXT', 'IMAGE', 'LINK')),
  CONSTRAINT chk_post_single_payload CHECK (
    (post_type = 'TEXT' AND body_chars IS NOT NULL
     AND body_chars BETWEEN 1 AND 1000000
     AND alt_text IS NULL AND image_width_px IS NULL AND link_kind IS NULL)
    OR
    (post_type = 'IMAGE' AND body_chars IS NULL AND alt_text IS NOT NULL
     AND image_width_px IS NOT NULL AND image_width_px BETWEEN 1 AND 20000
     AND link_kind IS NULL)
    OR
    (post_type = 'LINK' AND body_chars IS NULL AND alt_text IS NULL
     AND image_width_px IS NULL AND link_kind IS NOT NULL
     AND link_kind IN ('EXTERNAL', 'INTERNAL', 'DOWNLOAD'))
  )
) ENGINE=InnoDB;

CREATE TABLE member_post_single (
  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_single (post_id) ON DELETE RESTRICT
) ENGINE=InnoDB;

DELIMITER //
CREATE TRIGGER prevent_post_type_change BEFORE UPDATE ON posts_single
FOR EACH ROW BEGIN
  IF NEW.post_type <> OLD.post_type THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'post type is immutable';
  END IF;
END//
DELIMITER ;

SET @member_id = (SELECT id FROM members ORDER BY id LIMIT 1);
INSERT INTO posts_single
  (post_type, title, canonical_url, body_chars)
VALUES ('TEXT', 'B+Tree 글', 'https://example.com/a/btree', 30);
SET @single_text = LAST_INSERT_ID();
INSERT INTO posts_single
  (post_type, title, canonical_url, alt_text, image_width_px)
VALUES ('IMAGE', '인덱스 구조 이미지', 'https://example.com/image/index',
        'B+Tree 구조', 1800);
INSERT INTO posts_single
  (post_type, title, canonical_url, link_kind)
VALUES ('LINK', '실행 계획 실습', 'https://example.com/l/explain', 'INTERNAL');
INSERT INTO member_post_single VALUES (@member_id, @single_text);

SELECT post_type, COUNT(*) AS posts
FROM posts_single
GROUP BY post_type
ORDER BY post_type;
post_type | posts
IMAGE     | 1
LINK      | 1
TEXT      | 1

세 하위 유형의 데이터가 CHECK를 통과했고, 공통 연결은 텍스트 게시글을 참조합니다. 모든 소비자가 post_type과 post_id를 같은 뜻으로 씁니다.

유형별 목록이 필요하다면, 작업이 요구할 때 (post_type, created_at, post_id) 인덱스를 더합니다. 지금의 데이터 규모만 보고 인덱스를 미리 늘리지는 않습니다. 넓은 테이블에서 대부분 NULL인 열은 저장의 부가 비용뿐 아니라 스키마를 누가 관리하는지의 문제를 만들며, 서로 다른 팀이 하위 유형의 마이그레이션을 자주 한다면 테이블 DDL의 잠금과 릴리스의 조정이 한곳에 몰립니다.

canonical_url의 유일 범위도 검토합니다. 텍스트와 이미지 게시글이 같은 URL을 공유할 수 없다면 전체 UNIQUE가 맞고, 유형별로 중복을 허용한다면 UNIQUE (post_type, canonical_url)로 바꿉니다. 상속 전략과 별개로, 후보 키의 업무 범위를 명시해 둡니다.

정상 조합과 금지 조합의 검증

오염 검사는 필수 값의 IS NOT NULL까지 DDL과 똑같은 조건을 만든 뒤 NOT으로 감쌉니다. 조건이 UNKNOWN으로 남으면 WHERE에서도 위반 행을 찾지 못하기 때문이며, 나중에 CHECK를 바꿀 때는 감사 쿼리도 같은 릴리스에서 고칩니다.

단일 테이블 하위 유형 입력 값 검증
SELECT post_id, post_type FROM posts_single
WHERE NOT (
  (post_type = 'TEXT' AND body_chars IS NOT NULL
   AND body_chars BETWEEN 1 AND 1000000
   AND alt_text IS NULL AND image_width_px IS NULL AND link_kind IS NULL)
  OR
  (post_type = 'IMAGE' AND body_chars IS NULL AND alt_text IS NOT NULL
   AND image_width_px IS NOT NULL AND image_width_px BETWEEN 1 AND 20000
   AND link_kind IS NULL)
  OR
  (post_type = 'LINK' AND body_chars IS NULL AND alt_text IS NULL
   AND image_width_px IS NULL AND link_kind IS NOT NULL
   AND link_kind IN ('EXTERNAL', 'INTERNAL', 'DOWNLOAD'))
);

INSERT INTO posts_single
  (post_type, title, canonical_url, body_chars, link_kind)
VALUES ('TEXT', '오염 예제', 'https://example.com/bad', 10, 'DOWNLOAD');

UPDATE posts_single
SET post_type = 'IMAGE', body_chars = NULL,
    alt_text = '바꾼 이미지', image_width_px = 600
WHERE post_id = @single_text;

SELECT l.member_id, l.post_id FROM member_post_single AS l
LEFT JOIN posts_single AS r ON r.post_id = l.post_id
WHERE r.post_id IS NULL;
ERROR 3819 (HY000): Check constraint 'chk_post_single_payload' is violated.
ERROR 1644 (45000): post type is immutable

전용 속성의 감사와 고아 쿼리는 빈 결과입니다. 링크의 종류가 붙은 텍스트 게시글은 CHECK에서, 유형을 바꾸려는 UPDATE는 트리거에서 거부됩니다. BEFORE UPDATE 트리거는 CHECK보다 먼저 실행되므로, 바꾸려던 값이 이미지의 규칙으로는 유효했는데도 유형이 바뀌는 것 자체가 막혔고, 기존 텍스트 게시글의 열은 그대로입니다.

ORM이 하위 유형마다 클래스를 만들더라도, 데이터베이스의 이 예제 데이터와 제약은 유지합니다. 대량 적재기가 제약의 검사를 끄는 경로가 있다면, 가져오기용 임시 적재 테이블에서 같은 진리표를 실행한 뒤 본 테이블로 옮깁니다.

하위 유형 세 개와 전용 열 네 개 정도는 진리표를 한눈에 읽을 수 있지만, 유형이 열 개가 되고 열이 수십 개라면 ALTER의 빈도와 NULL의 비율, CHECK의 길이가 빠르게 늘어나므로 그 숫자의 기준을 팀의 스키마 지침에 둡니다. 반대로 게시글의 생성과 작성자 연결이 두 테이블만 건드리므로, 조인 테이블 상속처럼 부모와 자식을 함께 만들 필요가 없어 쓰기의 경로가 짧고, 공통 제목의 수정도 한 행의 잠금으로 끝납니다.

모든 하위 유형이 같은 테이블에 있으므로, 테이블의 GRANT만으로 텍스트 게시글의 편집자와 링크 게시글의 편집자를 나누기는 어렵습니다. 프로시저나 서비스의 범위에서 유형 조건을 강제하고, 보안의 분리가 강하게 요구된다면 구체 테이블이 낫습니다.

따져 볼 것질문
유형 수하위 유형의 수가 적고 천천히 늘어나는가
열공통 열이 많고 전용 열이 적은가
관계모든 게시글을 외래 키 하나의 대상으로 참조해야 하는가
권한하위 유형별 테이블 권한의 분리가 필요하지 않은가
검증CHECK 하나의 진리표를 사람이 전부 검토할 수 있는가

공통 식별자와 단순한 연결이 중요하고 전용 속성이 적다면 단일 테이블이 효율적입니다. NULL을 허용하는 열과 분기가 계속 늘어난다면 단순함이 이미 사라진 것이므로, 조인 테이블이나 구체 테이블의 모델로 옮깁니다.

정리

  • 단일 테이블은 공통 post_id와 외래 키를 가장 짧은 경로로 제공합니다.
  • 판별자의 IN 목록만으로는 유형별 전용 열의 잘못된 조합을 막지 못합니다.
  • NULL을 허용하는 전용 열은 판별자별 필수와 금지의 조합을 CHECK로 제약하고, 필수 열에는 IS NOT NULL을 명시합니다.
  • 유형을 바꾸는 UPDATE는 트리거나 권한으로 막습니다.
  • 유형과 듬성듬성한 열이 늘어 진리표를 읽기 어려워지면 전략을 다시 검토합니다.

연습 문제

1. 투표 하위 유형의 추가

POLL 하위 유형을 더해 보세요. 투표 게시글은 option_count만 전용 값으로 쓰고 선택지는 2개 이상 20개 이하이며, 기존 세 유형에서는 새 열이 NULL이어야 합니다.

해설 보기

NULL을 허용하는 option_count를 더하고, CHECK에 POLL 분기를 넣습니다. 기존 세 분기에는 그 열의 IS NULL을 명시해 서로의 전용 속성이 섞이지 않게 합니다. 같은 이름의 CHECK를 한 ALTER 안에서 지우고 다시 만들지 않도록 두 문장으로 나누었습니다.

ALTER TABLE posts_single
  ADD COLUMN option_count SMALLINT UNSIGNED NULL,
  DROP CHECK chk_post_single_type,
  DROP CHECK chk_post_single_payload;

ALTER TABLE posts_single
  ADD CONSTRAINT chk_post_single_type
    CHECK (post_type IN ('TEXT', 'IMAGE', 'LINK', 'POLL')),
  ADD CONSTRAINT chk_post_single_payload CHECK (
    (post_type = 'TEXT' AND body_chars IS NOT NULL
     AND body_chars BETWEEN 1 AND 1000000 AND alt_text IS NULL
     AND image_width_px IS NULL AND link_kind IS NULL AND option_count IS NULL)
    OR (post_type = 'IMAGE' AND body_chars IS NULL AND alt_text IS NOT NULL
     AND image_width_px IS NOT NULL AND image_width_px BETWEEN 1 AND 20000
     AND link_kind IS NULL AND option_count IS NULL)
    OR (post_type = 'LINK' AND body_chars IS NULL AND alt_text IS NULL
     AND image_width_px IS NULL AND link_kind IS NOT NULL
     AND link_kind IN ('EXTERNAL', 'INTERNAL', 'DOWNLOAD')
     AND option_count IS NULL)
    OR (post_type = 'POLL' AND body_chars IS NULL AND alt_text IS NULL
     AND image_width_px IS NULL AND link_kind IS NULL
     AND option_count IS NOT NULL AND option_count BETWEEN 2 AND 20)
  );

INSERT INTO posts_single (post_type, title, canonical_url, option_count)
VALUES ('POLL', '다음 글의 난이도는?', 'https://example.com/poll/difficulty', 4);
INSERT INTO posts_single (post_type, title, canonical_url)
VALUES ('POLL', '선택지 없는 투표', 'https://example.com/poll/empty');
ERROR 3819 (HY000): Check constraint 'chk_post_single_payload' is violated.

선택지 4개의 투표는 통과하고, option_count가 없는 투표는 거부됩니다. 기존 세 행은 새 열이 NULL이라 새 CHECK도 지키므로, 두 번째 ALTER가 실패하지 않으며 본문의 전용 속성 감사도 계속 빈 결과입니다.

2. 필수 조건을 뺀 CHECK

TEXT 행의 body_chars가 NULL이고 다른 유형의 전용 열은 모두 NULL이라고 합시다. TEXT 분기에서 body_chars IS NOT NULL만 빼고 범위의 비교는 남기면, 이 행은 CHECK에서 거부될까요? 각 비교의 결과를 TRUE, FALSE, UNKNOWN으로 먼저 적어 보세요.

해설 보기

body_chars BETWEEN 1 AND 1000000은 NULL 때문에 UNKNOWN입니다. 나머지 TEXT 조건은 TRUE이므로 TEXT 분기 전체는 UNKNOWN이 되고, IMAGE와 LINK 분기는 판별자가 달라 FALSE입니다. 전체는 UNKNOWN OR FALSE OR FALSE, 곧 UNKNOWN이며, MySQL의 CHECK는 FALSE일 때만 거부하므로 이 행은 통과합니다.

body_chars IS NOT NULL을 되돌리면 FALSE AND UNKNOWN이 FALSE가 되어 세 분기가 모두 FALSE이므로 거부됩니다. NULL 자체를 금지하는 조건과 값의 범위를 검사하는 조건은 서로를 대신할 수 없습니다.

다음 글 조인 테이블 상속에서는 공통 식별자를 부모 테이블에 두고, 하위 유형의 전용 속성을 자식 테이블로 나눕니다.