목차
- 정규화가 막아주는 세 가지 이상
- 함수 종속과 후보키 — 정규화의 언어부터
- 1NF: 원자값과 반복 그룹 제거
- 2NF: 복합키의 부분 종속을 끊는다
- 3NF: 이행 종속 제거
- BCNF: 3NF의 빈틈을 메운다
- 검증: PostgreSQL에서 함수 종속을 확인하는 SQL
- 정규화를 의도적으로 깨야 할 때
- 신입에게 넘기는 체크리스트
데이터베이스 정규화 1NF 2NF 3NF를 빼먹은 채 운영되던 테이블에서 사용자 주소 한 줄을 바꿨더니, 같은 사용자의 주문 행 14,247건에 옛 주소가 그대로 남아 있었다. 정규화된 스키마였다면 customers 한 행을 UPDATE하는 것으로 끝난다. 변경 행 수로는 14000:1, 트랜잭션 로그 크기로는 그 이상의 차이가 난다.
실무에서 정규화를 "학교에서 배운 이론"으로 묻어둔 채 ORM이 만들어주는 스키마에 의존하다 보면 위 같은 데이터 이상(anomaly)이 운영 단계에서 터진다. 이 글은 1NF·2NF·3NF·BCNF를 PostgreSQL 예제로 풀어가는 실무 가이드다. 신입 백엔드가 첫 스키마를 들고 들어가도 그대로 적용할 수 있는 수준으로 정리한다.
정규화가 막아주는 세 가지 이상
실제로, 정규화 이론이 막는 건 세 가지로 정리된다.
- 갱신 이상(update anomaly): 같은 값이 여러 행에 중복돼 있어, 일부만 갱신되면 데이터가 어긋난다. 위 주소 예시가 전형이다.
- 삽입 이상(insertion anomaly): 의미상 독립된 정보를 다른 정보 없이는 넣을 수 없다. 수강 정보 없이는 학생을 등록할 수 없는 식이다.
- 삭제 이상(deletion anomaly): 한 행을 지웠더니 함께 묻혀 있던 다른 의미의 데이터까지 사라진다. 학생의 마지막 수강을 지웠더니 학생 자체가 사라지는 경우다.
물론, 세 이상은 결국 "독립된 사실을 한 테이블에 묶어버려서" 생긴다. 정규화는 이 묶음을 해체하는 절차다.
함수 종속과 후보키 — 정규화의 언어부터
그래서, NF 단계를 이야기하기 전에 두 단어는 명확히 잡아야 한다.
실제로, **함수 종속(functional dependency)**은 X → Y로 쓴다. "같은 X 값에 대해 Y는 항상 같은 값으로 결정된다"는 뜻이다. 예를 들어 user_id → email은 어떤 사용자 ID가 두 개의 이메일을 가질 수 없다는 의미다. user_id → last_login_at은 함수 종속이 아니다. 로그인 시각은 같은 사용자에서도 계속 바뀐다.
그래서, **후보키(candidate key)**는 튜플을 유일하게 식별할 수 있는 최소 컬럼 집합이다. (order_id, line_no)처럼 복합으로 묶일 수도 있다. 후보키 중 하나를 기본키로 고른다. 후보키가 두 개 이상인 모델에서는 BCNF가 의미를 가지기 시작한다.
물론, NF는 결국 "어떤 함수 종속이 어떤 키와 어떤 관계인가"를 따지는 규칙이다. 이 두 개념 없이는 책의 설명이 외계어로 들린다.
1NF: 원자값과 반복 그룹 제거
한편, 1NF는 "한 컬럼에 한 값"이다. 콤마로 이어 붙인 다중 값, 의미가 다른 값들이 하나의 문자열에 섞여 있는 컬럼이 모두 1NF 위반이다.
-- 비정규화: 한 컬럼에 여러 전화번호
CREATE TABLE contacts_bad (
user_id INT PRIMARY KEY,
name TEXT,
phones TEXT -- '010-1111-2222, 010-3333-4444'
);
한편, 이 스키마는 검색이 즉시 깨진다. 특정 번호로 사용자를 찾으려면 phones LIKE '%010-3333%'로 풀스캔을 돌려야 한다. 번호 단위 인덱스도 못 만든다.
-- 1NF
CREATE TABLE users (
user_id INT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE user_phones (
user_id INT REFERENCES users(user_id),
phone_kind TEXT, -- 'mobile', 'home'
phone TEXT NOT NULL,
PRIMARY KEY (user_id, phone_kind)
);
PostgreSQL의 TEXT[] 배열 컬럼이 1NF를 위반하는지는 책마다 의견이 갈린다. 관계대수 관점에서는 위반이지만, GIN 인덱스를 쓰면 검색은 가능하다. (개인적으로 태그처럼 검색 조건이 단순하면 배열 컬럼, 별도 속성이 붙는 값이면 테이블 분리로 간다.)
2NF: 복합키의 부분 종속을 끊는다
2NF는 1NF를 만족하면서 "비키 속성이 기본키의 일부에만 종속되지 않을 것"을 추가한다. 기본키가 단일 컬럼이면 2NF는 자동으로 만족된다. 복합키일 때만 검사할 가치가 있다.
-- 1NF는 만족하지만 2NF 위반
CREATE TABLE order_items (
order_id INT,
product_id INT,
quantity INT,
product_name TEXT, -- product_id에만 종속
product_price NUMERIC, -- product_id에만 종속
PRIMARY KEY (order_id, product_id)
);
product_name은 (order_id, product_id) 전체가 아니라 product_id 하나로 결정된다. 같은 상품이 1만 건 주문에 들어가면 같은 이름이 1만 번 중복된다. 상품명이 바뀌면 1만 행을 UPDATE해야 한다.
-- 2NF
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
product_name TEXT NOT NULL,
product_price NUMERIC NOT NULL
);
CREATE TABLE order_items (
order_id INT,
product_id INT REFERENCES products(product_id),
quantity INT NOT NULL,
PRIMARY KEY (order_id, product_id)
);
결국, 여기서 멈추는 사람이 많다. 가격 이력 처리는 뒤의 비정규화 절에서 다시 다룬다.
3NF: 이행 종속 제거
물론, 3NF는 "비키 속성 사이의 함수 종속을 허용하지 않는다"는 조건이다. X → Y → Z 구조에서 Z가 키가 아닌 Y에 종속되는 형태를 이행 종속(transitive dependency)이라 부른다.
-- 3NF 위반
CREATE TABLE employees (
employee_id INT PRIMARY KEY,
name TEXT,
dept_id INT,
dept_name TEXT, -- dept_id → dept_name
dept_floor INT -- dept_id → dept_floor
);
dept_name이 바뀌면 같은 부서 직원 행을 전부 UPDATE해야 한다. 직원이 2,000명인 부서면 2,000행이 트랜잭션에 묶인다. 일부 행이 빠지는 사고도 흔하다.
CREATE TABLE departments (
dept_id SERIAL PRIMARY KEY,
dept_name TEXT NOT NULL,
dept_floor INT
);
CREATE TABLE employees (
employee_id INT PRIMARY KEY,
name TEXT,
dept_id INT REFERENCES departments(dept_id)
);
3NF가 충분한 경우와 그렇지 않은 경우
예를 들어, 대부분의 실무 스키마는 3NF에서 멈춰도 운영상 큰 문제가 없다. 단일 후보키 모델, 즉 기본키 외에 다른 유일성 후보가 없는 테이블은 3NF가 BCNF와 같은 결과를 만든다. 후보키가 여러 개 얽힌 도메인에서만 한 단계 더 보면 된다.
BCNF: 3NF의 빈틈을 메운다
게다가, BCNF(Boyce-Codd Normal Form)는 "모든 함수 종속의 결정자(왼쪽 항)가 후보키여야 한다"는 조건을 추가한다. 3NF가 비키 속성의 이행 종속만 막는 데 비해, BCNF는 후보키끼리 얽힌 종속까지 잡는다.
예시 — 후보키가 두 개인 수강 테이블이다.
-- 후보키: (student_id, course_id), (student_id, instructor_id)
-- 추가 종속: instructor_id → course_id (한 강사는 한 과목만 가르친다)
CREATE TABLE enrollments (
student_id INT,
course_id INT,
instructor_id INT,
PRIMARY KEY (student_id, course_id)
);
instructor_id가 course_id를 결정하는데, instructor_id 자체는 후보키가 아니다. 따라서 BCNF 위반이다. 한 강사의 담당 과목이 바뀌면 그 강사의 모든 수강 행을 동시에 갱신해야 한다.
CREATE TABLE instructor_courses (
instructor_id INT PRIMARY KEY,
course_id INT NOT NULL
);
CREATE TABLE enrollments (
student_id INT,
instructor_id INT REFERENCES instructor_courses(instructor_id),
PRIMARY KEY (student_id, instructor_id)
);
즉, BCNF 분해는 종종 일부 다중값 종속을 잃는다. 위 예시도 "한 학생이 같은 과목을 두 강사에게 동시에 듣지 못한다"는 제약이 스키마만으로는 표현되지 않는다. 이런 제약은 UNIQUE 인덱스나 트리거로 따로 걸어야 한다.
검증: PostgreSQL에서 함수 종속을 확인하는 SQL
스키마가 정말 정규화돼 있는지 의심스러우면 데이터로 검사가 가능하다. A → B를 가정한다면, A별로 B의 distinct count가 1이어야 한다.
-- A → B 검증
SELECT a_col, COUNT(DISTINCT b_col) AS distinct_b
FROM my_table
GROUP BY a_col
HAVING COUNT(DISTINCT b_col) > 1;
그래서, 한 행이라도 결과가 나오면 두 가지 중 하나다. 함수 종속이 깨졌거나, 애초에 그 종속이 데이터에 존재하지 않거나. 둘을 구분해서 봐야 한다. 전자는 정합성 사고, 후자는 모델링 실수다.
반면, 레거시 데이터를 인수받았을 때 위 쿼리로 의심 컬럼 쌍을 훑으면 비정규화된 구간이 빠르게 드러난다. 컬럼이 30개 넘는 와이드 테이블이라면 information_schema.columns로 컬럼 목록을 가져와 동적 SQL로 자동화하는 게 편하다.
정규화를 의도적으로 깨야 할 때
3NF/BCNF를 무조건 따르면 조인이 늘어나서 읽기 성능이 떨어진다. 다음 경우는 비정규화를 검토할 만하다.
- 집계 캐시:
orders.total_amount컬럼을 두고 트리거나 애플리케이션에서 갱신한다.order_itemsSUM 조인을 매번 돌리지 않아도 된다. - 이력성 데이터:
order_items.product_price_at_purchase같은 컬럼. 현재 가격(products.price)과 별도로 주문 시점 가격을 저장한다. 정규화 관점에서는 중복이지만 가격 변동이 과거 주문 금액을 흔들지 않게 한다. - 카운터:
posts.comment_count.comments테이블COUNT(*)는 인덱스가 있어도 큰 테이블에서 느리다. - 임베딩 컬럼:
posts테이블에embedding VECTOR(1536)컬럼을 두고 pgvector로 ANN 검색을 돌린다. 별도 테이블로 빼면 매 KNN 쿼리마다 조인이 필요해 latency가 늘어난다. 정규화 이론과 별개로 핫 경로 최적화에 한해 허용한다.
비정규화는 "데이터 정합성을 코드로 책임진다"는 선언이다. 트리거, 트랜잭션, 배치 보정 중 하나로 정합성을 지킬 수 있어야 한다. 다만 이 책임을 명시적으로 정해두지 않으면 6개월 후 아무도 그 컬럼이 어떻게 채워지는지 모르게 된다.
반면, 비교가 필요한 부분만 표로 정리한다.
| 단계 | 핵심 규칙 | 깨졌을 때 대표 증상 |
|---|---|---|
| 1NF | 원자값, 반복 그룹 제거 | 콤마 split 코드, LIKE 검색 |
| 2NF | 복합키의 부분 종속 제거 | 같은 product_name이 수만 번 중복 |
| 3NF | 이행 종속 제거 | dept_name 변경 시 수천 행 UPDATE |
| BCNF | 결정자가 모두 후보키 | 후보키끼리 얽힌 갱신 이상 |
신입에게 넘기는 체크리스트
또한, 신입이 첫 스키마를 들고 오면 다음 순서로 본다.
- 컬럼 하나에 콤마/세미콜론으로 여러 값이 들어가는가? → 1NF 검토
- 기본키가 복합키인가? 비키 컬럼이 그 일부에만 종속하는가? → 2NF 검토
- 비키 컬럼끼리 결정 관계가 있는가? (
dept_id → dept_name) → 3NF 검토 - 후보키가 둘 이상인가? 후보키가 아닌 결정자가 있는가? → BCNF 검토
- 위를 다 만족하는데도 조인이 너무 많아 느린 핫 경로가 있는가? → 비정규화 후보
이 순서대로 검토하고, 비정규화는 마지막 단계에서만 손댄다. 정규화부터 시작한 스키마에는 언제든 캐시 컬럼이나 인덱스를 덧붙일 수 있다. 비정규화부터 시작한 테이블은 사실상 새 테이블을 만들고 데이터를 옮겨야 한다.
실제로, 각 NF의 공식 정의와 PostgreSQL 제약 옵션을 다시 확인하고 싶을 때 참고하는 외부 자료다.
- PostgreSQL 16 공식 문서 — Constraints (외래키, CHECK, EXCLUDE 제약의 동작 정리)
- pgvector README — Indexing (v0.7.0 기준 HNSW와 IVFFlat 옵션, 2024-04 릴리스)
관련 글
- PostgreSQL pgvector 벡터 검색 — 임베딩 저장부터 유사도 쿼리 최적화까지 – 프론트엔드에서 백엔드로 전환한 지 2년. 벡터 검색이 필요해졌을 때 별도 벡터 DB를 붙일지 PostgreSQL pgvector로 해결할지…
- PostgreSQL autovacuum 튜닝 — 5천만 행 테이블 dead tuple 30%를 4%로 줄인 회고 – 5천만 행 주문 테이블이 점점 느려졌다. 인덱스 문제인 줄 알고 한 달을 헤맸는데 범인은 autovacuum이었다. 파라미터를 어떻게 잡았…
- PostgreSQL 커넥션 풀 설정 회고: PgBouncer 3개월 운영 기록 – PostgreSQL 커넥션 풀 설정으로 PgBouncer를 도입했다가 Transaction 모드에서 prepared statement 충돌…