정규화와 스키마 설계 — 1NF부터 BCNF, 그리고 역정규화 트레이드오프
함수 종속으로 이해하는 1NF~BCNF 정규형이 이상 현상을 없애는 원리와, 읽기 성능을 위해 조인을 줄이는 역정규화의 트레이드오프·판단 기준.
한 테이블에 모든 걸 욱여넣으면 처음엔 편하다. 문제는 데이터가 쌓인 뒤에 터진다. 같은 값이 수백 행에 중복되고, 한 곳만 고치면 나머지가 어긋나며, 마지막 주문을 지웠더니 고객 정보까지 사라진다. 정규화는 이 사고들을 “설계 단계에서” 구조적으로 막는 이론이고, 역정규화는 그 대가로 늘어난 조인을 성능을 위해 되돌리는 실무 판단이다. 둘은 대립이 아니라 순서다.
1. 정규화가 푸는 문제 — 이상 현상과 중복
정규화되지 않은 테이블은 하나의 사실을 여러 곳에 중복 저장한다. 그 중복이 아래 세 가지 이상 현상(anomaly)을 낳는다.
다음 주문 테이블을 보자. 한 행에 주문·고객·상품 정보가 모두 들어 있다.
| order_id | customer_id | customer_name | customer_tier | product_id | product_name | qty |
|---|---|---|---|---|---|---|
| 1001 | C1 | 김철수 | GOLD | P1 | 노트북 | 1 |
| 1002 | C1 | 김철수 | GOLD | P2 | 마우스 | 2 |
| 1003 | C2 | 이영희 | SILVER | P1 | 노트북 | 1 |
- 갱신 이상(update anomaly): 김철수가 GOLD에서 VIP로 등급이 바뀌면 그의 모든 주문 행을 빠짐없이 고쳐야 한다. 하나라도 놓치면 같은 고객의 등급이 행마다 달라진다.
- 삽입 이상(insert anomaly): 아직 주문한 적 없는 신규 고객을 등록하려는데, 이 테이블에는 주문 없이 고객만 넣을 수 없다.
product_id등 주문 컬럼을 억지로 NULL로 채워야 한다. - 삭제 이상(delete anomaly): C2의 마지막 주문(1003)을 삭제하면 이영희라는 고객이 존재했다는 사실 자체가 함께 사라진다.
근본 원인은 하나다. 서로 독립적인 사실(고객·상품·주문)이 한 테이블에 엉켜 있다. 정규화는 이 엉킴을 함수 종속을 기준으로 풀어낸다.
함수 종속(functional dependency)
X가 정해지면 Y가 유일하게 결정될 때,
X → Y(Y는 X에 함수적으로 종속된다)라고 쓴다.
위 테이블에서:
customer_id → customer_name, customer_tier— 고객 ID가 정해지면 이름·등급이 하나로 정해진다.product_id → product_name— 상품 ID가 정해지면 상품명이 정해진다.order_id → customer_id, product_id, qty— 주문 ID가 나머지 전부를 결정한다.
정규화란 결국 “각 함수 종속을, 그 결정자(X)를 기본키로 갖는 별도 테이블로 옮기는 작업“이다. 정규형 단계는 이 옮기기를 얼마나 철저히 하느냐의 등급이다.
2. 정규형 단계 — 무엇을 분리하는가
암기 대상이 아니라, 단계마다 “어떤 잘못된 종속을 떼어내는가”로 이해하면 순서가 자연스럽게 읽힌다.
2-1. 제1정규형(1NF) — 원자값
모든 컬럼 값이 원자적(더 쪼갤 수 없는 단일 값)이어야 한다. 반복 그룹·다중값·중첩 구조를 금지한다.
한 컬럼에 여러 값을 몰아넣은 상태를 푼다.
1
2
3
4
5
6
7
8
-- 위반: phone 한 컬럼에 값 여러 개
customer_id | name | phones
C1 | 김철수 | "010-1111, 010-2222"
-- 1NF: 값 하나당 한 행
customer_id | name | phone
C1 | 김철수 | 010-1111
C1 | 김철수 | 010-2222
phones를 콤마로 이어 붙이면 특정 번호 검색·수정이 문자열 파싱으로 전락한다. 1NF는 “값을 컬럼이 아니라 행으로 표현하라”는 규칙이다.
2-2. 제2정규형(2NF) — 부분 종속 제거
1NF이면서, 복합 기본키의 일부에만 종속되는 컬럼이 없어야 한다.
2NF는 기본키가 여러 컬럼으로 이뤄진 복합키일 때만 문제가 된다. 주문 상세 테이블의 기본키가 (order_id, product_id)라고 하자.
| order_id | product_id | qty | product_name |
|---|---|---|---|
| 1001 | P1 | 1 | 노트북 |
| 1002 | P1 | 3 | 노트북 |
(order_id, product_id) → qty— 수량은 주문+상품 조합 전체에 종속된다. 정상.product_id → product_name— 상품명은 기본키의 일부인product_id에만 종속된다. 이것이 부분 종속(partial dependency) 이다.
product_name이 상품 하나에만 딸린 사실인데 주문 상세마다 반복 저장되니, 상품명을 바꾸면 갱신 이상이 재발한다. 부분 종속을 별도 테이블로 분리한다.
1
2
order_items(order_id, product_id, qty) -- 조합 전체에 종속되는 것만
products(product_id, product_name) -- product_id에만 종속되던 것
2-3. 제3정규형(3NF) — 이행 종속 제거
2NF이면서, 기본키가 아닌 컬럼이 다른 비(非)키 컬럼을 거쳐 기본키에 종속되는 경우가 없어야 한다.
고객 테이블을 보자. 기본키는 customer_id.
| customer_id | customer_tier | tier_discount_rate |
|---|---|---|
| C1 | GOLD | 0.10 |
| C2 | SILVER | 0.05 |
customer_id → customer_tier(정상)customer_tier → tier_discount_rate— 할인율은 등급에 종속된다.- 따라서
customer_id → customer_tier → tier_discount_rate— 기본키가 비키 컬럼(tier)을 경유해 할인율을 결정한다. 이것이 이행 종속(transitive dependency) 이다.
할인율은 고객이 아니라 “등급”의 속성인데 고객 행마다 중복된다. GOLD 할인율을 0.12로 바꾸면 GOLD 고객 전원을 갱신해야 한다. 이행 종속을 떼어낸다.
1
2
customers(customer_id, customer_tier)
tiers(customer_tier, tier_discount_rate) -- 등급의 속성은 등급 테이블로
2NF는 “키의 일부에 매달린 것”, 3NF는 “다른 컬럼을 경유해 매달린 것”을 분리한다. 둘 다 “이 사실은 진짜 이 키의 속성인가?”라는 같은 질문의 다른 각도다.
2-4. 보이스-코드 정규형(BCNF) — 모든 결정자가 후보키
모든 함수 종속
X → Y에서 결정자 X가 반드시 후보키(candidate key)여야 한다.
3NF는 대부분의 이상을 잡지만, 후보키가 여러 개이고 서로 겹칠 때 예외가 남는다. 이 틈을 막는 것이 BCNF다. 3NF보다 엄격한 상위 정규형이다.
강의 배정 예시. 규칙은 (1) 한 학생은 한 과목당 한 강사에게만 배정되고, (2) 한 강사는 한 과목만 가르친다.
| student_id | subject | instructor |
|---|---|---|
| S1 | 수학 | 김강사 |
| S2 | 수학 | 김강사 |
| S1 | 영어 | 박강사 |
함수 종속은 두 개다.
(student_id, subject) → instructor— 학생+과목이 강사를 결정. 이 조합이 후보키다.instructor → subject— 강사가 과목을 결정. 그런데instructor는 후보키가 아니다.
instructor → subject의 결정자 instructor가 후보키가 아니므로 BCNF 위반이다. 결과적으로 “김강사가 수학을 가르친다”는 사실이 학생 수만큼 중복된다. 김강사의 담당 과목이 바뀌면 여러 행을 고쳐야 한다. 결정자가 후보키가 되도록 분리한다.
1
2
instructor_subject(instructor, subject) -- instructor가 기본키(후보키)
student_instructor(student_id, instructor) -- 학생-강사 배정
이 테이블은 3NF는 만족하지만(비키 컬럼의 이행 종속은 없다) BCNF는 위반한다. 3NF와 BCNF가 갈리는 지점이 바로 이런 겹치는 후보키 상황이다. 실무에서는 대부분 스키마가 후보키가 하나뿐이라 3NF만 지켜도 자동으로 BCNF까지 만족한다.
| 정규형 | 무엇을 분리하는가 | 핵심 질문 |
|---|---|---|
| 1NF | 다중값을 행으로 | 값이 원자적인가? |
| 2NF | 복합키 일부에만 매달린 컬럼 | 키 전체에 종속되는가? |
| 3NF | 비키 컬럼을 경유하는 종속 | 키에 직접 종속되는가? |
| BCNF | 후보키 아닌 결정자 | 모든 결정자가 후보키인가? |
3. 역정규화(denormalization) — 정규화의 대가를 되사기
정규화의 대가는 명확하다. 하나였던 테이블이 여럿으로 쪼개지면서, 원래 한 번에 읽던 데이터를 조인으로 다시 합쳐야 한다. 조인은 공짜가 아니다(조인 알고리즘별 비용은 아래 관련 글 참고).
주문 목록 화면에서 고객명·상품명·등급 할인율을 함께 보여준다고 하자. 완전히 정규화된 스키마에서는 매 조회마다 orders → customers → tiers → order_items → products 4중 조인이 돈다. 이 화면이 초당 수천 번 호출되면 조인 비용이 병목이 된다.
역정규화는 읽기 성능을 위해 의도적으로 중복을 다시 허용하는 것이다.
1
2
3
4
5
6
7
8
9
10
-- 정규화된 상태: 매 조회마다 조인
SELECT o.order_id, c.customer_name, t.tier_discount_rate
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id
JOIN tiers t ON c.customer_tier = t.customer_tier;
-- 역정규화: 자주 함께 읽는 값을 orders에 복제해 조인 제거
ALTER TABLE orders ADD COLUMN customer_name VARCHAR(100);
ALTER TABLE orders ADD COLUMN tier_discount_rate NUMERIC(3,2);
-- 이제 orders 단일 테이블 조회로 끝
대가는 정확히 정규화가 없앴던 그 갱신 이상이다. customer_name이 바뀌면 customers뿐 아니라 orders에 복제된 값도 함께 고쳐야 한다. 즉 역정규화는 “읽기 비용을 쓰기 시점의 일관성 관리 부담으로 옮기는” 거래다.
역정규화는 정규화를 몰라서 안 하는 게 아니라, 알고 나서 특정 대가를 감수하고 되돌리는 결정이다. 정규화를 건너뛴 스키마와 의도적으로 역정규화한 스키마는 겉이 같아도 성격이 다르다. 후자는 “어떤 중복을, 왜, 어떻게 동기화하는지”가 설계에 명시돼 있다.
4. 정규화 vs 역정규화 판단 기준
무엇을 기본값으로 둘지는 아래 세 축으로 갈린다.
| 판단 축 | 정규화 유리 | 역정규화 유리 |
|---|---|---|
| 읽기/쓰기 비율 | 쓰기가 잦다 | 읽기가 압도적으로 많다 |
| 조인 비용 | 조인이 가볍다(작은 테이블·좋은 인덱스) | 조인이 무겁다(대용량·다중 조인) |
| 데이터 정합성 요구 | 강함(금융·재고 등 틀리면 안 됨) | 약함/지연 허용(통계·목록 캐시) |
- 쓰기가 잦을수록 중복은 독이다. 갱신할 곳이 늘어 일관성 관리 비용과 버그 위험이 함께 커진다.
- 읽기가 압도적일수록 역정규화의 이득이 크다. 한 번 쓰고 수만 번 읽는 데이터라면 쓰기 시 동기화 한 번이 매 읽기의 조인보다 싸다.
- 정합성이 생명인 도메인(결제 금액, 재고 수량)은 역정규화한 중복값을 진실의 원천으로 삼으면 안 된다. 원본은 정규화된 테이블에 두고, 복제본은 어긋날 수 있음을 전제로 다룬다.
5. 실무 — 3NF 기본, 병목만 선택적 역정규화
현실적인 원칙은 단순하다.
- 3NF(사실상 BCNF)를 기본값으로 설계한다. 이상 현상 없는 깨끗한 스키마가 유지보수의 출발점이다. 처음부터 성능을 이유로 중복을 허용하지 않는다.
- 측정으로 병목을 특정한 뒤에만 역정규화한다. 실행 계획(EXPLAIN)으로 조인이 실제 병목임을 확인한 특정 쿼리·화면에 한해, 국소적으로 중복을 도입한다. “느릴 것 같아서” 미리 뭉개지 않는다.
- 역정규화한 중복은 동기화 책임을 명시한다. 트리거, 애플리케이션 레이어, 배치 중 무엇으로 원본과 맞출지를 설계에 못 박는다. 동기화 방식이 없는 복제는 시한폭탄이다.
캐시·집계 테이블과의 관계
역정규화의 상당수는 사실 별도의 파생 테이블로 구현하는 것이 원본을 오염시키지 않아 깔끔하다.
- 집계 테이블(summary table):
SUM,COUNT같은 집계를 미리 계산해 저장. 일별 매출 합계를 매번 수억 행에서 계산하는 대신daily_sales테이블에 적재한다. 원본 트랜잭션 테이블은 정규화된 채 그대로 둔다. - 읽기 전용 캐시 테이블: 여러 테이블을 조인한 결과를 미리 펼쳐(flatten) 저장. 조회는 이 테이블만 보고, 원본이 바뀌면 배치·이벤트로 갱신한다.
이렇게 하면 “정규화된 원본(진실의 원천)”과 “역정규화된 읽기 뷰(성능용 복제)”의 역할이 분리된다. 원본의 무결성은 정규형이 지키고, 읽기 성능은 파생 테이블이 책임진다. 어느 한쪽이 다른 쪽을 훼손하지 않는다.
정규화는 기본기, 역정규화는 예외 처리다. “일단 3NF로 짜고, 측정된 병목만 명시적으로 되돌린다”가 대부분의 백엔드 스키마에 맞는 순서다.
관련 글
| 글 | 이 글과의 연결 |
|---|---|
| RDB에서 조인(Join) 방식 총정리 | 정규화가 늘리는 조인의 실행 원리 |
| RECORD_ID를 레벨 테이블에 사전 적재해 조인 제거 | 역정규화(사전 적재)로 조인을 없앤 실제 사례 |