부록 C — 정규화 완전 정복 (1NF ~ BCNF, 그리고 반정규화)

학습 목표

  • 함수 종속(functional dependency)이상현상(anomaly) 이라는 정규화의 두 축을 이해한다
  • 하나의 지저분한 테이블을 0NF → 1NF → 2NF → 3NF → BCNF 로 직접 분해할 수 있다
  • 각 정규형이 어떤 이상현상을 없애는지 를 원인과 함께 설명한다
  • 4NF·5NF 를 "언제 신경 쓰면 되는지" 수준으로 안다
  • 실무에서 마주치는 정규화 위반 패턴(콤마 문자열, EAV, 계산 컬럼 중복 등)을 알아보고 고친다
  • 반정규화를 언제·어떻게 하고, 동기화를 어떻게 지키는지 판단한다

선행 스텝: Step 13 — 제약 조건과 정규화 (특히 13-8) 예상 소요: 70분

Step 13-8 이 정규화의 결론(요약표와 반정규화 판단)을 줬다면, 이 부록은 그 원리와 손기술을 채웁니다. "왜 그 규칙이 그렇게 생겼는가"와 "실제 스키마를 어떻게 쪼개는가"에 집중합니다.


C-0. 정규화는 왜 하는가 — 한 문장

정규화 = "하나의 사실은 딱 한 곳에만 저장한다."

같은 사실이 여러 곳에 중복되면 세 가지 이상현상이 생깁니다. 이것을 없애는 게 정규화의 전부입니다.

이상현상
갱신 이상 (update)중복된 값 중 하나만 고쳐서 모순 발생고객 도시를 한 행만 바꿔 나머지와 어긋남
삽입 이상 (insert)다른 정보가 없어서 넣을 수 없음주문한 적 없는 고객을 등록 못 함
삭제 이상 (delete)한 행을 지웠더니 다른 사실까지 소멸마지막 주문 삭제 → 고객 정보까지 사라짐

💡 정규형(Normal Form)은 이 이상현상을 없애기 위한 점점 엄격해지는 조건입니다. 상위 정규형은 하위를 포함합니다(3NF면 이미 2NF·1NF).


C-1. 먼저 알아야 할 도구 — 함수 종속(FD)

정규화의 모든 규칙은 함수 종속 하나로 표현됩니다.

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(오직 키에만)를 한 줄로 요약한 유명한 문장입니다.


C-2. 하나의 예제를 끝까지 — 학원 수강 신청

아래 지저분한 테이블 하나를 0NF 부터 BCNF 까지 관통합니다. 이 예제만 손으로 따라가면 정규화가 몸에 붙습니다.

0NF — 모든 걸 한 테이블에

enrollment (수강 신청)
+--------+----------+------------------+-----------+-------------+----------+
| stu_id | stu_name | courses          | course_fee| instructor  | ins_tel  |
+--------+----------+------------------+-----------+-------------+----------+
| 1      | 김민수   | SQL:3, 파이썬:2  | 30만,20만 | 박교수,이교수| 010-1111,010-2222 |
| 2      | 이지은   | SQL:3            | 30만      | 박교수      | 010-1111 |
+--------+----------+------------------+-----------+-------------+----------+

세 이상현상이 전부 살아 있습니다: 박교수 전화가 바뀌면 여러 행 수정(갱신), 수강생 없는 신규 과목 등록 불가(삽입), 이지은 삭제 시 SQL 과목·박교수 정보 소멸(삭제).


1NF — 한 칸에 하나의 값 (원자값)

courses, course_fee, instructor 에 여러 값이 뭉쳐 있으면 위반입니다. 행으로 쪼갭니다.

enrollment (1NF)  —  PK = (stu_id, course_name)
+--------+----------+-------------+-----------+------------+----------+
| stu_id | stu_name | course_name | course_fee| instructor | ins_tel  |
+--------+----------+-------------+-----------+------------+----------+
| 1      | 김민수   | SQL         | 30만      | 박교수     | 010-1111 |
| 1      | 김민수   | 파이썬      | 20만      | 이교수     | 010-2222 |
| 2      | 이지은   | SQL         | 30만      | 박교수     | 010-1111 |
+--------+----------+-------------+-----------+------------+----------+

⚠️ 1NF 위반의 현대적 변종 — 콤마 문자열 / JSON 남용 tags VARCHAR(255)='세일,신상' 은 지금도 흔한 1NF 위반입니다. FIND_IN_SET 으로 조회는 되지만 인덱스를 못 탑니다(풀스캔). → product_tags(product_id, tag) 로 분리. 단, MySQL 8 의 JSON 타입은 함수 인덱스·JSON_TABLE 이 있어 예외적으로 허용되는 경우가 있습니다(Step 18). "정말 관계형이 맞는가"를 먼저 판단하세요.


2NF — 부분 함수 종속 제거 (후보키의 "일부"에만 의존 금지)

PK 는 (stu_id, course_name) 입니다. 각 비키 컬럼이 키 전부에 의존하는지 봅니다.

컬럼무엇에 의존?문제
stu_namestu_id 에만⚠️ 부분 종속
course_feecourse_name 에만⚠️ 부분 종속
instructorcourse_name 에만⚠️ 부분 종속
ins_telcourse_name(→instructor) 에⚠️ 부분 종속

stu_namecourse_name 과 무관한데 SQL·파이썬 두 행에 김민수가 중복 저장됩니다. 키의 일부에만 의존하는 것들을 각자의 테이블로 보냅니다.

students                 courses                          enrollments
+--------+----------+    +-------------+------+-----------+  +--------+-------------+
| stu_id | stu_name |    | course_name | fee  | instructor|  | stu_id | course_name |
+--------+----------+    +-------------+------+ ins_tel   |  +--------+-------------+
| 1      | 김민수   |    | SQL         | 30만 | 박교수... |  | 1      | SQL         |
| 2      | 이지은   |    | 파이썬      | 20만 | 이교수... |  | 1      | 파이썬      |
+--------+----------+    +-------------+------+-----------+  | 2      | SQL         |
                                                            +--------+-------------+

💡 2NF 문제는 복합 PK 가 있을 때만 생깁니다. PK 가 단일 컬럼(대리키)이면 부분 종속이 성립할 수 없어 2NF 는 자동 충족입니다. 그래서 실무에서 대리키를 쓰면 2NF 는 거의 신경 쓸 일이 없고, 진짜 싸움은 3NF 입니다.


3NF — 이행 함수 종속 제거 (비키 → 비키 의존 금지)

courses 테이블을 봅니다. course_name → instructor → ins_tel 입니다. ins_tel 이 후보키(course_name)가 아니라 다른 비키 컬럼(instructor)에 의존합니다. 이것이 이행 종속입니다.

박교수가 두 과목을 맡으면 전화번호가 두 번 저장되고, 전화가 바뀌면 여러 행을 고쳐야 합니다. 강사를 분리합니다.

courses                       instructors
+-------------+------+--------+  +------------+----------+
| course_name | fee  | ins_id |  | ins_id     | ins_name | ins_tel  |
+-------------+------+--------+  +------------+----------+----------+
| SQL         | 30만 | 1      |  | 1          | 박교수   | 010-1111 |
| 파이썬      | 20만 | 2      |  | 2          | 이교수   | 010-2222 |
+-------------+------+--------+  +------------+----------+----------+

이제 강사 전화는 딱 한 곳에만 있습니다. → 갱신 이상 소멸.

💡 3NF 한 줄 판별법: "이 컬럼, 키가 아닌 다른 컬럼만 알아도 알 수 있나?" 그렇다면 3NF 위반입니다. (zonecity 만 알면 아니까 위반, city 를 분리)


BCNF — 3NF 로도 안 잡히는 마지막 함정

3NF 를 만족해도 문제가 남는 드문 경우가 있습니다. BCNF(Boyce-Codd) 는 "모든 결정자(X→Y 의 X)가 후보키여야 한다"는 더 강한 조건입니다.

전형적 예 — 한 학생이 한 과목에서 한 강사에게만 배우고, 각 강사는 한 과목만 가르칠 때:

teaches   —  후보키: (stu_id, course), 그리고 (stu_id, instructor)
+--------+---------+-------------+
| stu_id | course  | instructor  |
+--------+---------+-------------+
  • FD: (stu_id, course) → instructor ✅ (후보키가 결정자)
  • FD: instructor → course ⚠️ (강사가 과목을 결정하는데, instructor 는 후보키가 아님)

3NF 는 통과하지만(비키→비키 아님, course 는 키의 일부라 애매) BCNF 는 위반입니다. instructor → course 를 별도 테이블로 빼서 해소합니다.

💡 실무 감각: BCNF 위반은 "후보키가 여러 개이고 서로 겹치는" 특수 상황에서만 나옵니다. 대리키 + 단순한 도메인을 쓰는 대부분의 OLTP 스키마는 3NF = BCNF 입니다. BCNF 는 "이런 게 있다"만 알고, 실무 기준선은 3NF 로 잡으세요.


C-3. 4NF·5NF — 알기만 하면 되는 것

정규형없애는 것언제 신경?
4NF다치(多値) 종속 — 독립적인 두 다대다 관계를 한 테이블에 뒤섞음"학생-취미"와 "학생-언어"를 한 테이블에 넣어 조합이 폭발할 때
5NF조인 종속 — 더 쪼개야만 가짜 조합(spurious tuple)이 안 생기는 극단적 경우거의 만날 일 없음 (이론적)

4NF 위반 예: (student, hobby, language) 한 테이블 → 학생이 취미 2개·언어 2개면 2×2=4행이 의미 없이 생김. student_hobby, student_language 두 테이블로 분리하면 끝. 실무에서 "다대다는 각각 별도 연결 테이블"이라는 습관이 곧 4NF 입니다.

💡 결론: 실무 목표는 3NF(사실상 BCNF). 4NF 는 "독립적인 다대다는 섞지 마라"는 상식으로 자연히 지켜집니다. 5NF 는 면접용 지식.


C-4. 실무 정규화 위반 패턴 카탈로그

교과서 예제보다 실제 코드에서 이렇게 생겨서 온다는 걸 아는 게 중요합니다.

패턴어떻게 생기나위반해결
콤마 구분 문자열tags='a,b,c', role_ids='1,2'1NF연결 테이블 entity_tag(entity_id, tag)
반복 컬럼phone1, phone2, phone31NF (변형)contacts(person_id, type, phone)
중복 스냅샷 아님customers.cityorders 에 그대로 복사3NFFK 로 참조 (단, "배송지 스냅샷"이면 정당)
계산 컬럼 저장total = qty*price 를 컬럼으로3NF-ish생성 컬럼/뷰(Step 14) 또는 의도된 반정규화
EAVattr_name/attr_value 세로 저장으로 모든 속성 표현(역)정규화 과잉정형 속성은 컬럼으로, 진짜 가변은 JSON
다목적 코드 테이블모든 코드값을 common_code 한 테이블에관계 모호도메인별 코드 테이블 + FK

⚠️ "스냅샷 vs 중복"을 구분하라 (가장 헷갈리는 지점) orders.shipping_citycustomers.city 와 값이 같아 보여도, 배송지는 주문 시점에 얼린 별개의 사실입니다. order_items.unit_price 도 마찬가지 — 상품 가격이 나중에 올라도 과거 결제액은 불변이어야 합니다. 값이 같아 보여도 의미가 다르면 중복이 아니라 별개 컬럼입니다. 이걸 "정규화한답시고" JOIN 으로 대체하면 회계가 망가집니다.

⚠️ EAV 의 유혹과 대가 "속성이 계속 추가되니 product_attr(product_id, key, value) 로 다 넣자"는 EAV 설계는 유연하지만, 타입 검증 불가·인덱싱 지옥·조회 시 자기조인 폭발을 부릅니다. MySQL 8 이라면 EAV 대신 JSON 컬럼 + 함수 인덱스가 대개 낫습니다(Step 18).


C-5. 반정규화 — 일부러 규칙을 깨기

정규화는 쓰기 정합성에 최적, 반정규화는 읽기 성능에 최적입니다. 트레이드오프입니다.

정규화반정규화
데이터 정합성강함 (한 곳 저장)약함 (동기화 책임이 개발자)
쓰기 성능좋음나쁨 (여러 곳 UPDATE)
읽기 성능나쁨 (JOIN 필요)좋음 (JOIN 없이)
저장 공간적음많음

shop 스키마의 orders.total_amount 가 대표적 반정규화입니다. 원칙대로면 매번 SUM(order_items) 로 계산해야 하지만, 주문 목록 화면이 하루 수백만 번 열리므로 미리 계산해 저장합니다. 대가는 order_items 가 바뀔 때마다 total_amount반드시 같이 갱신해야 한다는 것.

💡 반정규화 3원칙

  1. 일단 3NF 로 설계. 반정규화는 "느려서 못 쓴다"는 측정된 증거가 나온 뒤에만.
  2. 반정규화했다면 동기화 수단을 함께 만들어라 — 트랜잭션, 트리거, 또는 야간 정합성 검증 배치.
  3. 집계값은 생성 컬럼/뷰로 대체 가능한지 먼저 검토(Step 14).

⚠️ 가장 흔한 실패: 성능 위해 반정규화 → 동기화 코드 한 곳 누락 → 6개월 뒤 정산 불일치 → 밤샘 데이터 복구.

동기화 누락을 잡는 검증 쿼리 (반정규화 필수 세트)

-- 저장된 total_amount 와 실제 합계가 다른 주문 찾기
SELECT o.order_id, o.total_amount AS stored,
       SUM(oi.quantity * oi.unit_price) AS real_sum
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.total_amount
HAVING stored <> real_sum;

반정규화 컬럼을 뒀다면 이런 검증 배치를 반드시 함께 운영하세요. 결과가 0건이어야 정상입니다.


C-6. 정규화 실전 절차 (스키마 설계할 때 이 순서로)

  1. 엔터티와 사실을 나열 — "무엇을 저장하나?" 명사를 뽑는다.
  2. 각 사실의 결정자를 찾는다X → Y 형태로 FD 를 적는다.
  3. 하나의 사실 = 하나의 테이블 — 결정자가 다르면 테이블을 나눈다.
  4. 관계를 FK 로 연결 (부록 B).
  5. 다대다는 연결 테이블로 (자연스럽게 4NF).
  6. 여기까지가 3NF. 대부분 여기서 멈춘다.
  7. 측정 후 읽기 병목이 확인되면 그때 반정규화 + 동기화 수단.

C-7. 체크리스트 (설계 리뷰 때 이것만 훑어라)

  • 한 칸에 콤마로 여러 값을 넣은 컬럼이 있는가? → 1NF 위반, 연결 테이블로
  • phone1, phone2 … 처럼 번호 붙은 반복 컬럼이 있는가? → 1NF 위반
  • 복합 PK 인데 일부 컬럼에만 의존하는 컬럼이 있는가? → 2NF 위반
  • 키가 아닌 컬럼이 다른 키 아닌 컬럼만으로 결정되는가? → 3NF 위반 (city→zone)
  • 값이 중복돼 보이는 컬럼이 "스냅샷(주문 시점 사실)"인가, 진짜 중복인가? → 스냅샷이면 유지
  • 계산으로 얻을 수 있는 값을 컬럼에 저장했는가? → 생성 컬럼/뷰 검토, 아니면 동기화 배치
  • 반정규화 컬럼에 정합성 검증 쿼리/배치가 있는가? → 없으면 시한폭탄
  • 후보키가 여러 개 겹치는 특수 구조인가? → BCNF 점검 (드묾)

되짚기: Step 13 — 제약과 정규화 · 관계를 강제하는 FK 는 부록 B · 계산 컬럼 자동화는 Step 14 · JSON 으로 유연성을 얻는 법은 Step 18.