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

안동민 개발노트

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

엔티티·속성·식별자

첨부파일 공유 요구를 수명과 정체성 질문으로 분해해 속성으로 묻힌 엔티티를 찾아냅니다.

명사를 전부 테이블로 만들면 값 객체까지 과도하게 분리되고, 화면 입력칸을 전부 속성으로 두면 독립 수명을 가진 개념이 한 행에 묻힙니다.

엔티티 후보는 이름보다 생성·변경·참조·보존 수명과 자체 식별 필요성으로 판단합니다.

요구사항은 ‘첨부파일은 URL과 제목을 가지며 여러 게시글에서 재사용할 수 있다.

게시글에 첨부파일을 연결한 사람과 메모를 기록한다.

URL이 바뀌어도 같은 첨부파일 정체성은 유지한다’입니다.

게시글에 attachment_urlattachment_name을 직접 넣는 설계부터 오류를 확인합니다.


재사용 첨부파일의 중복 저장

같은 첨부파일이 게시글마다 반복되고 제목 변경은 여러 행 UPDATE가 됩니다.

게시글 없이 첨부파일을 북마크할 수 없고, 한 게시글에 첨부파일 여러 개를 붙이려면 attachment_url_1, attachment_url_2 같은 반복 컬럼이 생깁니다.

첨부파일 정체성과 연결 속성을 게시글에 혼합
CREATE TABLE post_attachment_flat_bad (
  post_id BIGINT UNSIGNED NOT NULL,
  member_id BIGINT UNSIGNED NOT NULL,
  attachment_url VARCHAR(1000) NULL,
  attachment_name VARCHAR(200) NULL,
  attached_by_email VARCHAR(255) NULL,
  attachment_note VARCHAR(500) NULL,
  PRIMARY KEY (post_id)
);
INSERT INTO post_attachment_flat_bad VALUES
  (1, 1, 'https://dev.mysql.com/doc/refman/8.4/en/', 'MySQL Manual', 'min@example.com', '인덱스 읽기'),
  (2, 1, 'https://dev.mysql.com/doc/refman/8.4/en/', 'MYSQL 문서', 'min@example.com', '트랜잭션 읽기');
SELECT attachment_url, COUNT(DISTINCT attachment_name) AS titles
FROM post_attachment_flat_bad GROUP BY attachment_url;

같은 URL 정체성에 서로 다른 현재 제목 두 개가 저장되어 어떤 값이 원본인지 알 수 없습니다.

오류 실행 결과
attachment_url                                      | titles
https://dev.mysql.com/doc/refman/8.4/en/             | 2

independent attachment rows: impossible
attachments per post: maximum 1

URL과 제목은 첨부파일의 속성이고 attached_by와 메모는 게시글-첨부파일 연결의 속성입니다.

소유권이 다른 값을 한 행에 두면서 첨부파일 재사용과 관계의 여러 건을 모두 잃었습니다.

URL은 변경될 수 있으므로 대표 정체성으로 직접 참조하면 변경이 관계까지 전파됩니다.

안정적인 attachment_id와 URL 후보 키 책임을 분리합니다.

첨부파일과 연결 속성의 소유자를 가르기

  1. 수명 — 게시글 없이 생성·수정·비활성화할 수 있는 개념인지 묻습니다.
  2. 재사용 — 여러 부모가 같은 값을 참조하는지 확인합니다.
  3. 다중성 — 한 게시글에 첨부파일이 몇 개 가능한지 상한을 요구에서 찾습니다.
  4. 속성 소유 — 메모가 첨부파일 자체인지 이 게시글에 붙인 행인지 구분합니다.

엔티티와 속성의 소유권

attachments는 게시글과 독립적으로 생성되고 여러 게시글이 참조하므로 엔티티입니다.

첨부 관계는 게시글과 첨부파일의 조합에 의존하지만 attached_by·메모·attached_at을 가지므로 관계 엔티티입니다.

URL은 변경 가능한 후보 키이지 대표 정체성 전체가 아닙니다.

식별자는 화면 표시값과 분리합니다.

내부 attachment_id, 업무 유일성 canonical_url, 표시 제목은 각각 대표 PK·후보 키·일반 속성의 책임을 가집니다.

엔티티를 성격·역할·의존성으로 분류하기

엔티티 분류는 용어 시험이 아니라 요구사항에서 서로 다른 수명과 식별 규칙을 발견하는 도구입니다.

하나의 엔티티가 여러 분류 축에 동시에 속할 수 있으므로 “게시글은 사건 엔티티이면서 행위 엔티티다”처럼 읽습니다.

분류 축종류회원 게시판 예시
존재 형태유형 엔티티현실에서 독립된 대상을 식별member
존재 형태개념 엔티티분류·정책처럼 추상적인 개념을 관리category, tag
존재 형태사건 엔티티특정 시점에 발생한 일을 기록posts
업무 역할기본 엔티티다른 행위가 없어도 먼저 존재member, attachments
업무 역할중심 엔티티여러 기본·행위 엔티티를 업무 흐름의 중심에서 연결posts
업무 역할행위 엔티티가입·게시·첨부처럼 업무 사건을 기록posts, post_attachments
존재 의존성강한 엔티티자체 식별자와 독립 수명을 가짐attachments
존재 의존성약한 엔티티부모 없이는 식별·존재할 수 없음(post_id, sequence_no)로 식별되는 comment

구조를 표현하기 위한 특수한 분류도 있습니다.

연관 엔티티는 M:N 관계를 한 행으로 승격해 관계 자체의 속성을 담습니다.

이 문서의 post_attachments가 그 예입니다.

슈퍼타입·서브타입은 여러 종류가 공통 식별자와 속성을 공유할 때 사용하며 17장에서 단일 테이블·구체 테이블·분리 테이블 전략을 비교합니다.

요구 문장 “회원은 여러 첨부파일을 게시글에 첨부하고, 첨부마다 메모를 남긴다”를 분류하면 다음 결과가 나옵니다.

후보분류 결과식별·수명 판단
회원유형·기본·강한 엔티티member_id로 독립 존재
첨부파일기본·강한 엔티티게시글과 별개로 생성·재사용
게시글사건·행위 엔티티회원에게 속하지만 자체 사건 ID를 가짐
첨부 관계행위·연관 엔티티게시글과 첨부파일 조합에 의존하고 메모·시각을 소유

분류 이름만으로 테이블을 자동 생성해서는 안 됩니다.

관리자라는 명사가 요구사항에 등장해도 별 수명·속성·식별이 없고 회원의 역할일 뿐이라면 member_role 같은 속성이나 관계로 충분할 수 있습니다.

반대로 처음에는 단순 역할이던 판매자가 독립 정산·규칙·수명을 갖게 되면 별 엔티티로 승격할 근거가 생깁니다.

수명·재사용·식별 질문으로 후보 좁히기

  1. 문장 주어 — 독립적으로 행동하거나 참조되는 주어를 후보로 표시합니다.
  2. 소유권 — 각 속성을 하나의 엔티티 또는 관계에만 배치합니다.
  3. 식별 — 유일성·최소성·변경 안정성을 별 질문으로 검토합니다.
  4. 파생값 — 다른 값으로 재계산할 수 있는 표시값을 원본과 구분합니다.

관계 엔티티 도출

첨부파일 원본과 첨부 관계를 분리하고 한 게시글-첨부파일 조합 중복을 UNIQUE로 막습니다.

누가 언제 어떤 메모로 붙였는지는 연결 행에 둡니다.

아래 예제는 ch9-1의 concept_members와 게시글 두 건을 먼저 만든 상태에서 실행합니다.

attachments와 post_attachments 관계 모델
DROP TABLE IF EXISTS concept_post_attachments;
DROP TABLE IF EXISTS concept_attachments;

CREATE TABLE concept_attachments (
  attachment_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  canonical_url VARCHAR(700) NOT NULL,
  title VARCHAR(200) NOT NULL,
  PRIMARY KEY (attachment_id),
  UNIQUE (canonical_url)
);
CREATE TABLE concept_post_attachments (
  post_id BIGINT UNSIGNED NOT NULL,
  attachment_id BIGINT UNSIGNED NOT NULL,
  attached_by_member_id BIGINT UNSIGNED NOT NULL,
  attachment_note VARCHAR(500) NULL,
  attached_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (post_id, attachment_id),
  FOREIGN KEY (post_id) REFERENCES concept_posts (post_id),
  FOREIGN KEY (attachment_id) REFERENCES concept_attachments (attachment_id),
  FOREIGN KEY (attached_by_member_id) REFERENCES concept_members (member_id)
);

SET @concept_member_id := (
  SELECT member_id
  FROM concept_members
  WHERE email = 'min@example.com'
);
SET @concept_post_1 := (SELECT MIN(post_id) FROM concept_posts);
SET @concept_post_2 := (SELECT MAX(post_id) FROM concept_posts);

INSERT INTO concept_attachments (canonical_url, title)
VALUES ('https://cdn.example.com/mysql-index.pdf', 'MySQL 인덱스 참고 자료');

SET @concept_attachment_id := LAST_INSERT_ID();

INSERT INTO concept_post_attachments
  (post_id, attachment_id, attached_by_member_id, attachment_note,
   attached_at)
VALUES
  (@concept_post_1, @concept_attachment_id, @concept_member_id,
   '인덱스 게시글의 보충 자료', '2026-07-14 11:00:00'),
  (@concept_post_2, @concept_attachment_id, @concept_member_id,
   '트랜잭션 글에서도 함께 참고', '2026-07-14 11:10:00');

SELECT COUNT(*) AS attachment_rows
FROM concept_attachments;

SELECT COUNT(*) AS post_attachment_rows
FROM concept_post_attachments;

SELECT COUNT(DISTINCT r.title) AS titles_for_same_attachment
FROM concept_attachments AS r
JOIN concept_post_attachments AS sr
  ON sr.attachment_id = r.attachment_id
WHERE r.attachment_id = @concept_attachment_id;

같은 첨부파일 한 행을 여러 게시글 연결이 참조하고 제목 변경은 첨부파일 한 행만 수정합니다.

같은 게시글-첨부파일 조합의 중복 실패
INSERT INTO concept_post_attachments
  (post_id, attachment_id, attached_by_member_id, attachment_note)
VALUES
  (@concept_post_1, @concept_attachment_id, @concept_member_id,
   '같은 연결을 다시 저장');
개선 실행 결과
attachment rows: 1
post-attachment rows: 2
titles for same attachment_id: 1
attachments per post: 0..N
duplicate same pair: ERROR 1062

복합 PK는 연결 행을 직접 다른 테이블이 참조하지 않는 단순 모델에 적합합니다.

첨부 관계 자체의 수명·참조가 늘면 대리 attachment_id와 조합 UNIQUE를 함께 둘 수 있습니다.

정규형 URL 정규화는 대소문자·프래그먼트·추적 매개변수 정책이 필요합니다.

DB UNIQUE만으로 의미가 같은 모든 URL을 자동 통합할 수 없습니다.

첨부파일 한 행을 여러 게시글에 연결해 보기

  1. 독립 생성 — 첨부파일을 먼저 만들고 게시글 없이 조회합니다.
  2. 두 번 연결 — 같은 attachment_id를 서로 다른 post_id에 넣습니다.
  3. 원본 변경 — 제목 한 행 변경 뒤 두 연결 조회가 같은 제목을 읽는지 봅니다.
  4. 중복 차단 — 같은 조합 재삽입이 1062인지 확인합니다.

다중성과 소유권 검증

첨부파일 중복, 연결 행 증가, 고아 행을 각각 조회해 모델 문장을 실제 행 수와 연결합니다.

첨부파일-게시글 관계 대사
SELECT r.attachment_id, r.title, COUNT(sr.post_id) AS post_count
FROM concept_attachments AS r
LEFT JOIN concept_post_attachments AS sr ON sr.attachment_id = r.attachment_id
GROUP BY r.attachment_id, r.title;

SELECT sr.post_id, COUNT(*) AS attachment_count
FROM concept_post_attachments AS sr
GROUP BY sr.post_id;

SELECT sr.* FROM concept_post_attachments AS sr
LEFT JOIN concept_attachments AS r ON r.attachment_id = sr.attachment_id
WHERE r.attachment_id IS NULL;

첫 쿼리는 첨부파일이 연결 0건이어도 남고, 두 번째는 게시글별 0개 이상 관계 수를 보여 줍니다.

고아 쿼리는 FK 아래 0행입니다.

연결 키 전략 비교

선택얻는 효과감수할 비용적합한 조건
복합 PK 연결조합 정체성 직접 표현하위 참조 키가 넓음관계가 단순할 때
대리 연결 ID확장·참조 용이조합 UNIQUE 추가 필요관계 자체 수명이 클 때
URL 자연 키직접 이해 가능변경·길이·정규화 비용URL이 안정적일 때
attachment_id짧고 안정적 참조별 조회 필요재사용과 변경이 있을 때

URL 정규화와 시점 스냅샷의 경계

다중 값 속성은 관계형 모델에서 반복 컬럼이나 CSV가 아니라 별 행 집합으로 표현합니다.

그러나 전화번호처럼 값 자체의 독립 수명·속성이 거의 없다면 값 테이블인지 엔티티인지 요구 규모에 맞춥니다.

첨부파일 이름의 과거 스냅샷이 필요하면 첨부 관계 시점 제목을 명시적 스냅샷 속성으로 추가하되 현재 제목과 혼동하지 않습니다.

삭제·역할·탈퇴·긴 URL 예외

  • 첨부파일 삭제가 과거 게시글 증거를 없애면 소프트 삭제 또는 RESTRICT가 필요합니다.
  • 한 게시글에 같은 첨부파일을 역할별로 여러 번 붙일 수 있다면 역할이 후보 키에 포함되는지 검토합니다.
  • attached_by 회원이 탈퇴해도 감사 행을 보존할 익명화 정책이 필요합니다.
  • 매우 긴 URL 인덱스는 바이트 길이와 정렬 규칙을 확인하고 정규형 해시 보조값을 검토합니다.

중복 병합과 외부 URL 검증 운영

URL 중복 병합은 연결 FK를 대표 첨부파일로 옮긴 뒤 패자 행을 삭제하는 마이그레이션입니다.

동시 생성에는 UNIQUE 충돌 처리와 재조회 패턴이 필요합니다.

외부 URL 검증을 DB 트랜잭션 안에서 호출하지 않습니다.

검증 상태와 checked_at을 저장해 비동기로 갱신합니다.

속성 카드를 세 소유자에 배치하기

  1. 명사 필터 — 요구 문장의 명사를 수명·재사용·식별 질문으로 평가합니다.
  2. 소유권 카드 — 각 속성 이름을 첨부파일·게시글·첨부 관계 세 칸 중 하나에 둡니다.
  3. 카디널리티 예제 데이터 — 첨부파일 0연결, 1연결, 2연결을 만들고 LEFT JOIN 결과를 봅니다.
  4. 변경 예제 데이터 — URL과 제목 변경을 분리해 후보 키 정책을 논의합니다.

속성 소유권을 정했으므로 다음 문서에서는 관계의 최대·최소 수와 참여도를 명확히 하고 M:N 연결 엔티티에 업무 속성을 둡니다.


엔티티와 속성 구분 기준

판단 축확인할 질문
수명부모 없이 생성·보존·변경될 수 있는가?
재사용여러 행이 같은 정체성을 참조하는가?
식별자체 후보 키와 대표 식별자가 필요한가?
소유권각 속성이 한 사실에만 완전히 종속되는가?
관계 속성두 엔티티 조합에서만 의미가 생기는 값인가?

테이블 수를 줄이는 것보다 사실의 소유권을 한 곳에 두는 것이 우선입니다.

엔티티 후보를 찾은 뒤 지나치게 작은 값 객체를 무조건 테이블로 분리하지는 않습니다.


연습 문제

게시글에 여러 해시태그를 붙이고 태그는 게시글 밖에서도 설명·색상을 가집니다.

자동 추천 여부와 붙인 사람은 연결마다 다릅니다.

엔티티·속성·관계 속성을 나누세요.

해설과 예시 답안

태그는 재사용 정체성과 속성을 가진 엔티티, post_tags는 원본과 attached_by를 가진 관계 엔티티입니다.

태그 이름의 후보 키와 조합 UNIQUE를 둡니다.

CREATE TABLE concept_tag (
  tag_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  name VARCHAR(80) NOT NULL,
  color_hex CHAR(7) NULL,
  PRIMARY KEY (tag_id), UNIQUE (name)
);
CREATE TABLE concept_post_tags (
  post_id BIGINT UNSIGNED NOT NULL,
  tag_id BIGINT UNSIGNED NOT NULL,
  attached_by_member_id BIGINT UNSIGNED NOT NULL,
  source VARCHAR(16) NOT NULL,
  PRIMARY KEY (post_id, tag_id),
  FOREIGN KEY (post_id) REFERENCES concept_posts (post_id),
  FOREIGN KEY (tag_id) REFERENCES concept_tag (tag_id)
);

같은 태그가 두 게시글에 재사용되고 동일 조합 중복이 거절되는지, 원본이 태그가 아니라 연결마다 다르게 저장되는지 확인합니다.


핵심 정리

  • 엔티티는 이름보다 독립 수명·정체성·재사용으로 판단합니다.
  • 속성은 한 소유자를 가지며 관계에 종속된 값은 연결 행에 둡니다.
  • 변경 가능한 자연 값과 안정적인 대표 ID의 책임을 구분합니다.
  • 다중 값은 반복 컬럼이 아니라 관계 행 집합으로 표현합니다.

다음 문서에서는 카디널리티와 참여도를 최소·최대 수로 읽고 M:N을 관계 엔티티로 해결합니다.