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

안동민 개발노트

본문 시작
9장 : 개념 모델과 ERD

ERD와 데이터 모델 검증

회원·게시글·댓글·카테고리·태그·첨부파일 요구를 ERD로 연결하고 반복 컬럼·고아 관계·잘못된 카디널리티를 검증 쿼리로 차단합니다.

ERD는 상자를 보기 좋게 배치한 그림이 아니라 업무 문장을 데이터 규칙으로 번역한 결과입니다.

회원은 게시글을 여러 개 작성할 수 있고 게시글은 작성자 한 명에게 속합니다.

게시글에는 댓글·태그·첨부파일이 없을 수도 있고 여러 개 있을 수도 있습니다.

댓글 수를 comment_1, comment_2 열로 펼치면 세 번째 댓글마다 DDL과 모든 쿼리를 바꿔야 합니다.

태그와 첨부파일도 같은 이유로 반복 열이 아니라 관계 행으로 분리합니다.

이 문서는 앞의 엔티티·카디널리티 결정을 하나의 회원 게시판 ERD로 합치고, 각 선을 위반 데이터와 진단 SQL로 검증합니다.


반복 열의 확장 한계

고정 슬롯 모델은 최대 개수를 스키마에 박아 넣고 빈 구간을 허용합니다.

첫 댓글은 NULL인데 두 번째 댓글만 존재하는 구조도 타입만 맞으면 저장됩니다.

댓글을 고정 컬럼으로 펼친 잘못된 모델
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, '첫 슬롯 없이 두 번째 댓글');

댓글 추가는 행 INSERT가 아니라 ALTER TABLE이 되고 댓글별 작성 시각·상태·부모 댓글 같은 속성이 늘 때마다 열 묶음이 반복됩니다.

댓글을 검색하거나 회원별 작성 수를 세려면 모든 슬롯을 UNION해야 합니다.

오류 실행 결과
post_id=1
comment_1_content=NULL
comment_2_content='첫 슬롯 없이 두 번째 댓글'
SQL error=none
third comment column=absent

반복 열은 댓글을 독립적인 반복 사실로 보지 않고 고정된 화면 칸으로 오해한 결과입니다.

댓글은 자기 식별자·작성자·시각·상태를 가지므로 별 엔티티가 적합합니다.

반복 속성을 엔티티로 승격하는 질문

  1. 개수 — 요구가 0..N인데 스키마가 임의의 최대값을 두고 있는가?
  2. 정체성 — 댓글 하나를 수정·삭제·신고할 때 가리킬 안정적인 ID가 필요한가?
  3. 속성 — 작성자·시각·상태가 각 반복값에 따로 붙는가?
  4. 검색 — 댓글 자체를 정렬·집계·페이지 처리해야 하는가?

회원 게시판 개념 모델

핵심 엔티티와 관계를 다음처럼 고정합니다.

  • members 1:N posts: 게시글은 작성자 한 명이 필수입니다.
  • posts 1:N comments: 댓글은 게시글 한 개가 필수입니다.
  • members 1:N comments: 댓글도 작성자 한 명이 필수입니다.
  • categories 1:N posts: 카테고리는 선택 사항이며 게시글은 최대 한 카테고리에 속합니다.
  • posts M:N tags: post_tags가 연결 관계를 가집니다.
  • posts M:N attachments: post_attachments가 순서·설명 같은 관계 속성을 가집니다.

카디널리티는 현재 예제 데이터 건수가 아니라 허용 규칙입니다.

댓글이 아직 0개인 게시글도 posts 1:N comments 관계에서 정상입니다.

관계마다 키와 수명 결정하기

  1. 대표 키 — 회원·게시글·댓글·첨부파일은 단일 대리 키를 사용합니다.
  2. 업무 키 — 이메일과 (author_id, request_key), 태그 이름은 별도 UNIQUE로 보존합니다.
  3. 연결 키post_tags(post_id, tag_id)는 두 FK 조합을 PK로 사용합니다.
  4. 삭제 규칙 — 회원 삭제는 게시글 보존 때문에 RESTRICT, 게시글 삭제 시 연결 행은 CASCADE 여부를 명시합니다.

논리 모델 DDL

개념 모델을 논리 키와 제약으로 옮긴 최소 골격입니다.

타입 길이·인덱스·물리 배치는 뒤 장에서 확정합니다.

아래 통합 예제는 ch9-1의 회원·게시글 두 건과 ch9-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')),
  CONSTRAINT chk_concept_comments_not_self
    CHECK (parent_comment_id IS NULL OR parent_comment_id <> id)
) 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 = 'min@example.com'
);
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건입니다.

이어지는 일치 검사 네 개는 모두 0행이어야 합니다.

대댓글의 parent_comment_id는 같은 테이블을 참조하지만 부모가 같은 게시글에 속하는지 FK 하나만으로는 보장하지 못합니다.

복합 FK를 사용하거나 서비스 트랜잭션에서 부모의 post_id를 확인하고 일치 검사 쿼리를 운영합니다.


요구사항별 검증 쿼리

ERD의 모든 선은 고아·중복·다중성 오류를 찾는 SQL로 연결합니다.

관계·카디널리티 일치 검사
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;

모든 결과가 0행이어야 합니다.

PK·FK·UNIQUE가 이미 차단하는 오류도 운영 이관·제약 비활성화 경로를 검증하기 위해 일치 검사로 남깁니다.

요구 변경으로 ERD 흔들기

  1. 익명화 — 회원 탈퇴 뒤 게시글·댓글을 보존할 때 작성자 FK와 표시 이름을 어떻게 처리할지 정합니다.
  2. 대댓글 — 깊이 제한이 필요한지, 임의 깊이면 재귀 조회 비용을 어떻게 감당할지 정합니다.
  3. 첨부 공유 — 한 파일을 여러 게시글이 공유할지, 게시글 삭제가 실제 파일 수명에 어떤 영향을 주는지 정합니다.
  4. 태그 병합 — 이름 변경과 중복 태그 병합 중 연결 행을 어떻게 이동할지 정합니다.

삭제·스냅샷·감사 예외

  • 게시글 물리 삭제가 댓글 감사 근거를 없애면 소프트 삭제 상태를 사용합니다.
  • 회원 이름 변경 뒤 과거 표시 이름이 필요한 경우 스냅샷과 현재 조인의 책임을 분리합니다.
  • 첨부파일 원본 삭제와 post_attachments 연결 삭제를 같은 수명으로 가정하지 않습니다.
  • comments.status 변경 이력이 업무라면 현재 상태 열과 별도 감사 사건을 구분합니다.

ERD 실습 완료 기준

판단 축확인할 질문
엔티티독립 식별·수명·속성을 가진 사실을 별 테이블로 두었는가?
관계모든 FK를 양방향 최소·최대 수로 읽었는가?
반복0..N 값을 고정 컬럼이나 CSV가 아니라 행으로 표현했는가?
수명회원·게시글·댓글·첨부파일 삭제 규칙이 감사 요구와 맞는가?
검증각 관계의 고아·중복·불일치 SQL 결과가 0행인가?

ERD 리뷰는 상자 이름이 아니라 허용해서는 안 되는 상태를 실제 INSERT로 시도하는 과정입니다.

선 하나를 추가할 때 FK·UNIQUE·삭제 정책·일치 검사도 함께 추가합니다.


연습 문제

한 댓글에 여러 회원이 좋아요를 누를 수 있고 같은 회원은 같은 댓글에 한 번만 누를 수 있습니다.

댓글 삭제 시 반응은 제거하지만 회원 삭제는 반응을 익명화해 보존하려는 요구를 모델링하세요.

해설과 예시 답안

comment_reactions를 댓글과 회원 사이의 관계 엔티티로 둡니다.

(comment_id, member_id, reaction_type) 후보 키가 중복 반응을 막고 삭제 정책은 두 FK에서 서로 다르게 선택합니다.

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;

회원 FK를 NULL로 바꾼 뒤 UNIQUE의 NULL 의미가 중복 익명 반응을 허용하는지 검토하고, 필요하면 익명화 전 별도 불변 식별자를 보존합니다.


핵심 정리

  • 회원 게시판의 핵심은 회원·게시글·댓글과 태그·첨부파일 연결 관계입니다.
  • 0..N 반복값은 고정 슬롯이 아니라 관계 행으로 표현합니다.
  • 카디널리티는 현재 건수가 아니라 허용 가능한 최소·최대 수입니다.
  • ERD의 모든 선은 제약과 일치 검사 SQL로 검증합니다.

다음 장에서는 이 개념 모델의 자연 키·대리 키와 일대일·일대다·다대다 키 배치를 구체화합니다.