ERD와 데이터 모델 검증
회원과 게시글, 댓글, 카테고리, 태그, 첨부파일의 요구를 하나의 ERD로 잇고, 댓글을 고정된 열로 펼친 모델의 한계를 확인합니다. 개념 모델을 관계의 키와 제약으로 옮긴 DDL과, 고아 행과 중복, 부모 댓글의 게시글 불일치를 찾는 진단 쿼리로 모델을 검증하는 방법을 다룹니다.
ERD는 상자를 보기 좋게 늘어놓은 그림이 아니라, 업무의 문장을 데이터의 규칙으로 옮긴 결과입니다. 회원은 게시글을 여러 개 쓸 수 있고, 게시글은 작성자 한 명에게 속하며, 게시글에는 댓글과 태그, 첨부파일이 없을 수도 있고 여러 개 있을 수도 있습니다.
댓글을 comment_1, comment_2 같은 열로 펼치면, 세 번째 댓글을 더하려 할 때 DDL과 모든 쿼리를 바꾸어야 합니다. 태그와 첨부파일도 같은 이유로 반복 열이 아니라 관계의 행으로 나눕니다. 이 글은 앞에서 정한 엔티티와 카디널리티를 하나의 회원 게시판 ERD로 합치고, 선언한 제약과 몇 가지 위반 상태를 찾는 진단 SQL을 함께 검토합니다.
반복 열은 넓어지지 않는다
고정된 칸의 모델은 최대 개수를 스키마에 박아 넣고, 빈 칸을 허용합니다. 다음 테이블은 첫 댓글 칸이 비어 있는데 두 번째 칸에만 댓글이 있는 상태도, 타입만 맞으면 저장합니다.
DROP TABLE IF EXISTS post_comments_wide_bad;
CREATE TABLE post_comments_wide_bad (
post_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(80) NOT NULL,
comment_1_author_id BIGINT UNSIGNED NULL,
comment_1_content VARCHAR(1000) NULL,
comment_2_author_id BIGINT UNSIGNED NULL,
comment_2_content VARCHAR(1000) NULL,
PRIMARY KEY (post_id)
) ENGINE = InnoDB;
INSERT INTO post_comments_wide_bad (
post_id, author_id, title,
comment_2_author_id, comment_2_content
)
VALUES (1, 1, '반복 열의 문제', 2, '첫 슬롯 없이 두 번째 댓글');
SELECT post_id, comment_1_content, comment_2_content
FROM post_comments_wide_bad;post_id | comment_1_content | comment_2_content
1 | NULL | 첫 슬롯 없이 두 번째 댓글SQL은 아무 오류 없이 이 상태를 받아들였고, 세 번째 댓글을 넣을 열은 아예 없습니다. 댓글을 더하는 일이 행의 INSERT가 아니라 ALTER TABLE이 되며, 댓글마다 작성 시각과 상태, 부모 댓글 같은 속성이 늘 때마다 열의 묶음도 되풀이됩니다. 댓글을 검색하거나 회원별 댓글 수를 세려면 모든 칸을 UNION해야 합니다.
반복 열은 댓글을 따로 되풀이되는 사실이 아니라, 고정된 화면의 칸으로 오해한 결과입니다. 댓글은 자기 식별자와 작성자, 시각, 상태를 가지므로 별도의 엔티티가 알맞습니다. 반복 속성을 엔티티로 올릴지 판단할 때는, 요구가 0..N인데 스키마가 임의의 최댓값을 두었는지, 댓글 하나를 고치거나 지우거나 신고할 때 가리킬 안정적인 id가 필요한지, 작성자와 시각, 상태가 반복되는 값마다 따로 붙는지, 댓글 자체를 정렬하고 집계하고 페이지로 나누어야 하는지를 묻습니다.
회원 게시판의 개념 모델
핵심 엔티티와 관계를 다음처럼 고정합니다. 게시글은 작성자 한 명이 꼭 있어야 하는 members 1:N posts이고, 댓글은 게시글 하나와 작성자 한 명이 꼭 있어야 하는 posts 1:N comments와 members 1:N comments입니다. 카테고리는 선택이어서 게시글은 많아야 카테고리 하나에 속하는 categories 1:N posts이고, 게시글과 태그는 post_tags가 잇는 M:N, 게시글과 첨부파일은 순서와 설명 같은 관계의 속성을 가진 post_attachments가 잇는 M:N입니다.
카디널리티는 지금 예제 데이터의 건수가 아니라 허용하는 규칙입니다. 댓글이 아직 0개인 게시글도 posts 1:N comments의 관계에서 정상입니다. 아래 도표는 주요 키와 관계만 나타내며, #는 기본 키, →는 외래 키, 0..N은 없거나 여러 개를 뜻합니다.
ER MODEL · ENTITIES / RELATION ROWS
게시글의 반복 사실을 댓글과 연결 행으로 분리한다
게시글은 작성자 한 명이 필수이고 카테고리는 선택입니다. 댓글·태그·첨부파일이 0개여도 정상이며 여러 건은 고정 열 대신 별 행으로 저장합니다.
회원·카테고리·게시글·댓글
게시글·태그의 연결
게시글·첨부파일의 연결
# 기본 키 · → 외래 키 · 0..N 없거나 여러 개 · 0..1 없거나 한 개. 첨부 연결은 순서·설명을 소유하며 (post_id, display_order)도 UNIQUE입니다.
댓글의 부모 FK와 서비스 검증을 구분
parent_comment_id가 NULL이면 루트 댓글입니다. 값이 있으면 존재하는 부모 댓글을 참조합니다.
그 FK만으로 자기 참조·여러 댓글 순환·같은 게시글 소속까지 보장하지는 않습니다. 그림은 댓글 재귀선을 생략했고 본문의 트랜잭션 검증과 게시글 불일치 진단 SQL을 함께 봅니다.
관계마다 키와 수명을 정합니다. 회원과 게시글, 댓글, 첨부파일은 단일한 대리 키를 쓰고, 이메일과 카테고리의 코드, 태그의 이름은 별도의 UNIQUE로 지킵니다. 이 개념 스키마의 게시글에는 누적 모델 posts의 request_key가 아직 없다는 점도 기억해 둡니다. post_tags(post_id, tag_id)는 두 외래 키의 조합을 기본 키로 쓰고, 회원의 삭제는 게시글을 보존하려고 RESTRICT로 두며, 게시글을 지울 때 연결 행을 CASCADE로 함께 지울지는 명시합니다.
논리 모델의 DDL
개념 모델을 논리의 키와 제약으로 옮긴 최소한의 골격입니다. 타입의 길이와 인덱스, 물리적인 배치는 뒤의 장에서 확정합니다. 아래 통합 예제는 9장 1편의 회원과 게시글 두 개, 9장 2편의 첨부파일 하나를 만든 상태에서 실행합니다. 이 글의 첨부 연결은 보여 줄 순서와 설명을 예로 들며, 앞 글의 붙인 사람과 시각 같은 감사 속성까지 모두 담은 최종 모델은 아닙니다.
DROP TABLE IF EXISTS concept_comment_reactions;
DROP TABLE IF EXISTS concept_post_attachments;
DROP TABLE IF EXISTS concept_post_tags;
DROP TABLE IF EXISTS concept_tags;
DROP TABLE IF EXISTS concept_comments;
CREATE TABLE concept_comments (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
parent_comment_id BIGINT UNSIGNED NULL,
content VARCHAR(1000) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'VISIBLE',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
CONSTRAINT fk_concept_comments_post
FOREIGN KEY (post_id) REFERENCES concept_posts (post_id),
CONSTRAINT fk_concept_comments_author
FOREIGN KEY (author_id) REFERENCES concept_members (member_id),
CONSTRAINT fk_concept_comments_parent
FOREIGN KEY (parent_comment_id) REFERENCES concept_comments (id),
CONSTRAINT chk_concept_comments_content
CHECK (CHAR_LENGTH(content) BETWEEN 1 AND 1000),
CONSTRAINT chk_concept_comments_status
CHECK (status IN ('VISIBLE', 'HIDDEN', 'DELETED'))
) ENGINE = InnoDB;
CREATE TABLE concept_tags (
tag_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(80) NOT NULL,
PRIMARY KEY (tag_id),
UNIQUE KEY uq_concept_tags_name (name)
) ENGINE = InnoDB;
CREATE TABLE concept_post_tags (
post_id BIGINT UNSIGNED NOT NULL,
tag_id BIGINT UNSIGNED NOT NULL,
attached_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (post_id, tag_id),
FOREIGN KEY (post_id) REFERENCES concept_posts (post_id),
FOREIGN KEY (tag_id) REFERENCES concept_tags (tag_id)
) ENGINE = InnoDB;
CREATE TABLE concept_post_attachments (
post_id BIGINT UNSIGNED NOT NULL,
attachment_id BIGINT UNSIGNED NOT NULL,
display_order SMALLINT UNSIGNED NOT NULL,
caption VARCHAR(200) NULL,
PRIMARY KEY (post_id, attachment_id),
UNIQUE (post_id, display_order),
FOREIGN KEY (post_id) REFERENCES concept_posts (post_id),
FOREIGN KEY (attachment_id)
REFERENCES concept_attachments (attachment_id)
) ENGINE = InnoDB;
SET @concept_member_id := (
SELECT member_id FROM concept_members WHERE email = '[email protected]'
);
SET @concept_post_id := (SELECT MIN(post_id) FROM concept_posts);
SET @concept_attachment_id := (
SELECT MIN(attachment_id) FROM concept_attachments
);
INSERT INTO concept_comments
(post_id, author_id, content, status, created_at)
VALUES
(@concept_post_id, @concept_member_id, '관계 모델이 이해됩니다.',
'VISIBLE', '2026-07-14 13:00:00');
SET @concept_parent_comment_id := LAST_INSERT_ID();
INSERT INTO concept_comments
(post_id, author_id, parent_comment_id, content, status, created_at)
VALUES
(@concept_post_id, @concept_member_id, @concept_parent_comment_id,
'대댓글도 같은 게시글에 둡니다.', 'VISIBLE',
'2026-07-14 13:10:00');
INSERT INTO concept_tags (name) VALUES ('mysql');
SET @concept_tag_id := LAST_INSERT_ID();
INSERT INTO concept_post_tags (post_id, tag_id, attached_at)
VALUES (@concept_post_id, @concept_tag_id, '2026-07-14 13:20:00');
INSERT INTO concept_post_attachments
(post_id, attachment_id, display_order, caption)
VALUES
(@concept_post_id, @concept_attachment_id, 1, '인덱스 참고 자료');이 블록을 실행하면 댓글 2건과 태그 1건, 태그 연결 1건, 첨부파일 연결 1건이 생깁니다. 9장 2편의 concept_post_attachments와 연습 문제의 태그 연결은 여기서 지우고 새 구조로 다시 만듭니다.
parent_comment_id의 외래 키는 부모 댓글이 있다는 것만 보장합니다. 자기 자신을 부모로 삼거나, 여러 댓글이 돌며 순환하거나, 다른 게시글의 댓글을 부모로 삼는 경우까지 막지는 않습니다. MySQL 8.4의 CHECK는 AUTO_INCREMENT 열인 id를 참조할 수 없으므로 이 예제는 그런 CHECK를 선언하지 않았고, 자기 참조와 순환, 게시글의 일치는 서비스 트랜잭션에서 검사해야 합니다. 아래의 진단 SQL은 그 가운데 게시글의 불일치만 확인합니다.
요구 사항별 진단 쿼리
다음 네 쿼리는 댓글의 작성자와 게시글이 고아인지, 부모 댓글과 게시글이 어긋나는지, 태그 연결이 겹치는지, 첨부의 순서가 겹치는지를 확인합니다. ERD의 모든 관계나 댓글의 자기 참조와 순환까지 전부 검사하는 목록은 아닙니다.
SELECT c.id
FROM concept_comments AS c
LEFT JOIN concept_members AS m
ON m.member_id = c.author_id
LEFT JOIN concept_posts AS p
ON p.post_id = c.post_id
WHERE m.member_id IS NULL OR p.post_id IS NULL;
SELECT child.id, child.post_id, parent.post_id AS parent_post_id
FROM concept_comments AS child
JOIN concept_comments AS parent
ON parent.id = child.parent_comment_id
WHERE child.post_id <> parent.post_id;
SELECT post_id, tag_id, COUNT(*) AS duplicate_count
FROM concept_post_tags
GROUP BY post_id, tag_id
HAVING COUNT(*) > 1;
SELECT post_id, display_order, COUNT(*) AS duplicate_count
FROM concept_post_attachments
GROUP BY post_id, display_order
HAVING COUNT(*) > 1;네 쿼리 모두 빈 결과를 돌려주어야 합니다. 대댓글은 부모와 같은 게시글에 두었으므로 둘째 쿼리도 비어 있습니다. 기본 키와 외래 키, UNIQUE가 이미 막는 오류도, 이관하거나 제약을 잠시 끈 경로를 검증하려고 진단 쿼리로 남겨 둡니다.
요구가 바뀌면 ERD도 흔들어 봅니다. 회원이 탈퇴한 뒤 게시글과 댓글을 남긴다면 작성자의 외래 키와 표시 이름을 어떻게 처리할지 정하고, 대댓글에 깊이 제한이 필요한지, 깊이에 제한이 없다면 재귀 조회의 비용을 어떻게 감당할지 정합니다(14장). 파일 하나를 여러 게시글이 함께 쓸지와 게시글의 삭제가 실제 파일의 수명에 어떤 영향을 주는지, 태그의 이름을 바꾸거나 겹친 태그를 합칠 때 연결 행을 어떻게 옮길지도 정합니다.
게시글을 물리적으로 지우면 댓글의 감사 기록까지 사라진다면 소프트 삭제의 상태를 씁니다. 회원의 이름이 바뀐 뒤에도 지난 표시 이름이 필요하다면 스냅샷과 지금의 JOIN이 맡는 일을 나누고, 첨부파일의 원래 행을 지우는 것과 post_attachments의 연결을 지우는 것을 같은 수명으로 가정하지 않습니다. comments.status가 바뀐 이력이 업무에 필요하다면, 지금의 상태 열과 별도의 감사 사건을 구별합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 엔티티 | 따로 식별되고 수명과 속성을 가진 사실을 별도의 테이블로 두었는가 |
| 관계 | 모든 외래 키를 양쪽 방향의 최소와 최대의 수로 읽었는가 |
| 반복 | 0..N의 값을 고정된 열이나 쉼표 문자열이 아니라 행으로 나타냈는가 |
| 수명 | 회원과 게시글, 댓글, 첨부파일의 삭제 규칙이 감사의 요구와 맞는가 |
| 확인 | 관계마다 고아와 중복, 불일치를 찾는 SQL이 빈 결과를 돌려주는가 |
ERD의 리뷰는 상자의 이름을 보는 일이 아니라, 허용해서는 안 되는 상태를 실제 INSERT로 시도해 보는 과정입니다. 선 하나를 더할 때마다 외래 키와 UNIQUE, 삭제의 규칙, 진단 쿼리도 함께 더합니다.
정리
- 회원 게시판의 핵심은 회원과 게시글, 댓글에 태그와 첨부파일의 연결 관계를 더한 것입니다.
- 0..N으로 되풀이되는 값은 고정된 칸이 아니라 관계의 행으로 나타냅니다.
- 카디널리티는 지금의 건수가 아니라 허용되는 최소와 최대의 수입니다.
- ERD의 관계마다 제약과 진단 SQL, 서비스의 검증이 각각 어디까지 보장하는지 구별합니다.
연습 문제
1. 댓글의 좋아요
한 댓글에 여러 회원이 좋아요를 누를 수 있고, 같은 회원은 같은 댓글에 한 번만 누를 수 있습니다. 댓글을 지우면 반응도 지우지만, 회원을 지우면 반응을 익명으로 바꾸어 남기려 합니다. 이 요구를 모델링해 보세요.
해설 보기
comment_reactions를 댓글과 회원 사이의 관계 엔티티로 둡니다. (comment_id, member_id, reaction_type)의 UNIQUE가 회원마다 같은 종류의 반응을 한 번으로 제한하고, 삭제의 규칙은 두 외래 키에서 서로 다르게 고릅니다.
CREATE TABLE concept_comment_reactions (
reaction_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
comment_id BIGINT UNSIGNED NOT NULL,
member_id BIGINT UNSIGNED NULL,
reaction_type VARCHAR(16) NOT NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (reaction_id),
UNIQUE (comment_id, member_id, reaction_type),
FOREIGN KEY (comment_id)
REFERENCES concept_comments (id) ON DELETE CASCADE,
FOREIGN KEY (member_id)
REFERENCES concept_members (member_id) ON DELETE SET NULL
) ENGINE = InnoDB;
ALTER TABLE concept_comment_reactions
ADD CONSTRAINT chk_concept_comment_like
CHECK (reaction_type = 'LIKE');좋아요만 제공한다는 조건은 마지막 CHECK로 데이터베이스의 값 목록에도 적용했습니다. 종류마다 반응을 여러 개 허용하는 요구로 바뀌면 후보 키부터 다시 검토합니다.
같은 댓글 C에 회원 11과 42의 LIKE가 하나씩 있다면 두 조합이 달라 모두 허용됩니다. 다른 참조의 삭제 규칙도 만족한 상황에서 두 회원을 지우면, 반응은 지워지지 않고 member_id가 NULL인 두 행으로 남습니다. UNIQUE는 NULL이 든 키의 행을 여러 개 허용하므로 두 행이 하나로 합쳐지지 않으며, 이어서 댓글 C를 지우면 댓글 외래 키의 CASCADE가 두 반응을 함께 지웁니다.
회원의 외래 키가 NULL로 바뀐 뒤 UNIQUE의 NULL 규칙이 익명 반응의 중복을 허용해도 되는지 검토하고, 필요하면 익명으로 바꾸기 전에 별도의 불변 식별자를 남깁니다. 이 SET NULL은 반응 관계의 동작일 뿐이며, 게시글이나 댓글의 다른 외래 키가 같은 회원의 삭제를 거부할 수 있으므로 전체 참조의 보존 규칙도 함께 정해야 합니다.
2. 다른 게시글의 부모 댓글
parent_comment_id가 다른 게시글의 댓글을 가리키는 대댓글을 넣어 보면, 본문의 진단 쿼리 가운데 어느 것이 그 행을 찾나요? 그런 행이 처음부터 들어오지 못하게 하려면 어떻게 해야 하나요?
해설 보기
부모의 외래 키는 부모 댓글이 있다는 것만 확인하므로 INSERT는 성공하고, 둘째 쿼리가 child.post_id와 parent.post_id가 다른 그 행을 찾습니다. 처음부터 막으려면 5장 4편처럼 부모 쪽에 (post_id, id)의 UNIQUE를 두고, (post_id, parent_comment_id)가 그것을 가리키는 복합 외래 키를 걸 수 있습니다. 최상위 댓글은 parent_comment_id가 NULL이므로, 열 하나가 NULL인 복합 외래 키는 검사되지 않아 그대로 허용됩니다. 이 방법을 쓰지 않는다면 댓글을 쓰는 트랜잭션에서 부모의 게시글을 확인합니다.
다음 글 키의 종류와 역할부터는 이 개념 모델의 자연 키와 대리 키, 일대일과 일대다, 다대다 관계에서 키를 어디에 둘지 구체화합니다.