학습 목표
- 함수 종속(functional dependency) 과 이상현상(anomaly) 이라는 정규화의 두 축을 이해한다
- 하나의 지저분한 테이블을 0NF → 1NF → 2NF → 3NF → BCNF 로 직접 분해할 수 있다
- 각 정규형이 어떤 이상현상을 없애는지 를 원인과 함께 설명한다
- 4NF·5NF 를 "언제 신경 쓰면 되는지" 수준으로 안다
- 실무에서 마주치는 정규화 위반 패턴(콤마 문자열, EAV, 계산 컬럼 중복 등)을 알아보고 고친다
- 반정규화를 언제·어떻게 하고, 동기화를 어떻게 지키는지 판단한다
선행 스텝: Step 13 — 제약 조건과 정규화 (특히 13-8) 예상 소요: 70분
Step 13-8 이 정규화의 결론(요약표와 반정규화 판단)을 줬다면, 이 부록은 그 원리와 손기술을 채웁니다. "왜 그 규칙이 그렇게 생겼는가"와 "실제 스키마를 어떻게 쪼개는가"에 집중합니다.
정규화 = "하나의 사실은 딱 한 곳에만 저장한다."
같은 사실이 여러 곳에 중복되면 세 가지 이상현상이 생깁니다. 이것을 없애는 게 정규화의 전부입니다.
| 이상현상 | 뜻 | 예 |
|---|---|---|
| 갱신 이상 (update) | 중복된 값 중 하나만 고쳐서 모순 발생 | 고객 도시를 한 행만 바꿔 나머지와 어긋남 |
| 삽입 이상 (insert) | 다른 정보가 없어서 넣을 수 없음 | 주문한 적 없는 고객을 등록 못 함 |
| 삭제 이상 (delete) | 한 행을 지웠더니 다른 사실까지 소멸 | 마지막 주문 삭제 → 고객 정보까지 사라짐 |
💡 정규형(Normal Form)은 이 이상현상을 없애기 위한 점점 엄격해지는 조건입니다. 상위 정규형은 하위를 포함합니다(3NF면 이미 2NF·1NF).
정규화의 모든 규칙은 함수 종속 하나로 표현됩니다.
X → Y ("X가 Y를 결정한다") : X 값이 정해지면 Y 값이 하나로 정해진다.
customer_id → name : 고객 ID 를 알면 이름은 하나로 정해진다. ✅order_id → order_date : 주문 ID 를 알면 주문일이 정해진다. ✅city → zone : 도시를 알면 권역이 정해진다. ✅ (그런데 이게 3NF 위반의 씨앗)name → customer_id : 이름으로 ID 를 정할 수 있나? 동명이인 때문에 ❌핵심 용어 두 개만:
| 용어 | 뜻 |
|---|---|
| 후보키(candidate key) | 그 값만 알면 한 행 전체가 정해지는 최소 컬럼 조합 |
| 비(非)키 컬럼 | 후보키에 속하지 않는 나머지 컬럼 |
정규화는 결국 "모든 비키 컬럼이 오직 후보키에만, 후보키 전부에, 후보키에만 의존하게" 만드는 작업입니다.
암기용: "the key, the whole key, and nothing but the key" — 2NF(전부에)·3NF(오직 키에만)를 한 줄로 요약한 유명한 문장입니다.
아래 지저분한 테이블 하나를 0NF 부터 BCNF 까지 관통합니다. 이 예제만 손으로 따라가면 정규화가 몸에 붙습니다.
세 이상현상이 전부 살아 있습니다: 박교수 전화가 바뀌면 여러 행 수정(갱신), 수강생 없는 신규 과목 등록 불가(삽입), 이지은 삭제 시 SQL 과목·박교수 정보 소멸(삭제).
courses, course_fee, instructor 에 여러 값이 뭉쳐 있으면 위반입니다. 행으로 쪼갭니다.
⚠️ 1NF 위반의 현대적 변종 — 콤마 문자열 / JSON 남용
tags VARCHAR(255)='세일,신상'은 지금도 흔한 1NF 위반입니다.FIND_IN_SET으로 조회는 되지만 인덱스를 못 탑니다(풀스캔). →product_tags(product_id, tag)로 분리. 단, MySQL 8 의JSON타입은 함수 인덱스·JSON_TABLE이 있어 예외적으로 허용되는 경우가 있습니다(Step 18). "정말 관계형이 맞는가"를 먼저 판단하세요.
PK 는 (stu_id, course_name) 입니다. 각 비키 컬럼이 키 전부에 의존하는지 봅니다.
| 컬럼 | 무엇에 의존? | 문제 |
|---|---|---|
stu_name | stu_id 에만 | ⚠️ 부분 종속 |
course_fee | course_name 에만 | ⚠️ 부분 종속 |
instructor | course_name 에만 | ⚠️ 부분 종속 |
ins_tel | course_name(→instructor) 에 | ⚠️ 부분 종속 |
stu_name 은 course_name 과 무관한데 SQL·파이썬 두 행에 김민수가 중복 저장됩니다. 키의 일부에만 의존하는 것들을 각자의 테이블로 보냅니다.
💡 2NF 문제는 복합 PK 가 있을 때만 생깁니다. PK 가 단일 컬럼(대리키)이면 부분 종속이 성립할 수 없어 2NF 는 자동 충족입니다. 그래서 실무에서 대리키를 쓰면 2NF 는 거의 신경 쓸 일이 없고, 진짜 싸움은 3NF 입니다.
courses 테이블을 봅니다. course_name → instructor → ins_tel 입니다. ins_tel 이 후보키(course_name)가 아니라 다른 비키 컬럼(instructor)에 의존합니다. 이것이 이행 종속입니다.
박교수가 두 과목을 맡으면 전화번호가 두 번 저장되고, 전화가 바뀌면 여러 행을 고쳐야 합니다. 강사를 분리합니다.
이제 강사 전화는 딱 한 곳에만 있습니다. → 갱신 이상 소멸.
💡 3NF 한 줄 판별법: "이 컬럼, 키가 아닌 다른 컬럼만 알아도 알 수 있나?" 그렇다면 3NF 위반입니다. (
zone은city만 알면 아니까 위반,city를 분리)
3NF 를 만족해도 문제가 남는 드문 경우가 있습니다. BCNF(Boyce-Codd) 는 "모든 결정자(X→Y 의 X)가 후보키여야 한다"는 더 강한 조건입니다.
전형적 예 — 한 학생이 한 과목에서 한 강사에게만 배우고, 각 강사는 한 과목만 가르칠 때:
(stu_id, course) → instructor ✅ (후보키가 결정자)instructor → course ⚠️ (강사가 과목을 결정하는데, instructor 는 후보키가 아님)3NF 는 통과하지만(비키→비키 아님, course 는 키의 일부라 애매) BCNF 는 위반입니다. instructor → course 를 별도 테이블로 빼서 해소합니다.
💡 실무 감각: BCNF 위반은 "후보키가 여러 개이고 서로 겹치는" 특수 상황에서만 나옵니다. 대리키 + 단순한 도메인을 쓰는 대부분의 OLTP 스키마는 3NF = BCNF 입니다. BCNF 는 "이런 게 있다"만 알고, 실무 기준선은 3NF 로 잡으세요.
| 정규형 | 없애는 것 | 언제 신경? |
|---|---|---|
| 4NF | 다치(多値) 종속 — 독립적인 두 다대다 관계를 한 테이블에 뒤섞음 | "학생-취미"와 "학생-언어"를 한 테이블에 넣어 조합이 폭발할 때 |
| 5NF | 조인 종속 — 더 쪼개야만 가짜 조합(spurious tuple)이 안 생기는 극단적 경우 | 거의 만날 일 없음 (이론적) |
4NF 위반 예: (student, hobby, language) 한 테이블 → 학생이 취미 2개·언어 2개면 2×2=4행이 의미 없이 생김. student_hobby, student_language 두 테이블로 분리하면 끝. 실무에서 "다대다는 각각 별도 연결 테이블"이라는 습관이 곧 4NF 입니다.
💡 결론: 실무 목표는 3NF(사실상 BCNF). 4NF 는 "독립적인 다대다는 섞지 마라"는 상식으로 자연히 지켜집니다. 5NF 는 면접용 지식.
교과서 예제보다 실제 코드에서 이렇게 생겨서 온다는 걸 아는 게 중요합니다.
| 패턴 | 어떻게 생기나 | 위반 | 해결 |
|---|---|---|---|
| 콤마 구분 문자열 | tags='a,b,c', role_ids='1,2' | 1NF | 연결 테이블 entity_tag(entity_id, tag) |
| 반복 컬럼 | phone1, phone2, phone3 | 1NF (변형) | contacts(person_id, type, phone) |
| 중복 스냅샷 아님 | customers.city 를 orders 에 그대로 복사 | 3NF | FK 로 참조 (단, "배송지 스냅샷"이면 정당) |
| 계산 컬럼 저장 | total = qty*price 를 컬럼으로 | 3NF-ish | 생성 컬럼/뷰(Step 14) 또는 의도된 반정규화 |
| EAV | attr_name/attr_value 세로 저장으로 모든 속성 표현 | (역)정규화 과잉 | 정형 속성은 컬럼으로, 진짜 가변은 JSON |
| 다목적 코드 테이블 | 모든 코드값을 common_code 한 테이블에 | 관계 모호 | 도메인별 코드 테이블 + FK |
⚠️ "스냅샷 vs 중복"을 구분하라 (가장 헷갈리는 지점)
orders.shipping_city가customers.city와 값이 같아 보여도, 배송지는 주문 시점에 얼린 별개의 사실입니다.order_items.unit_price도 마찬가지 — 상품 가격이 나중에 올라도 과거 결제액은 불변이어야 합니다. 값이 같아 보여도 의미가 다르면 중복이 아니라 별개 컬럼입니다. 이걸 "정규화한답시고" JOIN 으로 대체하면 회계가 망가집니다.
⚠️ EAV 의 유혹과 대가 "속성이 계속 추가되니
product_attr(product_id, key, value)로 다 넣자"는 EAV 설계는 유연하지만, 타입 검증 불가·인덱싱 지옥·조회 시 자기조인 폭발을 부릅니다. MySQL 8 이라면 EAV 대신 JSON 컬럼 + 함수 인덱스가 대개 낫습니다(Step 18).
정규화는 쓰기 정합성에 최적, 반정규화는 읽기 성능에 최적입니다. 트레이드오프입니다.
| 정규화 | 반정규화 | |
|---|---|---|
| 데이터 정합성 | 강함 (한 곳 저장) | 약함 (동기화 책임이 개발자) |
| 쓰기 성능 | 좋음 | 나쁨 (여러 곳 UPDATE) |
| 읽기 성능 | 나쁨 (JOIN 필요) | 좋음 (JOIN 없이) |
| 저장 공간 | 적음 | 많음 |
shop 스키마의 orders.total_amount 가 대표적 반정규화입니다. 원칙대로면 매번 SUM(order_items) 로 계산해야 하지만, 주문 목록 화면이 하루 수백만 번 열리므로 미리 계산해 저장합니다. 대가는 order_items 가 바뀔 때마다 total_amount 를 반드시 같이 갱신해야 한다는 것.
💡 반정규화 3원칙
- 일단 3NF 로 설계. 반정규화는 "느려서 못 쓴다"는 측정된 증거가 나온 뒤에만.
- 반정규화했다면 동기화 수단을 함께 만들어라 — 트랜잭션, 트리거, 또는 야간 정합성 검증 배치.
- 집계값은 생성 컬럼/뷰로 대체 가능한지 먼저 검토(Step 14).
⚠️ 가장 흔한 실패: 성능 위해 반정규화 → 동기화 코드 한 곳 누락 → 6개월 뒤 정산 불일치 → 밤샘 데이터 복구.
반정규화 컬럼을 뒀다면 이런 검증 배치를 반드시 함께 운영하세요. 결과가 0건이어야 정상입니다.
X → Y 형태로 FD 를 적는다.phone1, phone2 … 처럼 번호 붙은 반복 컬럼이 있는가? → 1NF 위반city→zone)되짚기: Step 13 — 제약과 정규화 · 관계를 강제하는 FK 는 부록 B · 계산 컬럼 자동화는 Step 14 · JSON 으로 유연성을 얻는 법은 Step 18.