데이터 모델링 단계
중복 이름과 반복 컬럼이 만든 갱신·삽입 이상을 재현하고 개념·논리·물리 모델의 서로 다른 결정을 구분합니다.
설계는 ERD 도구에서 상자를 그리는 일이 아니라 요구사항이 바뀌어도 사실의 의미가 흔들리지 않게 경계를 정하는 일입니다.
한 화면을 빨리 만들려고 회원 이름, 카테고리 이름, 게시글 값을 한 행에 복사하면 처음에는 JOIN이 없어 편하지만 이름 변경과 카테고리 통합에서 서로 다른 진실이 생깁니다.
이번 문서는 회원 게시판의 검색 화면을 바로 테이블로 옮긴 오류를 출발점으로 삼습니다.
개념 모델은 어떤 사실이 존재하는지, 논리 모델은 키와 관계를 어떻게 표현하는지, 물리 모델은 MySQL 타입·인덱스·제약을 어떻게 구현하는지 답한다는 경계를 세웁니다.
요구사항은 다음과 같습니다.
회원은 여러 게시글을 기록하고, 각 게시글은 하나의 카테고리를 선택할 수 있습니다.
회원과 카테고리 이름이 바뀌어도 과거 게시글은 같은 정체성을 계속 가리켜야 합니다.
화면 열을 그대로 복사한 board_flat_bad가 이 문장을 어떻게 위반하는지 관찰합니다.
먼저 모델링 용어를 지금까지 만든 데이터에 대응합니다.
엔티티는 회원·카테고리·게시글처럼 독립된 정체성과 수명을 가진 업무 대상이고, 속성은 이메일·카테고리 이름·게시글 제목처럼 그 대상을 설명하는 값입니다.
관계는 ‘회원이 게시글을 기록한다’처럼 두 엔티티를 잇는 규칙이며, 카디널리티는 한쪽 행 하나가 다른 쪽 행 몇 개와 연결될 수 있는지를 뜻합니다.
예를 들어 회원 1명은 게시글 0개 이상을 가질 수 있고 게시글 1개는 회원 정확히 1명에게 속하므로 1:N입니다.
지금까지는 이 관계를 PK·FK와 JOIN으로 먼저 경험했습니다.
9장에서는 SQL 문법을 잠시 내려놓고 ‘무엇을 별도 사실로 볼 것인가’를 먼저 정한 뒤, 그 결정을 다시 키와 테이블로 내립니다.
평면 테이블의 이상 현상
member_email과 member_name, category_code와 category_name이 게시글마다 반복됩니다.
이름을 한 행만 수정하면 동일 회원이 두 이름을 갖고, 아직 게시글이 없는 새 카테고리는 게시글 PK가 필요해 저장할 곳이 없습니다.
삭제한 마지막 게시글과 함께 카테고리 정보도 사라집니다.
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, 'min@example.com', '안동민', 'DB', '데이터베이스', '인덱스', 40, '2026-07-14 09:00:00'),
(2, 'min@example.com', '안동민', '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;한 사람을 식별하는 이메일은 같지만 이름이 두 값으로 갈라져 갱신 이상이 실제 조회에 드러납니다.
오류 실행 결과member_email | member_name | COUNT(*)
min@example.com | 안도민 | 1
min@example.com | 안동민 | 1
one member identity → two current names오타 UPDATE는 성공했지만 한 사실을 여러 행에 저장한 구조 때문에 전체 변경 단위를 알 수 없습니다.
WHERE member_email로 모두 고치면 지금 문제는 덮을 수 있어도 중복 저장과 부분 오류 가능성은 남습니다.
화면이 한 줄이라고 업무 사실도 하나인 것은 아닙니다.
회원 정체성, 카테고리 분류, 게시글 사건은 서로 독립적으로 생성·변경·삭제될 수 있으므로 별 엔티티 후보입니다.
갱신·삽입·삭제 이상을 각각 찾기
- 중복 표시 — 같은 업무 사실이 몇 행에 복제되는지 표시합니다.
- 삽입 이상 — 다른 사실 없이 독립적으로 생성할 수 없는 엔티티를 찾습니다.
- 삭제 이상 — 행 삭제가 보존해야 할 다른 사실까지 제거하는지 봅니다.
- 갱신 이상 — 한 변경을 여러 행에 적용해야 하는지와 부분 오류를 확인합니다.
개념·논리·물리 모델
개념 모델은 회원·카테고리·게시글 같은 업무 개념과 카디널리티·참여도를 기술에 독립적으로 합의합니다.
논리 모델은 후보 키, PK, FK, 관계 엔티티로 관계형 구조를 정합니다.
물리 모델은 MySQL 8.4의 BIGINT, DATETIME(6), 인덱스, 이름 규칙과 운영 용량을 확정합니다.
세 단계를 엄격한 폭포처럼 분리할 필요는 없지만 결정 종류를 섞지 않습니다.
‘member_id를 BIGINT로’는 물리 결정이고 ‘게시글은 정확히 한 회원에게 속한다’는 개념·논리 규칙입니다.
구현 변경이 업무 규칙을 몰래 바꾸지 않게 추적합니다.
업무 문장을 세 모델 단계로 나누기
- 명사보다 사건 — 독립 수명·정체성·여러 속성을 가진 개념을 엔티티 후보로 봅니다.
- 문장으로 관계 — 회원 1명은 게시글 0개 이상, 게시글 1개는 회원 정확히 1명이라고 읽습니다.
- 키는 다음 단계 — 업무 식별 후보를 적되
AUTO_INCREMENT타입은 물리 단계로 미룹니다. - 화면은 조회 모델 — 세 엔티티를 JOIN한 결과가 화면 한 줄임을 분리합니다.
사실의 분리와 재결합
회원·카테고리·게시글을 별 테이블로 두고 게시글의 FK가 두 정체성을 참조하게 합니다.
이름 변경은 원본 한 행만 수정하며 화면은 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 ('min@example.com', '안동민');
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;회원 이름 한 행을 바꾸면 두 게시글을 JOIN한 화면에서 모두 같은 새 이름으로 보입니다.
개선 실행 결과post_id | display_name | category_name | title
1 | 안도민 | 데이터베이스 | 인덱스
2 | 안도민 | 데이터베이스 | 트랜잭션
member rows updated: 1category_id NULL은 게시글에 카테고리를 아직 정하지 않아도 된다는 선택 참여를 구현합니다.
이 요구가 없다면 NOT NULL이 더 강한 모델입니다.
NULL 허용을 편의로 두지 않고 업무 문장과 연결합니다.
현재 DDL은 개념 분리 확인용이며 최종 물리 모델은 ch12에서 이름·타입·인덱스·감사 컬럼을 다시 검토합니다.
이 단계에서 모든 성능 컬럼을 넣어 개념 경계를 흐리지 않습니다.
수명·변경·삭제 예제 데이터로 재검토하기
- 수명 테스트 — 게시글 없이 회원과 카테고리를 각각 만들 수 있는지 확인합니다.
- 변경 테스트 — 이름 한 행 변경 뒤 모든 화면이 새 값을 읽는지 봅니다.
- 삭제 테스트 — 참조 게시글이 있을 때 부모 삭제가 FK로 거절되는지 확인합니다.
- 화면 재구성 — JOIN 결과와 평면 테이블의 정상 초기 결과가 같은지 일치 여부를 확인합니다.
이상 현상 검증 쿼리
설계 설명을 그림에서 끝내지 않고 중복·고아·화면 재구성 쿼리로 확인합니다.
중복 사실 수가 0인지와 FK가 삭제를 막는지가 증거입니다.
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;중복 이메일 쿼리와 고아 게시글 쿼리는 0행이어야 합니다.
마지막 JOIN은 게시글 수와 같은 행 수를 반환하고 이름은 원본 엔티티의 현재 값 하나만 사용합니다.
개념 분리와 정규화를 혼동하지 않기
정규화와 개념 분리는 같은 단어가 아닙니다.
먼저 업무상 독립 사실을 찾고, 논리 모델에서 함수 종속과 이상 현상을 검토합니다.
화면 편의 중복은 이후 명시적 역정규화 결정으로만 추가합니다.
과거 이름을 보존해야 한다면 현재 member_name을 복사한 실수가 아니라 ‘게시글 당시 표시 이름 스냅샷’이라는 별 요구입니다.
컬럼 이름·갱신 금지·감사 근거를 분명히 둡니다.
ERD 변경을 마이그레이션 증거와 연결하기
모델 변경 제안서에는 현재 이상 현상 SQL, 목표 관계, 마이그레이션 일치 검사 쿼리를 함께 둡니다.
ERD 이미지 하나만으로 운영 위험을 검토할 수 없습니다.
이름 중복 제거 마이그레이션은 대표 행 선택과 FK 치환을 트랜잭션·배치로 설계합니다.
삭제 전에 고아와 중복 수를 측정합니다.
원본·스냅샷·읽기 모델 비교
| 선택 | 얻는 효과 | 감수할 비용 | 적합한 조건 |
|---|---|---|---|
| 분리 모델 | 단일 진실과 독립 수명 | JOIN 필요 | 원본 사실을 관리할 때 |
| 의도한 스냅샷 | 과거 표시 보존 | 중복과 불변성 관리 | 시점 사실 자체가 요구일 때 |
| 평면 읽기 모델 | 빠른 단순 조회 | 동기화·신선도 | 원본과 분리된 파생 모델일 때 |
| 즉시 물리 설계 | 빠른 구현 | 업무 합의 누락 | 요구가 매우 작고 안정적일 때 |
평면 모델의 이상을 직접 만들기
- 이상 재현 — 한 행 수정·마지막 게시글 삭제·카테고리만 삽입을 평면 테이블에서 실행합니다.
- 결정 분류 — 각 요구를 개념·논리·물리 결정으로 색칠합니다.
- ER 문장 — 상자보다 먼저 엔티티 두 개 사이 관계를 자연어 수량으로 씁니다.
- 화면 일치 검사 — 정상 예제 데이터에서 두 모델의 표시 결과가 같은지 확인합니다.
동명이인·미지정 카테고리·감사 보존
- 동명이인은 이름이 같아도 서로 다른 회원이므로 이름을 식별자로 쓰지 않습니다.
- 이메일 변경 가능성이 있으므로 업무 후보 키와 대표 PK 책임을 다음 장에서 분리합니다.
- 카테고리 미지정 게시글을 허용하면 LEFT JOIN과 NULL 표시 규칙이 필요합니다.
- 게시글 삭제 후에도 감사 보존이 필요하면 물리 삭제와 부모 FK 정책을 별도로 정합니다.
다음 문서는 엔티티 후보를 명사 목록이 아니라 수명·식별·속성 소유권으로 검증합니다.
ch10은 이 개념 모델을 키와 FK가 있는 논리 모델로 구체화합니다.
모델링 단계 완료 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 개념 | 업무 엔티티·관계·수량을 기술 독립 문장으로 합의했는가? |
| 논리 | 후보 키·FK·선택 관계가 이상 현상을 막는가? |
| 물리 | MySQL 타입·인덱스·제약이 논리 규칙을 구현하는가? |
| 추적 | 요구 문장과 DDL 결정 사이 근거가 이어지는가? |
| 검증 | 중복·고아·화면 일치 검사 쿼리가 있는가? |
단계를 문서 폴더로 나누는 것보다 결정 종류와 검증 근거를 구분하는 것이 중요합니다.
한 물리 변경이 개념 관계를 바꾸면 요구사항 검토로 되돌아갑니다.
연습 문제
회원이 여러 카테고리를 만들고 게시글은 카테고리 하나를 선택적으로 참조할 수 있습니다.
평면 테이블의 이상 현상 세 가지를 쓰고 개념·논리·물리 단계별 산출물을 제안하세요.
해설과 예시 답안
회원, 카테고리, 게시글은 독립 수명을 가지며 회원 1:N 카테고리, 회원 1:N 게시글, 카테고리 1:N 게시글 선택 관계입니다.
논리 단계에서 게시글의 category_id에 NULL 허용 FK를 두고 물리 단계에서 타입과 인덱스를 정합니다.
ALTER TABLE concept_categories
ADD COLUMN created_by_member_id BIGINT UNSIGNED NOT NULL,
ADD CONSTRAINT fk_concept_category_creator
FOREIGN KEY (created_by_member_id)
REFERENCES concept_members (member_id);게시글의 기존 category_id FK는 카테고리 존재만 보장합니다.
다른 회원이 만든 비공개 카테고리를 참조하지 못하게 하려면 단일 FK만으로 충분한지 검토합니다.
소유자 일치가 필요하면 복합 후보 키나 서비스 트랜잭션 검증을 추가합니다.
핵심 정리
- 화면 한 줄과 업무 사실 하나는 같지 않습니다.
- 삽입·삭제·갱신 이상은 잘못된 사실 경계를 드러냅니다.
- 개념·논리·물리 모델은 서로 다른 질문과 산출물을 가집니다.
- 설계는 ERD뿐 아니라 오류·일치 검사 SQL로 검증합니다.
다음 문서에서는 요구 문장에서 엔티티·속성·식별자를 체계적으로 추출합니다.