[TIL] 데이터베이스 정규화(1~3NF) 완전 정리 + Python으로 MapReduce 구현하기
📌 오늘의 학습 목표
- 데이터베이스 정규화 1·2·3차를 예제와 함께 이해한다.
- 4·5차 정규화 개념을 파악한다. (진행 중 🔄)
- Python으로 MapReduce 패턴을 직접 구현한다.
Part 1. 데이터베이스 정규화 (Normalization)
정규화란?
정규화(Normalization) 는 관계형 데이터베이스에서 데이터 중복을 최소화하고 데이터 무결성을 보장하기 위해 테이블 구조를 체계적으로 개선하는 과정이다.
정규화를 하지 않으면 다음과 같은 이상 현상(Anomaly) 이 발생한다.
| 이상 현상 | 설명 | 예시 |
|---|---|---|
| 삽입 이상 | 불필요한 데이터를 함께 삽입해야 함 | 신규 교수를 등록하려면 과목도 있어야 함 |
| 삭제 이상 | 삭제 시 의도치 않은 데이터까지 사라짐 | 마지막 수강생 삭제 → 강좌 정보도 사라짐 |
| 갱신 이상 | 중복 데이터 중 일부만 수정되어 불일치 발생 | 교수 이름이 여러 행에 있어 하나만 수정됨 |
제1 정규형 (1NF, First Normal Form)
핵심 규칙
“모든 컬럼의 값은 원자값(Atomic Value)을 가져야 한다.”
- 하나의 셀에 여러 값이 들어가면 안 된다.
- 반복 그룹(Repeating Group)이 없어야 한다.
🔴 1NF 위반 테이블
아래는 도서관 대출 테이블의 초기 상태다.
| member_id | member_name | phone | loaned_books |
|---|---|---|---|
| 1 | 박종민 | 010-1234-5678 | 파이썬 입문, Clean Code |
| 2 | 이지훈 | 010-9876-5432 | 데이터베이스 개론 |
| 3 | 김수현 | 010-1111-2222 | 알고리즘, 운영체제, 자료구조 |
문제점: loaned_books 컬럼에 여러 도서가 쉼표로 구분되어 저장 → 원자값 위반
✅ 1NF 적용 후
| member_id | member_name | phone | loaned_book |
|---|---|---|---|
| 1 | 박종민 | 010-1234-5678 | 파이썬 입문 |
| 1 | 박종민 | 010-1234-5678 | Clean Code |
| 2 | 이지훈 | 010-9876-5432 | 데이터베이스 개론 |
| 3 | 김수현 | 010-1111-2222 | 알고리즘 |
| 3 | 김수현 | 010-1111-2222 | 운영체제 |
| 3 | 김수현 | 010-1111-2222 | 자료구조 |
각 행이 하나의 대출 도서를 나타내어 원자값을 만족한다.
하지만 member_name, phone이 중복 저장되는 문제가 남아있다. → 2NF로 해결
제2 정규형 (2NF, Second Normal Form)
핵심 규칙
“1NF를 만족하고, 부분 함수 종속(Partial Dependency)을 제거해야 한다.”
- 완전 함수 종속: 기본키 전체가 있어야만 결정되는 속성
- 부분 함수 종속: 기본키의 일부만으로도 결정되는 속성 → 제거 대상
※ 2NF는 복합 기본키가 있을 때 의미 있다.
🔴 2NF 위반 테이블
도서 대출 테이블에서 복합 PK를 (member_id, book_id)로 설정했다고 가정하자.
| member_id | book_id | member_name | member_phone | book_title | loan_date |
|---|---|---|---|---|---|
| 1 | 101 | 박종민 | 010-1234-5678 | 파이썬 입문 | 2025-03-01 |
| 1 | 102 | 박종민 | 010-1234-5678 | Clean Code | 2025-03-05 |
| 2 | 103 | 이지훈 | 010-9876-5432 | 데이터베이스 개론 | 2025-03-02 |
함수 종속 분석:
(member_id, book_id) → loan_date ✅ 완전 함수 종속
member_id → member_name ❌ 부분 함수 종속 (PK 일부에만 종속)
member_id → member_phone❌ 부분 함수 종속
book_id → book_title ❌ 부분 함수 종속
✅ 2NF 적용 후 — 테이블 분리
member 테이블
| member_id (PK) | member_name | member_phone |
|---|---|---|
| 1 | 박종민 | 010-1234-5678 |
| 2 | 이지훈 | 010-9876-5432 |
book 테이블
| book_id (PK) | book_title |
|---|---|
| 101 | 파이썬 입문 |
| 102 | Clean Code |
| 103 | 데이터베이스 개론 |
loan 테이블
| member_id (FK) | book_id (FK) | loan_date |
|---|---|---|
| 1 | 101 | 2025-03-01 |
| 1 | 102 | 2025-03-05 |
| 2 | 103 | 2025-03-02 |
부분 함수 종속이 제거되어 데이터 중복이 크게 줄었다.
하지만 아직 이행 함수 종속이 존재할 수 있다. → 3NF로 해결
제3 정규형 (3NF, Third Normal Form)
핵심 규칙
“2NF를 만족하고, 이행 함수 종속(Transitive Dependency)을 제거해야 한다.”
- A → B이고 B → C이면 A → C의 이행 종속 발생 → B를 별도 테이블로 분리
🔴 3NF 위반 테이블
도서 테이블에 출판사 정보가 포함된 경우를 보자.
| book_id (PK) | title | publisher_id | publisher_name | publisher_phone |
|---|---|---|---|---|
| 101 | 파이썬 입문 | P01 | 한빛미디어 | 02-325-5544 |
| 102 | Clean Code | P02 | 인사이트 | 02-322-5143 |
| 103 | 데이터베이스 개론 | P01 | 한빛미디어 | 02-325-5544 |
함수 종속 분석:
book_id → publisher_id ✅ 직접 종속
publisher_id → publisher_name ⚠️ 이행 종속!
publisher_id → publisher_phone ⚠️ 이행 종속!
즉, book_id → publisher_id → publisher_name (이행 함수 종속)
문제점: 한빛미디어의 전화번호가 변경되면 모든 해당 행을 수정해야 한다. (갱신 이상)
✅ 3NF 적용 후 — publisher 분리
publisher 테이블
| publisher_id (PK) | publisher_name | publisher_phone |
|---|---|---|
| P01 | 한빛미디어 | 02-325-5544 |
| P02 | 인사이트 | 02-322-5143 |
book 테이블 (수정)
| book_id (PK) | title | publisher_id (FK) |
|---|---|---|
| 101 | 파이썬 입문 | P01 |
| 102 | Clean Code | P02 |
| 103 | 데이터베이스 개론 | P01 |
이행 종속이 제거되어 출판사 정보를 한 곳에서만 관리한다.
1~3NF 핵심 비교 요약
| 정규형 | 핵심 규칙 | 제거 대상 |
|---|---|---|
| 1NF | 원자값 | 반복 그룹, 다중값 컬럼 |
| 2NF | 1NF + 완전 함수 종속 | 부분 함수 종속 |
| 3NF | 2NF + 직접 종속만 허용 | 이행 함수 종속 |
4~5차 정규형 (4NF, 5NF)
다치 종속과 조인 종속이라는 보다 복잡한 종속성을 제거하여 이상 현상을 방지하는 고급 정규화 단계임. 일반적으로는 3NF(또는 BCNF)까지만 정규화하는 경우가 많지만, 데이터베이스 무결성이 매우 중요한 경우 4NF와 5NF까지 고려함.
보이스-코드 정규형 (BCNF, Boyce-Codd Normal Form)
4NF를 다루기 전에 강한 3정규형이라고 불리는 BCNF를 짚고 넘어감.
“모든 결정자가 후보키(Candidate Key)여야 함.”
3NF를 만족하더라도, 기본키가 아닌 속성이 기본키의 일부를 결정하는 경우 발생할 수 있는 이상 현상을 해결하는 정규형임.
제4 정규형 (4NF, Fourth Normal Form)
“BCNF를 만족하고, 다치 종속(Multi-valued Dependency, MVD)을 제거해야 함.”
- 다치 종속(A ↠ B): 기본키 A의 값 하나에 B의 값이 여러 개 존재하고, A에 종속된 또 다른 속성 C와 무관하게 완전히 맵핑되는 경우를 말함.
- 한 릴레이션에 독립적인 다치 종속이 2개 이상 있으면 데이터 중복이 기하급수적으로 발생함. 이를 두 개의 개별적인 릴레이션으로 분해함.
🔴 4NF 위반 예시
개발자(Developer)가 여러 프로그래밍 언어(Skill)를 다루고, 여러 프로젝트(Project)에 참여한다고 가정함. (스킬과 프로젝트는 서로 무관함)
| Developer | Skill | Project |
|---|---|---|
| 박종민 | Python | A프로젝트 |
| 박종민 | Python | B프로젝트 |
| 박종민 | Java | A프로젝트 |
| 박종민 | Java | B프로젝트 |
- 개발자 하나에 스킬과 프로젝트가 각각 대응되면서 모든 가능한 조합(카테시안 곱)이 중복 저장됨.
- 이 상태에서는 특정 스킬만 삭제하거나, 새 프로젝트만 추가하는 상황에서 삽입/삭제 이상이 발생함.
✅ 4NF 적용 후 — 테이블 분리
| Developer | Skill |
|---|---|
| 박종민 | Python |
| 박종민 | Java |
| Developer | Project |
|---|---|
| 박종민 | A프로젝트 |
| 박종민 | B프로젝트 |
제5 정규형 (5NF, Fifth Normal Form / PJNF)
“4NF를 만족하고, 조인 종속(Join Dependency)이 없어야 함.” (Project-Join Normal Form, PJNF라고도 부름)
- 릴레이션을 3개 이상의 테이블로 분해했다가 다시 조인했을 때, 데이터 손실이나 원래 존재하지 않던 가짜 튜플(Spurious Tuple)이 생기지 않도록 설계하는 과정임.
- 즉, 조인 연산을 했을 때의 결과가 원래의 데이터와 완벽하게 동일하게 복원(무손실 분해)되어야 5NF를 만족함.
💡 실무적 관점: 4NF와 5NF는 이론적인 측면에서 데이터를 가장 무결하게 관리할 수 있지만, 테이블이 너무 잘게 쪼개져 쿼리 작성 시 수많은 조인(JOIN)을 필요로 함. 이는 복잡도를 높이고 성능 저하를 초래할 수 있어, 실무에서는 성능과 관리 편의성의 트레이드 오프(Trade-off) 를 고려해 보통 3NF 또는 BCNF까지만 정규화를 수행하는 경우가 일반적임. 심지어 조회 성능 향상을 위해 의도적으로 역정규화(Denormalization) 를 허용하기도 함.
Part 2. Python으로 MapReduce 구현하기
MapReduce란?
MapReduce는 Google이 2004년에 발표한 분산 데이터 처리 프레임워크다. 대용량 데이터를 여러 노드에 분산하여 병렬 처리하는 프로그래밍 모델로, 두 가지 핵심 단계로 구성된다.
Input → [Split] → [Map] → [Shuffle & Sort] → [Reduce] → Output
| 단계 | 역할 |
|---|---|
| Split | 입력 데이터를 작은 청크로 분할 |
| Map | 각 청크를 처리하여 (key, value) 쌍 생성 |
| Shuffle & Sort | 같은 key끼리 그룹화 |
| Reduce | 그룹별로 집계 연산 수행 |
예시 데이터 소개
asmith26/python-mapreduce-examples를 참고하여 세 가지 대표 패턴을 Python으로 구현한다.
| 패턴 | 설명 | 실세계 활용 |
|---|---|---|
| TALLY | 단어 빈도 계산 (Word Frequency) | 로그 분석, 검색 엔진 |
| MAX | 매장별 최고가 상품 추출 | 가격 비교, 성능 모니터링 |
| COUNT & SUM | 매장별 판매 건수 및 총액 | 매출 집계, 통계 리포트 |
공통 MapReduce 프레임워크 구현
# mapreduce_framework.py
"""
Python MapReduce 프레임워크
- Map : 입력 데이터를 (key, value) 리스트로 변환
- Shuffle: 동일 key끼리 그룹화
- Reduce: 그룹별 집계 처리
"""
from collections import defaultdict
from typing import Callable, Iterable, Any, List, Tuple
def shuffle(mapped: List[Tuple]) -> dict:
"""Shuffle & Sort: 동일 key의 value를 리스트로 묶는다."""
grouped = defaultdict(list)
for key, value in mapped:
grouped[key].append(value)
return dict(grouped)
def mapreduce(
data: Iterable,
mapper: Callable,
reducer: Callable,
) -> dict:
"""
MapReduce 파이프라인 실행기
Args:
data : 입력 데이터 (리스트, 제너레이터 등)
mapper : (item) → [(key, value), ...] 를 반환하는 함수
reducer : (key, values) → result 를 반환하는 함수
Returns:
{key: result} 형태의 최종 집계 딕셔너리
"""
# 1. Map Phase
mapped = []
for item in data:
mapped.extend(mapper(item))
# 2. Shuffle & Sort Phase
shuffled = shuffle(mapped)
# 3. Reduce Phase
result = {}
for key, values in shuffled.items():
result[key] = reducer(key, values)
return result
예제 1. TALLY — 단어 빈도 계산 (Word Frequency)
예시 데이터
# 도서관 도서 제목 목록 (텍스트 파일 시뮬레이션)
documents = [
"python is great and python is fun",
"database design is important",
"python and database go well together",
"great database design leads to great systems",
]
구현 코드
# word_frequency.py
import re
from mapreduce_framework import mapreduce
# ── Map: 각 문서를 단어별 (word, 1) 쌍으로 분해 ──────────────────────
def word_mapper(document: str):
"""
입력: 문자열 한 줄
출력: [(word, 1), (word, 1), ...]
"""
words = re.findall(r'[a-z가-힣]+', document.lower())
return [(word, 1) for word in words]
# ── Reduce: 단어별 count를 합산 ────────────────────────────────────────
def word_reducer(key: str, values: list):
"""
입력: key=단어, values=[1, 1, 1, ...]
출력: 단어 등장 횟수 합계
"""
return sum(values)
# ── 실행 ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
documents = [
"python is great and python is fun",
"database design is important",
"python and database go well together",
"great database design leads to great systems",
]
result = mapreduce(documents, word_mapper, word_reducer)
# 빈도 내림차순 정렬 후 출력
sorted_result = sorted(result.items(), key=lambda x: x[1], reverse=True)
print("=" * 35)
print(f"{'단어':<15} {'빈도':>5}")
print("=" * 35)
for word, count in sorted_result:
bar = "█" * count
print(f"{word:<15} {count:>5} {bar}")
print("=" * 35)
print(f"총 고유 단어 수: {len(result)}")
실행 결과
===================================
단어 빈도
===================================
is 4 ████
python 3 ███
great 3 ███
database 3 ███
design 2 ██
and 2 ██
fun 1 █
important 1 █
go 1 █
well 1 █
together 1 █
leads 1 █
to 1 █
systems 1 █
===================================
총 고유 단어 수: 14
예제 2. MAX — 매장별 최고가 상품 추출
예시 데이터
# 매장별 상품 판매 데이터 (store, product, price)
sales_data = [
("서울점", "파이썬 입문", 28000),
("서울점", "Clean Code", 35000),
("서울점", "알고리즘", 32000),
("부산점", "데이터베이스", 27000),
("부산점", "운영체제", 31000),
("부산점", "자료구조", 24000),
("대구점", "머신러닝", 45000),
("대구점", "딥러닝", 48000),
("대구점", "통계학", 29000),
]
구현 코드
# max_value_by_store.py
from mapreduce_framework import mapreduce
# ── Map: 각 판매 레코드를 (store, (product, price)) 로 변환 ───────────
def store_mapper(record: tuple):
"""
입력: (store, product, price) 튜플
출력: [(store, (product, price))]
"""
store, product, price = record
return [(store, (product, price))]
# ── Reduce: 매장별 최고가 상품 선택 ───────────────────────────────────
def max_reducer(key: str, values: list):
"""
입력: key=매장명, values=[(product, price), ...]
출력: 가장 비싼 (product, price) 튜플
"""
return max(values, key=lambda x: x[1])
# ── 실행 ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
sales_data = [
("서울점", "파이썬 입문", 28000),
("서울점", "Clean Code", 35000),
("서울점", "알고리즘", 32000),
("부산점", "데이터베이스", 27000),
("부산점", "운영체제", 31000),
("부산점", "자료구조", 24000),
("대구점", "머신러닝", 45000),
("대구점", "딥러닝", 48000),
("대구점", "통계학", 29000),
]
result = mapreduce(sales_data, store_mapper, max_reducer)
print("=" * 45)
print(f"{'매장':<10} {'최고가 상품':<15} {'가격':>10}")
print("=" * 45)
for store, (product, price) in sorted(result.items()):
print(f"{store:<10} {product:<15} {price:>10,}원")
print("=" * 45)
실행 결과
=============================================
매장 최고가 상품 가격
=============================================
대구점 딥러닝 48,000원
부산점 운영체제 31,000원
서울점 Clean Code 35,000원
=============================================
예제 3. COUNT & SUM — 매장별 판매 건수 및 총매출
예시 데이터
# 매장별 일별 판매 트랜잭션 데이터 (store, amount)
transactions = [
("서울점", 28000), ("서울점", 35000), ("서울점", 32000),
("서울점", 15000), ("서울점", 42000),
("부산점", 27000), ("부산점", 31000), ("부산점", 24000),
("부산점", 18000),
("대구점", 45000), ("대구점", 48000), ("대구점", 29000),
("대구점", 33000), ("대구점", 21000), ("대구점", 37000),
]
구현 코드
# count_and_sum.py
from mapreduce_framework import mapreduce
# ── Map: 각 트랜잭션을 (store, amount) 쌍으로 변환 ────────────────────
def transaction_mapper(record: tuple):
"""
입력: (store, amount) 튜플
출력: [(store, amount)]
"""
store, amount = record
return [(store, amount)]
# ── Reduce: 매장별 판매 건수와 총액을 동시에 집계 ─────────────────────
def count_sum_reducer(key: str, values: list):
"""
입력: key=매장명, values=[금액, 금액, ...]
출력: {'count': 판매건수, 'total': 총매출, 'avg': 평균}
"""
count = len(values)
total = sum(values)
avg = total / count
return {"count": count, "total": total, "avg": avg}
# ── 실행 ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
transactions = [
("서울점", 28000), ("서울점", 35000), ("서울점", 32000),
("서울점", 15000), ("서울점", 42000),
("부산점", 27000), ("부산점", 31000), ("부산점", 24000),
("부산점", 18000),
("대구점", 45000), ("대구점", 48000), ("대구점", 29000),
("대구점", 33000), ("대구점", 21000), ("대구점", 37000),
]
result = mapreduce(transactions, transaction_mapper, count_sum_reducer)
print("=" * 60)
print(f"{'매장':<10} {'건수':>6} {'총매출':>12} {'평균단가':>12}")
print("=" * 60)
grand_count = 0
grand_total = 0
for store, stats in sorted(result.items()):
grand_count += stats["count"]
grand_total += stats["total"]
print(
f"{store:<10} {stats['count']:>6}건 "
f"{stats['total']:>10,}원 "
f"{stats['avg']:>10,.0f}원"
)
print("-" * 60)
print(
f"{'합계':<10} {grand_count:>6}건 "
f"{grand_total:>10,}원 "
f"{grand_total/grand_count:>10,.0f}원"
)
print("=" * 60)
실행 결과
============================================================
매장 건수 총매출 평균단가
============================================================
대구점 6건 213,000원 35,500원
부산점 4건 100,000원 25,000원
서울점 5건 152,000원 30,400원
------------------------------------------------------------
합계 15건 465,000원 31,000원
============================================================
MapReduce 흐름 시각화
단어 빈도 예제를 기준으로 전체 흐름을 시각화하면 다음과 같다.
┌─────────────────────────────────────────────────────────┐
│ INPUT │
│ "python is great" / "database is important" │
└──────────────────────────────┬──────────────────────────┘
│
┌──────────▼──────────┐
│ SPLIT PHASE │
│ 문서를 행 단위로 │
│ 청크로 분할 │
└──────────┬──────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ MAP (Doc1) │ │ MAP (Doc2) │ │ MAP (Doc3) │
│ (python,1) │ │ (database,1) │ │ (python,1) │
│ (is,1) │ │ (is,1) │ │ (and,1) │
│ (great,1) │ │ (important,1)│ │ (database,1) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────────┼────────────────┘
│
┌──────────▼──────────┐
│ SHUFFLE & SORT │
│ python: [1, 1, 1] │
│ is: [1, 1] │
│ great: [1] │
│ database:[1, 1] │
└──────────┬──────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ REDUCE(key1) │ │ REDUCE(key2) │ │ REDUCE(key3) │
│ python → 3 │ │ is → 2 │ │ database → 2 │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────────┼────────────────┘
│
┌──────────▼──────────┐
│ OUTPUT │
│ {python:3, is:2, │
│ database:2, ...} │
└─────────────────────┘
MapReduce 설계 핵심 원칙 정리
| 원칙 | 설명 |
|---|---|
| 데이터 지역성 | 연산을 데이터가 있는 곳으로 이동 (네트워크 I/O 최소화) |
| 키 설계 | Map의 key 설계가 Shuffle 성능을 좌우한다 |
| Combiner 활용 | Reduce 전 로컬에서 부분 집계 → 네트워크 부하 감소 |
| 멱등성(Idempotency) | 동일 입력에 동일 출력 보장 → 재실행 안전성 확보 |
| 무상태(Stateless) | Map/Reduce 함수는 외부 상태에 의존하지 않아야 함 |
Comments