데이터베이스 정규화 예제: 함수 종속으로 1NF·2NF·3NF 판단하기


1장. 상품명 하나를 바꿨는데 왜 여러 행을 수정해야 할까#

쇼핑몰 주문 원장이 다음과 같이 하나의 테이블에 저장되어 있다고 하겠습니다.

주문ID 주문일 고객ID 고객명 상품ID 상품명 기본단가 판매단가 수량
O001 2026-09-01 M001 김하늘 P001 연필 500 500 2
O001 2026-09-01 M001 김하늘 P002 공책 2,000 1,800 1
O002 2026-09-02 M002 이바다 P001 연필 500 450 3

처음 보면 한눈에 모든 정보를 확인할 수 있어 편해 보입니다.

하지만 상품 P001의 이름을 연필에서 고급 연필로 바꾸려면 어떻게 해야 할까요?

P001이 들어간 모든 주문 행을 찾아 수정해야 합니다.

O001 | P001 | 연필
O002 | P001 | 연필

한 행만 수정하면 다음처럼 됩니다.

O001 | P001 | 고급 연필
O002 | P001 | 연필

같은 상품ID인데 상품명이 서로 다릅니다.

문제는 데이터가 많아서가 아닙니다.

하나의 사실을 여러 행이 동시에 책임지고 있다는 것이 문제입니다.

정규화는 바로 이 책임을 정리하는 과정입니다.


2장. 정규화는 테이블을 많이 만드는 기술이 아니다#

정규화를 처음 배우면 다음처럼 생각하기 쉽습니다.

큰 테이블을 작은 테이블 여러 개로 나누는 방법.

결과만 보면 맞는 말처럼 보입니다.

하지만 정규화의 핵심은 테이블 개수가 아닙니다.

중요한 것은 다음 질문입니다.

어떤 값이 어떤 값에 의해 결정되는가?

예를 들어 다음 규칙이 있다고 하겠습니다.

상품ID → 상품명

상품ID가 P001이라면 상품명은 하나로 결정되어야 합니다.

또 다음 규칙이 있습니다.

고객ID → 고객명

M001이라는 고객ID가 하나의 현재 고객명을 결정합니다.

정규화는 이런 함수 종속을 이용해 서로 다른 사실을 적절한 테이블로 분리합니다.


3장. 하나의 주문원장에는 서로 다른 종류의 사실이 섞여 있다#

앞의 주문원장을 자세히 보면 다음 네 종류의 사실이 한곳에 들어 있습니다.

주문에 관한 사실

고객에 관한 사실

상품에 관한 사실

주문한 상품 한 줄에 관한 사실

예를 들어 다음 값들은 주문 자체의 사실입니다.

주문ID
주문일
고객ID

다음은 고객의 사실입니다.

고객ID
고객명

상품의 사실은 다음과 같습니다.

상품ID
상품명
기본단가

그리고 다음은 특정 주문 안의 특정 상품에 관한 사실입니다.

판매단가
수량

정규화는 이 서로 다른 사실을 구분해 각자 책임지는 위치를 만들어 줍니다.


4장. 정규화가 필요한 이유는 이상 현상 때문이다#

하나의 테이블에 서로 다른 사실을 섞으면 대표적으로 세 가지 문제가 발생합니다.

  • 삽입 이상
  • 삭제 이상
  • 갱신 이상

이를 이상 현상이라고 합니다.


5장. 삽입 이상은 다른 데이터가 없으면 새로운 사실을 저장하지 못하는 문제다#

새로운 상품 P003을 판매하기로 했다고 하겠습니다.

상품ID = P003
상품명 = 볼펜
기본단가 = 1,000

아직 아무도 주문하지 않았습니다.

그런데 상품 정보를 주문원장 하나에서만 관리한다면 문제가 생깁니다.

상품을 저장하려면 주문ID도 필요합니다.

주문ID = ?

존재하지 않는 가짜 주문을 만들어야 할 수도 있습니다.

새 상품 하나를 등록하는데 주문이 필요하다는 것은 이상합니다.

이것이 삽입 이상입니다.

서로 독립적으로 존재할 수 있는 사실이 한 테이블에 묶여 있기 때문에 발생합니다.


6장. 삭제 이상은 하나의 데이터를 지웠는데 다른 사실까지 사라지는 문제다#

주문 O001에서 공책 P002를 취소한다고 하겠습니다.

다음 행을 삭제합니다.

O001 | P002 | 공책

그런데 P002가 현재 이 주문에서만 등장하고 있었다면 어떻게 될까요?

행을 삭제하는 순간 다음 정보도 함께 사라집니다.

상품ID P002
상품명 공책
기본단가 2,000

우리는 주문 항목 하나를 삭제하려고 했을 뿐인데 상품 자체의 정보까지 사라졌습니다.

이것이 삭제 이상입니다.


7장. 갱신 이상은 같은 사실을 여러 번 수정해야 하는 문제다#

상품 P001의 이름이 다음처럼 바뀌었다고 하겠습니다.

연필
→
고급 연필

P001이 주문 1,000건에 등장한다면 1,000개 행을 모두 수정해야 합니다.

그중 하나라도 놓치면 다음처럼 됩니다.

P001 | 고급 연필
P001 | 고급 연필
P001 | 연필

같은 상품인데 서로 다른 상품명이 존재합니다.

이것이 갱신 이상입니다.

정규화는 같은 사실을 한 곳에서 관리하도록 구조를 조정해 이런 문제를 줄입니다.


8장. 모든 반복값이 이상 현상은 아니다#

여기서 중요한 함정이 있습니다.

앞의 데이터에서 판매단가를 보겠습니다.

주문ID 상품ID 판매단가
O001 P001 500
O002 P001 450

같은 상품 P001인데 판매단가가 다릅니다.

이것도 중복 문제일까요?

아닙니다.

O001에서는 정상가 500원에 팔렸고, O002에서는 할인된 450원에 팔렸을 수 있습니다.

두 값은 서로 다른 거래 사실입니다.

즉:

상품의 현재 기본단가

와

특정 주문에서 실제 판매된 단가

는 다릅니다.

정규화에서 가장 위험한 실수는 반복되는 값을 무조건 중복이라고 판단하는 것입니다.

값이 같거나 비슷해 보여도 의미와 시점이 다르면 서로 다른 사실일 수 있습니다.


9장. 함수 종속은 정규화의 핵심 언어다#

함수 종속은 다음과 같이 표현합니다.

X → Y

뜻은 다음과 같습니다.

X의 값이 정해지면 Y의 값도 하나로 결정된다.

X를 결정자라고 하고 Y를 종속자라고 합니다.

예를 들어:

고객ID → 고객명

고객ID M001이 정해지면 현재 고객명도 하나로 결정된다는 업무 규칙입니다.

또 다음과 같습니다.

상품ID → 상품명, 기본단가

상품ID가 정해지면 현재 상품명과 기본단가를 결정할 수 있습니다.


10장. 현재 데이터에서 우연히 맞는다고 함수 종속이 되는 것은 아니다#

다음 데이터가 있다고 하겠습니다.

고객명 지역
김하늘 서울
이바다 부산
박구름 대전

현재 데이터에서는 고객명이 모두 다릅니다.

그렇다면 다음 함수 종속이 성립한다고 볼 수 있을까요?

고객명 → 지역

그렇게 판단하면 안 됩니다.

나중에 김하늘이라는 다른 고객이 부산에 가입할 수 있습니다.

함수 종속은 현재 몇 행의 데이터에서 우연히 성립하는 패턴이 아닙니다.

업무 규칙상 항상 성립해야 하는 관계입니다.


11장. 주문원장의 함수 종속을 적어보자#

주문원장에서 한 주문에는 여러 상품이 들어갈 수 있다고 하겠습니다.

한 주문 안에서는 같은 상품을 한 줄로 관리한다고 가정합니다.

그러면 한 행을 다음 조합으로 식별할 수 있습니다.

주문ID + 상품ID

주요 함수 종속은 다음과 같이 정리할 수 있습니다.

주문ID → 주문일, 고객ID

고객ID → 고객명

상품ID → 상품명, 기본단가

(주문ID, 상품ID) → 판매단가, 수량

이 네 줄이 정규화의 핵심입니다.


12장. 왜 주문ID만으로 수량을 결정할 수 없을까#

주문 O001에는 두 상품이 있습니다.

O001 | P001 | 수량 2

O001 | P002 | 수량 1

주문ID는 둘 다 O001입니다.

하지만 수량은 다릅니다.

따라서 다음 함수 종속은 성립하지 않습니다.

주문ID → 수량

상품ID만으로도 안 됩니다.

P001은 주문마다 수량이 다를 수 있기 때문입니다.

O001 | P001 | 2

O002 | P001 | 3

따라서 다음 조합이 필요합니다.

(주문ID, 상품ID) → 수량

이처럼 함수 종속을 적을 때는 한 행의 단위를 정확하게 알아야 합니다.


13장. 후보키를 찾으려면 속성 폐포를 계산할 수 있다#

정규형을 정확하게 판단하려면 후보키를 알아야 합니다.

후보키를 찾을 때 유용한 개념이 속성 폐포입니다.

속성 집합 X의 폐포는 X로부터 함수 종속을 이용해 알아낼 수 있는 모든 속성의 집합입니다.

보통 다음처럼 표시합니다.

X+

14장. 주문ID의 폐포를 계산해 보자#

함수 종속은 다음과 같습니다.

주문ID → 주문일, 고객ID

고객ID → 고객명

주문ID에서 시작합니다.

{주문ID}

첫 번째 함수 종속으로 다음을 얻습니다.

주문일
고객ID

고객ID를 얻었으므로 다음 함수 종속을 적용합니다.

고객ID → 고객명

따라서 주문ID의 폐포는 다음과 같습니다.

주문ID+
=
{
 주문ID,
 주문일,
 고객ID,
 고객명
}

하지만 상품 관련 정보와 수량은 얻을 수 없습니다.

따라서 주문ID만으로 전체 행을 식별할 수 없습니다.


15장. 상품ID의 폐포도 전체 속성을 결정하지 못한다#

상품ID의 함수 종속은 다음과 같습니다.

상품ID → 상품명, 기본단가

따라서:

상품ID+
=
{
 상품ID,
 상품명,
 기본단가
}

역시 주문과 고객 관련 정보는 얻지 못합니다.

상품ID 하나도 후보키가 아닙니다.


16장. 주문ID와 상품ID를 합치면 전체 속성을 결정할 수 있다#

이번에는 다음 조합에서 시작합니다.

{주문ID, 상품ID}

주문ID를 통해 다음을 얻습니다.

주문일
고객ID
고객명

상품ID를 통해 다음을 얻습니다.

상품명
기본단가

두 값을 함께 사용하면 다음도 결정됩니다.

판매단가
수량

결국 모든 속성을 얻을 수 있습니다.

(주문ID, 상품ID)+
=
전체 속성

따라서 (주문ID, 상품ID)는 슈퍼키입니다.

주문ID나 상품ID 하나를 제거하면 전체 속성을 결정할 수 없으므로 최소성도 만족합니다.

따라서 후보키입니다.


17장. 함수 종속의 기본 규칙을 알아두면 폐포 계산이 쉬워진다#

함수 종속에서는 대표적으로 세 가지 기본 규칙을 사용합니다.

반사 규칙#

Y가 X의 부분집합이면:

X → Y

가 성립합니다.

예를 들어:

(주문ID, 상품ID) → 주문ID

는 당연합니다.


증대 규칙#

다음이 성립하면:

X → Y

양쪽에 같은 속성을 더해도 성립합니다.

XZ → YZ

예를 들어:

상품ID → 상품명

이라면:

(주문ID, 상품ID)
→
(주문ID, 상품명)

도 성립합니다.


이행 규칙#

다음이 성립하면:

X → Y

Y → Z

다음도 성립합니다.

X → Z

예를 들어:

주문ID → 고객ID

고객ID → 고객명

이므로:

주문ID → 고객명

이 성립합니다.


18장. 1NF는 한 칸에 여러 값을 뭉쳐 넣는 문제부터 본다#

학생 테이블이 다음과 같다고 하겠습니다.

학번 이름 전화번호
S001 김하늘 010-1111-2222, 02-333-4444

전화번호 한 칸에 두 개 값이 들어 있습니다.

이렇게 만들면 다음 작업이 불편해집니다.

휴대전화만 검색

전화번호 하나만 삭제

전화번호별 중복 검사

전화번호 유형 저장

관계형 구조에서는 다음처럼 분리할 수 있습니다.

erDiagram
    STUDENT ||--o{ STUDENT_PHONE : has

    STUDENT {
        string student_id PK
        string name
    }

    STUDENT_PHONE {
        string student_id PK, FK
        string phone_number PK
    }

학생:

학번 이름
S001 김하늘

학생전화:

학번 전화번호
S001 010-1111-2222
S001 02-333-4444

이제 전화번호 하나가 하나의 행으로 관리됩니다.


19장. 1NF의 원자값을 무조건 더 쪼개라는 뜻으로 이해하면 안 된다#

전화번호 010-1111-2222를 보면 숫자 여러 개로 구성되어 있습니다.

그렇다고 다음처럼 분리해야 1NF가 되는 것은 아닙니다.

010
1111
2222

업무에서 전화번호 하나를 하나의 값으로 취급한다면 충분히 원자적인 값으로 볼 수 있습니다.

주소도 마찬가지입니다.

전체 주소를 하나의 문자열로 관리하는 업무도 있을 수 있습니다.

1NF의 핵심은 단순히 문자열을 더 작은 문자열로 나누는 것이 아닙니다.

한 칸에 반복되는 값의 집합을 억지로 넣지 않는 것에 가깝습니다.


20장. 2NF는 복합 후보키 일부에만 의존하는 값을 찾는다#

주문원장의 후보키는 다음과 같습니다.

(주문ID, 상품ID)

그런데 주문일은 무엇으로 결정될까요?

주문ID → 주문일

상품ID는 필요 없습니다.

고객ID 역시 주문ID만으로 결정됩니다.

주문ID → 고객ID

상품명과 기본단가는 상품ID만으로 결정됩니다.

상품ID → 상품명, 기본단가

즉 후보키 전체가 아니라 일부만으로 결정되는 속성이 존재합니다.

이것을 부분 함수 종속이라고 합니다.


21장. 완전 함수 종속과 부분 함수 종속#

후보키가 다음과 같다고 하겠습니다.

(A, B)

어떤 속성 C가 A와 B를 모두 알아야 결정된다면:

(A, B) → C

이고 A만으로도 안 되고 B만으로도 안 된다면 C는 복합키 전체에 완전 함수 종속되어 있습니다.

반대로 A만으로 C가 결정된다면:

A → C

C는 복합키의 일부에만 의존합니다.

이것이 부분 함수 종속입니다.

주문원장에서는:

(주문ID, 상품ID) → 수량

은 완전 함수 종속입니다.

하지만:

주문ID → 주문일

과

상품ID → 상품명

은 부분 함수 종속입니다.


22장. 2NF를 만들려면 주문과 상품의 사실을 분리한다#

부분 함수 종속을 제거하면 다음과 같이 나눌 수 있습니다.

주문#

주문ID
주문일
고객ID
고객명

상품#

상품ID
상품명
기본단가

주문항목#

주문ID
상품ID
판매단가
수량

ERD로 보면 다음과 같습니다.

erDiagram
    ORDER ||--|{ ORDER_ITEM : contains
    PRODUCT ||--o{ ORDER_ITEM : included

    ORDER {
        string order_id PK
        date order_date
        string customer_id
        string customer_name
    }

    PRODUCT {
        string product_id PK
        string product_name
        decimal base_price
    }

    ORDER_ITEM {
        string order_id PK, FK
        string product_id PK, FK
        decimal sale_price
        int quantity
    }

이제 새 상품을 주문 없이 등록할 수 있습니다.

상품 하나를 삭제하지 않는 한 주문항목 삭제가 상품 자체를 없애지도 않습니다.


23장. 그런데 주문 테이블에도 아직 문제가 남아 있다#

2NF까지 분해한 주문 테이블을 보겠습니다.

주문ID 주문일 고객ID 고객명
O001 2026-09-01 M001 김하늘
O002 2026-09-02 M002 이바다
O003 2026-09-03 M001 김하늘

M001 고객의 이름이 주문마다 반복됩니다.

함수 종속을 다시 보면:

주문ID → 고객ID

고객ID → 고객명

입니다.

고객명은 주문ID에 직접 의존한다기보다 고객ID를 거쳐 결정됩니다.


24장. 이행 함수 종속은 중간 결정자를 거쳐 값이 결정되는 구조다#

다음 관계를 보겠습니다.

주문ID → 고객ID

고객ID → 고객명

따라서:

주문ID → 고객명

도 성립합니다.

하지만 고객명의 직접적인 결정자는 주문ID가 아니라 고객ID입니다.

구조를 그리면 다음과 같습니다.

주문ID
  ↓
고객ID
  ↓
고객명

이런 형태를 이행 함수 종속이라고 설명할 수 있습니다.


25장. 3NF에서는 고객 정보를 별도 테이블로 분리한다#

고객 정보를 분리하면 다음 구조가 됩니다.

erDiagram
    CUSTOMER ||--o{ ORDER : places
    ORDER ||--|{ ORDER_ITEM : contains
    PRODUCT ||--o{ ORDER_ITEM : included

    CUSTOMER {
        string customer_id PK
        string customer_name
    }

    ORDER {
        string order_id PK
        date order_date
        string customer_id FK
    }

    PRODUCT {
        string product_id PK
        string product_name
        decimal base_price
    }

    ORDER_ITEM {
        string order_id PK, FK
        string product_id PK, FK
        decimal sale_price
        int quantity
    }

테이블은 다음과 같습니다.

고객#

고객ID 고객명
M001 김하늘
M002 이바다

주문#

주문ID 주문일 고객ID
O001 2026-09-01 M001
O002 2026-09-02 M002
O003 2026-09-03 M001

이제 고객명을 변경할 때 고객 테이블 한 행만 수정하면 됩니다.


26장. 3NF를 단순히 “이행 종속 제거”로만 외우면 부족하다#

학습 단계에서는 흔히 다음처럼 설명합니다.

3NF
=
이행 함수 종속 제거

이해를 위한 요약으로는 유용합니다.

하지만 조금 더 정확하게 보면 3NF의 조건은 다음과 같습니다.

비자명한 함수 종속:

X → A

가 있을 때 다음 중 하나를 만족해야 합니다.

X가 슈퍼키다.

또는

A가 주속성이다.

여기서 주속성은 하나 이상의 후보키에 포함되는 속성입니다.

따라서 정규형을 정확하게 판정하려면 단순히 기본키만 보는 것이 아니라 후보키 전체를 확인해야 합니다.


27장. 단일 기본키라고 무조건 2NF라는 말도 조심해야 한다#

흔히 다음처럼 설명하기도 합니다.

기본키가 한 개 속성이면 자동으로 2NF다.

대부분의 단순 예제에서는 그렇게 볼 수 있습니다.

하지만 엄밀하게는 모든 후보키를 고려해야 합니다.

기본키가 단일 속성이더라도 다른 복합 후보키가 존재하고 비주속성이 그 복합 후보키 일부에 부분 종속된다면 2NF 판정을 다시 봐야 할 수 있습니다.

따라서 정규화에서는 기본키 하나만 보고 끝내지 않고 후보키 전체를 확인하는 습관이 중요합니다.


28장. 주문 데이터를 3NF까지 정리하면 구조가 선명해진다#

최종 구조를 다시 보겠습니다.

erDiagram
    CUSTOMER ||--o{ ORDER : places
    ORDER ||--|{ ORDER_ITEM : contains
    PRODUCT ||--o{ ORDER_ITEM : included

    CUSTOMER {
        string customer_id PK
        string customer_name
    }

    ORDER {
        string order_id PK
        date order_date
        string customer_id FK
    }

    PRODUCT {
        string product_id PK
        string product_name
        decimal current_price
    }

    ORDER_ITEM {
        string order_id PK, FK
        string product_id PK, FK
        decimal sale_price
        int quantity
    }

각 테이블이 책임지는 사실이 명확합니다.

CUSTOMER
→ 고객의 현재 정보

ORDER
→ 주문 자체의 정보

PRODUCT
→ 상품의 현재 정보

ORDER_ITEM
→ 특정 주문에서 특정 상품이 어떻게 판매됐는지

29장. 현재 상품 가격과 주문 당시 판매단가는 반드시 구분해야 한다#

상품 P001의 현재 가격이 다음처럼 변했다고 하겠습니다.

1,000원
→
1,200원

지난달 주문 O001에서는 1,000원에 판매했습니다.

그렇다면 주문 금액은 어떻게 되어야 할까요?

당연히 과거 거래는 그대로 유지되어야 합니다.

O001
P001
판매단가 1,000원
수량 2
총액 2,000원

현재 상품 가격이 1,200원으로 바뀌었다고 과거 주문까지 2,400원이 되어서는 안 됩니다.

그래서 다음 두 속성은 서로 다릅니다.

PRODUCT.current_price

와

ORDER_ITEM.sale_price

겉으로는 둘 다 가격이지만 의미와 시점이 다릅니다.


30장. 현재 상품명과 주문 당시 상품명도 업무에 따라 다를 수 있다#

상품 이름이 다음처럼 바뀌었다고 하겠습니다.

연필
→
프리미엄 연필

과거 주문 화면에서 현재 상품명을 보여주고 싶다면 상품 테이블의 현재 이름을 조인하면 됩니다.

하지만 영수증이나 계약 기록에서 거래 당시 상품명을 보존해야 한다면 이야기가 달라집니다.

주문 당시 이름을 별도로 저장할 수도 있습니다.

예:

ORDER_ITEM.product_name_snapshot

이 값은 중복 오류가 아닙니다.

거래 당시의 사실을 보존하기 위한 스냅샷입니다.

정규화는 역사를 지우기 위한 기술이 아닙니다.


31장. 정규화에서 가장 중요한 것은 값이 아니라 의미다#

다음 두 값이 같습니다.

500
500

하나는 현재 상품 기본가격일 수 있습니다.

다른 하나는 특정 주문에서 확정된 판매가격일 수 있습니다.

숫자는 같지만 의미는 다릅니다.

반대로 다음 두 값은 다릅니다.

500
450

같은 상품의 두 주문에서 실제 판매단가일 수 있습니다.

값이 다르다고 잘못된 것도 아닙니다.

정규화는 값의 모양을 보는 작업이 아니라 업무 의미와 함수 종속을 보는 작업입니다.


32장. 정규화 이후 삽입 이상이 어떻게 사라지는지 확인해 보자#

새 상품 P003을 추가하려고 합니다.

정규화 전에는 주문ID가 필요했습니다.

정규화 후에는 상품 테이블에 바로 넣습니다.

INSERT INTO product (
    product_id,
    product_name,
    current_price
)
VALUES (
    'P003',
    '볼펜',
    1000
);

아직 한 번도 팔리지 않은 상품도 문제없이 등록할 수 있습니다.

상품이라는 사실이 주문과 분리되었기 때문입니다.


33장. 삭제 이상도 다시 확인해 보자#

주문 O001에서 P002를 취소합니다.

주문항목만 삭제합니다.

DELETE FROM order_item
WHERE order_id = 'O001'
  AND product_id = 'P002';

상품 테이블의 P002는 그대로 남습니다.

P002 | 공책 | 2,000

주문항목의 삭제와 상품의 존재가 서로 독립적으로 관리됩니다.

이것이 정규화의 효과입니다.


34장. 갱신 이상은 상품 정보 한 번 수정으로 줄어든다#

P001의 현재 상품명을 변경합니다.

UPDATE product
SET product_name = '프리미엄 연필'
WHERE product_id = 'P001';

수정은 한 행에서 끝납니다.

주문 10건에 P001이 있든 100만 건에 있든 현재 상품명 자체는 한 곳에서 관리됩니다.

같은 사실을 여러 주문 행이 책임지지 않기 때문입니다.


35장. 그러나 과거 거래값까지 함께 수정하면 정규화의 목적을 오해한 것이다#

상품의 현재 가격을 수정합니다.

UPDATE product
SET current_price = 1200
WHERE product_id = 'P001';

이때 주문항목의 과거 판매단가까지 다음처럼 수정하면 안 됩니다.

O001 | P001 | 1000
        ↓
O001 | P001 | 1200

이미 완료된 거래가 1,000원이었다면 그 값은 거래의 역사입니다.

현재 상품 정보와 과거 거래 기록의 책임을 분리해야 합니다.


36장. 분해한 테이블은 다시 조인해 원래 의미를 복원할 수 있어야 한다#

정규화로 테이블을 여러 개로 나눴다고 하겠습니다.

그렇다면 주문 상세 화면에서는 다시 데이터를 합쳐야 합니다.

SELECT
    o.order_id,
    o.order_date,
    c.customer_name,
    p.product_name,
    oi.sale_price,
    oi.quantity
FROM orders AS o
JOIN customer AS c
    ON c.customer_id = o.customer_id
JOIN order_item AS oi
    ON oi.order_id = o.order_id
JOIN product AS p
    ON p.product_id = oi.product_id;

정규화는 정보를 없애는 작업이 아닙니다.

필요한 정보를 다른 테이블에 나눠 저장하고 관계를 이용해 다시 결합합니다.


37장. 잘못 분해하면 원래 없던 데이터가 생길 수도 있다#

테이블을 나눈다고 무조건 올바른 정규화가 되는 것은 아닙니다.

잘못 분해하면 다시 조인했을 때 원래 없던 행이 생성될 수 있습니다.

정규화를 할 때는 다음 질문을 확인해야 합니다.

분해한 테이블을 다시 조인하면
원래 데이터를 정확하게 복원할 수 있는가?

이 성질을 무손실 분해라고 합니다.

즉 정규화는 단순히 나누는 것이 아니라 정보를 잃지 않고 나누는 것이어야 합니다.


38장. 함수 종속도 분해 후 검사 가능한지 살펴야 한다#

원래 다음 규칙이 있었다고 하겠습니다.

고객ID → 고객명

고객 테이블로 분리하면 이 규칙을 한 테이블 안에서 바로 검사할 수 있습니다.

CUSTOMER
customer_id
customer_name

마찬가지로:

상품ID → 상품명

도 상품 테이블에서 검사할 수 있습니다.

하지만 분해 방식에 따라 어떤 함수 종속은 여러 테이블을 조인해야 확인할 수도 있습니다.

따라서 좋은 분해에서는 함수 종속 보존도 중요한 검토 대상입니다.


39장. 간단한 예제로 1NF·2NF·3NF를 한 번에 판정해 보자#

다음 릴레이션이 있다고 하겠습니다.

R(A, B, C, D)

함수 종속은 다음과 같습니다.

A → B

B → C

C → D

A 하나로 B를 결정합니다.

B를 통해 C를 얻습니다.

C를 통해 D를 얻습니다.

따라서 A의 폐포는:

A+
=
{A, B, C, D}

입니다.

A가 후보키입니다.


40장. 이 예제는 2NF인가#

후보키가 A 하나입니다.

복합키가 아니므로 후보키의 일부에만 의존하는 부분 함수 종속은 존재하지 않습니다.

따라서 이 예제는 2NF 조건을 만족합니다.

하지만 3NF는 다릅니다.

다음 함수 종속이 있습니다.

B → C

B는 슈퍼키가 아닙니다.

C 역시 후보키에 속하는 주속성이 아니라고 가정합니다.

따라서 3NF를 위반합니다.

다음도 마찬가지입니다.

C → D

C도 슈퍼키가 아니고 D도 주속성이 아닙니다.

따라서 3NF 위반입니다.


41장. 이행 종속을 분리하면 구조가 단순해진다#

다음처럼 분해할 수 있습니다.

R1(A, B)

R2(B, C)

R3(C, D)

각 테이블은 자신의 함수 종속을 관리합니다.

A → B

B → C

C → D

한 테이블 안에서 같은 종속 데이터를 반복 저장할 필요가 줄어듭니다.


42장. 정규형을 판정하는 실전 순서#

정규화 문제를 풀 때는 다음 순서가 효과적입니다.

1단계#

한 행이 무엇을 의미하는지 확인합니다.

주문 한 건인가?

주문 상품 한 줄인가?

학생 한 명인가?

학생의 수강 한 건인가?

2단계#

업무상 성립하는 함수 종속을 적습니다.

X → Y

3단계#

속성 폐포를 계산해 후보키를 찾습니다.

4단계#

1NF를 확인합니다.

반복 그룹이나 한 칸에 여러 값을 묶어 관리하고 있는지 확인합니다.

5단계#

2NF를 확인합니다.

복합 후보키의 일부에만 의존하는 비주속성이 있는지 확인합니다.

6단계#

3NF를 확인합니다.

키가 아닌 결정자가 다른 비주속성을 결정하는 구조가 있는지 확인합니다.

7단계#

분해 후 원래 데이터를 복원할 수 있는지 확인합니다.

8단계#

분해 후 함수 종속을 적절하게 검사할 수 있는지 확인합니다.


43장. 1NF·2NF·3NF를 비교하면#

정규형 핵심 질문 대표 문제
1NF 한 속성에 반복값을 뭉쳐 넣고 있지 않은가 한 칸에 여러 전화번호
2NF 복합 후보키 일부만으로 결정되는 비주속성이 있는가 주문ID만으로 주문일 결정
3NF 키가 아닌 결정자가 다른 비주속성을 결정하는가 고객ID가 고객명 결정

쉽게 기억하면 다음과 같습니다.

1NF
→ 값의 구조

2NF
→ 복합키 일부에 대한 종속

3NF
→ 키가 아닌 결정자에 대한 종속

다만 실제 판정에서는 후보키와 함수 종속을 기준으로 정확하게 확인해야 합니다.


44장. 정규화했다고 중복이 모두 사라지는 것은 아니다#

정규화 이후에도 같은 값이 여러 곳에서 보일 수 있습니다.

예를 들어 주문 테이블에는 M001이 여러 번 나타날 수 있습니다.

주문ID 고객ID
O001 M001
O003 M001
O008 M001

문제가 아닙니다.

같은 고객이 여러 주문을 했기 때문입니다.

이것은 불필요한 중복이 아니라 관계를 표현하는 외래키의 반복입니다.

정규화의 목적은 모든 반복값을 없애는 것이 아닙니다.

같은 사실을 여러 곳에서 독립적으로 관리해야 하는 불필요한 중복을 줄이는 것입니다.


45장. 정규화와 이력 보존은 충돌하는 개념이 아니다#

현재 고객 이름은 CUSTOMER에 저장할 수 있습니다.

하지만 계약 당시 고객 이름을 증빙해야 한다면 주문이나 계약에 당시 이름을 스냅샷으로 저장할 수도 있습니다.

현재 상품 가격은 PRODUCT에 있습니다.

주문 당시 판매단가는 ORDER_ITEM에 있습니다.

이렇게 보면 정규화된 구조에서도 필요한 중복이 존재할 수 있습니다.

중요한 것은 이유를 설명할 수 있는가입니다.

왜 이 값을 여기에 한 번 더 저장하는가?

언제 생성되는가?

누가 변경할 수 있는가?

현재값인가 과거 시점의 사실인가?

이 질문에 답할 수 있어야 합니다.


46장. 최종 구조를 SQL로 구성해 보기#

CREATE TABLE customer (
    customer_id VARCHAR(20) PRIMARY KEY,
    customer_name VARCHAR(100) NOT NULL
);

CREATE TABLE product (
    product_id VARCHAR(20) PRIMARY KEY,
    product_name VARCHAR(100) NOT NULL,
    current_price DECIMAL(12, 2) NOT NULL
);

CREATE TABLE orders (
    order_id VARCHAR(20) PRIMARY KEY,
    order_date DATE NOT NULL,
    customer_id VARCHAR(20) NOT NULL,

    FOREIGN KEY (customer_id)
        REFERENCES customer(customer_id)
);

CREATE TABLE order_item (
    order_id VARCHAR(20) NOT NULL,
    product_id VARCHAR(20) NOT NULL,
    sale_price DECIMAL(12, 2) NOT NULL,
    quantity INTEGER NOT NULL,

    PRIMARY KEY (order_id, product_id),

    FOREIGN KEY (order_id)
        REFERENCES orders(order_id),

    FOREIGN KEY (product_id)
        REFERENCES product(product_id),

    CHECK (quantity >= 1)
);

이 구조에서는 각 사실의 책임이 명확합니다.


47장. Mermaid ERD로 최종 정규화 구조 확인하기#

erDiagram
    CUSTOMER ||--o{ ORDER : places
    ORDER ||--|{ ORDER_ITEM : contains
    PRODUCT ||--o{ ORDER_ITEM : included

    CUSTOMER {
        string customer_id PK
        string customer_name
    }

    ORDER {
        string order_id PK
        date order_date
        string customer_id FK
    }

    PRODUCT {
        string product_id PK
        string product_name
        decimal current_price
    }

    ORDER_ITEM {
        string order_id PK, FK
        string product_id PK, FK
        decimal sale_price
        int quantity
    }

이 ERD에서 각 테이블이 어떤 사실을 책임지는지 바로 읽을 수 있습니다.

고객 정보는 CUSTOMER가 관리합니다.

현재 상품 정보는 PRODUCT가 관리합니다.

주문 자체는 ORDER가 관리합니다.

특정 주문에서 어떤 상품이 얼마에 몇 개 팔렸는지는 ORDER_ITEM이 관리합니다.


48장. 핵심 정리#

정규화는 큰 테이블을 단순히 여러 개로 나누는 기술이 아닙니다.

핵심은 다음 질문입니다.

어떤 값이 어떤 값에 의해 결정되는가?

함수 종속을 이용하면 데이터가 어느 테이블에 속해야 하는지 판단할 수 있습니다.

1NF는 반복되는 값을 한 칸에 뭉쳐 관리하는 구조를 확인합니다.

2NF는 복합 후보키의 일부에만 의존하는 비주속성을 분리합니다.

3NF는 키가 아닌 결정자가 다른 비주속성을 결정하는 구조를 정리합니다.

하지만 정규화에서 가장 중요한 것은 정규형 이름 자체가 아닙니다.

다음 세 가지 이상 현상을 줄이는 것이 핵심입니다.

삽입 이상
삭제 이상
갱신 이상

그리고 값이 반복된다는 이유만으로 무조건 제거해서도 안 됩니다.

현재 상품 가격
≠
주문 당시 판매단가

현재 고객 이름
≠
계약 당시 기록해야 할 고객 이름

정규화에서 제거해야 하는 것은 같은 사실의 불필요한 반복입니다.

과거 거래나 당시 상태처럼 업무적으로 보존해야 하는 값은 별도의 사실로 남겨야 합니다.

정규화된 설계가 좋은지 확인하려면 마지막으로 세 가지 질문을 해보면 됩니다.

새로운 고객이나 상품을 다른 데이터 없이 독립적으로 등록할 수 있는가?

주문 한 건을 삭제했을 때 고객이나 상품이라는 별개의 사실까지 사라지지 않는가?

고객명이나 상품명을 변경할 때 같은 사실을 여러 행에서 반복 수정하지 않아도 되는가?

이 세 질문에 명확하게 답할 수 있다면 정규화는 단순한 이론이 아니라 데이터가 시간이 지나도 일관성을 유지하도록 책임을 나누는 설계 방법으로 작동하고 있는 것입니다.

이 페이지의 목차