회원 게시판 논리 모델
이메일과 시각, 순번 같은 업무의 값을 끝까지 복사한 거대한 복합 키가 이메일을 바꾸는 순간 갈라지는 과정을 재현하고, 대리 키와 업무 UNIQUE를 기본으로 하되 공유 기본 키와 조합 기본 키를 예외로 쓰는 논리 모델을 완성합니다. 키 결정의 기록과 진단 쿼리로 모델을 검증하는 방법을 다룹니다.
요즘의 서비스는 짧고 안정적인 대리 키와 비식별 외래 키를 기본으로 많이 씁니다. API와 이벤트, 분산된 쓰기에서 참조가 단순하기 때문입니다. 그러나 이것이 모든 관계에 대리 id를 더하라는 규칙이나, 복합 업무 키를 버리라는 뜻은 아닙니다.
완성할 모델에는 회원과 게시글, 댓글, 답글, 프로필, 태그, post_tags가 있습니다. 댓글과 답글은 바깥의 참조와 재정렬을 고려해 대리 키를, 프로필은 공유 기본 키를, post_tags는 단순한 조합 기본 키를 고르며, 각 선택의 이유를 설계 기록에 남깁니다.
자연 키를 끝까지 전파하면
회원의 이메일, 게시글의 created_at, 댓글의 순서, reply_no를 복합 기본 키 하나에 쌓으면, 이메일이나 순서를 바꾸는 일이 계층 전체의 식별자를 바꿉니다. 그런 모델에서는 문자열과 시각, 순번이 아래의 키와 바깥의 참조에 복사될 수 있습니다. 아래 코드는 그 위험 가운데 이메일을 복사한 답글 하나만 재현하며, 아래의 트리나 이벤트의 발행은 구현하지 않습니다.
예제의 이메일 세 개([email protected], [email protected], [email protected])는 다른 회원이 쓰고 있지 않아야 하고, 뒤의 댓글 예제에는 posts에 행이 하나 이상 있어야 합니다.
DROP TABLE IF EXISTS reply_key_explosion_bad;
INSERT INTO members (email, password_hash, name, status)
VALUES ('[email protected]', '$2b$12$logical.key.fixture',
'논리 키 실습 회원', 'ACTIVE');
SET @logical_member_id := LAST_INSERT_ID();
CREATE TABLE reply_key_explosion_bad (
member_email VARCHAR(255) NOT NULL,
post_created_at DATETIME(6) NOT NULL,
comment_sequence SMALLINT UNSIGNED NOT NULL,
reply_no INT UNSIGNED NOT NULL,
body VARCHAR(500) NOT NULL,
PRIMARY KEY (
member_email, post_created_at,
comment_sequence, reply_no
)
);
INSERT INTO reply_key_explosion_bad
(member_email, post_created_at, comment_sequence, reply_no, body)
VALUES
('[email protected]', '2026-07-14 09:00:00', 1, 1,
'자연 키를 복사한 답글');
UPDATE members
SET email = '[email protected]'
WHERE id = @logical_member_id;
SELECT email FROM members WHERE id = @logical_member_id;
SELECT member_email FROM reply_key_explosion_bad;email
[email protected]
member_email
[email protected]회원 원래의 이메일은 바뀌었지만, 외래 키가 아니라 그냥 복사한 문자열은 따라 바뀌지 않아 같은 회원의 키가 두 값으로 갈라졌습니다. 두 테이블 사이에는 선언된 외래 키가 없고, 기본 키는 긴 문자열과 시각, 숫자 두 개로 넓어졌습니다.
자연의 값을 다른 테이블의 열에 복사하는 것은 식별 관계가 아니라 중복 저장입니다. 식별 관계를 쓰더라도 실제 외래 키가 부모의 후보 키를 참조해야 합니다. 안정적인 member_id를 전달하면, 이메일의 변경은 일반 속성의 UPDATE로 끝납니다.
대리 키의 모델도 테넌트나 업무상의 소유를 저절로 보장하지는 않으므로, 다른 소유자끼리의 참조를 막는 데 필요한 복합 UNIQUE와 외래 키는 골라서 둡니다. 복사한 자연 키가 끊어진 곳을 찾을 때는, 복사한 열이 선언된 후보 키를 참조하는지, 이메일과 시각, 순번이 업무에서 바뀔 수 있는지, 기본 키와 이벤트 본문의 바이트가 얼마인지를 보고, 공유 기본 키나 조합 기본 키가 더 간결한 관계를 따로 찾습니다.
대리 키와 업무의 제약
따로 존재하고 바깥의 참조가 많은 엔티티에는 BIGINT 대리 기본 키를 두고, 자연의 후보 키는 UNIQUE로 지키며, 외래 키는 짧고 안정적인 id를 참조합니다. 종속된 1:1의 프로필이나 단순한 M:N의 연결처럼 조합 자체가 정체성인 곳에서는, 공유 기본 키나 조합 기본 키를 예외로 씁니다.
"모든 기본 키는 BIGINT"라는 규칙보다 키 결정의 기록이 중요합니다. 후보 키와 바뀔 가능성, 참조의 깊이, 삭제의 수명, 테넌트의 범위, 예상하는 쿼리를 테이블마다 남겨 두면, 예외가 우연이 아니라 의식적인 설계가 됩니다. 따로 수명을 갖고 바깥의 참조가 큰 엔티티부터 대리 기본 키의 후보로 두고, 이메일과 게시글의 조합, 순서의 조합 같은 UNIQUE를 빠뜨리지 않으며, 공유 기본 키와 조합 기본 키를 쓰는 이유와 바꿀 신호를 기록한 뒤, 중복과 고아, 소유의 불일치, 삭제 규칙을 예제 데이터로 확인합니다.
LOGICAL MODEL · PK / BUSINESS UNIQUENESS
대표 ID와 업무 UNIQUE를 관계별로 함께 선택한다
댓글·답글은 짧은 대리 ID로 참조하고, 프로필은 공유 PK, 단순 태그 연결은 조합 PK를 사용합니다. 대리 키가 있어도 이메일과 게시글별 순번의 업무 유일성은 따로 보호합니다.
| 테이블 | 대표 PK | 별도 업무 UNIQUE |
|---|---|---|
| members | id | |
| logical_comment | comment_id | post_id · sequence_no |
| logical_reply | reply_id | 이 DDL에는 없음 |
| member_profiles | member_id: PK이자 FK | 공유 PK로 회원당 최대 한 행 |
| post_tags | post_id · tag_id | 조합 PK로 중복 연결 차단 |
- members
- 대표 PK: id별도 업무 UNIQUE: email
- logical_comment
- 대표 PK: comment_id별도 업무 UNIQUE: post_id · sequence_no
- logical_reply
- 대표 PK: reply_id별도 업무 UNIQUE: 이 DDL에는 없음
- member_profiles
- 대표 PK: member_id: PK이자 FK별도 업무 UNIQUE: 공유 PK로 회원당 최대 한 행
- post_tags
- 대표 PK: post_id · tag_id별도 업무 UNIQUE: 조합 PK로 중복 연결 차단
member_profiles와 post_tags는 ch11-3의 정의를 사용합니다. logical_reply는 comment_id와 author_id로 부모를 참조합니다.
이메일·순번이 바뀌어도 답글의 참조는 그대로
회원 이메일 old → new → stable-id
댓글 sequence_no 1 → 2
답글 reply_id · comment_id · author_id → 유지
본문의 두 UPDATE는 각 원본 한 행을 바꾸며 하위 FK를 갱신하지 않습니다. 이미 사용한 게시글·순번 2는 1062, 없는 댓글 999999 참조는 1452입니다. 소유권·테넌트 규칙은 짧은 ID만으로 자동 보장되지 않습니다.
섞어 쓰는 키의 전략
아래에서는 댓글과 답글의 참조와 중복 규칙을 구현합니다. 프로필의 공유 기본 키와 post_tags의 조합 기본 키는 앞 글의 정의를 그대로 씁니다.
DROP TABLE IF EXISTS logical_reply;
DROP TABLE IF EXISTS logical_comment;
CREATE TABLE logical_comment (
comment_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
sequence_no SMALLINT UNSIGNED NOT NULL,
summary VARCHAR(240) NOT NULL,
PRIMARY KEY (comment_id),
UNIQUE (post_id, sequence_no),
FOREIGN KEY (post_id) REFERENCES posts (id)
);
CREATE TABLE logical_reply (
reply_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
comment_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
body VARCHAR(500) NOT NULL,
PRIMARY KEY (reply_id),
FOREIGN KEY (comment_id) REFERENCES logical_comment (comment_id),
FOREIGN KEY (author_id) REFERENCES members (id)
);
SET @logical_post_id := (SELECT MIN(id) FROM posts);
INSERT INTO logical_comment (post_id, sequence_no, summary)
VALUES (@logical_post_id, 1, '논리 모델 댓글');
SET @logical_comment_id := LAST_INSERT_ID();
INSERT INTO logical_reply (comment_id, author_id, body)
VALUES (@logical_comment_id, @logical_member_id, '안정적인 대리 키 답글');
UPDATE members
SET email = '[email protected]'
WHERE id = @logical_member_id;
UPDATE logical_comment
SET sequence_no = 2
WHERE comment_id = @logical_comment_id;
SELECT r.reply_id, r.comment_id, c.sequence_no, m.email
FROM logical_reply AS r
JOIN logical_comment AS c ON c.comment_id = r.comment_id
JOIN members AS m ON m.id = r.author_id;reply_id | comment_id | sequence_no | email
1 | 1 | 2 | [email protected]이메일과 순번을 바꾸는 두 UPDATE는 각각 회원과 댓글의 한 행만 바꾸었고, 답글의 reply_id와 comment_id는 그대로입니다. 같은 게시글의 순번 중복은 UNIQUE가, 없는 댓글을 가리키는 답글은 외래 키가 계속 막습니다. 두 번째 문장은 id가 999999인 댓글이 없다고 전제합니다.
INSERT INTO logical_comment (post_id, sequence_no, summary)
VALUES (@logical_post_id, 2, '중복 순번');
INSERT INTO logical_reply (comment_id, author_id, body)
VALUES (999999, @logical_member_id, '고아 답글');ERROR 1062 (23000): Duplicate entry '1-2' for key 'logical_comment.post_id'
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`board_lab`.`logical_reply`, CONSTRAINT `logical_reply_ibfk_1` ...)순번을 2로 바꾼 댓글이 있으므로 같은 게시글의 순번 2가 거부되었습니다. 프로필과 게시글-태그의 중복도 앞 글의 기본 키가 같은 방식으로 1062로 거부합니다.
논리 모델은 MySQL 타입의 최적화 전에도 관계와 키, NULL, 삭제의 규칙이 완전해야 합니다. 12장의 물리 모델에서 이름과 인덱스를 바꾸더라도 후보 키와 카디널리티는 유지합니다. 복합적인 소유를 데이터베이스에서 강제하려면 UNIQUE (comment_id, post_id) 같은 겹치는 키와 복합 외래 키가 필요할 수 있고, 그 비용이 크다면 서비스 트랜잭션과 정기적인 일치 확인을 고른 이유를 남깁니다.
논리 모델의 진단 쿼리
고아와 업무의 중복, 프로필의 중복, 연결의 중복을 한 묶음의 쿼리로 진단합니다. 모델의 검토를 한 번의 그림 회의가 아니라, 다시 실행할 수 있는 진단으로 만드는 것입니다.
SELECT c.reply_id
FROM logical_reply AS c
LEFT JOIN logical_comment AS p ON p.comment_id = c.comment_id
WHERE p.comment_id IS NULL;
SELECT post_id, sequence_no, COUNT(*)
FROM logical_comment
GROUP BY post_id, sequence_no HAVING COUNT(*) > 1;
SELECT member_id, COUNT(*)
FROM member_profiles
GROUP BY member_id HAVING COUNT(*) > 1;
SELECT post_id, tag_id, COUNT(*)
FROM post_tags
GROUP BY post_id, tag_id HAVING COUNT(*) > 1;앞의 정상적인 INSERT와 제약을 전제로, 네 쿼리는 모두 빈 결과를 돌려줍니다. 외래 키와 기본 키, UNIQUE가 이미 위반을 막지만, 이관이나 제약을 잠시 끈 이력을 찾아내는 운영의 일치 확인으로도 씁니다.
| 전략 | 얻는 것 | 알맞은 경우와 비용 |
|---|---|---|
| 대리 기본 키를 기본으로 | 짧고 안정적인 참조 | 대부분의 독립된 엔티티에 알맞고, 업무 UNIQUE를 따로 두어야 한다 |
| 공유 기본 키의 예외 | 종속된 1:1을 간결하게 나타낸다 | 확장의 수명이 부모와 같을 때 알맞고, 나중에 따로 떼어 내기 어렵다 |
| 조합 기본 키의 예외 | 단순한 연결의 정체성 | 연결 자체를 참조하지 않을 때 알맞고, 아래의 참조가 넓어진다 |
| 복합 소유 외래 키 | 데이터베이스가 소유의 범위를 강제한다 | 보안과 테넌트 규칙이 중요할 때 알맞고, 열과 인덱스가 겹친다 |
대리 키는 도메인의 범위를 가릴 수 있으므로, API에서 id만 받아 UPDATE할 때는 반드시 소유의 조건을 함께 둡니다. WHERE reply_id = ? AND author_id = ?나 테넌트의 조건을 빼면 짧은 키가 보안 취약점을 만듭니다. UUIDv7 같은 분산 id는 여러 쓰기 주체가 id를 만들 수 있다는 이점이 있지만 저장 크기와 페이지 분할, 디버깅의 비용이 있으므로, MySQL 하나의 AUTO_INCREMENT로 충분한 요구에서 유행만으로 바꾸지는 않습니다.
소프트 삭제·병합·버전·연결 출처 예외
소프트 삭제한 행이 자연 키를 다시 쓰면 UNIQUE와 부딪히므로, 활성인 키만 유일하게 하는 방법을 따로 설계합니다(16장 1편). 회원 두 명을 하나로 합칠 때는 모든 외래 키를 한 트랜잭션이나 배치로 옮기고, 후보 키의 충돌을 해결합니다.
공유 기본 키의 프로필을 여러 버전으로 넓히려면 (member_id, version)이나 별도의 프로필 버전 엔티티가 필요합니다. post_tags에 같은 태그를 여러 출처로 겹쳐 붙이는 것을 허용한다면, 조합의 후보 키에 출처가 들어가야 하는지 관계의 뜻을 다시 묻습니다.
스키마를 정적으로 검사해 외래 키 열의 타입과 제약의 이름, 후보 키 UNIQUE의 누락을 감시하고, 모델의 다이어그램과 실제 DDL이 어긋나지 않는지도 정기적으로 비교합니다. 키를 바깥의 이벤트에 실어 보내면, 나중에 키를 바꾸는 이관이 훨씬 비싸집니다. 그래서 내부 id와 공개 id를 나눌지는 API의 수명과 함께 검토합니다.
모델을 정리할 때는 모든 테이블의 대표 기본 키와 후보 키를 고른 이유를 한 줄씩 적은 키의 목록을 만들고, 이메일과 순서, 태그 이름을 바꿀 때 영향받는 행의 수를 예측한 뒤 실행해 봅니다. 다른 회원의 게시글이나 자료를 잘못 잇는 예제 데이터를 만들어 보고, 조합으로 이은 연결에 검토용 답글이 더해질 때 대리 id를 들이는 이관도 설계해 봅니다.
| 따져 볼 것 | 질문 |
|---|---|
| 대표 키 | 독립된 엔티티의 참조가 짧고 변경에 안정적인가 |
| 업무 키 | 자연의 후보 키와 복합 후보 키를 UNIQUE로 지켰는가 |
| 예외 | 공유 기본 키와 조합 기본 키를 쓴 데에 종속된 수명이라는 이유가 있는가 |
| 소유 | 짧은 id를 쓰면서도 테넌트와 소유자의 범위를 지키는가 |
| 변경 | 키의 변경과 삭제, 병합의 시나리오와 이관의 경로가 있는가 |
비식별 관계를 기본으로 쓰되, 식별 관계가 더 정확하고 단순한 곳은 예외로 남깁니다. 일관성은 모든 테이블의 기본 키 모양이 같다는 것이 아니라, 결정하는 기준과 확인하는 방법이 같다는 것입니다.
정리
- 대리 키를 기본으로 쓸 때도 업무의 후보 키는 UNIQUE로 함께 지킵니다.
- 자연의 값을 다른 테이블에 복사하는 것은 식별 관계가 아니라 중복 저장이며, 원래의 값이 바뀌면 갈라집니다.
- 공유 기본 키와 조합 기본 키는 종속된 수명이 강한 관계에서 정당한 예외입니다.
- 짧은 id가 소유와 테넌트의 검증을 저절로 해결하지는 않습니다.
- 논리 모델은 변경의 예제 데이터와, 빈 결과를 돌려주어야 하는 진단 쿼리로 확인합니다.
연습 문제
1. 게시글의 공유 링크
게시글의 공유 링크는 따로 된 URL 토큰으로 참조되고, 게시글마다 여러 개이며, 만료와 폐기의 상태가 있습니다. post_id와 순서로 식별하는 키와, share_link_id라는 대리 키를 비교해 설계해 보세요.
해설 보기
바깥의 토큰과 폐기의 수명, 따로 된 참조가 있으므로, share_link_id 대리 기본 키와 토큰의 UNIQUE, post_id의 외래 키를 고릅니다. 게시글별로 보여 줄 순서가 필요하다면 조합의 UNIQUE를 따로 둡니다.
DROP TABLE IF EXISTS post_share_link;
CREATE TABLE post_share_link (
share_link_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
public_token BINARY(16) NOT NULL,
status VARCHAR(16) NOT NULL,
expires_at DATETIME(6) NULL,
PRIMARY KEY (share_link_id),
UNIQUE (public_token),
FOREIGN KEY (post_id) REFERENCES posts (id),
CHECK (status IN ('ACTIVE', 'REVOKED', 'EXPIRED'))
);게시글 10에 공유 링크 100과 200이 서로 다른 토큰으로 이어져 있다고 해 봅시다. 링크 100을 ACTIVE에서 REVOKED로 바꾸면 바뀐 행은 링크 100 하나이고, 게시글 10과 링크 200의 식별자는 그대로입니다. 링크 100의 토큰을 가진 새 행은 상태가 바뀐 뒤에도 UNIQUE가 거부하므로, 폐기한 토큰을 다시 발급할 수 있는지는 대표 키와 별개의 재사용 규칙으로 정합니다.
expires_at에 지난 시각이 들어 있다고 CHECK가 status를 저절로 EXPIRED로 바꾸지는 않으며, 이 CHECK는 상태 문자열의 허용 목록만 검사합니다. 링크를 쓸 때 상태와 만료 시각을 함께 판단하는 조건과 EXPIRED로 기록하는 처리, expires_at이 NULL일 때의 뜻은 서비스의 규칙으로 정합니다. 토큰으로 링크를 찾는 조회는 UNIQUE (public_token)이 만드는 인덱스를 쓰고, 만료 배치에 쓸 인덱스는 7장 4편에서 본 것처럼 배치가 실제로 거는 조건과 호출량을 보고 정합니다.
2. 다른 회원의 답글을 고치는 요청
API가 UPDATE logical_reply SET body = ? WHERE reply_id = ?로 답글을 고칩니다. 이 쿼리에는 어떤 위험이 있고, 어떻게 고쳐야 하나요?
해설 보기
reply_id만 알면 누구의 답글이든 고칠 수 있습니다. 대리 키는 숫자가 이어져 있어 추측하기도 쉬우므로, 로그인한 사용자가 다른 회원의 답글 id를 넣으면 그 답글이 바뀝니다. WHERE reply_id = ? AND author_id = ?처럼 소유자의 조건을 함께 두고, 영향받은 행이 0이면 "없거나 권한이 없음"으로 응답합니다. 테넌트가 있는 시스템이라면 테넌트의 조건도 함께 둡니다. 키가 짧고 안정적이라는 것과, 그 키로 행을 고칠 권한이 있다는 것은 별개의 문제입니다.
다음 글 정규화와 함수 종속부터는 완성한 논리 모델에서 부분 종속과 이행 종속을 없애고, 이름과 타입, 인덱스, 역정규화를 MySQL의 물리 모델로 확정합니다.