데이터 모델링 단계
회원 이름과 카테고리를 게시글마다 복사한 평면 테이블에서 갱신과 삽입, 삭제의 이상을 재현하고, 회원과 카테고리, 게시글을 따로 저장해 JOIN으로 다시 잇는 모델로 고칩니다. 개념, 논리, 물리 모델이 각각 어떤 질문에 답하는지와 설계를 SQL로 검증하는 방법을 다룹니다.
설계는 ERD 도구에서 상자를 그리는 일이 아니라, 요구 사항이 바뀌어도 사실의 뜻이 흔들리지 않도록 무엇을 하나의 사실로 볼지 정하는 일입니다. 화면 하나를 빨리 만들려고 회원의 이름과 카테고리의 이름, 게시글의 값을 한 행에 복사하면, 처음에는 JOIN이 없어 편하지만 이름이 바뀌거나 카테고리를 합칠 때 서로 다른 진실이 생깁니다.
이 글은 회원 게시판의 검색 화면을 그대로 테이블로 옮긴 실수에서 출발합니다. 요구 사항은 이렇습니다. 회원은 여러 게시글을 쓰고, 게시글은 카테고리 하나를 고를 수 있으며, 회원과 카테고리의 이름이 바뀌어도 지난 게시글은 같은 회원과 카테고리를 계속 가리켜야 합니다.
먼저 모델링의 용어를 지금까지 만든 데이터에 맞춰 봅니다. 엔티티(entity)는 회원, 카테고리, 게시글처럼 따로 구별되는 정체성과 수명을 가진 업무의 대상이고, 속성(attribute)은 이메일, 카테고리 이름, 게시글 제목처럼 그 대상을 설명하는 값입니다. 관계(relationship)는 "회원이 게시글을 쓴다"처럼 두 엔티티를 잇는 규칙이며, 카디널리티는 한쪽의 행 하나가 다른 쪽의 행 몇 개와 이어질 수 있는지를 뜻합니다.
예를 들어 회원 한 명은 게시글을 0개 이상 가질 수 있고 게시글 하나는 정확히 회원 한 명에게 속하므로, 둘은 1:N 관계입니다. 지금까지는 이 관계를 기본 키와 외래 키, JOIN으로 먼저 경험했습니다. 9장에서는 SQL의 문법을 잠시 내려놓고 무엇을 별도의 사실로 볼지부터 정한 뒤, 그 결정을 다시 키와 테이블로 옮깁니다.
평면 테이블의 이상
다음 테이블은 게시글마다 member_email과 member_name, category_code와 category_name을 되풀이해 저장합니다. 아래 코드는 같은 회원이 같은 카테고리에 쓴 게시글 두 개를 넣은 뒤, 회원 이름을 1번 게시글의 행에서만 바꿉니다. 마지막 SELECT는 이메일과 이름의 조합별로 행을 세어, 같은 회원의 이름이 몇 가지로 저장되어 있는지 보여 줍니다.
DROP TABLE IF EXISTS board_flat_bad;
CREATE TABLE board_flat_bad (
post_id BIGINT UNSIGNED NOT NULL,
member_email VARCHAR(255) NOT NULL,
member_name VARCHAR(80) NOT NULL,
category_code VARCHAR(40) NOT NULL,
category_name VARCHAR(100) NOT NULL,
post_title VARCHAR(120) NOT NULL,
view_count SMALLINT UNSIGNED NOT NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (post_id)
);
INSERT INTO board_flat_bad VALUES
(1, '[email protected]', '안동민', 'DB', '데이터베이스', '인덱스', 40, '2026-07-14 09:00:00'),
(2, '[email protected]', '안동민', 'DB', '데이터베이스', '트랜잭션', 50, '2026-07-14 10:00:00');
UPDATE board_flat_bad
SET member_name = '안도민'
WHERE post_id = 1;
SELECT member_email, member_name, COUNT(*)
FROM board_flat_bad
GROUP BY member_email, member_name
ORDER BY member_name;member_email | member_name | COUNT(*)
[email protected] | 안도민 | 1
[email protected] | 안동민 | 1한 사람을 식별하는 이메일은 같은데 이름이 두 값으로 갈라졌습니다. 이름을 한 행만 고쳤기 때문에 생긴 갱신 이상(update anomaly)입니다. UPDATE 자체는 성공했지만, 한 사실을 여러 행에 저장한 구조 탓에 바꿔야 할 범위 전체를 알 수 없습니다. WHERE member_email로 모두 고치면 지금의 문제는 덮을 수 있어도, 중복 저장과 일부만 바뀌는 오류의 가능성은 그대로 남습니다.
같은 테이블에는 다른 이상도 있습니다. 아직 게시글이 없는 새 카테고리는 게시글의 기본 키가 있어야 저장할 수 있어 넣을 곳이 없는 삽입 이상(insertion anomaly)이 생기고, 카테고리의 마지막 게시글을 지우면 카테고리의 정보까지 사라지는 삭제 이상(deletion anomaly)이 생깁니다. 화면이 한 줄이라고 업무의 사실도 하나인 것은 아닙니다.
이름이라는 한 사실을 복사하면 수정도 여러 행에 걸친다
평면 모델의 실습 데이터는 이름을 두 번 저장한다. 같은 회원의 현재 이름이 두 개 나온다. 각 단계의 값과 전환 이유를 비교합니다.
MODELING · UPDATE ANOMALY
이름 한 사실을 복사하면 수정도 여러 행에 걸친다
본문의 두 게시글은 같은 [email protected] 회원을 가리킵니다. 평면 모델은 이름을 두 번 저장하지만 분리 모델은 회원 한 행을 JOIN합니다.
평면 모델은 post_id 1만 UPDATE하면 게시글 1 · 안도민 / 게시글 2 · 안동민이 남습니다. 분리 모델은 회원 1행 UPDATE 뒤 두 JOIN 모두 안도민을 읽습니다.
이는 현재 이름의 원본 모델입니다. 게시 당시 이름을 보존하려면 시점 스냅샷이라는 별 요구와 갱신 정책을 명시해야 합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 두 복사본부터 이상 조회까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 이름이라는 한 사실을 복사하면 수정도 여러 행에 걸친다 |
| 설명·조건 | 이는 현재 이름의 원본 모델입니다. 게시 당시 이름을 보존하려면 시점 스냅샷이라는 별도 요구와 갱신 정책을 명시해야 합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 복사본 평면 모델의 실습 데이터는 이름을 두 번 저장한다. |
|
|
| 한 행 UPDATE post_id 1만 안도민으로 바꾼다. |
|
|
| 이상 조회 같은 회원의 현재 이름이 두 개 나온다. |
|
|
이름이라는 한 사실을 복사하면 수정도 여러 행에 걸친다
평면 모델의 실습 데이터는 이름을 두 번 저장한다. 같은 회원의 현재 이름이 두 개 나온다. 각 단계의 값과 전환 이유를 비교합니다.
MODELING · UPDATE ANOMALY
이름 한 사실을 복사하면 수정도 여러 행에 걸친다
본문의 두 게시글은 같은 [email protected] 회원을 가리킵니다. 평면 모델은 이름을 두 번 저장하지만 분리 모델은 회원 한 행을 JOIN합니다.
평면 모델은 post_id 1만 UPDATE하면 게시글 1 · 안도민 / 게시글 2 · 안동민이 남습니다. 분리 모델은 회원 1행 UPDATE 뒤 두 JOIN 모두 안도민을 읽습니다.
이는 현재 이름의 원본 모델입니다. 게시 당시 이름을 보존하려면 시점 스냅샷이라는 별 요구와 갱신 정책을 명시해야 합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 두 복사본부터 이상 조회까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 제목 | 이름이라는 한 사실을 복사하면 수정도 여러 행에 걸친다 |
| 설명·조건 | 이는 현재 이름의 원본 모델입니다. 게시 당시 이름을 보존하려면 시점 스냅샷이라는 별도 요구와 갱신 정책을 명시해야 합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 두 복사본 평면 모델의 실습 데이터는 이름을 두 번 저장한다. |
|
|
| 한 행 UPDATE post_id 1만 안도민으로 바꾼다. |
|
|
| 이상 조회 같은 회원의 현재 이름이 두 개 나온다. |
|
|
회원의 정체성과 카테고리의 분류, 게시글이라는 사건은 서로 따로 생기고 바뀌고 사라질 수 있으므로 별도의 엔티티 후보입니다. 이상을 찾을 때는 같은 업무의 사실이 몇 행에 복제되는지 표시하고, 다른 사실 없이는 혼자 만들 수 없는 엔티티와, 행을 지우면 보존해야 할 다른 사실까지 사라지는 경우, 변경 하나를 여러 행에 적용해야 하는 경우를 차례로 확인합니다.
개념, 논리, 물리 모델
개념 모델(conceptual model)은 회원과 카테고리, 게시글 같은 업무의 개념과 관계의 수량, 참여 여부를 기술과 관계없이 합의합니다. 논리 모델(logical model)은 후보 키와 기본 키, 외래 키, 관계를 나타내는 엔티티로 관계형 구조를 정합니다. 물리 모델(physical model)은 MySQL 8.4의 BIGINT와 DATETIME(6), 인덱스, 이름의 규칙과 운영의 용량을 확정합니다.
세 단계를 엄격하게 한 방향으로만 밟을 필요는 없지만, 결정의 종류를 섞지는 않습니다. "member_id를 BIGINT로"는 물리의 결정이고, "게시글은 정확히 회원 한 명에게 속한다"는 개념과 논리의 규칙입니다. 구현을 바꾸다가 업무의 규칙이 몰래 바뀌지 않도록 둘을 따로 추적합니다.
업무의 문장을 세 단계로 나누어 봅니다. 명사보다 사건을 보며 따로 구별되는 정체성과 수명, 여러 속성을 가진 개념을 엔티티의 후보로 삼고, "회원 한 명은 게시글을 0개 이상, 게시글 하나는 정확히 회원 한 명"처럼 관계를 문장으로 읽습니다. 업무상의 식별 후보는 적되 AUTO_INCREMENT 같은 타입은 물리 단계로 미루며, 세 엔티티를 JOIN한 결과가 화면의 한 줄이라는 것을 분리해서 봅니다.
사실을 나누고 다시 잇는다
회원과 카테고리, 게시글을 별도의 테이블로 두고, 게시글의 외래 키가 두 정체성을 가리키게 합니다. 이름을 바꿀 때는 원래의 한 행만 고치고, 화면은 JOIN으로 다시 구성합니다.
DROP TABLE IF EXISTS concept_posts;
DROP TABLE IF EXISTS concept_categories;
DROP TABLE IF EXISTS concept_members;
CREATE TABLE concept_members (
member_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
display_name VARCHAR(80) NOT NULL,
PRIMARY KEY (member_id),
UNIQUE (email)
);
CREATE TABLE concept_categories (
category_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
category_code VARCHAR(40) NOT NULL,
category_name VARCHAR(100) NOT NULL,
PRIMARY KEY (category_id),
UNIQUE (category_code)
);
CREATE TABLE concept_posts (
post_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
member_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED NULL,
title VARCHAR(120) NOT NULL,
view_count SMALLINT UNSIGNED NOT NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (post_id),
FOREIGN KEY (member_id) REFERENCES concept_members (member_id),
FOREIGN KEY (category_id) REFERENCES concept_categories (category_id)
);
INSERT INTO concept_members (email, display_name)
VALUES ('[email protected]', '안동민');
SET @concept_member_id := LAST_INSERT_ID();
INSERT INTO concept_categories (category_code, category_name)
VALUES ('DB', '데이터베이스');
SET @concept_category_id := LAST_INSERT_ID();
INSERT INTO concept_posts
(member_id, category_id, title, view_count, created_at)
VALUES
(@concept_member_id, @concept_category_id, '인덱스', 40,
'2026-07-14 09:00:00'),
(@concept_member_id, @concept_category_id, '트랜잭션', 50,
'2026-07-14 10:00:00');
UPDATE concept_members
SET display_name = '안도민'
WHERE member_id = @concept_member_id;
SELECT s.post_id, l.display_name, sub.category_name, s.title
FROM concept_posts AS s
JOIN concept_members AS l ON l.member_id = s.member_id
LEFT JOIN concept_categories AS sub ON sub.category_id = s.category_id
ORDER BY s.post_id;post_id | display_name | category_name | title
1 | 안도민 | 데이터베이스 | 인덱스
2 | 안도민 | 데이터베이스 | 트랜잭션회원의 이름 한 행만 바꾸었는데, 두 게시글을 JOIN한 화면에서 모두 같은 새 이름으로 보입니다. 테이블을 새로 만들었으므로 게시글의 id는 1과 2입니다. category_id가 NULL을 허용하는 것은 게시글에 카테고리를 아직 정하지 않아도 된다는 선택적인 참여를 구현한 것이며, 그런 요구가 없다면 NOT NULL이 더 강한 모델입니다. NULL의 허용을 편의로 두지 않고 업무의 문장과 잇습니다.
이 DDL은 사실을 나누었는지 확인하려는 것이며, 최종 물리 모델은 12장에서 이름과 타입, 인덱스, 감사용 열을 다시 검토합니다. 이 단계에서 성능을 위한 열까지 모두 넣어 개념의 구분을 흐리지 않습니다.
나눈 모델은 수명과 변경, 삭제로 다시 검토합니다. 게시글 없이 회원과 카테고리를 각각 만들 수 있는지, 이름을 한 행 바꾼 뒤 모든 화면이 새 값을 읽는지 확인하고, 참조하는 게시글이 있을 때 부모의 삭제를 외래 키가 거부하는지 봅니다. 마지막으로 JOIN한 결과가 평면 테이블의 정상적인 처음 결과와 같은지 맞춰 봅니다.
이상이 없어졌는지 쿼리로 확인한다
설계의 설명을 그림에서 끝내지 않고, 중복과 고아, 화면의 재구성을 쿼리로 확인합니다. 첫 쿼리는 같은 이메일로 저장된 회원이 둘 이상인지 찾고, 둘째 쿼리는 LEFT JOIN으로 짝이 되는 회원이 없는 게시글, 곧 고아 게시글을 찾습니다. 셋째 쿼리는 나눈 세 테이블을 다시 이어 평면 테이블이 보여 주던 한 줄을 만들며, 카테고리는 NULL일 수 있으므로 LEFT JOIN으로 붙입니다.
SELECT email, COUNT(*) AS member_rows
FROM concept_members GROUP BY email HAVING COUNT(*) > 1;
SELECT s.post_id
FROM concept_posts AS s
LEFT JOIN concept_members AS l ON l.member_id = s.member_id
WHERE l.member_id IS NULL;
SELECT s.post_id, l.display_name, sub.category_name, s.title
FROM concept_posts AS s
JOIN concept_members AS l ON l.member_id = s.member_id
LEFT JOIN concept_categories AS sub ON sub.category_id = s.category_id
ORDER BY s.post_id;중복된 이메일을 찾는 쿼리와 고아 게시글을 찾는 쿼리는 빈 결과를 돌려주고, 마지막 JOIN은 앞의 결과와 같은 두 행을 돌려줍니다. JOIN의 행 수가 게시글의 수와 같고, 이름은 원래 엔티티의 지금 값 하나만 쓰였습니다. 중복된 사실이 0개이고 외래 키가 삭제를 막는다는 것이 이 설계가 이상을 없앴다는 확인입니다.
사실을 나누는 것과 정규화는 같은 말이 아닙니다. 먼저 업무에서 따로 존재하는 사실을 찾고, 논리 모델에서 함수 종속과 이상을 검토하며, 화면을 위한 중복은 나중에 명시적인 역정규화의 결정으로만 더합니다(12장 3편). 지난 이름을 남겨야 한다면 그것은 지금의 member_name을 복사한 실수가 아니라 "글을 쓸 때의 표시 이름"이라는 별도의 요구이므로, 열의 이름과 갱신 금지, 감사의 이유를 분명히 둡니다.
모델을 바꾸자고 제안할 때는 지금의 이상을 보여 주는 SQL과 목표 관계, 이관한 뒤의 일치를 확인하는 쿼리를 함께 둡니다. ERD 그림 하나만으로는 운영의 위험을 검토할 수 없습니다. 이름의 중복을 없애는 이관은 대표 행의 선택과 외래 키의 치환을 트랜잭션과 배치로 설계하고, 지우기 전에 고아와 중복의 수를 잽니다.
| 선택 | 얻는 것 | 알맞은 경우와 부담 |
|---|---|---|
| 나눈 모델 | 하나의 진실과 따로 관리되는 수명 | 원래의 사실을 관리할 때 알맞고, JOIN이 필요하다 |
| 의도한 스냅샷 | 지난 표시의 보존 | 그 시점의 사실 자체가 요구일 때 알맞고, 중복과 불변성을 관리해야 한다 |
| 평면 읽기 모델 | 빠르고 단순한 조회 | 원래 데이터와 떨어진 파생 모델일 때 알맞고, 동기화와 최신성을 관리해야 한다 |
| 곧바로 물리 설계 | 빠른 구현 | 요구가 아주 작고 안정적일 때만 알맞고, 업무의 합의가 빠질 수 있다 |
이름이 같은 다른 회원은 서로 다른 사람이므로 이름을 식별자로 쓰지 않습니다. 이메일도 바뀔 수 있으므로, 업무의 후보 키와 대표 기본 키의 역할은 10장에서 나눕니다. 카테고리가 정해지지 않은 게시글을 허용하면 LEFT JOIN과 NULL을 보여 주는 규칙이 필요하고, 게시글을 지운 뒤에도 감사를 위해 남겨야 한다면 물리 삭제와 부모 외래 키의 규칙을 따로 정합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 개념 | 업무의 엔티티와 관계, 수량을 기술과 무관한 문장으로 합의했는가 |
| 논리 | 후보 키와 외래 키, 선택적인 관계가 이상을 막는가 |
| 물리 | MySQL의 타입과 인덱스, 제약이 논리의 규칙을 구현하는가 |
| 추적 | 요구의 문장과 DDL의 결정이 서로 이어지는가 |
| 확인 | 중복과 고아, 화면의 일치를 확인하는 쿼리가 있는가 |
단계를 문서의 폴더로 나누는 것보다, 결정의 종류와 그것을 확인하는 방법을 나누는 것이 중요합니다. 물리의 변경 하나가 개념의 관계를 바꾼다면 요구 사항의 검토로 되돌아갑니다.
정리
- 화면의 한 줄과 업무의 사실 하나는 같지 않습니다.
- 갱신과 삽입, 삭제의 이상은 사실을 잘못 묶었다는 것을 드러냅니다.
- 개념, 논리, 물리 모델은 서로 다른 질문에 답하고 다른 산출물을 냅니다.
- 설계는 ERD만이 아니라 이상과 일치를 확인하는 SQL로 검증합니다.
연습 문제
1. 회원이 만드는 카테고리
회원이 여러 카테고리를 만들 수 있고, 게시글은 카테고리 하나를 선택적으로 참조합니다. 평면 테이블에서 생기는 이상 세 가지를 쓰고, 개념, 논리, 물리 단계마다의 산출물을 제안해 보세요.
해설 보기
회원과 카테고리, 게시글은 따로 수명을 가지며, 회원 1:N 카테고리, 회원 1:N 게시글, 카테고리 1:N 게시글의 선택적인 관계입니다. 평면 테이블의 이상을 구체적으로 적으면, 게시글 1에서만 회원의 이름을 고쳐 게시글 2와 지금 이름이 달라지는 것은 갱신 이상입니다. 게시글이 없는 새 카테고리를 저장하려면 가짜 post_id와 제목을 만들어야 하는 것은 삽입 이상이고, 카테고리의 마지막 게시글을 지우면 카테고리의 이름도 사라지는 것은 삭제 이상입니다. 원인은 행이 많아서가 아니라 수명이 다른 사실이 한 행에 묶였기 때문입니다.
개념의 산출물에는 세 엔티티와 각 관계의 수량, 선택적인 참여를 문장으로 적습니다. 논리의 산출물에는 각각의 식별자와, 카테고리를 만든 사람과 게시글의 작성자, 게시글의 선택 카테고리를 가리키는 외래 키를 둡니다. 물리의 산출물에서는 BIGINT와 NULL의 허용, 인덱스와 제약의 이름을 정합니다. 기존의 DB 카테고리를 만든 회원을, 같은 연결에서 앞서 저장한 @concept_member_id라고 정하고 다음처럼 바꿉니다.
ALTER TABLE concept_categories
ADD COLUMN created_by_member_id BIGINT UNSIGNED NULL,
ADD CONSTRAINT fk_concept_category_creator
FOREIGN KEY (created_by_member_id)
REFERENCES concept_members (member_id);
UPDATE concept_categories
SET created_by_member_id = @concept_member_id
WHERE category_id = @concept_category_id
AND created_by_member_id IS NULL;
SELECT COUNT(*) AS missing_creator_count
FROM concept_categories
WHERE created_by_member_id IS NULL;
ALTER TABLE concept_categories
MODIFY created_by_member_id BIGINT UNSIGNED NOT NULL;missing_creator_count
0기존 행에 맞는 생성자를 채우고 누락이 0인 것을 확인한 뒤에 필수 제약으로 강화합니다. 다른 데이터에서는 실제 생성자를 확인해 채워야 하며, 아무 회원으로나 모두 덮어쓰지 않습니다. 게시글의 기존 category_id 외래 키는 카테고리가 있다는 것만 보장하므로, 다른 회원이 만든 비공개 카테고리를 참조하지 못하게 하려면 복합 후보 키나 서비스 트랜잭션의 검증을 더해야 합니다.
2. 지난 이름을 남기라는 요구
"게시글 목록에는 글을 쓸 때의 작성자 이름을 보여 주라"는 요구가 새로 생겼습니다. 평면 테이블처럼 member_name을 게시글에 복사하는 것과 무엇이 다른가요?
해설 보기
겉으로는 같은 열이지만 뜻이 다릅니다. 평면 테이블의 member_name은 지금의 이름을 복사한 것이어서 회원의 이름이 바뀌면 함께 바뀌어야 했고, 일부만 바뀌면 갱신 이상이 되었습니다. 새 요구의 열은 글을 쓴 시점의 이름이라는 별도의 사실이어서, 회원의 이름이 바뀌어도 바뀌면 안 됩니다. 그래서 author_name_at_post처럼 시점이 드러나는 이름을 붙이고, 글을 쓸 때 한 번만 채운 뒤 갱신하지 않는다는 규칙을 두며, 지금의 이름은 여전히 회원 테이블에서 JOIN으로 읽습니다.
다음 글 엔티티·속성·식별자에서는 요구 사항의 문장에서 엔티티와 속성, 식별자를 수명과 식별, 속성의 소유로 하나씩 가려냅니다.