본문으로 건너뛰기

백엔드

DB 정규화와 함수 종속

2026. 10. 14. 수요일 오후 9시 0분

정규화는 함수 종속을 기준으로 테이블을 분할해서 이상현상이 생길 여지를 줄이는 작업입니다. 이상현상은 한 테이블에 서로 다른 대상의 정보가 섞여 있을 때 생기는 갱신 이상, 삽입 이상, 삭제 이상을 말합니다. 정규화는 1NF, 2NF, 3NF처럼 단계를 나누어 진행합니다.

1NF는 값이 원자값이어야 한다는 조건이고, 2NF와 3NF는 함수 종속으로 정의됩니다. 그래서 2NF부터는 종속을 찾아서 그 종속을 기준으로 테이블을 분할합니다. 2NF와 3NF는 찾는 종속의 종류가 다릅니다.

단계찾는 대상처리
1NF한 셀에 값이 여러 개 들어 있는 경우값을 하나씩 풀어 행으로 늘린다 (테이블 분할 없음)
2NF키의 일부에만 종속되는 속성 (부분 종속)그 속성을 키의 일부를 기본키로 하는 테이블로 분할
3NF키가 아닌 속성을 거쳐 종속되는 속성 (이행 종속)중간에 낀 속성을 기본키로 하는 테이블로 분할

표의 키, 부분 종속, 이행 종속은 아래 함수 종속 절에서 정의합니다. 주문, 주문상품, 상품, 고객 정보를 담은 표에서 시작해 PostgreSQL 18.4에서 실제로 분할하면서 확인합니다. 예제 SQL과 실행 스크립트는 저장소의 playground/db-normalization에 있습니다.


정규화와 이상현상

정규화하지 않은 테이블에서는 세 가지 이상현상이 생깁니다.

이상현상내용
갱신 이상같은 정보가 여러 행에 있어서 일부 행만 고치면 값이 서로 달라진다
삽입 이상다른 정보가 없으면 필요한 정보를 저장할 수 없다
삭제 이상행 하나를 지웠더니 함께 들어 있던 다른 정보까지 사라진다

정규화를 하는 이유는 네 가지입니다.

  • 데이터 중복이 줄어듭니다.
  • 하나의 데이터가 한 곳에만 있어서 한 곳만 고치면 됩니다. 이것이 일관성입니다.
  • 테이블을 나누고 외래 키로 연결하므로, 없는 값을 참조하는 행을 DB가 막습니다. 이것이 무결성입니다.
  • 구조를 바꾸거나 유지보수하기 쉽습니다.

예제 환경

docker로 PostgreSQL 18을 띄웁니다.

# scripts/up.sh
docker run -d --name pg-norm-lab \
  -e POSTGRES_PASSWORD=lab -e POSTGRES_DB=lab \
  -p 55434:5432 postgres:18

SQL은 docker exec -i pg-norm-lab psql -U postgres -d lab로 실행했습니다. 실행 결과는 psql이 출력한 그대로 옮겼습니다.

이상현상 실험은 BEGIN과 ROLLBACK 사이에서 실행해서 데이터를 원래대로 되돌리고, 다음 실험이 같은 데이터로 시작하게 했습니다. 단계별 테이블은 스키마(nf2, nf3)를 나누어 만들어서 같은 이름의 테이블(orders 등)이 단계마다 따로 존재합니다.


1NF: 원자값 조건

처음 상태는 주문 한 건이 상품 여러 개를 담은 표입니다.

주문번호고객연락처주문상품
1001김민수010-1111-2222키보드 1개, 마우스 2개
1002이영희010-3333-4444키보드 2개, 모니터 1개

주문상품 셀 하나에 상품 여러 개가 들어 있어서, 특정 상품을 주문한 건을 찾거나 상품별 판매 개수를 합산하려면 문자열을 잘라야 합니다. 1NF는 한 셀에 값 하나만 두는 원자값 조건을 요구합니다. 이 조건은 함수 종속으로 정의하지 않으며, 셀을 상품 하나씩 풀어 행으로 늘리면 만족합니다. 1NF 단계에서는 테이블을 분할하지 않습니다.

아래 order_flat이 행으로 푼 결과입니다. 행을 식별하고 고객과 상품을 구분하기 위해 customer_id, product_code 컬럼을 추가했고, 이상현상 실험에 쓸 1003번과 1004번 주문을 더했습니다.

-- sql/01_flat_table.sql
CREATE TABLE order_flat (
  order_no       int  NOT NULL,
  customer_id    text NOT NULL,
  customer_name  text NOT NULL,
  customer_phone text NOT NULL,
  product_code   text NOT NULL,
  product_name   text NOT NULL,
  quantity       int  NOT NULL,
  PRIMARY KEY (order_no, product_code)
);
 
INSERT INTO order_flat VALUES
  (1001, 'C001', '김민수', '010-1111-2222', 'P100', '키보드',   1),
  (1001, 'C001', '김민수', '010-1111-2222', 'P200', '마우스',   2),
  (1002, 'C002', '이영희', '010-3333-4444', 'P100', '키보드',   2),
  (1002, 'C002', '이영희', '010-3333-4444', 'P300', '모니터',   1),
  (1003, 'C001', '김민수', '010-1111-2222', 'P200', '마우스',   1),
  (1003, 'C001', '김민수', '010-1111-2222', 'P300', '모니터',   1),
  (1003, 'C001', '김민수', '010-1111-2222', 'P400', 'USB 허브', 3),
  (1004, 'C003', '박지훈', '010-5555-6666', 'P100', '키보드',   1);
 order_no | customer_id | customer_name | customer_phone | product_code | product_name | quantity 
----------+-------------+---------------+----------------+--------------+--------------+----------
     1001 | C001        | 김민수        | 010-1111-2222  | P100         | 키보드       |        1
     1001 | C001        | 김민수        | 010-1111-2222  | P200         | 마우스       |        2
     1002 | C002        | 이영희        | 010-3333-4444  | P100         | 키보드       |        2
     1002 | C002        | 이영희        | 010-3333-4444  | P300         | 모니터       |        1
     1003 | C001        | 김민수        | 010-1111-2222  | P200         | 마우스       |        1
     1003 | C001        | 김민수        | 010-1111-2222  | P300         | 모니터       |        1
     1003 | C001        | 김민수        | 010-1111-2222  | P400         | USB 허브     |        3
     1004 | C003        | 박지훈        | 010-5555-6666  | P100         | 키보드       |        1
(8 rows)

한 행은 주문번호와 상품코드 조합으로 식별하므로 기본키는 (order_no, product_code) 복합 키입니다. 후보 키는 행을 유일하게 식별하는 최소한의 속성 집합이고, 그중 하나를 기본키로 고릅니다. 이 예제는 후보 키가 기본키 하나뿐이라 기본키 기준으로 설명합니다.

order_flat은 1NF를 만족하지만 2NF는 만족하지 않습니다. 같은 주문번호의 행마다 고객 정보가 반복되고, 같은 상품코드의 행마다 상품명이 반복됩니다.


이상현상 실행

order_flat에서 세 가지 이상현상을 차례로 실행합니다.

갱신 이상

키보드(P100)의 이름을 기계식 키보드로 바꾸려는 상황입니다. 키보드가 들어 있는 행은 세 개인데 한 행만 고쳤습니다.

BEGIN;
UPDATE order_flat
SET product_name = '기계식 키보드'
WHERE order_no = 1001 AND product_code = 'P100';
 
SELECT order_no, product_code, product_name
FROM order_flat
WHERE product_code = 'P100'
ORDER BY order_no;
 
SELECT product_code, count(DISTINCT product_name) AS name_count
FROM order_flat
GROUP BY product_code
HAVING count(DISTINCT product_name) > 1;
ROLLBACK;

order_flat에서 상품명을 한 행만 고친 뒤 같은 상품코드에 이름이 두 개가 된 psql 실행 결과"order_flat에서 상품명을 한 행만 고친 뒤 같은 상품코드에 이름이 두 개가 된 psql 실행 결과"

DB는 이 갱신을 막지 않습니다. 같은 상품코드 P100에 이름이 기계식 키보드와 키보드 두 개 생겼습니다. 어느 쪽이 맞는지는 테이블만 봐서는 알 수 없습니다. 모든 행을 빠짐없이 고쳐야만 일관성이 유지되는 구조입니다.

삽입 이상

아직 주문이 없는 새 상품 웹캠(P500)을 등록하려는 상황입니다.

INSERT INTO order_flat (product_code, product_name)
VALUES ('P500', '웹캠');
ERROR:  null value in column "order_no" of relation "order_flat" violates not-null constraint
DETAIL:  Failing row contains (null, null, null, null, P500, 웹캠, null).

기본키 (order_no, product_code)에 order_no가 들어 있어서 주문 없이는 상품을 저장할 수 없습니다. 주문 정보가 없으니 customer_id 같은 다른 NOT NULL 컬럼도 채울 값이 없습니다.

삭제 이상

P400(USB 허브)은 1003번 주문에만 있습니다. 1003번 주문에서 USB 허브 행을 지웠습니다.

BEGIN;
SELECT product_code, product_name FROM order_flat WHERE product_code = 'P400';
DELETE FROM order_flat WHERE order_no = 1003 AND product_code = 'P400';
SELECT product_code, product_name FROM order_flat WHERE product_code = 'P400';
ROLLBACK;
 product_code | product_name 
--------------+--------------
 P400         | USB 허브
(1 row)
 
DELETE 1
 product_code | product_name 
--------------+--------------
(0 rows)

주문 항목 하나를 지웠을 뿐인데 USB 허브라는 상품 정보가 테이블에서 사라졌습니다.

세 이상현상의 공통 원인은 주문, 고객, 상품이라는 서로 다른 대상의 정보가 한 행에 들어 있다는 점입니다. 그래서 한 대상의 정보를 독립적으로 추가, 수정, 삭제할 수 없습니다. 어떤 정보가 어떤 대상에 속하는지는 함수 종속으로 판단합니다.


함수 종속

정의

속성 X의 값이 정해지면 속성 Y의 값이 하나로 정해질 때, Y는 X에 함수 종속된다고 하고 X → Y로 씁니다. X를 결정자라고 부릅니다. 같은 X 값을 가진 행은 항상 같은 Y 값을 가진다는 뜻입니다.

order_flat에서 업무 규칙으로 성립하는 함수 종속은 아래와 같습니다.

함수 종속의미
(order_no, product_code) → quantity한 주문의 한 상품에는 개수가 하나 있다
order_no → customer_id주문 한 건은 고객 한 명의 주문이다
customer_id → customer_name, customer_phone고객 한 명에는 성명과 연락처가 하나씩 있다
product_code → product_name상품 하나에는 이름이 하나 있다

order_flat 테이블 컬럼 위에 함수 종속 네 개를 화살표로 표시한 다이어그램"order_flat 테이블 컬럼 위에 함수 종속 네 개를 화살표로 표시한 다이어그램"

키와 속성의 관계는 세 종류로 나뉩니다.

  • 완전 함수 종속: 키 전체에 종속됩니다. quantity는 order_no와 product_code가 둘 다 있어야 정해집니다.
  • 부분 종속: 키의 일부에만 종속됩니다. customer_id, customer_name, customer_phone은 order_no만으로 정해지고, product_name은 product_code만으로 정해집니다.
  • 이행 종속: X → Y이고 Y → Z이며, Y가 키가 아니고 Y → X는 성립하지 않을 때 Z는 X에 이행 종속입니다. customer_name과 customer_phone은 order_no → customer_id → customer_name, customer_phone 경로로 이행 종속입니다. customer_id는 키가 아니고, 고객 한 명이 주문을 여러 건 할 수 있어서 customer_id → order_no는 성립하지 않습니다.

그래서 customer_name과 customer_phone은 order_flat에서 키의 일부 order_no에 부분 종속이면서 customer_id를 거친 이행 종속이기도 합니다. 부분 종속을 먼저 제거하는 2NF 분할 뒤에도 이행 종속은 orders 테이블에 남고, 3NF 단계에서 제거합니다.

앞의 이상현상은 이 종속들에서 나옵니다. 상품명은 product_code만으로 정해지는데 주문 행마다 저장하므로 갱신 이상이 생기고, 상품코드만으로 상품 정보를 저장할 수 없으므로 삽입 이상과 삭제 이상이 생깁니다.

데이터로 확인하는 방법

X → Y가 성립하지 않는 곳은 쿼리로 찾을 수 있습니다. 같은 X 값에 서로 다른 Y 값이 둘 이상 있으면 이 데이터에서 X → Y는 성립하지 않습니다.

-- sql/03_dependency_check.sql
-- product_code → product_name
SELECT product_code FROM order_flat
GROUP BY product_code HAVING count(DISTINCT product_name) > 1;

order_flat에서 product_code → product_name, order_no → customer_id, customer_id → customer_name, customer_phone을 같은 방식으로 확인하면 세 쿼리 모두 0행입니다. 성립하지 않는 종속은 행을 반환합니다.

product_code → quantity 확인 쿼리입니다.

SELECT product_code FROM order_flat
GROUP BY product_code HAVING count(DISTINCT quantity) > 1;
 product_code 
--------------
 P100
 P200
(2 rows)

customer_id → order_no 확인 쿼리입니다.

SELECT customer_id FROM order_flat
GROUP BY customer_id HAVING count(DISTINCT order_no) > 1;
 customer_id 
-------------
 C001
(1 row)

P100과 P200은 주문마다 개수가 달라서 product_code → quantity가 성립하지 않고, C001은 주문이 두 건(1001, 1003)이라 customer_id → order_no가 성립하지 않습니다. 앞에서 상품명을 한 행만 고친 뒤에 product_code → product_name 확인 쿼리를 다시 실행하면 P100이 나옵니다. 갱신 이상은 이 종속이 성립하지 않게 된 상태입니다.

데이터로 할 수 있는 것은 반증까지입니다. 0행이 나왔다는 것은 지금 들어 있는 행에서 종속이 성립하지 않는 곳이 없다는 뜻이고, 앞으로 입력될 행까지 보장하지 않습니다.

함수 종속은 업무 규칙으로 정합니다. 상품코드 하나가 상품 하나를 가리킨다는 규칙이 있어서 product_code → product_name이 성립하고, 같은 상품코드에 이름이 둘이면 그 규칙을 어긴 데이터입니다. 이 규칙을 DB가 강제하게 만드는 방법이 결정자를 기본키로 하는 테이블로 분할하는 것입니다.


2NF: 부분 종속 제거

2NF는 1NF이면서, 키가 아닌 속성이 후보 키 전체에 완전 함수 종속인 형태입니다. 여기서 키가 아닌 속성은 어떤 후보 키에도 속하지 않는 속성입니다. 부분 종속이 없어야 합니다.

order_flat의 부분 종속은 두 개입니다.

  • order_no → customer_id, customer_name, customer_phone
  • product_code → product_name

두 종속을 한 번에 분할합니다. 결정자를 기본키로 하는 테이블을 만들고, 종속되는 속성을 그 테이블로 옮깁니다. 남은 quantity는 키 전체에 종속되므로 order_items에 둡니다.

-- sql/04_normalize.sql
CREATE SCHEMA nf2;
 
CREATE TABLE nf2.orders (
  order_no       int  PRIMARY KEY,
  customer_id    text NOT NULL,
  customer_name  text NOT NULL,
  customer_phone text NOT NULL
);
 
CREATE TABLE nf2.products (
  product_code text PRIMARY KEY,
  product_name text NOT NULL
);
 
CREATE TABLE nf2.order_items (
  order_no     int  NOT NULL REFERENCES nf2.orders (order_no),
  product_code text NOT NULL REFERENCES nf2.products (product_code),
  quantity     int  NOT NULL,
  PRIMARY KEY (order_no, product_code)
);
 
INSERT INTO nf2.orders
SELECT DISTINCT order_no, customer_id, customer_name, customer_phone FROM order_flat;
INSERT INTO nf2.products
SELECT DISTINCT product_code, product_name FROM order_flat;
INSERT INTO nf2.order_items
SELECT order_no, product_code, quantity FROM order_flat;

orders에는 주문 4건, products에는 상품 4개, order_items에는 8행이 들어갔습니다.

SELECT * FROM nf2.products ORDER BY product_code;
 product_code | product_name 
--------------+--------------
 P100         | 키보드
 P200         | 마우스
 P300         | 모니터
 P400         | USB 허브
(4 rows)

상품명은 products에만 있으므로 이름을 바꾸려면 한 행만 고치면 됩니다. order_items의 product_code와 order_no는 각각 products, orders를 참조하는 외래 키여서, 없는 상품이나 없는 주문을 가리키는 행은 들어갈 수 없습니다.

orders의 기본키는 컬럼 하나라서 부분 종속이 생길 수 없고, order_items의 quantity는 키 전체에 종속됩니다. 2NF를 만족합니다.

2NF 단계의 orders, order_items, products 테이블 구조. orders에 남은 고객 성명과 연락처의 이행 종속이 표시되어 있다"2NF 단계의 orders, order_items, products 테이블 구조. orders에 남은 고객 성명과 연락처의 이행 종속이 표시되어 있다"


3NF: 이행 종속 제거

3NF는 2NF이면서, 키가 아닌 속성이 키에 이행 종속되지 않는 형태입니다. 이행 종속은 앞에서 정의했습니다.

nf2.orders에 이행 종속이 남아 있습니다.

SELECT * FROM nf2.orders ORDER BY order_no;
 order_no | customer_id | customer_name | customer_phone 
----------+-------------+---------------+----------------
     1001 | C001        | 김민수        | 010-1111-2222
     1002 | C002        | 이영희        | 010-3333-4444
     1003 | C001        | 김민수        | 010-1111-2222
     1004 | C003        | 박지훈        | 010-5555-6666
(4 rows)

같은 고객이 여러 번 주문하면 성명과 연락처가 주문마다 반복됩니다. C001의 두 행이 그 예입니다. 종속은 order_no → customer_id → (customer_name, customer_phone)이고, customer_id는 orders의 키가 아닙니다.

customer_id → customer_name, customer_phone을 기준으로 고객 테이블을 분할합니다. orders에는 고객을 가리키는 customer_id만 남겨 외래 키로 연결합니다.

CREATE SCHEMA nf3;
 
CREATE TABLE nf3.customers (
  customer_id    text PRIMARY KEY,
  customer_name  text NOT NULL,
  customer_phone text NOT NULL
);
 
CREATE TABLE nf3.orders (
  order_no    int  PRIMARY KEY,
  customer_id text NOT NULL REFERENCES nf3.customers (customer_id)
);
 
CREATE TABLE nf3.products (
  product_code text PRIMARY KEY,
  product_name text NOT NULL
);
 
CREATE TABLE nf3.order_items (
  order_no     int  NOT NULL REFERENCES nf3.orders (order_no),
  product_code text NOT NULL REFERENCES nf3.products (product_code),
  quantity     int  NOT NULL,
  PRIMARY KEY (order_no, product_code)
);
 
INSERT INTO nf3.customers
SELECT DISTINCT customer_id, customer_name, customer_phone FROM nf2.orders;
INSERT INTO nf3.orders   SELECT order_no, customer_id FROM nf2.orders;
INSERT INTO nf3.products SELECT * FROM nf2.products;
INSERT INTO nf3.order_items SELECT * FROM nf2.order_items;

3NF 단계의 customers, orders, order_items, products 테이블 구조"3NF 단계의 customers, orders, order_items, products 테이블 구조"

각 테이블의 count(*)를 UNION ALL로 묶어 행 수를 조회했습니다(sql/04_normalize.sql의 마지막 쿼리).

 stage |     tbl     | row_count 
-------+-------------+-----------
 nf2   | orders      |         4
 nf2   | products    |         4
 nf2   | order_items |         8
 nf3   | customers   |         3
 nf3   | orders      |         4
 nf3   | products    |         4
 nf3   | order_items |         8
(7 rows)

3NF에서 고객 성명과 연락처는 customers에 고객당 한 행씩만 있고, 상품명은 products에 상품당 한 행씩만 있습니다.


JOIN으로 복원 확인

테이블을 분할해도 정보가 손실되지 않아야 합니다. 분해한 테이블을 JOIN했을 때 원래 테이블이 중복이나 누락 없이 복원되는 분해를 무손실 분해라고 합니다.

이 글의 분할은 모두 무손실 분해입니다. JOIN에 쓰는 공통 컬럼(order_no, product_code, customer_id)이 한쪽 테이블의 기본키이고, 다른 쪽 테이블에서는 NOT NULL 외래 키이기 때문입니다. 기본키 쪽은 값마다 행이 하나뿐이라 행이 중복되지 않고, 외래 키 쪽의 모든 행은 짝이 되는 행이 반드시 있어서 누락되지 않습니다.

실제로 원본 order_flat과 같은 결과가 나오는지 확인했습니다. EXCEPT는 집합 비교라서 중복 행을 구분하지 못하므로, 한쪽 결과에서 다른 쪽 결과를 뺀 행 수를 양방향으로 구하고 행 수도 함께 비교했습니다.

-- sql/05_verify_join.sql
CREATE TEMP VIEW restored_nf3 AS
SELECT o.order_no, c.customer_id, c.customer_name, c.customer_phone,
       i.product_code, p.product_name, i.quantity
FROM nf3.orders o
JOIN nf3.customers c ON c.customer_id = o.customer_id
JOIN nf3.order_items i ON i.order_no = o.order_no
JOIN nf3.products p ON p.product_code = i.product_code;
 
SELECT 'flat에만 있음' AS side, count(*)
FROM (SELECT * FROM order_flat EXCEPT SELECT * FROM restored_nf3) d
UNION ALL
SELECT 'nf3에만 있음', count(*)
FROM (SELECT * FROM restored_nf3 EXCEPT SELECT * FROM order_flat) d
UNION ALL
SELECT 'flat 행 수', count(*) FROM order_flat
UNION ALL
SELECT 'nf3 복원 행 수', count(*) FROM restored_nf3;
      side      | count 
----------------+-------
 flat에만 있음  |     0
 nf3에만 있음   |     0
 flat 행 수     |     8
 nf3 복원 행 수 |     8
(4 rows)

2NF 단계(nf2)도 같은 방식으로 확인했고, 결과는 양방향 0행에 행 수 8과 8이었습니다.


분할 후 이상현상 확인

3NF 구조에서 앞의 이상현상을 다시 실행했습니다.

-- sql/06_after_normalization.sql
SET search_path = nf3;
 
BEGIN;
UPDATE products SET product_name = '기계식 키보드' WHERE product_code = 'P100';
 
SELECT o.order_no, i.product_code, p.product_name
FROM orders o
JOIN order_items i ON i.order_no = o.order_no
JOIN products p ON p.product_code = i.product_code
WHERE i.product_code = 'P100'
ORDER BY o.order_no;
ROLLBACK;
 
INSERT INTO order_items VALUES (1001, 'P999', 1);
DELETE FROM products WHERE product_code = 'P100';

3NF 구조에서 UPDATE 한 문장으로 세 주문의 상품명이 모두 바뀌고, 없는 상품코드 삽입과 참조 중인 상품 삭제가 외래 키 오류로 막힌 psql 실행 결과"3NF 구조에서 UPDATE 한 문장으로 세 주문의 상품명이 모두 바뀌고, 없는 상품코드 삽입과 참조 중인 상품 삭제가 외래 키 오류로 막힌 psql 실행 결과"

  • 갱신: UPDATE products 한 문장(UPDATE 1)으로 상품명이 바뀌고, P100이 들어 있던 세 주문이 모두 새 이름으로 조회됩니다.
  • 무결성: 상품 테이블에 없는 P999를 주문상품에 넣으면 외래 키 제약이 막습니다. 주문상품이 참조하고 있는 P100 상품도 지울 수 없습니다.

삽입 이상과 삭제 이상이 사라졌는지도 같은 파일에서 확인했습니다.

-- 삽입: 주문이 없는 상품을 등록
INSERT INTO products VALUES ('P500', '웹캠');
 
-- 삭제: 주문상품 행을 지워도 상품 정보가 남는지 확인
DELETE FROM order_items WHERE order_no = 1003 AND product_code = 'P400';
SELECT * FROM products WHERE product_code = 'P400';

P500은 주문 없이 products에 들어갔고(INSERT 0 1), P400 주문상품 행을 지운 뒤에도 products에서 P400 | USB 허브가 조회됩니다. 상품 정보가 주문과 독립된 테이블에 있어서 두 이상현상이 사라졌습니다.


정리

단계조건찾은 대상결과
정규화 전한 셀에 상품이 여러 개원자값이 아닌 주문상품 셀표 하나
1NF모든 속성이 원자값(함수 종속과 무관)order_flat (분할 없음)
2NF1NF + 부분 종속 없음order_no → customer_id, customer_name, customer_phone, product_code → product_nameorders(고객 컬럼 포함), products, order_items
3NF2NF + 이행 종속 없음order_no → customer_id → customer_name, customer_phonecustomers 추가
  • 2NF와 3NF의 분할 기준은 함수 종속입니다. 결정자를 기본키로 하는 테이블을 만들고, 종속되는 속성을 그 테이블로 옮깁니다.
  • 분할한 테이블은 외래 키로 연결합니다. 하나의 데이터는 한 곳에만 저장되므로 고칠 곳이 하나입니다.
  • 종속은 업무 규칙이 정합니다. 데이터에 대한 쿼리는 종속이 성립하지 않는 곳만 보여 줍니다. 주문 시점의 고객 연락처나 상품명을 보존해야 하는 업무라면 종속이 달라지고, 그 값은 주문이 결정하므로 주문 쪽에 둡니다.

마치며

3NF 위에는 BCNF가 있습니다. 슈퍼키는 행을 유일하게 식별하는 속성 집합으로, 후보 키에 다른 속성을 더한 것도 포함합니다. 3NF는 결정자가 슈퍼키가 아니어도, 결정되는 속성이 후보 키의 구성 속성이면 허용합니다. BCNF는 모든 비자명 함수 종속의 결정자가 슈퍼키여야 합니다. 이 글의 예제는 최종 테이블의 결정자가 모두 각 테이블의 기본키여서 BCNF도 만족합니다. 후보 키가 여러 개이고 서로 겹치는 테이블에서는 3NF와 BCNF가 달라질 수 있습니다.

정규화하면 조회할 때 JOIN이 늘어날 수 있습니다. 읽기 성능을 위해 일부러 중복을 허용하는 반정규화도 있습니다. 어디에 중복이 생기는지 알려면 먼저 종속을 파악하고 정규화한 구조를 기준으로 삼아야 합니다.

예제 SQL과 실행 스크립트는 playground/db-normalization에 있습니다. 긴 글 읽어주셔서 감사합니다.