엔티티·속성·식별자
여러 게시글이 같은 첨부파일을 함께 쓰는 요구를 수명과 재사용, 식별의 질문으로 나누어, 게시글의 속성처럼 묻혀 있던 첨부파일 엔티티와 첨부 관계를 찾아냅니다. 엔티티를 존재 형태와 업무 역할, 의존성으로 분류하는 법과, 속성마다 소유자를 정하고 대표 식별자와 후보 키를 나누는 법을 다룹니다.
요구 사항의 명사를 모두 테이블로 만들면 작은 값까지 지나치게 나뉘고, 화면의 입력 칸을 모두 속성으로 두면 따로 수명을 가진 개념이 한 행에 묻힙니다. 엔티티의 후보는 이름보다, 생기고 바뀌고 참조되고 보존되는 수명과, 스스로 식별되어야 하는지로 판단합니다.
이 글의 요구 사항은 이렇습니다. 첨부파일은 URL과 제목을 가지며 여러 게시글에서 다시 쓸 수 있고, 게시글에 첨부파일을 붙인 사람과 메모를 기록하며, URL이 바뀌어도 같은 첨부파일이라는 정체성은 유지합니다. 먼저 게시글에 attachment_url과 attachment_name을 직접 넣은 설계에서 무엇이 잘못되는지 확인합니다.
다시 쓰는 첨부파일을 게시글마다 저장하면
같은 첨부파일이 게시글마다 되풀이되면, 제목이 바뀔 때마다 여러 행을 UPDATE해야 합니다. 게시글 없이 첨부파일만 따로 저장할 수 없고, 한 게시글에 첨부파일 여러 개를 붙이려면 attachment_url_1, attachment_url_2 같은 반복 열이 생깁니다.
DROP TABLE IF EXISTS post_attachment_flat_bad;
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', '[email protected]', '인덱스 읽기'),
(2, 1, 'https://dev.mysql.com/doc/refman/8.4/en/', 'MYSQL 문서', '[email protected]', '트랜잭션 읽기');
SELECT attachment_url, COUNT(DISTINCT attachment_name) AS titles
FROM post_attachment_flat_bad GROUP BY attachment_url;attachment_url | titles
https://dev.mysql.com/doc/refman/8.4/en/ | 2같은 URL의 첨부파일에 서로 다른 지금 제목이 두 개 저장되어, 어느 값이 원래의 것인지 알 수 없습니다. 게다가 기본 키가 post_id이므로 게시글 하나에는 첨부파일을 하나만 붙일 수 있고, 게시글과 무관한 첨부파일의 행은 처음부터 만들 수 없습니다. 소유자가 다른 값을 한 행에 두어, 첨부파일의 재사용과 여러 개의 연결을 모두 나타낼 수 없게 된 것입니다.
값이 바뀔 때 어떤 사실이 영향을 받는지를 기준으로 속성의 소유자를 나누면 다음과 같습니다.
MODELING · ATTRIBUTE OWNERSHIP
파일 제목은 원본에, 붙인 메모는 연결에 둔다
한 첨부파일을 두 게시글이 재사용해도 원본은 한 행입니다. 파일 제목 변경은 두 조회에 함께 보이지만 첨부 메모 변경은 해당 연결만 바뀝니다.
화살표: 연결 FK → 부모 PK. 같은 파일의 제목은 공통이고 메모는 각각의 연결 행에 속합니다. 아래 표는 각 값의 변경 범위를 비교합니다.
| 소유자 | 값 | 변경 범위 |
|---|---|---|
| 첨부파일 | URL · 제목 | 같은 파일을 참조하는 모든 연결 |
| 게시글 | 작성자 · 분류 · 제목 | 해당 게시글 |
| 첨부 관계 | 붙인 사람 · 메모 · 시각 | 그 게시글에 파일을 붙인 한 연결 |
- 첨부파일
- 값: URL · 제목변경 범위: 같은 파일을 참조하는 모든 연결
- 게시글
- 값: 작성자 · 분류 · 제목변경 범위: 해당 게시글
- 첨부 관계
- 값: 붙인 사람 · 메모 · 시각변경 범위: 그 게시글에 파일을 붙인 한 연결
본문 fixture · 원본 1행 / 연결 2행
첨부파일: MySQL 인덱스 참고 자료
게시글 1의 메모: 인덱스 게시글의 보충 자료
게시글 2의 메모: 트랜잭션 글에서도 함께 참고
같은 attachment_id의 제목은 1개이며 같은 게시글·파일 조합의 재삽입은 PK 오류 1062입니다. URL 정규화 정책까지 DB UNIQUE가 자동 처리하지는 않습니다.
URL은 바뀔 수 있으므로, 다른 테이블이 URL을 대표 정체성으로 직접 참조하면 URL의 변경이 관계까지 번집니다. 그래서 안정적인 attachment_id와, URL이 유일하다는 후보 키의 역할을 나눕니다.
첨부파일과 연결의 속성을 가를 때는 네 가지를 묻습니다. 게시글 없이도 만들고 고치고 비활성화할 수 있는 개념인지, 여러 부모가 같은 값을 참조하는지, 한 게시글에 첨부파일이 몇 개까지 가능한지를 요구에서 찾고, 메모가 첨부파일 자체의 속성인지 이 게시글에 붙인 연결의 속성인지 구별합니다.
엔티티와 속성의 소유
attachments는 게시글과 따로 만들어지고 여러 게시글이 참조하므로 엔티티입니다. 첨부 관계는 게시글과 첨부파일의 조합에 기대지만, 붙인 사람과 메모, 붙인 시각을 가지므로 관계를 나타내는 엔티티입니다. URL은 바뀔 수 있는 후보 키일 뿐 대표 정체성의 전부가 아닙니다.
식별자는 화면에 보이는 값과 나눕니다. 내부의 attachment_id, 업무상 유일한 canonical_url, 보여 주는 제목은 각각 대표 기본 키와 후보 키, 일반 속성의 역할을 맡습니다.
엔티티를 분류하는 세 축
엔티티의 분류는 용어 시험이 아니라, 요구 사항에서 서로 다른 수명과 식별의 규칙을 찾아내는 도구입니다. 한 엔티티가 여러 축에 동시에 속할 수 있으므로, "게시글은 사건 엔티티이면서 중심 엔티티다"처럼 읽습니다.
| 분류 축과 종류 | 뜻과 예 |
|---|---|
| 존재 형태: 유형 엔티티 | 현실에서 따로 구별되는 대상, 예: member |
| 존재 형태: 개념 엔티티 | 분류나 정책처럼 추상적인 개념, 예: category, tag |
| 존재 형태: 사건 엔티티 | 특정 시점에 일어난 일의 기록, 예: posts |
| 업무 역할: 기본 엔티티 | 다른 행위가 없어도 먼저 존재, 예: member, attachments |
| 업무 역할: 중심 엔티티 | 여러 기본 엔티티와 행위 엔티티를 업무 흐름의 중심에서 이음, 예: posts |
| 업무 역할: 행위 엔티티 | 가입, 첨부 같은 업무의 사건을 기록, 예: post_attachments |
| 존재 의존성: 강한 엔티티 | 자기 식별자와 독립된 수명을 가짐, 예: attachments |
| 존재 의존성: 약한 엔티티 | 부모 없이는 식별되거나 존재할 수 없음, 예: (post_id, sequence_no)로 식별되는 댓글 |
구조를 나타내기 위한 특별한 분류도 있습니다. 연관 엔티티(associative entity)는 M:N 관계를 한 행으로 올려 관계 자체의 속성을 담으며, 이 글의 post_attachments가 그 예입니다. 슈퍼타입과 서브타입은 여러 종류가 공통의 식별자와 속성을 함께 쓸 때 쓰며, 17장에서 구체 테이블과 단일 테이블, 조인 테이블의 전략을 비교합니다.
"회원은 여러 첨부파일을 게시글에 첨부하고, 첨부마다 메모를 남긴다"는 문장을 분류하면 다음과 같습니다.
| 후보 | 분류 | 식별과 수명의 판단 |
|---|---|---|
| 회원 | 유형, 기본, 강한 엔티티 | member_id로 따로 존재한다 |
| 첨부파일 | 기본, 강한 엔티티 | 게시글과 따로 만들어지고 다시 쓰인다 |
| 게시글 | 사건, 중심 엔티티 | 회원에게 속하지만 자기 사건의 id를 가진다 |
| 첨부 관계 | 행위, 연관 엔티티 | 게시글과 첨부파일의 조합에 기대고 메모와 시각을 가진다 |
분류의 이름만으로 테이블을 자동으로 만들어서는 안 됩니다. 관리자라는 명사가 요구에 나와도 별도의 수명과 속성, 식별이 없고 회원의 역할일 뿐이라면, member_role 같은 속성이나 관계로 충분할 수 있습니다. 반대로 처음에는 단순한 역할이던 판매자가 따로 정산과 규칙, 수명을 갖게 되면 별도의 엔티티로 올릴 이유가 생깁니다.
후보를 좁힐 때는 문장에서 따로 행동하거나 참조되는 주어를 후보로 표시하고, 속성마다 하나의 엔티티나 관계에만 배치합니다. 식별자는 유일한지, 최소한의 열인지, 바뀌지 않는지를 따로 묻고, 다른 값으로 다시 계산할 수 있는 표시용 값은 원래의 값과 구별합니다.
관계 엔티티를 도출한다
첨부파일의 원래 행과 첨부 관계를 나누고, 같은 게시글과 첨부파일의 조합이 겹치지 않게 기본 키로 막습니다. 누가 언제 어떤 메모로 붙였는지는 연결 행에 둡니다. 아래 예제는 앞 글의 concept_members와 게시글 두 개를 만든 상태에서 실행합니다.
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 = '[email protected]'
);
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;attachment_rows
1
post_attachment_rows
2
titles_for_same_attachment
1첨부파일 한 행을 두 게시글의 연결이 함께 참조하고, 그 첨부파일의 제목은 하나뿐입니다. 제목을 바꿀 때도 첨부파일 한 행만 고치면 됩니다. 같은 조합을 다시 넣으면 기본 키가 거부합니다.
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,
'같은 연결을 다시 저장');ERROR 1062 (23000): Duplicate entry '1-1' for key 'concept_post_attachments.PRIMARY'게시글 1과 첨부파일 1의 조합이 이미 있어 1062로 거부되었습니다. 이런 복합 기본 키는 다른 테이블이 연결 행을 직접 참조하지 않는 단순한 모델에 알맞습니다. 첨부 관계 자체의 수명과 참조가 늘어나면, 관계 전용의 대리 키 post_attachment_id를 두고 조합에는 UNIQUE를 함께 거는 방법도 있습니다. 이 키는 첨부파일 자체의 attachment_id와는 다른 식별자입니다.
URL을 정규형으로 맞추는 것에는 대소문자와 프래그먼트, 추적용 파라미터를 어떻게 다룰지라는 규칙이 필요합니다. 데이터베이스의 UNIQUE만으로는 뜻이 같은 모든 URL을 자동으로 하나로 묶을 수 없습니다.
다중성과 소유를 확인한다
첨부파일의 중복과 연결 행의 증가, 고아 행을 각각 조회해, 모델의 문장을 실제 행의 수와 맞춰 봅니다. 첫 쿼리는 첨부파일에서 시작해 연결 행을 LEFT JOIN하고, COUNT(*)가 아니라 COUNT(sr.post_id)로 세어 연결이 없는 첨부파일은 0으로 나오게 합니다. 둘째 쿼리는 연결 행만 게시글별로 묶어 세고, 셋째 쿼리는 짝이 되는 첨부파일이 없는 연결 행을 찾습니다.
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
ORDER 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;attachment_id | title | post_count
1 | MySQL 인덱스 참고 자료 | 2
post_id | attachment_count
1 | 1
2 | 1첫 쿼리는 연결이 0건인 첨부파일도 남기며, 지금은 첨부파일 하나가 게시글 두 개와 이어져 있습니다. 둘째 쿼리는 연결이 하나 이상 있는 게시글만 돌려주므로, 첨부파일이 없는 게시글까지 0건으로 보이려면 게시글에서 시작해 LEFT JOIN해야 합니다. 외래 키가 지켜지므로 마지막의 고아 쿼리는 빈 결과입니다.
| 연결의 키 | 얻는 것 | 알맞은 경우와 부담 |
|---|---|---|
| 복합 기본 키 | 조합이 곧 정체성임을 직접 나타낸다 | 관계가 단순할 때 알맞고, 아래로 참조하는 키가 넓어진다 |
| 대리 연결 id | 확장과 참조가 쉽다 | 관계 자체의 수명이 클 때 알맞고, 조합에 UNIQUE를 더해야 한다 |
| URL 자연 키 | 사람이 바로 알아본다 | URL이 안정적일 때만 알맞고, 변경과 길이, 정규화의 비용이 든다 |
attachment_id | 짧고 안정적으로 참조한다 | 재사용과 변경이 있을 때 알맞고, 따로 조회해야 한다 |
다중 값과 스냅샷, 예외
여러 개의 값을 가지는 속성은 관계형 모델에서 반복 열이나 쉼표로 이은 문자열이 아니라, 별도 행의 집합으로 나타냅니다. 다만 전화번호처럼 값 자체에 따로 수명이나 속성이 거의 없다면, 값의 테이블로 둘지 엔티티로 둘지는 요구의 규모에 맞춥니다. 첨부파일 이름의 지난 모습이 필요하다면, 첨부 관계에 그 시점의 제목을 명시적인 스냅샷 속성으로 더하되 지금의 제목과 헷갈리지 않게 합니다.
첨부파일을 지우면 지난 게시글의 기록이 사라진다면 소프트 삭제나 RESTRICT가 필요합니다. 한 게시글에 같은 첨부파일을 역할마다 여러 번 붙일 수 있다면 역할이 후보 키에 들어가는지 검토하고, 붙인 회원이 탈퇴해도 감사 행을 남기려면 익명화의 규칙을 정합니다(16장 2편). 아주 긴 URL에 인덱스를 걸 때는 바이트 길이와 정렬 규칙을 확인하고, 정규형 URL의 해시를 보조 값으로 두는 방법도 검토합니다.
URL이 겹친 첨부파일을 합치는 이관은, 연결의 외래 키를 대표 첨부파일로 옮긴 뒤 대표가 되지 못한 행을 지우는 작업입니다. 동시에 같은 첨부파일을 만들려는 요청에는 UNIQUE 충돌을 처리하고 다시 조회하는 방법이 필요합니다. 바깥 URL이 살아 있는지 확인하는 일은 트랜잭션 안에서 호출하지 말고, 확인 상태와 checked_at을 저장해 비동기로 갱신합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 수명 | 부모 없이 만들고 보존하고 바꿀 수 있는가 |
| 재사용 | 여러 행이 같은 정체성을 참조하는가 |
| 식별 | 자기 후보 키와 대표 식별자가 필요한가 |
| 소유 | 속성마다 하나의 사실에만 온전히 종속되는가 |
| 관계 속성 | 두 엔티티의 조합에서만 뜻이 생기는 값인가 |
테이블의 수를 줄이는 것보다 사실의 소유를 한곳에 두는 것이 먼저입니다. 다만 엔티티의 후보를 찾은 뒤, 지나치게 작은 값까지 무조건 테이블로 나누지는 않습니다.
정리
- 엔티티는 이름보다 따로 갖는 수명과 정체성, 재사용으로 판단합니다.
- 속성마다 소유자를 하나로 정하고, 관계에 종속된 값은 연결 행에 둡니다.
- 바뀔 수 있는 자연의 값과 안정적인 대표 id의 역할을 나눕니다.
- 여러 개의 값은 반복 열이 아니라 관계 행의 집합으로 나타냅니다.
연습 문제
1. 해시태그와 연결별 속성
게시글에 해시태그를 여러 개 붙이고, 태그는 게시글 밖에서도 설명과 색상을 가집니다. 자동으로 추천된 태그인지와 붙인 사람은 연결마다 다릅니다. 엔티티와 속성, 관계의 속성을 나누어 보세요.
해설 보기
태그는 다시 쓰이는 정체성과 속성을 가진 엔티티이고, 게시글과 태그의 연결은 출처와 붙인 사람을 가진 관계 엔티티입니다. 태그의 이름에는 후보 키를, 연결의 조합에는 기본 키를 둡니다.
DROP TABLE IF EXISTS concept_post_tags;
DROP TABLE IF EXISTS concept_tag;
CREATE TABLE concept_tag (
tag_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(80) NOT NULL,
description VARCHAR(500) 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),
CONSTRAINT fk_concept_tag_attached_by
FOREIGN KEY (attached_by_member_id)
REFERENCES concept_members (member_id),
CONSTRAINT chk_concept_tag_source
CHECK (source IN ('MANUAL', 'AUTO'))
);태그의 이름과 설명, 색상은 그 태그를 다시 쓰는 모든 게시글에 공통이므로 concept_tag에 둡니다. 자동 추천인지(AUTO)와 직접 고른 것인지(MANUAL), 누가 붙였는지는 같은 태그라도 게시글마다 다르므로 concept_post_tags에 둡니다.
같은 태그 T를 게시글 1에는 MANUAL로, 게시글 2에는 AUTO로 붙이면 태그의 행은 하나, 연결의 행은 둘입니다. 태그의 색상을 바꾸면 두 게시글의 조회가 모두 새 색상을 읽지만, 게시글 1의 출처만 바꾸면 게시글 2의 연결은 그대로입니다. 같은 조합을 다시 넣으면 기본 키의 1062로 거부되는지도 확인합니다.
2. 메모는 누구의 속성인가
본문의 요구에서 attachment_note를 concept_attachments에 두면 어떤 문제가 생기나요?
해설 보기
메모는 "이 게시글에 이 첨부파일을 붙이면서 남긴 말"이어서 게시글과 첨부파일의 조합에 종속됩니다. 첨부파일의 행에 두면 그 첨부파일을 쓰는 모든 게시글이 메모 하나를 함께 쓰게 되어, 게시글 1에 '인덱스 게시글의 보충 자료'를 남기면 게시글 2에서도 같은 메모가 보이고, 게시글 2의 메모로 고치면 게시글 1의 메모가 사라집니다. 첨부파일의 다중성은 맞게 나누었어도, 속성의 소유자를 잘못 정하면 평면 테이블과 비슷한 갱신의 문제가 다시 생깁니다.
다음 글 관계와 카디널리티에서는 관계의 최소와 최대의 수, 참여 여부를 분명히 하고, M:N 관계의 연결 엔티티에 업무의 속성을 둡니다.