반정규화
정규화가 데이터 무결성을 위한 분해라면, 반정규화(Denormalization)는 성능을 위한 의도적 역행입니다. 정규화만이 정답은 아닙니다 — 읽기 성능이 극도로 중요한 상황에서는 반정규화가 필요합니다. 반정규화는 무결성을 일부 희생하는 트레이드오프이므로, 언제·왜·어떻게 적용하는지 정확히 판단할 수 있어야 합니다.
반정규화가 필요한 상황
정규화된 테이블은 데이터 무결성이 높지만, 조인이 많아집니다. 조인은 비용이 큰 연산입니다.
"사용자별 주문과 상품명을 조회하라"
SELECT u.name, o.order_date, p.name, oi.quantity
FROM users u
JOIN orders o ON u.id = o.user_id
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id;
→ 4개 테이블 조인!
→ 데이터가 수천만 건이면 성능 저하
→ 대시보드에서 이 쿼리가 1초에 100번 호출된다면?| 반정규화를 고려해야 하는 신호 | 예시 |
|---|---|
| 자주 사용되는 조인이 3개 이상 | 대시보드 조회, 리포트 |
| 집계 쿼리가 실시간으로 필요 | 실시간 매출 합계 |
| 읽기/쓰기 비율이 10:1 이상 | 조회 위주 서비스 |
| 응답 시간 SLA가 매우 짧음 | 100ms 이내 응답 |
| 히스토리 데이터 대량 조회 | 분석 리포트, 통계 |
| 실시간 랭킹/카운트 필요 | 인기 상품, 좋아요 수 |
반정규화 의사결정 프로세스
반정규화는 무조건 적용하면 안 됩니다. 체계적인 의사결정 과정을 거쳐야 합니다.
성능 문제 발생
│
▼
┌──────────────────────────┐
│ 1. 문제 원인 분석 │
│ 실행 계획 확인 │
│ → 인덱스 문제인가? │
│ → 조인 비용 문제인가? │
│ → 집계 비용 문제인가? │
└─────────┬────────────────┘
│
▼
┌───────────────────────────┐
│ 2. 인덱스로 해결 시도 │ ← 우선 시도
│ Covering Index 검토 │
│ Materialized View 검토 │
└─────────┬─────────────────┘
│ 해결 안 됨
▼
┌─────────────────────────┐
│ 3. 반정규화 유형 결정 │
│ 테이블 병합? │
│ 중복 컬럼 추가? │
│ 요약 테이블? │
│ 파생 컬럼? │
└─────────┬───────────────┘
│
▼
┌─────────────────────────┐
│ 4. 데이터 정합성 전략 │
│ 동기 갱신? 비동기? │
│ 트리거? 배치? │
│ 허용 가능한 지연? │
└─────────┬───────────────┘
│
▼
┌─────────────────────────┐
│ 5. 적용 및 모니터링 │
│ 성능 개선 측정 │
│ 갱신 비용 측정 │
│ 정합성 검증 │
└─────────────────────────┘반정규화 기법
1. 테이블 병합
1:1 관계나 잘 변하지 않는 관계의 테이블을 하나로 합칩니다.
정규화
users (id, name, email)
user_profiles (user_id, bio, avatar_url, website)
→ 사용자 정보 조회 시 항상 JOIN 필요
반정규화
users (
id, name, email,
bio, avatar_url, website ← 프로필 컬럼 병합
)
→ JOIN 제거, 단일 테이블 조회정규화
orders (id, user_id, order_date)
order_details (id, order_id, product_id, qty, price)
주의: 1:N 병합은 데이터 중복 발생!
orders_merged (
id, user_id, order_date,
product_id, qty, price
)
→ order_date가 주문 상품 수만큼 중복 저장
적용 조건
* N쪽 데이터가 적을 때 (예: 주문당 상품 1~3개)
* 항상 함께 조회되는 경우
* 주의: 갱신 이상 위험 증가테이블 병합 유형 정리
| 유형 | 설명 | 적합한 경우 |
|---|---|---|
| 1:1 병합 | 두 테이블을 하나로 | 항상 함께 조회 |
| 슈퍼/서브타입 병합 | 상속 관계 통합 | 서브타입이 적고 컬럼 유사 |
| 1:N 병합 | 부모에 자식 정보 포함 | N이 작고 고정적 |
정규화 (테이블 3개)
Payment (id, amount, type)
CardPayment (payment_id, card_number, installment)
BankPayment (payment_id, bank_code, account)
전략 1: 단일 테이블 전략 (모든 컬럼 합침)
Payment (
id, amount, type,
card_number, installment, ← type='CARD'일 때만 사용
bank_code, account ← type='BANK'일 때만 사용
)
장점: JOIN 없음, 쿼리 단순
단점: NULL 컬럼 많음, 공간 낭비
전략 2: 각 타입별 테이블 전략 (완전 분리)
CardPayment (id, amount, card_number, installment)
BankPayment (id, amount, bank_code, account)
장점: NULL 없음, 각 타입 최적화
단점: 전체 결제 조회 시 UNION 필요
전략 3: 조인 전략 (정규화 유지)
Payment (id, amount, type) → 공통
CardPayment (payment_id, card_number, installment) → 개별
장점: 정규화 유지, 확장 용이
단점: JOIN 필요2. 중복 컬럼 추가
조인을 줄이기 위해 자주 참조하는 컬럼을 다른 테이블에 복사합니다.
정규화
orders (id, user_id, ...)
→ users.name은 조인 필요
반정규화
orders (id, user_id, user_name, ...)
└→ user_name은 users.name의 복사본
└→ 조인 없이 주문 목록에서 이름 조회 가능이 경우 users의 이름이 변경되면 orders의 user_name도 갱신해야 합니다 — 갱신 이상의 위험을 감수하는 것입니다.
방법 1: 트리거 (동기 갱신)
CREATE TRIGGER trg_user_name_sync
AFTER UPDATE OF name ON users
FOR EACH ROW
BEGIN
UPDATE orders SET user_name = :NEW.name
WHERE user_id = :NEW.id;
END;
장점: 즉시 반영, 정합성 보장
단점: 트리거 부하, 대량 UPDATE 위험
방법 2: 애플리케이션 코드에서 동기화
// 사용자 이름 변경 시
userRepo.updateName(userId, newName);
orderRepo.updateUserName(userId, newName);
장점: 명시적 제어
단점: 코드 누락 위험, 트랜잭션 관리 필요
방법 3: 배치 동기화 (비동기)
-- 매일 새벽 전체 동기화
UPDATE orders o SET user_name = (
SELECT u.name FROM users u WHERE u.id = o.user_id
) WHERE EXISTS (
SELECT 1 FROM users u
WHERE u.id = o.user_id AND u.name <> o.user_name
);
장점: 단순, 부하 집중 관리
단점: 동기화 지연3. 요약 테이블 (Summary Table)
집계 결과를 미리 계산하여 별도 테이블에 저장합니다. OLAP 성격의 쿼리에 매우 효과적입니다.
-- 1. 요약 테이블 생성
CREATE TABLE daily_sales_summary (
sale_date DATE PRIMARY KEY,
total_orders NUMBER,
total_amount NUMBER(15, 2),
avg_amount NUMBER(10, 2),
max_amount NUMBER(12, 2),
new_users NUMBER,
updated_at TIMESTAMP DEFAULT SYSTIMESTAMP
);
-- 2. MERGE로 일별 갱신 (Upsert)
MERGE INTO daily_sales_summary s
USING (
SELECT TRUNC(order_date) sale_date,
COUNT(*) total_orders,
SUM(total) total_amount,
AVG(total) avg_amount,
MAX(total) max_amount
FROM orders
WHERE TRUNC(order_date) = TRUNC(SYSDATE - 1)
GROUP BY TRUNC(order_date)
) src ON (s.sale_date = src.sale_date)
WHEN MATCHED THEN
UPDATE SET total_orders = src.total_orders,
total_amount = src.total_amount,
avg_amount = src.avg_amount,
max_amount = src.max_amount,
updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT VALUES (src.sale_date, src.total_orders,
src.total_amount, src.avg_amount,
src.max_amount, 0, SYSTIMESTAMP);┌──────────────────┬──────────────────────┬──────────────────┐
│ 유형 │ 예시 │ 갱신 주기 │
├──────────────────┼──────────────────────┼──────────────────┤
│ 일별 요약 │ daily_sales_summary │ 매일 새벽 배치 │
│ 월별 요약 │ monthly_report │ 매월 1일 │
│ 카테고리별 집계 │ category_stats │ 실시간 또는 1시간│
│ 사용자별 통계 │ user_activity_stats │ 실시간 또는 일별 │
│ 실시간 카운터 │ product_view_count │ 즉시 (트리거) │
│ 순위 테이블 │ product_ranking │ 1시간 또는 일별 │
└──────────────────┴──────────────────────┴──────────────────┘
갱신 전략
* 전체 재계산: 단순하지만 데이터 많으면 느림
* 증분 갱신: 변경분만 반영 (MERGE/Upsert)
* 실시간 갱신: 트리거 또는 애플리케이션에서 즉시 반영
* CDC 기반: 변경 데이터 캡처로 비동기 갱신4. 파생 컬럼 (Derived Column)
계산 결과를 컬럼으로 저장합니다. 매번 계산하는 비용을 줄입니다.
-- 정규화: total을 매번 서브쿼리로 계산
SELECT o.id,
(SELECT SUM(oi.price * oi.quantity)
FROM order_items oi WHERE oi.order_id = o.id) AS total
FROM orders o;
-- 반정규화: total을 orders에 컬럼으로 저장
ALTER TABLE orders ADD total NUMBER(12, 2);
-- 주문 생성/수정 시 total 계산하여 저장
UPDATE orders SET total = (
SELECT SUM(price * quantity)
FROM order_items WHERE order_id = orders.id
) WHERE id = :order_id;1. 주문 합계
orders.total_amount = SUM(order_items.price * qty)
2. 게시글 댓글 수
posts.comment_count = COUNT(comments)
→ 댓글 INSERT/DELETE 시 +1/-1 갱신
3. 사용자 주문 횟수
users.order_count = COUNT(orders)
→ 등급 판단에 활용
4. 상품 평균 별점
products.avg_rating = AVG(reviews.rating)
→ 리뷰 등록/삭제 시 재계산
5. 나이 (생년월일에서 파생)
users.age = EXTRACT(YEAR FROM SYSDATE)
- EXTRACT(YEAR FROM birth_date)
주의: 매년 갱신 필요 → Virtual Column이 더 적합5. 테이블 분할 (반정규화 관점)
정규화의 역방향이 아닌, 성능을 위한 테이블 분리입니다.
원래 테이블
users (id, name, email, password_hash,
bio, avatar_url, settings_json, login_history)
→ 모든 컬럼이 한 행에 → row 크기가 큼
→ 목록 조회에서 불필요한 BLOB/CLOB도 함께 로드
수직 분할
users (id, name, email, password_hash)
user_profiles (user_id, bio, avatar_url)
user_settings (user_id, settings_json)
장점
* 자주 조회되는 컬럼만 캐시에 유지
* I/O 감소 (필요한 컬럼만 읽기)
* LOB 컬럼 분리로 성능 향상원래 테이블
orders (id, user_id, order_date, total, status)
→ 5년치 주문 1억 건 → 전체 스캔 시 매우 느림
수평 분할 (파티셔닝)
orders_2020 (... WHERE order_date BETWEEN ...)
orders_2021 (...)
orders_2022 (...)
orders_2023 (...)
orders_2024 (...)
또는 DBMS 파티셔닝 기능
PARTITION BY RANGE (order_date) (
PARTITION p2022 VALUES LESS THAN (DATE '2023-01-01'),
PARTITION p2023 VALUES LESS THAN (DATE '2024-01-01'),
PARTITION p2024 VALUES LESS THAN (DATE '2025-01-01')
)
장점
* Partition Pruning → 특정 연도만 스캔
* 파티션 단위 백업/삭제 가능
* 병렬 쿼리 가능Materialized View (구체화 뷰)
반정규화의 대안으로, DBMS가 요약 데이터를 자동 관리하는 기능입니다. 수동으로 요약 테이블을 관리하는 것보다 안전합니다.
-- 일별 매출 집계를 MV로 생성
CREATE MATERIALIZED VIEW mv_daily_sales
BUILD IMMEDIATE
REFRESH FAST ON COMMIT
ENABLE QUERY REWRITE
AS
SELECT TRUNC(order_date) AS sale_date,
COUNT(*) AS total_orders,
SUM(total) AS total_amount,
AVG(total) AS avg_amount
FROM orders
GROUP BY TRUNC(order_date);┌──────────────┬──────────────────────┬────────────────────────┐
│ 리프레시 방식│ 설명 │ 적합한 경우 │
├──────────────┼──────────────────────┼────────────────────────┤
│ COMPLETE │ 전체 재생성 │데이터 적음, 복잡한 쿼리│
│ FAST │ 변경분만 반영 │ MV 로그 필요, 빠름 │
│ FORCE │ FAST 시도 → 실패 시 │ 일반적 권장 │
│ │ COMPLETE │ │
├──────────────┼──────────────────────┼────────────────────────┤
│ ON COMMIT │ 원본 커밋 시 자동 │ 실시간 정합성 필요 │
│ ON DEMAND │ 수동/스케줄 리프레시 │ 배치 작업에 적합 │
└──────────────┴──────────────────────┴────────────────────────┘
FAST 리프레시를 위한 MV 로그:
CREATE MATERIALIZED VIEW LOG ON orders
WITH ROWID, SEQUENCE (order_date, total)
INCLUDING NEW VALUES;┌──────────────────┬──────────────────┬──────────────────┐
│ │ Materialized View│ 요약 테이블 │
├──────────────────┼──────────────────┼──────────────────┤
│ 갱신 관리 │ DBMS 자동 │수동 (배치/트리거)│
│ 쿼리 투명성 │ Query Rewrite │ 직접 참조 필요 │
│ 정합성 │리프레시 방식 의존│ 구현에 의존 │
│ 유연성 │ SQL로 정의 │ 자유로움 │
│ 디버깅 │ DBMS에 위임 │ 직접 확인 │
│ 멀티 DBMS │ Oracle/PostgreSQL│ 모든 DBMS │
│ MySQL 지원 │ 미지원 │ 요약 테이블 사용 │
└──────────────────┴──────────────────┴──────────────────┘캐시와 반정규화
애플리케이션 레벨 캐시도 반정규화의 일종으로 볼 수 있습니다. DB 반정규화 전에 캐시를 먼저 검토해야 합니다.
┌──────────────────────────────────────────────────────┐
│ 성능 개선 우선순위 │
├──────────────────────────────────────────────────────┤
│ │
│ 1순위: SQL 최적화 (인덱스, 실행 계획 개선) │
│ → 가장 기본적, 부작용 없음 │
│ │
│ 2순위: 애플리케이션 캐시 (Redis, Memcached) │
│ → DB 부하 감소, 정합성은 TTL로 관리 │
│ │
│ 3순위: Materialized View │
│ → DBMS가 관리, 투명한 쿼리 최적화 │
│ │
│ 4순위: DB 반정규화 (중복 컬럼, 요약 테이블) │
│ → 마지막 수단, 갱신 이상 주의 │
│ │
└──────────────────────────────────────────────────────┘시나리오: 상품 목록에 카테고리명 표시
방법 A: DB 반정규화
products 테이블에 category_name 컬럼 추가
→ 카테고리명 변경 시 모든 상품 UPDATE 필요
방법 B: Redis 캐시
// 카테고리 정보를 Redis에 캐시
redis.set('category:1', { name: '전자제품' });
redis.set('category:2', { name: '의류' });
// 상품 조회 시 캐시에서 카테고리명 조합
const product = await db.query('SELECT * FROM products');
const category = await redis.get(`category:${product.category_id}`);
장점: DB 정규화 유지, 캐시 무효화가 UPDATE보다 간단
한계: 캐시 서버 장애 시 대비 필요반정규화와 NoSQL
NoSQL 데이터베이스는 설계 철학 자체가 반정규화에 기반합니다.
RDB (정규화)
users { id, name }
orders { id, user_id, date }
order_items { id, order_id, product_id, qty }
products { id, name, price }
→ 4개 테이블, JOIN으로 조합
MongoDB (반정규화 = 임베딩)
orders {
_id: ObjectId,
user: { name: "홍길동" }, ← 중복 저장
date: ISODate,
items: [
{ product: "노트북", ← 중복 저장
price: 1500000, qty: 1 },
{ product: "마우스",
price: 35000, qty: 2 }
],
total: 1570000 ← 파생 컬럼
}
→ 단일 문서로 모든 정보 포함
→ JOIN 불필요, 읽기 매우 빠름
→ 상품 이름/가격 변경 시 주문에는 주문 시점 값 유지임베딩 (반정규화) 적합
* 함께 조회되는 데이터
* 변경 빈도 낮은 데이터
* 1:Few 관계 (예: 주소, 전화번호)
* 문서 크기 16MB 이내 (MongoDB 제한)
참조 (정규화) 적합
* 독립적으로 자주 조회/수정되는 데이터
* 1:Many (매우 많은) 관계
* Many:Many 관계
* 데이터 크기가 문서 제한 초과 가능정규화 vs 반정규화 판단 기준
| 기준 | 정규화 유리 | 반정규화 유리 |
|---|---|---|
| 데이터 변경 빈도 | 잦은 갱신 | 거의 변경 없음 |
| 조회 패턴 | 다양한 조건 조회 | 고정된 패턴 읽기 |
| 데이터 무결성 | 최우선 | 약간의 불일치 허용 |
| 응답 시간 | 여유 있음 | 매우 짧아야 함 |
| 시스템 유형 | OLTP | OLAP, 대시보드 |
| 데이터 크기 | 적음~보통 | 대용량 |
| 복잡도 관리 | 단순한 코드 | 동기화 코드 추가 감수 |
OLTP vs OLAP 관점 비교
정규화/반정규화 결정에서 시스템 유형은 가장 큰 영향을 미칩니다.
┌──────────────────┬──────────────────┬──────────────────┐
│ │ OLTP │ OLAP (DW) │
├──────────────────┼──────────────────┼──────────────────┤
│ 목적 │ 트랜잭션 처리 │ 분석/리포트 │
│ 정규화 수준 │ 3NF (정규화) │ 스타 스키마 │
│ │ │ (반정규화) │
│ 조인 빈도 │ 보통 │ Dimension JOIN │
│ 읽기/쓰기 │ 50:50 ~ 80:20 │ 99:1 │
│ 쿼리 패턴 │ 단일 행 CRUD │ 대량 집계/스캔 │
│ 무결성 │ 최우선 │ 일부 허용 │
│ 인덱스 │ 선별적 │ 비트맵/컬럼나 │
│ 실시간성 │ 실시간 │ 배치/준실시간 │
└──────────────────┴──────────────────┴──────────────────┘
스타 스키마 (OLAP 반정규화 설계)
dim_product
│
dim_date ── fact_sales ── dim_customer
│
dim_store
* Fact 테이블: 측정값 (매출, 수량 등)
* Dimension 테이블: 분석 축 (시간, 상품, 고객 등)
* Dimension은 반정규화 (조인 최소화)반정규화 부작용과 대응
반정규화는 성능을 얻는 대신 부작용을 수반합니다. 부작용을 인지하고 관리해야 합니다.
1. 갱신 이상 (Update Anomaly)
→ 중복 데이터 불일치 위험
→ 대응: 트리거, 배치 동기화, CDC
2. 삽입 이상 (Insert Anomaly)
→ 불필요한 데이터도 함께 입력해야 함
→ 대응: NULL 허용 또는 기본값 설정
3. 삭제 이상 (Delete Anomaly)
→ 연관 데이터 의도치 않은 삭제
→ 대응: 소프트 삭제, 히스토리 테이블
4. 저장 공간 증가
→ 중복 데이터만큼 공간 사용
→ 대응: 비용-효과 분석 (디스크 vs 성능)
5. 애플리케이션 복잡도
→ 동기화 로직 추가 필요
→ 대응: 문서화, 테스트 코드 작성반정규화 종합 정리
┌─────────────────────────────────────────────────────────┐
│ 반정규화의 5가지 원칙 │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. 정규화가 먼저다 │
│ → 3NF까지 정규화 → 성능 문제 확인 → 반정규화 검토 │
│ │
│ 2. 측정 없이 반정규화하지 않는다 │
│ → 실행 계획과 성능 지표로 병목 확인 후 적용 │
│ │
│ 3. 최소 범위로 적용한다 │
│ → 문제가 되는 쿼리/테이블만 대상으로 │
│ → 전체 스키마를 반정규화하는 것은 위험 │
│ │
│ 4. 정합성 전략을 반드시 수립한다 │
│ → 중복 데이터의 동기화 방법 명확히 정의 │
│ → 동기화 실패 시 대응 방안 포함 │
│ │
│ 5. 문서화한다 │
│ → 왜 반정규화했는지, 어떤 트레이드오프인지 기록 │
│ → 향후 유지보수/리팩토링의 근거 자료 │
│ │
└─────────────────────────────────────────────────────────┘| 기법 | 효과 | 부작용 | 정합성 관리 |
|---|---|---|---|
| 테이블 병합 | JOIN 제거 | NULL 증가 | 낮음 |
| 중복 컬럼 | 조인 불필요 | 갱신 이상 | 트리거/배치 |
| 요약 테이블 | 집계 즉시 | 공간, 갱신 비용 | 배치/MV |
| 파생 컬럼 | 계산 비용 제거 | 갱신 이상 | 트리거/코드 |
| 수직 분할 | I/O 감소 | 조인 필요 (역행) | 없음 |
| 수평 분할 | 스캔 범위 축소 | 쿼리 복잡 | 파티션 관리 |
정규화와 반정규화는 양자택일이 아닙니다. 기본적으로 3NF까지 정규화하고, 성능 병목이 측정으로 확인된 곳에만 반정규화를 선별적으로 적용하는 것이 바람직합니다.
다음 장에서는 쿼리 성능의 핵심인 인덱스를 다루겠습니다.