정규화: 이상 현상을 막기 위해 함수적 종속성에 따라 테이블을 분해하는 설계 기법
정규화(Normalization)는 데이터 중복을 줄이고 삽입·수정·삭제 이상(Anomaly)을 방지하기 위해 릴레이션을 함수적 종속성(Functional Dependency)에 따라 분해하는 설계 과정이다. 1NF부터 BCNF까지의 단계별 조건을 PostgreSQL 예제로 살펴보고, 조인 비용과 반정규화(Denormalization)의 트레이드오프를 다룬
By 송민성
1. 개념
정규화(Normalization)는 릴레이션(테이블)을 함수적 종속성(Functional Dependency, FD)에 따라 더 작은 릴레이션으로 분해하여 데이터 중복과 이상 현상(Anomaly)을 제거하는 설계 절차다.
함수적 종속성 X → Y는 "X 값이 정해지면 Y 값이 하나로 정해진다"는 제약이다. 예를 들어 order_id → customer_id라면 주문 번호 하나는 정확히 한 명의 고객에 대응한다.
핵심 용어는 다음과 같다.
- 후보키(Candidate Key): 튜플을 유일하게 식별하는 최소 속성 집합
- 주요 속성(Prime Attribute): 어떤 후보키에든 포함된 속성
- 부분 함수 종속(Partial Dependency): 복합 후보키의 일부에만 종속되는 관계
- 이행적 종속(Transitive Dependency):
A → B,B → C로 인해A → C가 성립하는 관계
주요 정규형은 다음과 같다.
| 정규형 | 조건 |
|---|---|
| 1NF | 모든 속성이 원자값(Atomic Value)이고 반복 그룹이 없다 |
| 2NF | 1NF이면서 비주요 속성이 후보키에 부분 종속되지 않는다 |
| 3NF | 2NF이면서 비주요 속성이 후보키에 이행적으로 종속되지 않는다 |
| BCNF | 모든 결정자(Determinant)가 후보키다 |
2. 왜 사용하는가
정규화되지 않은 테이블은 세 가지 이상 현상을 일으킨다.
- 삽입 이상(Insertion Anomaly): 불필요한 정보를 함께 넣지 않으면 일부 데이터를 저장할 수 없다.
- 수정 이상(Update Anomaly): 중복 저장된 값 중 일부만 바꾸면 데이터가 불일치한다.
- 삭제 이상(Deletion Anomaly): 한 데이터를 지울 때 의도하지 않은 다른 정보까지 사라진다.
정규화는 "하나의 사실을 한 곳에만 저장한다"는 원칙을 구조적으로 강제한다. 그 결과 데이터 무결성(Integrity)이 스키마 수준에서 보장되고, 애플리케이션 코드가 중복 갱신을 신경 쓸 필요가 줄어든다.
3. 동작 원리
아래 비정규 테이블로 단계를 따라가 보자.
order_items(order_id, product_id, customer_id, customer_city, product_name, quantity)
함수적 종속성은 다음과 같다.
(order_id, product_id) → quantityorder_id → customer_idcustomer_id → customer_cityproduct_id → product_name
후보키는 (order_id, product_id)다.
1NF: 한 칸에 "A,B,C"처럼 여러 값을 넣거나 product1, product2 같은 반복 컬럼을 두지 않는다. 위 테이블은 이미 원자값이므로 1NF를 만족한다.
2NF: customer_id, product_name은 키의 일부(order_id 또는 product_id)에만 종속된다. 부분 종속이므로 분리한다.
orders(order_id, customer_id, customer_city)products(product_id, product_name)order_items(order_id, product_id, quantity)
3NF: orders에서 order_id → customer_id → customer_city라는 이행적 종속이 남아 있다. 고객 정보를 분리한다.
customers(customer_id, customer_city)orders(order_id, customer_id)
BCNF: 모든 결정자가 후보키여야 한다. 예를 들어 (student, course) → teacher이고 teacher → course인 테이블에서 teacher는 결정자이지만 후보키가 아니므로 BCNF 위반이다. (teacher, course)와 (student, teacher)로 분해한다. 분해 시에는 무손실 조인(Lossless Join)이 보장되어야 한다. 또한 BCNF 분해는 일부 함수적 종속성을 보존하지 못할 수 있다는 점이 3NF와의 차이다.
4. 코드 예제
PostgreSQL 기준으로 정규화 전후 스키마와 이상 현상을 비교한다.
-- 정규화 이전: 중복과 이상 현상이 발생하는 구조
CREATE TABLE order_items_raw (
order_id INT,
product_id INT,
customer_id INT,
customer_city TEXT,
product_name TEXT,
quantity INT,
PRIMARY KEY (order_id, product_id)
);
INSERT INTO order_items_raw VALUES
(1, 10, 100, 'Seoul', 'Keyboard', 2),
(1, 11, 100, 'Seoul', 'Mouse', 1),
(2, 10, 100, 'Seoul', 'Keyboard', 1);
-- 수정 이상: 일부 행만 갱신하면 같은 고객의 도시가 불일치한다
UPDATE order_items_raw
SET customer_city = 'Busan'
WHERE order_id = 1 AND product_id = 10;
SELECT DISTINCT customer_id, customer_city
FROM order_items_raw
WHERE customer_id = 100; -- 2행 반환: Seoul, Busan-- 3NF로 정규화한 스키마
CREATE TABLE customers (
customer_id INT PRIMARY KEY,
customer_city TEXT NOT NULL
);
CREATE TABLE products (
product_id INT PRIMARY KEY,
product_name TEXT NOT NULL
);
CREATE TABLE orders (
order_id INT PRIMARY KEY,
customer_id INT NOT NULL REFERENCES customers (customer_id)
);
CREATE TABLE order_items (
order_id INT REFERENCES orders (order_id),
product_id INT REFERENCES products (product_id),
quantity INT NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, product_id)
);
INSERT INTO customers VALUES (100, 'Seoul');
INSERT INTO products VALUES (10, 'Keyboard'), (11, 'Mouse');
INSERT INTO orders VALUES (1, 100), (2, 100);
INSERT INTO order_items VALUES (1, 10, 2), (1, 11, 1), (2, 10, 1);
-- 도시 변경은 한 행만 수정하면 된다
UPDATE customers SET customer_city = 'Busan' WHERE customer_id = 100;
-- 필요할 때 조인으로 원래 형태를 복원한다
SELECT o.order_id, p.product_name, c.customer_city, oi.quantity
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
JOIN products p ON p.product_id = oi.product_id
JOIN customers c ON c.customer_id = o.customer_id
ORDER BY o.order_id, p.product_id;5. 시간 복잡도 또는 성능 특성
정규화는 알고리즘이 아니라 설계 기법이므로 고정된 시간복잡도는 없다. 대신 다음 특성이 있다.
- 쓰기 성능: 한 사실이 한 곳에만 저장되므로 갱신 대상 행 수가 줄고 저장 공간도 절약된다.
- 읽기 성능: 여러 테이블을 조회하려면 조인(JOIN)이 필요하다. 조인 비용은 실행 계획에 따라 달라진다. PostgreSQL은 Nested Loop, Hash Join, Merge Join을 선택하며, 외래키 컬럼에 인덱스가 있으면 Nested Loop의 내부 탐색이 빨라진다. 단, PostgreSQL은 외래키 컬럼에 인덱스를 자동 생성하지 않는다(기본키와 유니크 제약만 자동 생성).
- 제약 검사: 외래키 제약은 삽입·삭제 시 참조 무결성 검사 비용을 추가한다.
-- 외래키 컬럼에 인덱스를 직접 만들어 조인과 참조 검사를 돕는다
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
CREATE INDEX idx_order_items_product_id ON order_items (product_id);
EXPLAIN ANALYZE
SELECT c.customer_city, SUM(oi.quantity)
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
JOIN customers c ON c.customer_id = o.customer_id
GROUP BY c.customer_city;6. 실무 사용 사례
- OLTP 시스템: 주문, 결제, 회원 관리처럼 쓰기가 잦고 정합성이 중요한 서비스는 보통 3NF를 기준으로 설계한다.
- 반정규화(Denormalization): 읽기 비중이 매우 높거나 조인 비용이 병목일 때 의도적으로 중복을 허용한다. 예를 들어 주문 시점의 상품 가격을
order_items에 복사해 저장하는 방식이다. 이는 중복이 아니라 "주문 시점의 사실"을 기록하는 것이어서 정규화 원칙과도 충돌하지 않는다. - 분석(OLAP): 데이터 웨어하우스는 조회 속도를 위해 스타 스키마(Star Schema) 같은 비정규화 구조를 흔히 쓴다.
- JSONB 컬럼 활용: 스키마가 유동적인 속성은 PostgreSQL의
jsonb에 담기도 한다. 다만 조건 검색과 무결성 요구가 커지면 별도 테이블로 분리하는 편이 낫다.
7. 주의할 점
- 과도한 정규화: 테이블을 지나치게 쪼개면 조인이 늘어 쿼리가 복잡해지고 성능이 떨어질 수 있다. 실무에서는 3NF 또는 BCNF까지 적용하고, 측정 결과를 근거로 반정규화한다.
- 반정규화 시 정합성 책임: 중복을 허용하면 동기화는 트리거, 애플리케이션 로직, 배치 등으로 직접 관리해야 한다.
- 무손실 분해 확인: 분해 후 조인했을 때 원본과 정확히 일치해야 한다. 잘못 분해하면 존재하지 않던 튜플이 생긴다(Spurious Tuple).
- 현재 값과 이력 구분: 상품 가격처럼 시간에 따라 변하는 값은 참조만 하면 과거 주문이 바뀐다. 거래 시점 값을 스냅샷으로 저장해야 한다.
- NULL 처리: 선택적 속성이 많은 테이블은 NULL이 늘어난다. 별도 테이블로 분리할지 판단한다.
- 데이터 변경 없이 판단하지 말 것: "조인은 느리다"는 가정만으로 반정규화하지 말고
EXPLAIN ANALYZE로 먼저 확인한다.
8. 핵심 정리
- 정규화는 함수적 종속성에 따라 테이블을 분해해 중복과 이상 현상을 없앤다.
- 1NF는 원자값, 2NF는 부분 종속 제거, 3NF는 이행적 종속 제거, BCNF는 모든 결정자가 후보키라는 조건이다.
- 정규화의 이점은 정합성과 쓰기 효율이며, 비용은 조인과 외래키 검사다.
- PostgreSQL은 외래키 컬럼에 인덱스를 자동 생성하지 않으므로 직접 추가해야 한다.
- OLTP는 3NF 또는 BCNF를 기본으로 하고, 병목이 측정된 지점에 한해 반정규화를 적용한다.