데이터베이스 정규화 1NF 2NF 3NF BCNF 실전 테이블 설계 가이드

목차

데이터베이스 정규화 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_idcourse_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_items SUM 조인을 매번 돌리지 않아도 된다.
  • 이력성 데이터: 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 결정자가 모두 후보키 후보키끼리 얽힌 갱신 이상

신입에게 넘기는 체크리스트

또한, 신입이 첫 스키마를 들고 오면 다음 순서로 본다.

  1. 컬럼 하나에 콤마/세미콜론으로 여러 값이 들어가는가? → 1NF 검토
  2. 기본키가 복합키인가? 비키 컬럼이 그 일부에만 종속하는가? → 2NF 검토
  3. 비키 컬럼끼리 결정 관계가 있는가? (dept_id → dept_name) → 3NF 검토
  4. 후보키가 둘 이상인가? 후보키가 아닌 결정자가 있는가? → BCNF 검토
  5. 위를 다 만족하는데도 조인이 너무 많아 느린 핫 경로가 있는가? → 비정규화 후보

이 순서대로 검토하고, 비정규화는 마지막 단계에서만 손댄다. 정규화부터 시작한 스키마에는 언제든 캐시 컬럼이나 인덱스를 덧붙일 수 있다. 비정규화부터 시작한 테이블은 사실상 새 테이블을 만들고 데이터를 옮겨야 한다.

실제로, 각 NF의 공식 정의와 PostgreSQL 제약 옵션을 다시 확인하고 싶을 때 참고하는 외부 자료다.

관련 글