복합 식별 키와 계층 구조
댓글→답글→반응으로 복합 키가 전파되는 DDL을 만들고 넓은 FK·인덱스·변경 비용을 대리 키 모델과 비교합니다.
복합 키 한 단계는 읽기 쉽지만 자식이 다시 부모가 되면 모든 구성 열이 다음 FK로 전달됩니다.
게시글 댓글의 답글, 답글 반응까지 식별 관계로 연결하면 마지막 테이블이 업무 의미보다 조상 키 복사에 더 많은 열을 씁니다.
식별안에서 댓글 PK는 (post_id, sequence_no), 답글 PK는 여기에 reply_no, 반응 PK는 reaction_no까지 더합니다.
비식별안에서는 comment_id, reply_id, reaction_id가 각 대표 PK이고 부모 FK는 한 열입니다.
누적되는 복합 키
DDL은 가능하지만 반응 조회에 post_id·sequence_no·reply_no·reaction_no를 모두 알아야 합니다.
댓글 순서 변경이 모든 하위 PK/FK 변경으로 번지고 보조 인덱스마다 긴 PK 접미 키가 붙습니다.
아래 예제는 ch11-1에서 만든 두 댓글 모델의 고정 예제 데이터를 부모로 사용합니다.
DROP TABLE IF EXISTS reply_reaction_identifying;
DROP TABLE IF EXISTS comment_reply_identifying;
CREATE TABLE comment_reply_identifying (
post_id BIGINT UNSIGNED NOT NULL,
sequence_no SMALLINT UNSIGNED NOT NULL,
reply_no INT UNSIGNED NOT NULL,
body VARCHAR(500) NOT NULL,
PRIMARY KEY (post_id, sequence_no, reply_no),
FOREIGN KEY (post_id, sequence_no)
REFERENCES post_comment_identifying (post_id, sequence_no)
);
CREATE TABLE reply_reaction_identifying (
post_id BIGINT UNSIGNED NOT NULL,
sequence_no SMALLINT UNSIGNED NOT NULL,
reply_no INT UNSIGNED NOT NULL,
reaction_no INT UNSIGNED NOT NULL,
emoji VARCHAR(16) NOT NULL,
PRIMARY KEY (post_id, sequence_no, reply_no, reaction_no),
FOREIGN KEY (post_id, sequence_no, reply_no)
REFERENCES comment_reply_identifying
(post_id, sequence_no, reply_no)
);
SET @identifying_post_id := (
SELECT MIN(post_id) FROM post_comment_identifying
);
INSERT INTO comment_reply_identifying
(post_id, sequence_no, reply_no, body)
VALUES (@identifying_post_id, 1, 1, '복합 키 답글');
INSERT INTO reply_reaction_identifying
(post_id, sequence_no, reply_no, reaction_no, emoji)
VALUES (@identifying_post_id, 1, 1, 1, '좋아요');세 단계 뒤 반응의 대표 PK가 네 열입니다.
기본 RESTRICT에서는 부모 순번 변경이 거절되고, ON UPDATE CASCADE를 선택하면 모든 하위 PK와 FK 변경으로 번집니다.
UPDATE post_comment_identifying
SET sequence_no = 2
WHERE post_id = @identifying_post_id
AND sequence_no = 1;
-- ERROR 1451: comment_reply_identifying이 기존 복합 키를 참조comment PK columns: 2
reply PK columns: 3
reaction PK columns: 4
move comment sequence 1 → 2 with RESTRICT: ERROR 1451
with ON UPDATE CASCADE: comment + all replies + all reactions change
simple reaction URL id: unavailable관계가 종속적이라는 사실과 모든 계층의 업무 식별을 조상 키 전체로 표현해야 한다는 결론은 다릅니다.
하위 행이 API·감사·검토에서 독립 참조되면 짧고 안정적인 ID가 유리합니다.
넓은 클러스터형 PK는 InnoDB의 모든 보조 인덱스 리프에 포함되어 저장 공간에도 전파됩니다.
열 수만이 아니라 타입 바이트와 보조 인덱스 개수를 계산합니다.
세대마다 늘어난 키 열 세기
- 키 성장 — 단계마다 PK 열·바이트 수를 표로 만듭니다.
- 보조 인덱스 — 각 리프에 클러스터형 PK가 추가되는 비용을 계산합니다.
- 변경 전파 — 부모 순번·소유 변경이 하위 식별을 바꾸는지 봅니다.
- API 참조 — 외부 링크와 로그가 어떤 키를 전달해야 하는지 확인합니다.
식별 의미와 참조 폭
comment_id·reply_id를 대표 PK로 두어 하위 FK를 짧게 만들고, 각 부모 범위의 순서·reply_no는 UNIQUE로 보존할 수 있습니다.
종속 수명은 FK와 삭제 정책이 표현하며 반드시 복합 PK만이 소유를 표현하는 것은 아닙니다.
대리 키는 모델 의미를 약화시키지 않지만 DDL만 보면 부모 범위 후보 키가 덜 눈에 띌 수 있습니다.
명명된 UNIQUE와 ERD 카디널리티, 용어집을 함께 유지합니다.
식별 의미와 참조 폭을 분리하기
- 외부 주소 — 반응을 단독 ID로 API·감사에서 찾는지 봅니다.
- 재정렬 — 순서 변경이 식별자 변경이어야 하는지 묻습니다.
- 인덱스 폭 — 보조 인덱스 수×클러스터형 PK 바이트를 비교합니다.
- 삭제 — 대리 키 전환 뒤에도 FK RESTRICT/CASCADE 정책을 명시합니다.
대표 ID와 부모 범위 키
reply_id와 reaction_id로 참조를 고정하고 (comment_id, reply_no) 같은 업무 후보 키를 유지합니다.
DROP TABLE IF EXISTS reply_reaction;
DROP TABLE IF EXISTS comment_reply;
CREATE TABLE comment_reply (
reply_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
comment_id BIGINT UNSIGNED NOT NULL,
reply_no INT UNSIGNED NOT NULL,
body VARCHAR(500) NOT NULL,
PRIMARY KEY (reply_id),
UNIQUE (comment_id, reply_no),
FOREIGN KEY (comment_id)
REFERENCES post_comment_surrogate (comment_id)
);
CREATE TABLE reply_reaction (
reaction_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
reply_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NOT NULL,
emoji VARCHAR(16) NOT NULL,
PRIMARY KEY (reaction_id),
UNIQUE (reply_id, member_id, emoji),
FOREIGN KEY (reply_id) REFERENCES comment_reply (reply_id),
FOREIGN KEY (member_id) REFERENCES members (id)
);
SET @surrogate_parent_comment_id := (
SELECT MIN(comment_id) FROM post_comment_surrogate
);
SET @reaction_member_id := (SELECT MIN(id) FROM members);
INSERT INTO comment_reply (comment_id, reply_no, body)
VALUES (@surrogate_parent_comment_id, 1, '대리 키 답글');
SET @reply_id := LAST_INSERT_ID();
INSERT INTO reply_reaction (reply_id, member_id, emoji)
VALUES (@reply_id, @reaction_member_id, '좋아요');반응은 한 ID로 참조하고 댓글·답글의 부모 범위 중복은 각 UNIQUE가 막습니다.
INSERT INTO comment_reply (comment_id, reply_no, body)
VALUES (@surrogate_parent_comment_id, 1, '중복 답글');
-- ERROR 1062: (comment_id, reply_no) UNIQUE 중복INSERT INTO reply_reaction (reply_id, member_id, emoji)
VALUES (@reply_id, @reaction_member_id, '좋아요');
-- ERROR 1062: (reply_id, member_id, emoji) UNIQUE 중복comment reference: comment_id
reply reference: reply_id
reaction reference: reaction_id
duplicate reply_no in comment: ERROR 1062
duplicate same member emoji on reply: ERROR 1062대리 키만으로는 다른 댓글의 답글을 섞는 복합 업무 조건을 자동 확인하지 않습니다.
각 FK가 직접 부모를 가리키고 필요한 테넌트·소유자 일치 규칙은 복합 UNIQUE/FK 또는 서비스 검증으로 보강합니다.
순번은 표시 순서일 뿐 정체성이 아니므로 변경해도 reply_id가 고정됩니다.
이 의미가 요구와 일치하지 않으면 복합 식별안을 유지합니다.
조회·재정렬·중복을 두 안으로 비교하기
- DDL 폭 — 두 안의 PK·FK 열 수와
index_length를 비교합니다. - 조회 —
reaction_id단독과 네 열 조회 계획을 비교합니다. - 재정렬 — 댓글 순서 UPDATE 뒤 답글 URL이 유지되는지 봅니다.
- 중복 — 업무 UNIQUE 위반 예제 데이터가 모두 거절되는지 확인합니다.
키 전파 폭 측정
키 열 수와 인덱스 크기를 감으로 말하지 않고 메타데이터에서 추출해 모델 결정 기록에 붙입니다.
SELECT table_name, constraint_name, COUNT(*) AS column_count
FROM information_schema.key_column_usage
WHERE table_schema = DATABASE()
AND table_name IN (
'comment_reply_identifying', 'reply_reaction_identifying',
'comment_reply', 'reply_reaction'
)
GROUP BY table_name, constraint_name
ORDER BY table_name, constraint_name;
SELECT table_name, index_length
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name LIKE '%reaction%';식별 반응 PK/FK는 4/3 열, 비식별 반응 PK/FK는 1/1 열입니다.
index_length는 샘플 행 수를 맞춘 뒤 비교해야 합니다.
같은 행 수로 인덱스 폭 측정하기
- 3단계 DDL — 식별안과 비식별안을 모두 만들고 SHOW 인덱스를 저장합니다.
- 행 수 적재 — 동일 10만 반응을 넣어
index_length·삽입 시간을 비교합니다. - 순번 변경 — 하위 FK ON UPDATE와 대리 ID 고정의 잠금 범위를 비교합니다.
- 오류 예제 데이터 — 고아·중복·다른 테넌트 참조를 각각 시도합니다.
클러스터형 PK가 보조 리프에 남기는 값
넓은 자연 키가 클러스터형 PK이면 보조 리프에도 복제됩니다.
반면 대리 PK를 추가하면 조합 UNIQUE라는 별 B+Tree가 생기므로 무조건 공간이 작아지는 것은 아닙니다.
실제 열 폭과 인덱스 수를 계산합니다.
식별 계층이 파티션·테넌트 경계를 자연스럽게 포함하는 장점도 있습니다.
tenant_id를 모든 키에 포함해 교차-테넌트 참조를 구조적으로 막는다면 전파 비용과 보안 이득을 같이 평가합니다.
공개 ID·UUID·순번 재사용 예외
AUTO_INCREMENTID를 외부에 노출할 때 추측 가능성이 권한 문제를 대신 해결하지는 않습니다.- UUID는 분산 생성 장점과 클러스터형 지역성 비용을 비교합니다.
- 순번 재사용이 허용되면 삭제된
reply_no와 감사 이력 의미를 정의합니다. - 이모지 정렬 규칙이 서로 다른 표현을 같은 값으로 보는지 UNIQUE 의미를 테스트합니다.
깊은 계층의 키 전략 비교
| 선택 | 얻는 효과 | 감수할 비용 | 적합한 조건 |
|---|---|---|---|
| 깊은 식별 계층 | 조상 문맥이 키에 명시 | 폭·변경 전파 | 얕고 외부 참조 없을 때 |
| 단계별 대리 ID | 짧은 FK·안정 URL | 업무 UQ 추가 | 참조와 재정렬이 많을 때 |
| 복합 테넌트 키 | 경계 구조적 강제 | 모든 인덱스 폭 | 보안 격리가 최우선일 때 |
| 서비스 소유 검증 | DDL 단순화 | 경쟁 조건·우회 경로 | DB 복합 FK가 과도할 때 |
PK 교체 마이그레이션을 단계화하기
키 마이그레이션은 새 ID 백필 후 이중 키 UNIQUE와 FK를 검증하고 애플리케이션 읽기/쓰기를 순차 전환합니다.
한 번에 PK를 교체하지 않습니다.
FK 열 유형·부호 없음 속성이 정확히 같아야 하며 대형 ALTER의 온라인 가능성과 복제본 지연을 확인합니다.
다음 문서는 일대일 공유 PK와 다대다 연결 테이블에서 식별 관계가 오히려 간결한 사례를 살펴봅니다.
모든 관계를 한 전략으로 통일하지 않습니다.
복합 키 전파 허용 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 깊이 | 복합 키가 몇 세대 하위 FK까지 전달되는가? |
| 폭 | PK 바이트가 보조 인덱스마다 얼마나 복제되는가? |
| 재정렬 | 표시 순서 변경이 정체성 변경이어야 하는가? |
| 외부 참조 | 로그·API·감사가 자식을 단독 식별하는가? |
| 경계 | 키 전파가 테넌트·소유권을 구조적으로 지키는 이득이 있는가? |
복합 키의 의미적 명확성과 대리 키의 참조 안정성을 실제 깊이·인덱스·변경 워크로드로 비교합니다.
‘현대적’이라는 말만으로 모든 복합 키를 제거하지 않습니다.
연습 문제
plan_item의 점검 목록과 checklist_reply 두 단계를 설계하세요.
항목 순서는 변경되고 답글은 외부 알림에서 참조됩니다.
키 전략을 선택하고 후보 키를 보존하세요.
해설과 예시 답안
plan_item_id, checklist_id, reply_id 대리 PK를 쓰고 (plan_item_id, sequence_no)를 UNIQUE로 둡니다.
순서 변경이 답글 식별자를 바꾸지 않으며 알림은 reply_id를 참조합니다.
CREATE TABLE checklist_reply (
reply_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
checklist_item_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
body VARCHAR(500) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (reply_id),
FOREIGN KEY (checklist_item_id)
REFERENCES concept_checklist_item (checklist_item_id)
);체크리스트 순번 변경 뒤 reply_id가 유지되고, 고아 답글이 FK로 거절되는지 확인합니다.
핵심 정리
- 식별 키는 하위 관계마다 열과 인덱스 폭을 전파합니다.
- 대리 ID는 참조를 짧게 하지만 업무 UNIQUE를 대신하지 않습니다.
- 재정렬·외부 URL·감사는 안정적인 자식 ID의 근거가 됩니다.
- 키 전략은 메타데이터와 워크로드로 비용을 측정해 결정합니다.
다음 문서에서는 공유-PK 일대일과 다대다 연결 키처럼 식별 관계가 자연스러운 사례를 비교합니다.