안동민 개발노트 아이콘

안동민 개발노트

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 이내 응답
히스토리 데이터 대량 조회분석 리포트, 통계
실시간 랭킹/카운트 필요인기 상품, 좋아요 수

위 신호는 반정규화 후보를 찾는 휴리스틱일 뿐입니다. 실제 적용 전에는 실행 계획, p95/p99 응답 시간, 쓰기 증가 비용, 허용 가능한 데이터 지연 시간, 장애 시 재동기화 방법을 함께 검토해야 합니다. 인덱스나 쿼리 재작성으로 충분히 해결되는 문제라면 스키마 중복을 만들지 않는 편이 안전합니다.


반정규화 의사결정 프로세스

반정규화는 무조건 적용하면 안 됩니다. 체계적인 의사결정 과정을 거쳐야 합니다. 특히 적용 전에는 원본(source of truth), 중복 데이터의 신선도 SLA, 백필(backfill)과 롤백 절차, 불일치 탐지 지표를 함께 정해야 합니다.


반정규화 기법

1. 테이블 병합

1:1 관계나 잘 변하지 않는 관계의 테이블을 하나로 합칩니다. 1:N 관계 병합은 N이 작고 상한이 명확할 때만 검토해야 하며, 반복 그룹을 컬럼으로 늘리는 방식은 1NF 위반으로 이어질 수 있습니다.


테이블 병합 유형 정리

유형설명적합한 경우
1:1 병합두 테이블을 하나로항상 함께 조회
슈퍼/서브타입 병합상속 관계 통합서브타입이 적고 컬럼 유사
1:N 병합부모에 자식 정보 포함N이 작고 고정적

2. 중복 컬럼 추가

조인을 줄이기 위해 자주 참조하는 컬럼을 다른 테이블에 복사합니다.

중복 컬럼은 먼저 목적을 구분해야 합니다. orders.user_name이 현재 사용자 이름을 빠르게 보여주기 위한 미러 값이면 users.name 변경 시 동기화해야 합니다. 반대로 주문 당시 이름을 보존하기 위한 스냅샷 값이면 사용자가 이름을 바꿔도 갱신하면 안 됩니다.

미러 값으로 설계한 경우에는 갱신 이상의 위험을 관리해야 합니다. 원본(source of truth)이 어디인지 정하고, 같은 트랜잭션 안에서 동기화할지 비동기 보정으로 충분한지 결정해야 합니다.

중복 컬럼의 정합성 유지 방법
방법 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),
    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 order_date >= TRUNC(SYSDATE) - 1
      AND order_date <  TRUNC(SYSDATE)
    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 (sale_date, total_orders, total_amount,
            avg_amount, max_amount, updated_at)
    VALUES (src.sale_date, src.total_orders, src.total_amount,
            src.avg_amount, src.max_amount, SYSTIMESTAMP);

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;

5. 테이블 분할 (반정규화 관점)

정규화의 역방향이 아닌, 성능을 위한 테이블 분리입니다. 수직 분할, 수평 분할, 파티셔닝, 샤딩은 모두 목적과 제약이 다르므로 “반정규화”라는 이름보다 성능 중심 물리 설계로 구분해서 보는 편이 안전합니다.


Materialized View (구체화 뷰)

반정규화의 대안으로, DBMS가 요약 데이터를 관리하는 기능입니다. 수동으로 요약 테이블을 관리하는 것보다 안전할 수 있지만, 지원 기능과 갱신 방식은 DBMS마다 다릅니다. 예를 들어 Oracle의 FAST ON COMMIT, QUERY REWRITE는 materialized view log, 제약 조건, 옵티마이저 설정 같은 전제 조건을 함께 확인해야 합니다. PostgreSQL처럼 기본 materialized view가 자동 증분 갱신을 제공하지 않는 DBMS도 있으므로, 선택한 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);

캐시와 반정규화

애플리케이션 레벨 캐시도 반정규화와 비슷한 효과를 냅니다. DB 스키마에 중복을 넣기 전에 캐시, 인덱스, 쿼리 튜닝, 읽기 전용 복제본을 먼저 검토해야 합니다. 캐시를 쓰는 경우에는 만료, 무효화, 재생성 실패 시 동작을 반드시 정해야 합니다.


반정규화와 NoSQL

많은 문서형 NoSQL 데이터베이스는 함께 읽는 데이터를 한 문서에 모으는 설계를 선호합니다. 하지만 모든 NoSQL이 무조건 반정규화만 하는 것은 아니며, 데이터 크기 제한과 변경 빈도, 참조 패턴을 함께 봐야 합니다.

NoSQL 임베딩 vs 참조 판단
임베딩 (반정규화) 적합
  * 함께 조회되는 데이터
  * 변경 빈도 낮은 데이터
  * 1:Few 관계 (예: 주소, 전화번호)
  * 사용하는 DBMS의 문서 크기 제한 이내

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

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

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

OLTP vs OLAP 관점 비교

정규화/반정규화 결정에서 시스템 유형은 가장 큰 영향을 미칩니다.


반정규화 부작용과 대응

반정규화는 성능을 얻는 대신 부작용을 수반합니다. 부작용을 인지하고 관리해야 합니다.


반정규화 종합 정리

기법효과부작용관리 포인트
테이블 병합JOIN 제거NULL 증가제약 조건 재정의
중복 컬럼조인 불필요갱신 이상미러/스냅샷 구분
요약 테이블집계 즉시공간, 갱신 비용배치/MV/재계산
파생 컬럼계산 비용 제거갱신 이상트리거/코드/동시성
수직 분할I/O 감소조인 필요1:1 FK와 hot/cold 구분
수평 분할스캔 범위 축소쿼리 복잡파티션 키와 프루닝

정규화와 반정규화는 양자택일이 아닙니다. 기본적으로 3NF까지 정규화하고, 성능 병목이 측정으로 확인된 곳에만 반정규화를 선별적으로 적용하는 것이 바람직합니다. 반정규화를 적용했다면 원본, 중복 데이터, 동기화 시점, 허용 지연, 보정 절차, 롤백 방법을 문서화해야 합니다.

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