icon

안동민 개발노트

8장 : 정규화

반정규화


정규화가 데이터 무결성을 위한 분해라면, 반정규화(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 관계나 잘 변하지 않는 관계의 테이블을 하나로 합칩니다.

1:1 관계 테이블 병합
정규화
  users (id, name, email)
  user_profiles (user_id, bio, avatar_url, website)
  → 사용자 정보 조회 시 항상 JOIN 필요

반정규화
  users (
    id, name, email,
    bio, avatar_url, website  ← 프로필 컬럼 병합
  )
  → JOIN 제거, 단일 테이블 조회
1:N 관계 테이블 병합 (주의 필요)
정규화
  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 성격의 쿼리에 매우 효과적입니다.

요약 테이블 생성과 갱신 (Oracle)
-- 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. 테이블 분할 (반정규화 관점)

정규화의 역방향이 아닌, 성능을 위한 테이블 분리입니다.

수직 분할 (Vertical Partitioning)
원래 테이블
  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 컬럼 분리로 성능 향상
수평 분할 (Horizontal Partitioning)
원래 테이블
  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가 요약 데이터를 자동 관리하는 기능입니다. 수동으로 요약 테이블을 관리하는 것보다 안전합니다.

Oracle Materialized View
-- 일별 매출 집계를 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);
Materialized View 리프레시 방식
┌──────────────┬──────────────────────┬────────────────────────┐
│ 리프레시 방식│         설명         │ 적합한 경우            │
├──────────────┼──────────────────────┼────────────────────────┤
│ 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;
MV vs 요약 테이블 비교
┌──────────────────┬──────────────────┬──────────────────┐
│                  │ Materialized View│ 요약 테이블      │
├──────────────────┼──────────────────┼──────────────────┤
│ 갱신 관리        │ DBMS 자동        │수동 (배치/트리거)│
│ 쿼리 투명성      │ Query Rewrite    │ 직접 참조 필요   │
│      정합성      │리프레시 방식 의존│ 구현에 의존      │
│      유연성      │ SQL로 정의       │     자유로움     │
│      디버깅      │ DBMS에 위임      │ 직접 확인        │
│ 멀티 DBMS        │ Oracle/PostgreSQL│ 모든 DBMS        │
│ MySQL 지원       │      미지원      │ 요약 테이블 사용 │
└──────────────────┴──────────────────┴──────────────────┘

캐시와 반정규화

애플리케이션 레벨 캐시도 반정규화의 일종으로 볼 수 있습니다. DB 반정규화 전에 캐시를 먼저 검토해야 합니다.

캐시 vs DB 반정규화
┌──────────────────────────────────────────────────────┐
│               성능 개선 우선순위                     │
├──────────────────────────────────────────────────────┤
│                                                      │
│  1순위: SQL 최적화 (인덱스, 실행 계획 개선)          │
│         → 가장 기본적, 부작용 없음                   │
│                                                      │
│  2순위: 애플리케이션 캐시 (Redis, Memcached)         │
│         → DB 부하 감소, 정합성은 TTL로 관리          │
│                                                      │
│  3순위: Materialized View                            │
│         → DBMS가 관리, 투명한 쿼리 최적화            │
│                                                      │
│  4순위: DB 반정규화 (중복 컬럼, 요약 테이블)         │
│         → 마지막 수단, 갱신 이상 주의                │
│                                                      │
└──────────────────────────────────────────────────────┘
Redis 캐시로 반정규화 대체
시나리오: 상품 목록에 카테고리명 표시

방법 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 정규화 vs 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 불필요, 읽기 매우 빠름
  → 상품 이름/가격 변경 시 주문에는 주문 시점 값 유지
NoSQL 임베딩 vs 참조 판단
임베딩 (반정규화) 적합
  * 함께 조회되는 데이터
  * 변경 빈도 낮은 데이터
  * 1:Few 관계 (예: 주소, 전화번호)
  * 문서 크기 16MB 이내 (MongoDB 제한)

참조 (정규화) 적합
  * 독립적으로 자주 조회/수정되는 데이터
  * 1:Many (매우 많은) 관계
  * Many:Many 관계
  * 데이터 크기가 문서 제한 초과 가능

정규화 vs 반정규화 판단 기준

기준정규화 유리반정규화 유리
데이터 변경 빈도잦은 갱신거의 변경 없음
조회 패턴다양한 조건 조회고정된 패턴 읽기
데이터 무결성최우선약간의 불일치 허용
응답 시간여유 있음매우 짧아야 함
시스템 유형OLTPOLAP, 대시보드
데이터 크기적음~보통대용량
복잡도 관리단순한 코드동기화 코드 추가 감수

OLTP vs 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까지 정규화하고, 성능 병목이 측정으로 확인된 곳에만 반정규화를 선별적으로 적용하는 것이 바람직합니다.

다음 장에서는 쿼리 성능의 핵심인 인덱스를 다루겠습니다.

목차