처음에는 한 테이블에 모든 정보를 넣는 편이 빨라 보입니다. 하지만 수정할 때마다 여러 행을 같이 바꿔야 하면 데이터 불일치가 생깁니다. 정규화는 이론 문제가 아니라 중복과 갱신 이상을 줄이는 실무 설계 도구입니다.
1NF, 2NF, 3NF를 단계적으로 적용하면 데이터 중복을 제거하고 구조적으로 일관된 스키마를 만들 수 있습니다. 단, 읽기 성능이 중요한 시스템에서는 의도적 역정규화를 고려합니다.
- 1이상 현상(Anomaly) — 나쁜 스키마가 만드는 3가지 문제
- 21NF — 원자값과 반복 그룹 제거
- 32NF — 부분 함수 종속 제거 (복합키 테이블)
- 43NF — 이행 함수 종속 제거
- 5역정규화(Denormalization) — 언제 정규화를 의도적으로 깨는가
정규화(Normalization) — 좋은 스키마 설계 원칙
스타트업 초기, 고객·주문·담당자 정보를 하나의 테이블에 몰아넣었습니다. 처음엔 편했지만 6개월 후 담당자 한 명의 전화번호가 바뀌었을 때 문제가 터졌습니다. 그 담당자가 연결된 주문 행이 32개였고, 모두 따로 UPDATE해야 했습니다. 3개를 빠뜨린 채 커밋했고, 이후 같은 담당자의 연락처가 두 가지 버전으로 데이터베이스에 공존했습니다. 어떤 게 맞는 번호인지 아무도 몰랐습니다.
데이터베이스 스키마를 잘못 설계하면 데이터를 추가하거나 수정하거나 삭제할 때 이런 의도치 않은 문제가 발생합니다. 이를 **이상 현상(Anomaly)**이라 부릅니다. 정규화(Normalization)는 이런 문제를 구조적으로 제거하는 스키마 설계 원칙입니다.
정규화가 필요한 이유 — 이상 현상(Anomaly) 3가지
강사 이름과 강의실 정보가 수강 테이블에 같이 있습니다. 강사가 이름을 바꾸면 그 강사가 담당하는 모든 수강 레코드를 하나씩 업데이트해야 합니다. 하나라도 빠지면 같은 강사인데 이름이 두 개가 됩니다. 이것이 수정 이상입니다. 정규화는 이런 이상 현상을 구조적으로 제거하는 설계 원칙입니다.

나쁜 스키마 예시 — 모든 것을 한 테이블에
아래는 학생, 수강 과목, 담당 교수를 하나의 테이블에 넣은 비정규화 구조입니다. student_id와 course_id를 복합 PK로 사용한다고 가정합니다.
CREATE TABLE student_courses (
student_id INT,
student_name VARCHAR(100),
course_id VARCHAR(10),
course_name VARCHAR(100),
professor_id INT,
professor_name VARCHAR(100),
professor_dept VARCHAR(100)
);
실행 완료 또는 조회 결과가 표시됩니다.
- 중복 제거—같은 사실이 여러 행이나 컬럼에 반복 저장되지 않는지 확인합니다.
- 갱신 이상—한 값을 바꿀 때 여러 위치를 동시에 수정해야 하는지 봅니다.
- 관계 테이블—다대다 관계가 별도 테이블로 분리됐는지 점검합니다.
실제 데이터를 채워보면 문제가 명확해집니다. 이교수의 이름과 소속이 CS101을 수강한 학생 수만큼 중복 저장됩니다.
student_id | student_name | course_id | course_name | professor_id | professor_name | professor_dept
-----------|--------------|-----------|-------------|--------------|----------------|---------------
1001 | 김학생 | CS101 | 데이터베이스 | 201 | 이교수 | 컴퓨터공학과
1001 | 김학생 | CS102 | 알고리즘 | 202 | 박교수 | 컴퓨터공학과
1002 | 박학생 | CS101 | 데이터베이스 | 201 | 이교수 | 컴퓨터공학과
3가지 이상 현상
1. 삽입 이상(Insertion Anomaly)
(student_id, course_id)가 복합 PK이면, 담당 과목과 수강 학생이 없는 신규 교수는 레코드 자체를 삽입할 수 없습니다. PK 컬럼에 NULL을 넣을 수 없기 때문입니다.
INSERT INTO student_courses VALUES
(NULL, NULL, NULL, NULL, 203, '정교수', '수학과');
2. 수정 이상(Update Anomaly)
이교수의 소속 학과를 변경하려면, CS101을 수강하는 모든 학생 행을 빠짐없이 업데이트해야 합니다. 한 행이라도 누락되면 같은 교수에 대해 서로 다른 소속 정보가 공존하는 데이터 불일치가 발생합니다.
UPDATE student_courses
SET professor_dept = 'AI학과'
WHERE professor_id = 201;
3. 삭제 이상(Deletion Anomaly)
박학생(1002)이 CS101 수강을 취소해 유일한 수강자가 사라지면, 그 행을 삭제할 때 이교수와 CS101 과목에 대한 정보까지 데이터베이스에서 완전히 사라집니다.
DELETE FROM student_courses
WHERE student_id = 1002 AND course_id = 'CS101';
정규화 전후 구조 비교
정규화 후에는 교수 정보가 professors 테이블 한 곳에만 존재합니다. 소속 학과를 변경할 때 단 1행만 수정하면 되고, 어떤 학생을 삭제해도 교수나 과목 정보는 영향받지 않습니다.
【정규화 전 — 비정규화 테이블】
student_courses
┌────────────┬──────────────┬───────────┬─────────────┬──────────────┬──────────────────┬───────────────┐
│ student_id │ student_name │ course_id │ course_name │ professor_id │ professor_name │ professor_dept│
├────────────┼──────────────┼───────────┼─────────────┼──────────────┼──────────────────┼───────────────┤
│ 1001 │ 김학생 │ CS101 │ 데이터베이스 │ 201 │ 이교수 │ 컴퓨터공학과 │
│ 1001 │ 김학생 │ CS102 │ 알고리즘 │ 202 │ 박교수 │ 컴퓨터공학과 │
│ 1002 │ 박학생 │ CS101 │ 데이터베이스 │ 201 │ 이교수 │ 컴퓨터공학과 │
└────────────┴──────────────┴───────────┴─────────────┴──────────────┴──────────────────┴───────────────┘
【정규화 후 — 3개 테이블로 분리】
students courses professors
┌────┬──────┐ ┌───────┬──────┬──────┐ ┌─────┬────────┬──────────────┐
│ id │ name │ │ id │ name │ prof │ │ id │ name │ dept │
├────┼──────┤ ├───────┼──────┼──────┤ ├─────┼────────┼──────────────┤
│1001│김학생│ │ CS101 │ DB │ 201 │ │ 201 │ 이교수 │ 컴퓨터공학과 │
│1002│박학생│ │ CS102 │ 알고 │ 202 │ │ 202 │ 박교수 │ 컴퓨터공학과 │
└────┴──────┘ └───────┴──────┴──────┘ └─────┴────────┴──────────────┘
enrollments (연결 테이블)
┌────────────┬───────────┐
│ student_id │ course_id │
├────────────┼───────────┤
│ 1001 │ CS101 │
│ 1001 │ CS102 │
│ 1002 │ CS101 │
└────────────┴───────────┘
1NF → 2NF → 3NF 단계별 변환
정규화가 필요하다는 건 알겠는데 실제로 어떻게 적용하는지 모릅니다. 1NF, 2NF, 3NF가 각각 어떤 기준인지 이론은 외울 수 있어도 실제 테이블에 어떻게 적용하는지는 다른 문제입니다. 단계별로 같은 예시 테이블을 변환하면서 따라가면 각 단계가 어떤 문제를 해결하는지 명확하게 보입니다.

1NF — 원자값과 반복 그룹 제거
규칙: 모든 컬럼 값은 더 이상 분해될 수 없는 원자값(Atomic Value)이어야 합니다. 반복 그룹(Repeating Group)도 금지됩니다.
하나의 셀에 '사과,바나나,딸기'처럼 여러 값을 콤마로 저장하는 것이 대표적인 위반입니다. 이런 구조에서는 특정 상품의 포함 여부를 검색하거나 한 항목만 수정하는 것이 불가능합니다. 해결책은 반복되는 값을 별도 행으로 분리하는 것입니다.
| 구분 | 위반 예시 (orders_bad) | 준수 예시 (order_items) |
|---|---|---|
| products 컬럼 | '사과,바나나,딸기' (단일 셀) | 상품마다 별도 행으로 분리 |
| 검색 가능 여부 | LIKE '%사과%' 필요 (불안정) | WHERE product = '사과' |
| 수정 | 문자열 파싱 필요 | 해당 행만 UPDATE |
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer VARCHAR(100) NOT NULL
);
CREATE TABLE order_items (
order_id INT REFERENCES orders(order_id),
product VARCHAR(100) NOT NULL,
quantity INT NOT NULL DEFAULT 1,
PRIMARY KEY (order_id, product)
);
2NF — 부분 함수 종속 제거
규칙: 1NF를 만족하고, 복합 기본키의 일부에만 종속되는 컬럼이 없어야 합니다.
(order_id, product_id)가 복합 PK인 테이블에서, product_name과 unit_price는 order_id와 무관하게 product_id 하나만으로 결정됩니다. 이런 부분 함수 종속 컬럼은 별도 테이블로 분리해야 합니다.
| 컬럼 | 종속 대상 | 판정 |
|---|---|---|
quantity | order_id + product_id 전체 | 2NF 준수 |
product_name | product_id 만 | 2NF 위반 |
unit_price | product_id 만 | 2NF 위반 |
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
unit_price NUMERIC NOT NULL
);
CREATE TABLE order_items (
order_id INT REFERENCES orders(id),
product_id INT REFERENCES products(id),
quantity INT NOT NULL DEFAULT 1,
PRIMARY KEY (order_id, product_id)
);
3NF — 이행 함수 종속 제거
규칙: 2NF를 만족하고, PK가 아닌 컬럼이 다른 PK가 아닌 컬럼에 종속되면 안 됩니다.
student_id → zip_code → city라는 종속 체인이 그 예입니다. city는 PK인 student_id가 직접 결정하는 값이 아니라 zip_code를 거쳐 간접적으로 결정됩니다. 같은 우편번호를 가진 학생이 100명이면 city 값도 100번 중복 저장됩니다.
| 구분 | 위반 (students_bad) | 준수 (분리 후) |
|---|---|---|
| city 저장 위치 | students 테이블에 중복 | zip_codes 테이블에 1회 |
| 도시명 변경 시 | zip_code 사용자 수만큼 UPDATE | zip_codes 1행만 UPDATE |
CREATE TABLE zip_codes (
zip_code VARCHAR(10) PRIMARY KEY,
city VARCHAR(100) NOT NULL,
district VARCHAR(100)
);
CREATE TABLE students (
student_id INT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
zip_code VARCHAR(10) REFERENCES zip_codes(zip_code)
);
역정규화(Denormalization) — 성능을 위해 의도적으로 중복 허용
정규화가 항상 정답은 아닙니다. 읽기 집중(Read-Heavy) 환경에서는 잦은 JOIN이 성능 병목이 됩니다.
아래 예시는 주문 목록 조회 시 매번 users 테이블과 JOIN하는 비용을 피하기 위해 orders 테이블에 고객 이름과 이메일을 직접 복사해 두는 역정규화 패턴입니다. 단, 사용자가 이메일을 변경하면 orders 테이블도 별도로 업데이트해야 하므로 애플리케이션 레벨에서 일관성 유지 전략을 수립해야 합니다.
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
customer_name VARCHAR(100) NOT NULL,
customer_email VARCHAR(255) NOT NULL,
total_amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
정규화 단계 요약표
각 정규형이 무엇을 추가로 요구하는지 단계별로 정리하면, 내가 설계한 테이블이 어느 단계까지 만족하는지 진단하는 기준이 됩니다.
| 단계 | 요구사항 |
|---|---|
| 1NF | 모든 컬럼 값이 원자값, 반복 그룹 없음 |
| 2NF | 1NF + 부분 함수 종속 제거 (복합 PK 테이블에만 해당) |
| 3NF | 2NF + 이행 함수 종속 제거 (비주요 속성 간 종속 제거) |
| BCNF | 3NF + 모든 결정자가 후보키 (실무에서 3NF로 충분한 경우 많음) |
실전 가이드: 대부분의 OLTP(트랜잭션 처리) 시스템은 3NF까지 적용합니다. 성능 문제가 실제로 측정된 경우에만 역정규화를 선택하고, 애플리케이션 레벨에서 데이터 일관성을 유지하는 전략을 수립하세요.
주문 목록 한 페이지를 보여주기 위해 7개 테이블을 JOIN해야 하는 상황이 발생했습니다. 정규화 원칙을 엄격하게 지키다 보니 도시 정보, 우편번호, 배송지 주소가 모두 별도 테이블로 분리되었고, 주문 1건 조회에 수십 ms가 걸리는 성능 문제가 나타났습니다.
원인: 정규화는 쓰기(INSERT/UPDATE) 이상 현상 방지에 최적화됩니다. 읽기 집중 화면에서 지나치게 많은 JOIN은 오히려 역효과를 냅니다.
해결 방법: 성능 병목을 먼저 측정(EXPLAIN ANALYZE)한 뒤, 자주 함께 조회되는 컬럼을 선택적으로 역정규화합니다. 또는 읽기 전용 Materialized View를 만들어 미리 JOIN된 결과를 캐싱합니다. 역정규화 없이 해결하려면 적절한 인덱스와 커버링 인덱스(Covering Index)를 먼저 시도하세요.
신규 이커머스 서비스 초기에 orders 테이블 하나에 사용자 이름, 배송지 주소(도시, 도로명, 우편번호), 결제 수단 이름까지 모두 넣었습니다. 서비스가 성장하면서 문제가 생겼습니다. 사용자가 주소를 변경하면 과거 주문 기록의 주소도 바뀌고, 동일 도시를 수만 건의 주문에서 중복 저장하는 비효율이 드러났습니다.
3NF 기준으로 재설계하면서 users, addresses, zip_codes, orders로 테이블을 분리했습니다. 주문 생성 시점의 배송지는 order_snapshots 테이블에 비정규화된 형태로 별도 보존해, 이후 주소 변경이 주문 이력에 영향을 주지 않도록 했습니다. 정규화와 역정규화를 목적에 맞게 혼합하는 것이 실전 설계의 핵심입니다.
다음 모듈에서는 SELECT, INSERT, UPDATE, DELETE의 정확한 동작 방식과 실수를 방지하는 안전한 쿼리 패턴을 다룹니다.