---
type: knowledge
domain: data
status: active
last-reviewed: 2026-07-27
---

# 스키마 모델링 실전

> 한 줄 정의
> 스키마는 "지금 데이터를 어떻게 저장하나"가 아니라 **"3년 뒤 누가 이 데이터를 어떻게 읽고 고치나"**의 결정이다. 저장소 유형 선택·모델링 원칙은 [[데이터 원리]], 인덱스·쿼리 성능은 [[SQL 성능과 인덱스 실전]] — 여기는 테이블·컬럼 설계의 구체 판단 기준이다.

## 정규화 vs 비정규화 — 읽기 패턴이 근거다

- 기본값은 정규화다 ([[데이터 원리]] 재확인). 비정규화는 **측정된 읽기 병목**에 대한 의식적 결정이지, "조인이 귀찮아서"가 아니다.
- 비정규화를 결정하면 반드시 세트로: **갱신 경로 문서화**(원본이 바뀔 때 사본을 누가 맞추나) + 불일치 감지 수단. 이 둘이 없는 비정규화는 데이터 이원화 사고의 예약이다.
- 자주 정당한 비정규화: 주문 시점의 상품명·단가 **스냅샷**(원본이 바뀌어도 과거 주문은 그대로여야 한다 — 이건 성능이 아니라 업무 요구다). "참조"와 "스냅샷"을 구분하는 질문: **원본이 바뀌면 이 값도 바뀌어야 하는가?**

## PK — 자연키 vs 대리키

| 선택 | 기준 | 함정 |
|------|------|------|
| 대리키 (auto increment·UUID) — **기본값** | 업무 식별자는 바뀔 수 있다 (사번 체계 개편·이메일 변경) | 대리키만 있고 자연키에 유니크 제약이 없으면 중복 데이터 무방비 |
| 자연키 PK | 절대 불변이 보장되는 표준 코드 (ISO 국가코드류) | "안 바뀔 것 같은" 것과 "안 바뀌는 것"의 혼동 — 주민번호·전화번호는 바뀐다 |

- 실전 규율: **PK는 대리키, 업무 식별자는 유니크 제약** — 둘 다 건다. 대리키는 조인·FK용, 자연키 유니크는 중복 방어용으로 역할이 다르다.
- UUID vs 순번: 분산 생성·외부 노출(URL에 순번 노출 = 규모 추정·순회 공격)이 필요하면 UUID, 아니면 순번이 인덱스에 유리하다.

## 상태: 컬럼으로 vs 이력 테이블로 — 감사 요구가 결정한다

| 방식 | 맞는 경우 | 대가 |
|------|----------|------|
| 상태 컬럼 (현재 값만) | "지금 무슨 상태인가"만 필요 | "언제 누가 왜 바꿨나"에 답 불가 |
| 상태 컬럼 + 이력 테이블 | 감사·추적 요구 (결제·승인·권한 변경) | 쓰기 2곳 — 같은 트랜잭션으로 묶는다 |
| 이력 테이블만 (현재 = 최신 행) | 이벤트 자체가 업무 데이터 (근태·시세) | 현재 상태 조회가 매번 최신 행 검색 — 조회 성능 설계 필요 |

- 판단 질문: **"이 값이 왜 이렇게 됐는지 소명할 일이 있는가?"** Yes면 이력 테이블. 나중에 붙이는 이력은 과거를 복원하지 못한다 — 감사 요구는 첫 설계에 반영한다.
- 이력 테이블 최소 컬럼: 무엇이(대상 FK)·무엇에서 무엇으로(전/후 값)·언제·누가·왜(사유 코드 또는 참조).

## 코드성 데이터 — 코드 테이블 vs enum vs 하드코딩

| 방식 | 맞는 경우 | 함정 |
|------|----------|------|
| DB 코드 테이블 | 값이 운영 중 추가·변경됨 (카테고리·지역·거래처 유형) | 화면 표시명까지 하드코딩해서 테이블을 장식으로 만드는 것 |
| 코드 enum (앱 코드) | 값 추가 = 로직 변경인 경우 (주문 상태·권한 등급) | 상태 추가를 "DB에 값만 넣으면 되지"로 착각 — 분기 로직이 세트다 |
| 하드코딩 | 금지에 가깝다 — 매직 넘버/문자열 산포 | `if status == 3` — 3이 뭔지 아는 사람이 퇴사한다 |

- 구분 질문: **값이 바뀔 때 코드 배포가 필요한가?** 필요하면 enum(로직과 함께 리뷰·배포), 불필요해야 하면 코드 테이블.
- DB에는 어느 쪽이든 **코드 값(문자열 코드)** 으로 저장하고 표시명은 분리한다 — 표시명 변경이 데이터 마이그레이션이 되면 안 된다.

## Soft delete — 기본값이 아니라 비용 계산 후 채택

soft delete(`deleted_at` 마킹)는 공짜 안전장치가 아니다:

- **유니크 제약이 깨진다** — 삭제된 행이 유니크를 점유해 같은 이메일 재가입 불가. 처방: 부분 인덱스(`WHERE deleted_at IS NULL`) 또는 삭제 시 값 변형 — 어느 쪽이든 설계가 필요하다.
- **모든 조회가 오염된다** — `WHERE deleted_at IS NULL`을 한 곳이라도 빠뜨리면 유령 데이터가 화면에 나온다. ORM 전역 필터로 강제하되, 그 필터의 존재를 팀 전원이 알아야 한다(모르면 "분명 있는 행이 안 나오는" 미스터리).
- FK로 물린 자식 데이터의 삭제 의미가 모호해진다 (부모만 soft delete되면 자식은?).
- 채택 기준: **복원 요구 또는 감사·법적 보존 요구가 있을 때만.** 둘 다 없으면 hard delete + (필요 시) 삭제 이력 테이블이 단순하다. 보존 기간·파기는 [[개인정보와 컴플라이언스]]를 따른다.

## 시간 데이터 — 시간대와 기간

- 저장은 **UTC(또는 오프셋 포함 타입)** 로 통일, 표시 시점에 변환. "서버 로컬 시간 저장"은 서버 이전·DST에서 데이터가 통째로 어긋난다.
- 단, "날짜만 있는 업무 값"(생일·휴일·정산 기준일)은 시간대 변환 대상이 아니다 — date 타입으로 저장하고 timestamp로 승격하지 않는다 (변환하는 순간 하루가 밀리는 고전 버그).
- 기간은 **[시작, 끝) 반개구간** 규약으로 통일 — 끝 포함/제외가 테이블마다 다르면 경계일 중복·누락이 난다.
- 기간 겹침 검사는 `시작A < 끝B AND 시작B < 끝A` 한 줄이다 — 예약·요금제·계약 테이블은 이 검사를 제약 또는 저장 로직에 넣는다. "겹치면 안 되는" 데이터에 겹침 검사가 없으면 반드시 겹친다.

## 외주 지점 — 스키마는 인수인계 산출물이다

- ERD·데이터 사전(테이블·컬럼·코드 값의 의미)은 납품 문서다 — **스키마 변경 시점에 같이 갱신**하는 규율이 없으면 검수 시점에 "문서 따로 DB 따로"가 된다. 변경 경로가 마이그레이션 파일로 단일화돼 있으면([[DB 접근과 트랜잭션 실전]] 마이그레이션 규율) 문서 갱신을 그 리뷰에 묶을 수 있다.
- 컬럼 의미가 문서에 없으면 발주사는 유지보수 때마다 수행사에 물어야 한다 — 그 질문 비용이 하자보수 분쟁의 씨앗이다. 특히 코드 값 목록과 상태 전이 규칙은 반드시 문서화한다.
- 데이터는 발주사 소유다 — 스키마 설계 근거(왜 이렇게 쪼갰나)까지 남겨야 발주사가 후속 개발을 다른 수행사에 맡길 수 있다.

## 안티패턴

- **만능 테이블** — `common_data(type, key, value)` 하나로 다 받기: 제약·FK·타입 검증 전부 포기한 설계다. 테이블 수를 아끼는 것은 절약이 아니다.
- **컬럼 재활용** — "안 쓰는 컬럼이니 다른 용도로" — 과거 데이터와 새 데이터가 같은 컬럼에서 다른 의미를 갖는 순간 그 컬럼은 영구 오염이다.
- **CSV 컬럼** — 한 컬럼에 `"1,3,7"` 저장: 조인·인덱스·제약 불가. 다대다는 연결 테이블이다.
- **"나중에 정리하지" 스키마** — 스키마는 코드보다 리팩터링이 비싸다(데이터가 실려 있다). 첫 설계에서 위 판단들을 명시적으로 내린다.

## 관련 문서

- [[00_데이터 허브]] — 07_데이터 진입점
- [[데이터 원리]] — 저장소 유형·모델링·트랜잭션 원칙 (원리는 여기에 위임)
- [[SQL 성능과 인덱스 실전]] — 인덱스 설계·실행 계획 (성능은 여기에 위임)
- [[DB 접근과 트랜잭션 실전]] — 마이그레이션 규율·스키마 변경 경로 (코드 측 짝)
- [[배포 전략 실전]] — 스키마 변경과 배포 순서 (expand-contract)
- [[개인정보와 컴플라이언스]] — PII 컬럼 표시·보존·파기
