정규화 — 1NF부터 BCNF까지, 반정규화는 언제 하나
하나의 테이블에 모든 걸 때려 넣으면 어떤 문제가 생길까? 고객 정보를 수정하려면 왜 100개 행을 전부 바꿔야 하는 걸까?
정규화가 왜 필요한가
하나의 테이블에 모든 걸 다 때려 넣으면 어떻게 될까요.
[주문 테이블] — 정규화 전
+--------+-----------+----------+----------+---------+
| 주문ID | 고객이름 | 고객전화 | 상품이름 | 가격 |
+--------+-----------+----------+----------+---------+
| 1 | 김철수 | 010-1234 | 키보드 | 50000 |
| 2 | 김철수 | 010-1234 | 마우스 | 30000 |
| 3 | 이영희 | 010-5678 | 모니터 | 300000 |
+--------+-----------+----------+----------+---------+
이 테이블은 세 가지 문제를 갖고 있습니다.
삽입 이상 (Insertion Anomaly)
새 고객 "박민수"를 등록하고 싶은데, 아직 주문한 게 없습니다. 주문ID가 PK니까 주문 없이는 고객 정보를 넣을 수가 없습니다.
갱신 이상 (Update Anomaly)
김철수가 전화번호를 바꿨습니다. 그런데 김철수의 주문이 100건이면? 100개 행을 전부 수정해야 합니다. 하나라도 놓치면 같은 사람인데 전화번호가 두 개가 됩니다.
삭제 이상 (Deletion Anomaly)
이영희의 주문 3번을 취소합니다. 근데 이영희의 주문이 이것 하나뿐이라 행을 삭제하면, 이영희라는 고객 정보 자체가 사라져 버립니다.
이런 문제들을 이상 현상(Anomaly) 이라 부르고, 정규화는 이 이상 현상을 체계적으로 제거하는 과정입니다.
함수적 종속 (Functional Dependency)
정규화를 이해하려면 함수적 종속 개념부터 잡아야 합니다.
X → Y: X의 값이 정해지면 Y의 값이 유일하게 결정된다.
예를 들어 학번 → 이름. 학번을 알면 이름이 하나로 정해집니다. 반대로 이름을 알아도 학번은 모릅니다. 동명이인이 있으니까요.
부분 종속 (Partial Dependency)
복합키 {A, B}가 PK인데, A → C처럼 PK의 일부만으로도 결정되는 경우. 2NF에서 제거합니다.
이행 종속 (Transitive Dependency)
A → B, B → C이면 A → C가 성립하는 경우. 3NF에서 제거합니다.
제1정규형 (1NF)
** 규칙: 모든 컬럼의 값이 원자값(atomic value)이어야 한다.**
반복 그룹이나 다중값이 들어가면 안 됩니다.
Before (1NF 위반)
+-----------+--------------------+
| 학생이름 | 수강과목 |
+-----------+--------------------+
| 김철수 | 수학, 영어, 물리 |
| 이영희 | 수학, 국어 |
+-----------+--------------------+
수강과목 컬럼에 값이 여러 개 들어가 있습니다. 이건 원자값이 아닙니다.
After (1NF 적용)
+-----------+----------+
| 학생이름 | 수강과목 |
+-----------+----------+
| 김철수 | 수학 |
| 김철수 | 영어 |
| 김철수 | 물리 |
| 이영희 | 수학 |
| 이영희 | 국어 |
+-----------+----------+
한 칸에 하나의 값만 들어갑니다. 단순하죠? 근데 1NF만으로는 이상 현상을 막기엔 부족합니다.
제2정규형 (2NF)
** 규칙: 1NF를 만족하면서, 부분 종속을 제거한다.**
부분 종속은 ** 복합키 **일 때만 발생합니다. PK가 단일 컬럼이면 2NF는 자동으로 만족합니다.
Before (2NF 위반)
PK = {학생ID, 과목ID}
+---------+--------+----------+------+
| 학생ID | 과목ID | 학생이름 | 성적 |
+---------+--------+----------+------+
| S1 | C1 | 김철수 | 90 |
| S1 | C2 | 김철수 | 85 |
| S2 | C1 | 이영희 | 95 |
+---------+--------+----------+------+
여기서 함수적 종속 관계를 보면:
{학생ID, 과목ID} → 성적— 완전 종속 (OK)학생ID → 학생이름— ** 부분 종속** (PK의 일부인 학생ID만으로 학생이름이 결정됨)
학생이름이 복합키 전체가 아니라 학생ID 하나에만 종속되어 있습니다.
After (2NF 적용)
테이블을 분리합니다.
[학생]
+---------+----------+
| 학생ID | 학생이름 |
+---------+----------+
| S1 | 김철수 |
| S2 | 이영희 |
+---------+----------+
[수강]
+---------+--------+------+
| 학생ID | 과목ID | 성적 |
+---------+--------+------+
| S1 | C1 | 90 |
| S1 | C2 | 85 |
| S2 | C1 | 95 |
+---------+--------+------+
김철수 이름을 바꿔도 한 곳만 수정하면 됩니다. 갱신 이상이 사라졌습니다.
제3정규형 (3NF)
** 규칙: 2NF를 만족하면서, 이행 종속을 제거한다.**
Before (3NF 위반)
PK = 주문ID
+---------+--------+-----------+
| 주문ID | 고객ID | 고객등급 |
+---------+--------+-----------+
| 1 | C1 | VIP |
| 2 | C2 | 일반 |
| 3 | C1 | VIP |
+---------+--------+-----------+
종속 관계:
주문ID → 고객ID(OK)고객ID → 고객등급(OK)- 따라서
주문ID → 고객ID → 고객등급— ** 이행 종속**
고객등급은 주문ID에 직접 종속되는 게 아니라, 고객ID를 거쳐서 종속됩니다.
문제가 뭐냐면, C1 고객이 VIP에서 골드로 바뀌면 C1이 포함된 모든 주문 행을 수정해야 합니다. 여전히 갱신 이상입니다.
After (3NF 적용)
[주문]
+---------+--------+
| 주문ID | 고객ID |
+---------+--------+
| 1 | C1 |
| 2 | C2 |
| 3 | C1 |
+---------+--------+
[고객]
+--------+-----------+
| 고객ID | 고객등급 |
+--------+-----------+
| C1 | VIP |
| C2 | 일반 |
+--------+-----------+
고객등급을 바꿔도 고객 테이블의 한 행만 수정하면 됩니다.
BCNF (Boyce-Codd Normal Form)
** 규칙: 3NF를 만족하면서, 모든 결정자가 후보키여야 한다.**
3NF를 만족하는데 BCNF를 만족하지 않는 경우가 있습니다. 흔하진 않지만 자주 헷갈리는 부분입니다.
Before (BCNF 위반)
수강 신청 시스템을 생각해 봅시다. 한 과목에 여러 교수가 가르치고, 교수는 하나의 과목만 담당합니다.
PK = {학생ID, 과목}
+---------+--------+---------+
| 학생ID | 과목 | 교수 |
+---------+--------+---------+
| S1 | DB | 박교수 |
| S2 | DB | 김교수 |
| S1 | OS | 이교수 |
+---------+--------+---------+
함수적 종속:
{학생ID, 과목} → 교수— PK가 교수를 결정교수 → 과목— 교수가 과목을 결정 (교수는 한 과목만 담당하니까)
여기서 ** 교수 **는 과목의 결정자입니다. 그런데 교수는 후보키가 아닙니다. {학생ID, 교수}는 후보키가 될 수 있지만, 교수 단독으로는 학생ID를 결정하지 못하니까요.
결정자인데 후보키가 아닌 속성이 존재하면 BCNF 위반입니다.
After (BCNF 적용)
[교수-과목]
+---------+--------+
| 교수 | 과목 |
+---------+--------+
| 박교수 | DB |
| 김교수 | DB |
| 이교수 | OS |
+---------+--------+
[수강]
+---------+---------+
| 학생ID | 교수 |
+---------+---------+
| S1 | 박교수 |
| S2 | 김교수 |
| S1 | 이교수 |
+---------+---------+
이제 모든 결정자(교수, {학생ID, 교수})가 각 테이블의 후보키입니다.
정규화 단계 요약
| 정규형 | 핵심 | 제거 대상 |
|---|---|---|
| 1NF | 원자값 | 반복 그룹, 다중값 |
| 2NF | 완전 종속 | 부분 종속 (복합키의 일부에 종속) |
| 3NF | 직접 종속 | 이행 종속 (A→B→C) |
| BCNF | 결정자 = 후보키 | 결정자가 후보키가 아닌 경우 |
실무에서는 보통 3NF까지 적용하면 충분합니다. BCNF까지 가는 경우는 드물고, 갈 필요가 있는지 판단하는 것 자체가 중요합니다.
반정규화 (Denormalization)
정규화를 열심히 해놨는데, 왜 다시 합치냐고요?
정규화의 대가: JOIN
정규화를 많이 하면 테이블이 쪼개집니다. 데이터를 조회할 때 JOIN을 해야 합니다.
-- 정규화 후: 주문 내역 + 고객 정보 + 상품 정보를 한 번에 보려면
SELECT o.주문ID, c.고객이름, c.고객전화, p.상품이름, p.가격
FROM 주문 o
JOIN 고객 c ON o.고객ID = c.고객ID
JOIN 상품 p ON o.상품ID = p.상품ID;
테이블이 3개가 되니까 JOIN이 2번입니다. 데이터가 수천만 건이면 이 JOIN이 꽤 비쌉니다.
반정규화를 하는 실제 상황
** 읽기 성능이 압도적으로 중요할 때** 반정규화를 합니다.
[반정규화 예시: 게시글 테이블에 댓글 수 컬럼 추가]
-- 정규화된 구조에서 댓글 수 조회
SELECT p.*, COUNT(c.id) as comment_count
FROM posts p
LEFT JOIN comments c ON p.id = c.post_id
GROUP BY p.id;
-- 반정규화: posts 테이블에 comment_count 컬럼을 추가
SELECT * FROM posts; -- comment_count가 이미 있으니 JOIN 불필요
매번 COUNT + JOIN을 하는 대신, 댓글이 달릴 때마다 comment_count를 증가시킵니다. 쓰기에서 약간의 오버헤드가 생기지만, 읽기가 월등히 빨라집니다.
반정규화 기법들
- **중복 컬럼 추가 **: 자주 조인하는 컬럼을 복사해서 가져다 놓기
- ** 파생 컬럼 추가 **: 합계, 평균, 개수 같은 집계값을 미리 저장
- ** 테이블 병합 **: 1:1 관계이거나 항상 같이 조회되는 테이블을 합치기
- ** 테이블 분할 **: 수직 분할(자주 쓰는 컬럼만 따로), 수평 분할(기간별로 나누기)
OLTP vs OLAP: 판단 기준
반정규화를 할지 말지는 시스템의 성격에 따라 결정됩니다.
| OLTP | OLAP | |
|---|---|---|
| 목적 | 트랜잭션 처리 | 분석/리포팅 |
| 특징 | 짧은 쿼리, 빈번한 CUD | 복잡한 쿼리, 대량 읽기 |
| 정규화 | 높은 정규화 (3NF 이상) | 반정규화 (스타 스키마 등) |
| 예시 | 쇼핑몰 주문 시스템 | 매출 분석 대시보드 |
OLTP 시스템은 데이터 무결성이 최우선이니 정규화를 충실히 합니다. OLAP 시스템은 읽기 위주고 데이터가 이미 적재된 상태라서 반정규화된 구조(팩트 테이블 + 디멘션 테이블)를 씁니다.
실무에서는 OLTP 시스템에서도 부분적으로 반정규화를 합니다. 게시판의 댓글 수, 유저 프로필의 팔로워 수 같은 것들이요. 전부 정규화하면 조회 쿼리가 너무 무거워지는 지점이 분명히 옵니다.
주의할 점
"4NF, 5NF도 있나요?"
있습니다. 근데 실무에서 거의 안 씁니다.
- 4NF: 다치 종속(Multi-Valued Dependency) 제거. 한 테이블에 두 개 이상의 독립적인 다치 속성이 있으면 분리합니다.
- 5NF: 조인 종속(Join Dependency) 제거. 세 개 이상의 테이블로 분해했다가 조인해도 원래 데이터를 복원할 수 있어야 합니다.
핵심만 정리하면 "존재한다는 건 알아두되, 실무에서는 BCNF까지로 충분합니다".
"반정규화 사례를 하나만 들어 주세요"
SNS의 피드 테이블이 대표적입니다. 게시글 작성자의 닉네임과 프로필 이미지를 피드 테이블에 중복 저장합니다. 피드를 조회할 때마다 유저 테이블을 JOIN하면 피드 로딩이 느려지거든요. 닉네임이 바뀌면 비동기로 피드 테이블도 업데이트합니다.
또 다른 예로는 주문 테이블에 주문 시점의 상품 가격을 저장하는 것입니다. 상품 가격은 바뀔 수 있는데, 주문 당시의 가격은 바뀌면 안 되니까요. 이건 반정규화이기도 하고 비즈니스 요구사항이기도 합니다.
"이상 현상을 구체적으로 설명해 주세요"
앞에서 나온 주문 테이블 예시를 그대로 쓰면 됩니다.
- ** 삽입 이상 **: 주문 없이 고객을 등록 못 함
- ** 갱신 이상 **: 전화번호 변경 시 여러 행을 수정해야 함, 하나라도 빠뜨리면 데이터 불일치
- ** 삭제 이상 **: 유일한 주문을 삭제하면 고객 정보도 같이 사라짐
세 가지 모두 ** 하나의 테이블에 여러 엔터티의 정보가 섞여 있기 때문 **에 발생합니다. 정규화로 엔터티를 분리하면 해결됩니다.
"정규화를 과하게 하면 어떤 문제가 있나요?"
테이블이 너무 많이 쪼개져서 조회 시 JOIN이 과도하게 발생합니다. JOIN은 디스크 I/O와 메모리를 소비하고, 특히 대용량 테이블 간 JOIN은 쿼리 성능에 직접적인 영향을 줍니다. 그래서 읽기 비율이 높은 시스템에서는 의도적으로 정규화 수준을 낮추기도 합니다.
파생되는 개념들
- ** 인덱스** — 정규화로 쪼갠 테이블을 JOIN할 때 FK에 인덱스가 없으면 성능이 급격히 떨어짐
- ERD 설계 — 정규화를 적용한 논리 모델 → 물리 모델 변환 과정
- NoSQL의 데이터 모델링 — 정규화와 정반대. 조회 패턴에 맞춰 데이터를 중복 저장하는 게 기본 전략
- ** 트랜잭션과 격리 수준** — 반정규화된 중복 데이터의 일관성을 유지하려면 트랜잭션 관리가 더 중요해짐