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

안동민 개발노트

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

단일 테이블 상속

게시글 공통 PK 하나를 사용하면서 텍스트 게시글·이미지 게시글·링크 게시글 전용 열 조합을 행 단위 제약으로 검증합니다.

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

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

posts_single은 텍스트 게시글, 이미지 게시글, 링크 게시글 세 종류를 보관합니다. canonical_url은 모든 타입에 필요하고 body_chars, alt_text·image_width_px, link_kind는 각각 한 하위 유형만 사용합니다. 작성자 연결은 post_id 하나를 FK로 참조합니다.

게시글 유형 변경은 허용하지 않습니다. 텍스트 게시글을 이미지 게시글로 바꾸는 UPDATE는 새 게시글 생성과 연결 마이그레이션으로 처리합니다. 한 행의 정체성을 바꾸면 하위 유형 필드와 외부 캐시가 동시에 흔들리기 때문입니다.


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

post_type IN 목록은 오타만 막습니다. 전용 열의 필수·금지 조합은 별도 검사가 없으면 모두 허용됩니다. 읽기 주체가 유형별로 NULL을 가정할 때 실행 시점 오류나 잘못된 UI가 생깁니다.

유효한 type 문자열 아래 숨은 잘못된 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;

세 행 모두 판별자는 맞지만 각 하위 유형의 업무 규칙을 위반합니다.

오류 실행 결과
rows inserted=3
valid IMAGE payloads=0
valid TEXT payloads=0
valid LINK payloads=0
constraint errors=0

NULL 허용은 “모든 타입에서 선택적”이라는 뜻이 아니라 “다른 하위 유형에서는 존재할 수 없음”이라는 뜻일 수 있습니다. 이 차이를 DDL에 쓰지 않으면 ORM(Object-Relational Mapping, 객체 관계 매핑) 검증을 우회한 가져오기와 운영 SQL이 오염 행을 만듭니다.

각 하위 유형 분기는 필요한 열 IS NOT NULL과 금지 열 IS NULL을 모두 적어야 합니다. 필수 조건만 쓰면 이미지 게시글 행에 link_kind가 함께 들어오는 오류를 놓칩니다.

검사가 길어질 때 이름을 숨기지 않기

하나의 거대한 표현식이라도 제약 조건 이름을 chk_post_subtype_payload로 두고 문서에 진리표를 제공합니다. 유형이 늘 때마다 기존 분기를 건드려야 하므로 마이그레이션 검토에서 전체 조합을 다시 실행합니다.


판별자와 하위 타입 규칙

텍스트 게시글 분기는 body_chars만 채우고 alt_text, image_width_px, link_kind를 비웁니다. 이미지 게시글 분기는 alt_textimage_width_px만 요구합니다. 링크 게시글 분기는 link_kind만 허용합니다. OR로 연결된 세 분기 이외의 상태는 모두 거절됩니다.

공통 PK 덕분에 member_post_single은 일반 FK 하나를 가집니다. 댓글, 즐겨찾기, 검색 인덱싱도 같은 post_id를 참조할 수 있습니다. 이 강점이 희소 열과 넓은 행 비용보다 중요한지를 워크로드로 판단합니다.

유형 변경을 막는 쓰기 경계

MySQL 검사는 기존 유형이 바뀌는 것을 직접 금지하지 않습니다. 애플리케이션 역할에는 post_type UPDATE 권한을 주지 않거나 변경 전 UPDATE 트리거에서 이전 값과 신규 값을 비교합니다. 이 예제는 변경 불가 트리거를 추가합니다.

MySQL 8.4 메모: 검사는 알 수 없음을 통과시킬 수 있으므로 분기에서 필수 열의 IS NOT NULL을 명시합니다. 단순 범위 비교만 쓰면 NULL이 알 수 없음이 되어 의도와 다를 수 있습니다.


공통 열과 진리표 제약

DDL은 게시글 행과 작성자 연결을 만들고 유형 변경 트리거를 정의합니다. 정상 시드 데이터 세 건과 잘못된 이미지 게시글 예제 데이터를 실행해 제약 조건 이름을 확인합니다.

single post table과 type별 payload 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 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 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 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);

세 하위 유형의 시드 데이터는 검사를 통과하고 공통 연결은 텍스트 게시글을 참조합니다.

개선 실행 결과
posts=3
TEXT=1, IMAGE=1, LINK=1
member links=1
invalid payload insert=ERROR 3819
type mutation=SIGNAL 45000

post_typepost_id는 모든 소비자가 동일하게 사용합니다. 유형별 목록은 (post_type, created_at, post_id) 인덱스를 워크로드가 요구할 때 추가합니다. 현재 시드 데이터 규모만으로 인덱스를 미리 늘리지 않습니다.

넓은 테이블에서 대부분 NULL인 열은 스토리지 부가 비용뿐 아니라 스키마 소유권 문제를 만듭니다. 서로 다른 팀이 하위 유형 마이그레이션을 자주 수행하면 테이블 DDL 잠금과 릴리스 조정이 집중됩니다.

canonical_url 유일 범위를 검토하기

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


정상·금지 조합 검증

오염 검사는 DDL 표현식과 같은 조건을 긍정형으로 만들고 NOT으로 감쌉니다. 향후 검사를 변경할 때 감사 쿼리도 같은 릴리스에서 수정합니다.

single table subtype payload 검증
SELECT post_id,post_type FROM posts_single
WHERE NOT(
 (post_type='TEXT' 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 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 IN('EXTERNAL','INTERNAL','DOWNLOAD'))
);

INSERT INTO posts_single
 (post_type,title,canonical_url,body_chars,link_kind)
VALUES('TEXT','오염 fixture','https://example.com/bad',10,'DOWNLOAD');
-- ERROR 3819: chk_post_single_payload

UPDATE posts_single SET post_type='IMAGE',body_chars=NULL,
 alt_text='ExampleTV',image_width_px=600 WHERE post_id=@single_text;
-- ERROR 1644: post type is immutable

SELECT l.member_id,l.post_id FROM member_post_single l
LEFT JOIN posts_single r ON r.post_id=l.post_id
WHERE r.post_id IS NULL;

전용 속성 감사와 고아 쿼리는 0행입니다. 잘못된 텍스트 게시글의 INSERT는 검사에서, 유형 변경은 트리거에서 거절됩니다. 오류 발생 뒤 기존 텍스트 게시글 열은 변하지 않습니다.

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

희소 열이 허용 가능한 규모

세 하위 유형과 전용 열 네 개 정도는 한눈에 진리표를 읽을 수 있습니다. 유형이 열 개가 되고 열이 수십 개라면 ALTER 빈도와 NULL 비율, 검사 길이가 빠르게 증가합니다. 숫자 기준을 팀 스키마 지침에 둡니다.

공통 트랜잭션의 이점

게시글 생성과 작성자 연결이 두 테이블만 건드립니다. 조인 테이블 상속처럼 부모와 자식을 함께 생성할 필요가 없어 쓰기 경로가 짧습니다. 단일 행 잠금으로 공통 제목 수정도 처리합니다.

하위 유형별 권한

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


단일 테이블 상속의 한계

판단 축확인할 질문
타입 수하위 유형 종류가 작고 천천히 증가하는가?
공통 필드가 많고 전용 필드가 소수인가?
관계모든 게시글을 한 FK 대상으로 참조해야 하는가?
권한하위 유형별 테이블 권한 분리가 필요하지 않은가?
검증한 검사 진리표를 사람이 전체 검토할 수 있는가?

공통 식별자와 단순 연결이 중요하고 전용 속성이 적으면 단일 테이블이 효율적입니다. NULL 열과 분기가 계속 늘면 단순함이 이미 사라진 것이므로 조인 또는 구체 모델로 전환합니다.


연습 문제

POLL 하위 유형을 추가하세요. 투표 게시글은 option_count만 전용 값으로 사용하며 2개 이상 20개 이하의 선택지를 가져야 합니다. 기존 세 유형에서는 새 열이 NULL이어야 합니다.

해설과 예시 답안

NULL 허용 option_count를 추가하고 POLL 분기를 검사에 넣습니다. 기존 세 분기에는 해당 열 IS NULL을 명시해 서로의 전용 속성이 섞이지 않게 합니다.

ALTER TABLE posts_single
 ADD COLUMN option_count SMALLINT UNSIGNED NULL,
 DROP CHECK chk_post_single_type,
 DROP CHECK chk_post_single_payload,
 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 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 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 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 BETWEEN 2 AND 20)
 );
INSERT INTO posts_single
 (post_type,title,canonical_url,option_count)
VALUES('POLL','다음 글의 난이도는?','https://example.com/poll/difficulty',4);

POLL 시드 데이터가 통과하고 option_count를 뺀 POLL 행은 검사 오류가 납니다. 기존 세 하위 유형 감사도 0행으로 유지되어야 합니다.


핵심 정리

  • 단일 테이블은 공통 post_id와 FK를 가장 짧은 경로로 제공합니다.
  • NULL 허용 전용 열은 판별자별 필수·금지 조합을 검사로 닫아야 합니다.
  • 유형 식별자를 바꾸는 UPDATE는 트리거 또는 권한으로 막습니다.
  • 유형과 희소 열 증가가 진리표 가독성을 넘으면 전략을 재검토합니다.

다음 문서에서는 공통 식별자를 부모에 두고 하위 유형 전용 속성을 자식 테이블로 분리합니다.