반정규화 적용 기준: 조회 성능 개선과 중복 데이터 동기화


1장. 조회를 빠르게 만들었는데 주문 금액이 틀렸다#

쇼핑몰 주문 상세 화면이 느립니다.

주문 하나를 표시할 때마다 주문항목을 읽고 다음 계산을 수행합니다.

상품 A 2개 × 10,000원 = 20,000원
상품 B 1개 × 5,000원  = 5,000원

주문 합계 = 25,000원

개발팀은 생각합니다.

매번 합계를 계산하지 말고 주문 테이블에 총액을 저장하면 훨씬 빠르지 않을까?

그래서 주문 테이블에 다음 열을 추가합니다.

total_amount = 25,000

이제 화면에서는 주문항목을 모두 더하지 않고 주문 테이블의 총액 하나만 읽으면 됩니다.

조회는 빨라졌습니다.

며칠 뒤 고객이 상품 B를 취소했습니다.

주문항목에서는 B가 삭제되었습니다.

남은 상품은 A뿐입니다.

실제 합계는 다음과 같습니다.

20,000원

그런데 주문 테이블에는 여전히 이렇게 저장되어 있습니다.

total_amount = 25,000

성능 문제는 해결했지만 새로운 문제가 생겼습니다.

같은 주문 금액에 두 개의 답이 존재하게 된 것입니다.

반정규화는 바로 이런 문제와 함께 이해해야 합니다.


2장. 반정규화는 정규화를 무시하는 설계가 아니다#

정규화에서는 하나의 사실을 가능한 한 한 곳에서 관리하려고 합니다.

예를 들어 다음 구조가 있다고 하겠습니다.

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
        string customer_id FK
        date order_date
    }

    PRODUCT {
        string product_id PK
        string product_name
        decimal current_price
    }

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

고객 이름은 CUSTOMER에 있습니다.

현재 상품 이름은 PRODUCT에 있습니다.

주문 당시 판매가격과 수량은 ORDER_ITEM에 있습니다.

논리적으로 매우 깔끔합니다.

하지만 서비스가 커지면 특정 조회에서 여러 테이블을 반복적으로 연결하거나 같은 계산을 계속 수행해야 할 수 있습니다.

이때 읽기 비용을 줄이기 위해 일부 값을 미리 저장하거나 중복해서 보관하는 선택을 할 수 있습니다.

이것이 반정규화입니다.

핵심은 이것입니다.

반정규화는 데이터를 아무렇게나 중복시키는 것이 아니라, 측정된 성능 문제를 해결하기 위해 중복과 갱신 책임을 의도적으로 받아들이는 설계다.


3장. 조인이 있다는 이유만으로 반정규화하면 안 된다#

다음 SQL을 보겠습니다.

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

조인이 세 번 있습니다.

그래서 다음과 같이 결론 내릴 수 있을까요?

조인이 세 개니까 느리다. 반정규화하자.

그렇지 않습니다.

실제 병목은 다른 곳에 있을 수 있습니다.

예를 들어:

  • 적절한 인덱스가 없을 수 있습니다.
  • 한 고객의 주문을 지나치게 많이 조회할 수 있습니다.
  • 불필요한 열을 대량으로 가져올 수 있습니다.
  • 통계가 부정확할 수 있습니다.
  • 잘못된 실행 계획을 선택할 수 있습니다.
  • 네트워크 응답 크기가 너무 클 수 있습니다.
  • 캐시 적중률이 낮을 수 있습니다.

따라서 반정규화 전에 먼저 실제로 어디에서 시간이 소모되는지 측정해야 합니다.


4장. 반정규화 전에 가장 먼저 측정해야 할 것#

단순히 평균 응답 시간만 보면 부족합니다.

다음 항목을 함께 확인하는 것이 좋습니다.

측정 항목 확인하는 이유
95백분위 응답 시간 느린 요청이 얼마나 존재하는지 확인
초당 호출 수 해당 조회가 실제로 얼마나 자주 실행되는지 확인
실제 반환 행 수 예상보다 많은 데이터가 처리되는지 확인
실행 계획 전체 스캔이나 비효율적 조인 여부 확인
읽은 페이지 수 실제 저장소 접근량 확인
고객명·상품명 변경 빈도 중복 값의 갱신 비용 계산
쓰기 요청량 반정규화 후 추가될 쓰기 부담 평가
허용 가능한 지연 시간 동기 또는 비동기 방식 결정

반정규화는 읽기 성능 하나만 보고 결정하면 안 됩니다.

읽기 비용을 줄이는 대신 쓰기·검증·복구 비용이 늘어나기 때문입니다.


5장. 반정규화는 비용을 없애는 것이 아니라 옮기는 것이다#

정규화된 구조에서 주문 총액은 조회 시 계산한다고 하겠습니다.

조회 시 계산

반정규화를 적용해 주문 테이블에 합계를 저장하면 계산 시점이 바뀝니다.

주문 변경 시 계산

즉 계산 비용이 사라진 것이 아닙니다.

읽기 시점에서 쓰기 시점으로 이동한 것입니다.

그리고 새로운 비용도 생깁니다.

합계를 언제 다시 계산할 것인가

일부 변경이 실패하면 어떻게 복구할 것인가

동시에 두 요청이 수정하면 어떻게 할 것인가

합계가 틀렸는지 어떻게 발견할 것인가

반정규화의 본질은 이 교환관계를 받아들이는 것입니다.


6장. 대표적인 반정규화 방식#

반정규화라고 부르는 설계에는 여러 형태가 있습니다.

대표적으로 다음과 같습니다.

방식 내용
중복 컬럼 다른 테이블의 값을 복사해 저장
파생 컬럼 계산 가능한 값을 미리 저장
요약 테이블 집계 결과를 별도로 저장
테이블 통합 자주 같이 읽는 데이터를 한 구조에 통합
중복 관계 여러 단계를 거칠 관계를 직접 연결
현재·이력 분리 자주 보는 현재 데이터와 큰 이력을 분리

수직 분할이나 수평 분할도 성능 설계와 함께 언급되지만, 중복을 만드는 반정규화와는 성격이 다릅니다.

중요한 것은 용어보다 어떤 읽기 비용을 줄이려고 어떤 새로운 책임을 추가하는지입니다.


7장. 고객 이름을 주문에 복사하면 정말 좋은가#

현재 구조를 보겠습니다.

erDiagram
    CUSTOMER ||--o{ ORDER : places

    CUSTOMER {
        string customer_id PK
        string customer_name
    }

    ORDER {
        string order_id PK
        string customer_id FK
        date order_date
    }

주문 목록을 표시할 때마다 CUSTOMER와 ORDER를 조인한다고 하겠습니다.

이를 줄이기 위해 ORDER에 고객명을 복사할 수 있습니다.

erDiagram
    CUSTOMER ||--o{ ORDER : places

    CUSTOMER {
        string customer_id PK
        string customer_name
    }

    ORDER {
        string order_id PK
        string customer_id FK
        string customer_name_copy
        date order_date
    }

조회는 간단해질 수 있습니다.

하지만 이제 중요한 질문이 생깁니다.

CUSTOMER의 고객명이 바뀌면 ORDER의 고객명도 바꿔야 하는가?

이 질문의 답에 따라 같은 컬럼 복사라도 의미가 완전히 달라집니다.


8장. 주문 당시 이름과 현재 이름 복사는 전혀 다른 데이터다#

고객 M001의 이름이 다음처럼 바뀌었다고 하겠습니다.

김하늘
↓
김하늘봄

주문 O001은 이름 변경 전에 발생했습니다.

주문에 복사한 이름이 주문 당시 이름이라면 O001에는 계속 다음 값이 남아 있어야 합니다.

김하늘

현재 고객 이름이 바뀌었다고 수정하면 오히려 과거 기록이 훼손됩니다.

반대로 주문 테이블의 이름이 현재 고객 이름을 빠르게 조회하기 위한 캐시라면 다음처럼 바뀌어야 합니다.

김하늘
↓
김하늘봄

같은 문자열 복사라도 의미가 다릅니다.

종류 고객 이름 변경 시
주문 당시 이름 변경하지 않음
현재 이름 캐시 사본도 변경
고객 원본 이름 CUSTOMER에서 변경

반정규화에서 가장 먼저 정의해야 할 것은 복사된 값이 무엇을 의미하는가입니다.


9장. 스냅샷과 캐시는 구분해야 한다#

둘 다 원본 값을 복사해 저장한다는 점에서는 비슷합니다.

하지만 역할은 완전히 다릅니다.

스냅샷#

특정 시점의 사실을 보존합니다.

예:

주문 당시 배송주소
주문 당시 상품명
계약 당시 고객명
결제 당시 세율

현재 원본이 바뀌어도 과거 스냅샷은 유지합니다.

캐시#

현재 원본 값을 더 빠르게 읽기 위해 복사합니다.

예:

현재 고객명 캐시
현재 상품명 캐시
현재 카테고리명 캐시

원본이 바뀌면 사본도 갱신해야 합니다.

이 둘을 혼동하면 데이터 동기화를 정확히 구현해 놓고도 업무 기록을 잘못 바꿀 수 있습니다.


10장. 가장 흔한 파생 컬럼은 합계다#

주문항목 테이블이 다음과 같다고 하겠습니다.

상품 수량 판매단가
A 2 10,000
B 1 5,000

합계는 계산 가능합니다.

2 × 10,000
+
1 × 5,000
=
25,000

정규화된 구조에서는 조회 시 계산할 수 있습니다.

SELECT
    SUM(quantity * sale_price)
FROM order_item
WHERE order_id = 'O001';

하지만 주문 조회가 매우 많고 주문항목도 많다면 합계를 주문 테이블에 저장할 수 있습니다.

orders.total_amount

이것이 파생 컬럼을 이용한 반정규화입니다.


11장. 파생 컬럼을 저장하는 순간 불변식이 생긴다#

주문 합계를 저장한다면 다음 규칙이 항상 성립해야 합니다.

orders.total_amount
=
SUM(order_item.quantity × order_item.sale_price)

이런 반드시 유지되어야 하는 관계를 불변식으로 볼 수 있습니다.

상품을 추가하면 합계가 바뀌어야 합니다.

상품을 취소하면 합계가 바뀌어야 합니다.

수량을 변경해도 합계가 바뀌어야 합니다.

할인을 적용해도 바뀔 수 있습니다.

반품이나 환불 규칙이 있으면 더 복잡해집니다.

따라서 합계 한 열을 추가하는 순간 변경 경로 전체를 검토해야 합니다.


12장. 주문 합계를 따로 갱신하면 중간 상태가 생긴다#

다음 상태에서 시작합니다.

품목 합계 = 25,000
저장 합계 = 25,000

상품 B를 취소합니다.

첫 번째 트랜잭션에서 주문항목을 삭제합니다.

품목 합계 = 20,000
저장 합계 = 25,000

그다음 별도의 작업에서 총액을 수정합니다.

저장 합계 = 20,000

두 작업 사이에 사용자가 화면을 조회하면 잘못된 금액을 볼 수 있습니다.

더 심각하게는 두 번째 작업이 실패하면 틀린 금액이 영구적으로 남을 수 있습니다.


13장. 즉시 일관성이 필요하면 같은 트랜잭션에서 갱신할 수 있다#

PostgreSQL을 예로 들면 다음처럼 처리할 수 있습니다.

BEGIN;

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

UPDATE orders AS o
SET total_amount = (
    SELECT COALESCE(
        SUM(quantity * sale_price),
        0
    )
    FROM order_item AS i
    WHERE i.order_id = o.order_id
)
WHERE o.order_id = 'O001';

COMMIT;

주문항목 삭제와 합계 수정이 같은 트랜잭션에 들어 있습니다.

둘 중 하나가 실패하면 전체 작업을 되돌릴 수 있습니다.

하지만 이것만으로 모든 문제가 해결되는 것은 아닙니다.

동시에 두 사용자가 같은 주문을 변경한다면 동시성 문제도 고려해야 합니다.


14장. 동시에 두 사용자가 주문을 바꾸면 어떻게 될까#

주문 합계가 다음이라고 하겠습니다.

30,000원

사용자 A는 상품 하나를 취소합니다.

사용자 B는 거의 동시에 다른 상품의 수량을 수정합니다.

두 트랜잭션이 같은 이전 상태를 기준으로 합계를 계산하면 마지막으로 저장한 값이 다른 변경을 덮어쓸 수도 있습니다.

따라서 상황에 따라 다음 방법이 필요할 수 있습니다.

  • 주문 행 잠금
  • 적절한 트랜잭션 격리 수준
  • 버전 컬럼
  • 낙관적 잠금
  • 원본에서 합계 재계산

중요한 것은 합계 컬럼 하나를 추가하는 일이 동시성 설계까지 영향을 줄 수 있다는 사실입니다.


15장. 저장 합계는 반드시 검산할 방법이 있어야 한다#

반정규화된 값은 언제든 틀어질 가능성이 있습니다.

따라서 원본으로 다시 계산해 비교할 수 있어야 합니다.

예를 들어 다음 쿼리로 불일치 주문을 찾을 수 있습니다.

SELECT
    o.order_id,
    o.total_amount,
    COALESCE(SUM(i.quantity * i.sale_price), 0) AS calculated_amount
FROM orders AS o
LEFT JOIN order_item AS i
    ON i.order_id = o.order_id
GROUP BY
    o.order_id,
    o.total_amount
HAVING
    o.total_amount
    <>
    COALESCE(SUM(i.quantity * i.sale_price), 0);

결과가 나온다면 저장된 합계와 실제 주문항목 합계가 다르다는 뜻입니다.

반정규화에서는 빠른 조회 경로뿐 아니라 검증 경로도 설계해야 합니다.


16장. 요약 테이블은 대규모 집계에서 강력하다#

매출 대시보드에서 다음 값을 계속 조회한다고 하겠습니다.

2026년 10월 고객별 매출

매번 수천만 건의 주문과 결제를 집계하면 비용이 클 수 있습니다.

따라서 다음과 같은 요약 테이블을 만들 수 있습니다.

CUSTOMER_MONTHLY_SALES

예:

고객ID 기준월 주문건수 매출합계
M001 2026-10 12 830,000
M002 2026-10 7 420,000

ERD에서는 원본 데이터와 요약 구조를 다음처럼 볼 수 있습니다.

erDiagram
    CUSTOMER ||--o{ ORDER : places
    CUSTOMER ||--o{ CUSTOMER_MONTHLY_SALES : summarized

    CUSTOMER {
        string customer_id PK
    }

    ORDER {
        string order_id PK
        string customer_id FK
        decimal amount
        date order_date
    }

    CUSTOMER_MONTHLY_SALES {
        string customer_id PK, FK
        string sales_month PK
        int order_count
        decimal total_sales
    }

대시보드는 매번 주문 전체를 읽지 않고 요약 테이블을 조회할 수 있습니다.


17장. 요약 테이블에서 가장 먼저 정할 것은 계산 공식이다#

월매출이라는 단어 하나만으로는 계산 기준이 부족합니다.

다음 질문에 답해야 합니다.

주문 접수 시 매출인가?

결제 완료 시 매출인가?

배송 완료 시 매출인가?

취소 주문은 제외하는가?

부분환불은 어떻게 처리하는가?

10월 주문이 11월에 환불되면 어느 달에서 차감하는가?

계산 정의가 없다면 같은 월매출이라는 이름으로 서로 다른 숫자가 만들어질 수 있습니다.

반정규화된 요약값에서는 숫자보다 계산 규칙이 먼저입니다.


18장. 요약 테이블은 실시간일 수도 있고 늦을 수도 있다#

요약 갱신 방법은 여러 가지입니다.

동기 갱신#

주문 변경 트랜잭션에서 요약값도 바로 수정합니다.

장점:

조회 즉시 최신 데이터

단점:

쓰기 경로가 복잡하고 무거워짐

비동기 이벤트 갱신#

주문이 변경되면 이벤트를 발행하고 별도 소비자가 요약을 갱신합니다.

장점:

원본 쓰기와 집계 처리를 분리

단점:

일시적인 지연
중복 이벤트
순서 역전
재처리 문제

배치 갱신#

몇 분이나 몇 시간 단위로 다시 집계합니다.

장점:

구조가 비교적 단순

단점:

실시간성이 낮음

어느 방식이 맞는지는 사용자가 어느 정도의 지연을 허용할 수 있는지에 따라 달라집니다.


19장. 비동기 반정규화에서는 중복 이벤트를 반드시 생각해야 한다#

주문 O001에 5,000원이 추가됐다는 이벤트가 있다고 하겠습니다.

ORDER_AMOUNT_ADDED
+5000

메시지 재전송 때문에 같은 이벤트가 두 번 처리되면:

+5000
+5000

이 되어 실제보다 5,000원이 더해집니다.

따라서 이벤트 기반 집계에서는 동일 이벤트를 다시 받아도 결과가 바뀌지 않도록 설계하거나 이미 처리한 이벤트를 구별할 방법이 필요합니다.

예를 들어 다음과 같은 식별자를 사용할 수 있습니다.

event_id

처리 완료 이벤트를 기록해 중복 반영을 막을 수 있습니다.


20장. 중복 이벤트와 이벤트 순서 문제는 서로 다르다#

이번에는 주문 합계 버전을 생각해 보겠습니다.

버전 7:

합계 = 15,000

버전 8에서 상품이 취소되었습니다.

합계 = 10,000

네트워크 문제로 버전 8 이벤트가 먼저 도착했습니다.

현재 합계 = 10,000
버전 = 8

그 뒤 늦게 버전 7 이벤트가 도착합니다.

이를 그대로 적용하면:

현재 합계 = 15,000
버전 = 7

이 되어 취소한 금액이 되살아납니다.

이것은 중복 이벤트 문제가 아닙니다.

오래된 이벤트가 새로운 상태를 덮어쓰는 순서 문제입니다.


21장. 전체 상태 이벤트에는 버전 비교를 사용할 수 있다#

이벤트가 다음처럼 전체 합계를 포함한다고 하겠습니다.

order_id = O001
version = 8
total_amount = 10,000

저장된 버전보다 새로운 이벤트만 반영하는 방법을 사용할 수 있습니다.

수신 버전 > 현재 버전
→ 갱신

수신 버전 <= 현재 버전
→ 무시

이렇게 하면 오래된 버전 7이 버전 8을 덮는 문제를 줄일 수 있습니다.

하지만 모든 이벤트 방식에 그대로 적용할 수 있는 것은 아닙니다.


22장. 증분 이벤트는 오래됐다고 무조건 버릴 수 없다#

다음처럼 증분 이벤트가 있다고 하겠습니다.

+5000
-3000
+2000

각 이벤트가 독립적인 금액 변화라면 버전이 낮게 도착했다는 이유만으로 버리면 필요한 변경 자체가 사라질 수 있습니다.

따라서 이벤트가 무엇을 의미하는지를 먼저 구분해야 합니다.

전체 상태 이벤트

증분 변화 이벤트

전체 상태라면 최신 버전만 유지할 수 있습니다.

증분 이벤트라면 중복, 순서, 누락을 별도로 관리해야 합니다.


23장. 기준 시각이 없는 요약값은 정확해도 잘못된 정보가 될 수 있다#

야간 배치로 고객별 매출을 계산했다고 하겠습니다.

새벽 2시에 다음 값이 생성되었습니다.

M001
10월 누적매출
830,000원

오전 10시에 고객이 주문을 취소했습니다.

요약 테이블은 다음 배치 전까지 그대로일 수 있습니다.

숫자 자체는 새벽 2시 기준으로 정확합니다.

하지만 화면에 다음처럼 표시한다면 문제가 됩니다.

현재 매출: 830,000원

실제로는 현재값이 아닙니다.

다음과 같이 기준 시점을 함께 표현할 수 있습니다.

2026-10-01 02:00 기준

반정규화 데이터에서는 값과 함께 기준 시각도 데이터의 일부가 될 수 있습니다.


24장. 요약 테이블에는 재계산 경로가 반드시 있어야 한다#

이벤트 누락이나 프로그램 오류로 요약값이 틀어졌다고 하겠습니다.

요약값만 존재하고 원본에서 다시 만들 방법이 없다면 복구하기 어렵습니다.

이상적인 구조는 다음과 같습니다.

원본 거래 데이터
        ↓
요약 계산
        ↓
요약 테이블

필요하면 원본으로부터 특정 기간을 다시 계산할 수 있어야 합니다.

2026년 10월 전체 재집계

또는 특정 고객만 다시 계산할 수 있습니다.

M001 재집계

반정규화 값은 언제든 다시 만들 수 있는 사본으로 설계하는 것이 운영에 유리한 경우가 많습니다.


25장. 중복 컬럼은 읽기 속도 대신 쓰기 증폭을 만든다#

상품명을 주문항목에 현재값 캐시로 복사한다고 하겠습니다.

상품 P001이 5만 개 주문항목에 존재합니다.

상품명을 바꾸면:

원본 PRODUCT
1행 수정

으로 끝나지 않습니다.

복사된 값을 모두 최신으로 유지하려면 최대 5만 행을 수정해야 할 수 있습니다.

이를 쓰기 증폭의 한 형태로 볼 수 있습니다.

상품명이 거의 바뀌지 않는다고 해도 데이터 규모가 크면 한 번의 변경 비용은 클 수 있습니다.

따라서 다음 두 가지를 함께 봐야 합니다.

변경 빈도

한 번 변경할 때 영향을 받는 행 수

26장. “거의 안 바뀌는 값”도 대규모 시스템에서는 비쌀 수 있다#

상품 이름이 하루 20번만 바뀐다고 하겠습니다.

각 상품이 평균 5만 주문항목에 복사되어 있다면 단순 계산으로 다음 정도의 사본 갱신 대상이 생길 수 있습니다.

20 × 50,000
=
1,000,000행

하루 20번이라는 변경 빈도만 보면 작아 보입니다.

하지만 실제 쓰기량은 클 수 있습니다.

반정규화에서는 변경 횟수뿐 아니라 변경의 전파 범위까지 계산해야 합니다.


27장. 인덱스로 해결할 수 있다면 반정규화하지 않는 편이 나을 수 있다#

예를 들어 정규화된 조회가 95백분위 기준 420ms라고 가정하겠습니다.

목표는 250ms입니다.

상품명을 주문항목에 복사했더니 240ms가 됐습니다.

처음에는 반정규화가 좋아 보입니다.

그런데 필요한 복합 인덱스를 추가하고 조회 기간을 제한했더니 다음 결과가 나왔다고 하겠습니다.

230ms

반정규화 없이 목표를 달성했습니다.

이 경우 중복 컬럼과 동기화 로직까지 추가할 이유가 줄어듭니다.

따라서 보통 다음 순서를 고려할 수 있습니다.

측정
↓
쿼리 개선
↓
인덱스 검토
↓
읽는 데이터 범위 조정
↓
실행 계획 검토
↓
그래도 병목이 남으면 반정규화 검토

28장. 테이블 통합이 유리할 수 있는 경우#

두 테이블이 거의 항상 1:1로 연결되고 항상 같이 조회된다고 하겠습니다.

erDiagram
    USER ||--|| USER_PROFILE : has

USER:

user_id
login_id
status

USER_PROFILE:

user_id
nickname
profile_image

거의 모든 화면에서 두 데이터를 항상 같이 읽고, 각각 독립적인 생명주기도 없다면 통합을 검토할 수 있습니다.

하지만 프로필 데이터가 매우 크거나 선택적인 정보라면 분리하는 편이 오히려 유리할 수 있습니다.

따라서 테이블 통합도 단순히 조인 하나를 줄인다는 이유만으로 결정하면 안 됩니다.


29장. 현재 데이터와 이력을 분리하는 방법도 있다#

상품의 가격 변경 이력이 수억 건 쌓였다고 하겠습니다.

현재 상품 화면에서는 최신 가격만 필요합니다.

이때 다음처럼 구조를 분리할 수 있습니다.

erDiagram
    PRODUCT ||--o{ PRODUCT_PRICE_HISTORY : has

    PRODUCT {
        string product_id PK
        decimal current_price
    }

    PRODUCT_PRICE_HISTORY {
        string product_id FK
        datetime changed_at
        decimal old_price
        decimal new_price
    }

PRODUCT에는 현재 가격을 둡니다.

이력은 별도 테이블에 저장합니다.

현재 조회는 이력 전체를 읽지 않아도 됩니다.

다만 현재 가격이 이력으로부터 계산 가능한 값이라면 두 구조를 일치시키는 책임이 생깁니다.


30장. 중복 관계는 여러 단계의 연결을 줄일 수 있다#

조직 구조가 다음과 같다고 하겠습니다.

직원
→ 팀
→ 본부
→ 회사

특정 조회에서 매번 본부까지 여러 단계 조인을 수행한다고 하겠습니다.

직원 테이블에 본부ID를 직접 저장하면 조회 경로를 줄일 수 있습니다.

하지만 다음 두 경로가 항상 같은 결과를 내야 합니다.

직원 → 팀 → 본부

직원 → 본부

팀이 다른 본부로 이동할 때 직원의 본부ID도 함께 변경해야 할 수 있습니다.

즉 연결을 하나 추가할수록 두 경로가 동일한 사실을 가리키는지 검증할 책임도 생깁니다.


31장. 수직 분할은 반정규화와 같은 개념이 아니다#

게시글 테이블에 다음 정보가 있다고 하겠습니다.

게시글ID
제목
작성자
작성일
본문
대용량 첨부 메타데이터

목록 화면에서는 제목과 작성자만 읽습니다.

본문이 매우 크다면 자주 읽는 열과 큰 열을 분리하는 방법을 고려할 수 있습니다.

POST
- 게시글ID
- 제목
- 작성자
- 작성일
POST_CONTENT
- 게시글ID
- 본문

이것은 열을 나누는 수직 분할입니다.

반드시 데이터 중복을 만들어내는 것은 아닙니다.

따라서 반정규화와 분할을 동일한 개념으로 보면 안 됩니다.


32장. 수평 분할과 파티셔닝도 구분해서 봐야 한다#

주문 데이터가 수년치 쌓였다고 하겠습니다.

2023 주문
2024 주문
2025 주문
2026 주문

날짜 기준으로 물리적으로 나누어 관리할 수 있습니다.

DBMS의 파티션 기능을 사용하면 논리적으로는 하나의 테이블처럼 보이면서 내부적으로 여러 파티션에 저장할 수도 있습니다.

ORDERS
├─ 2025 파티션
└─ 2026 파티션

이것은 중복 데이터를 만드는 반정규화와는 다릅니다.

목적과 구현 방식을 구분해야 합니다.


33장. 반정규화 설계에는 원본이 무엇인지 반드시 적어야 한다#

다음 컬럼이 있다고 하겠습니다.

orders.total_amount

문서에는 최소한 다음 내용을 남기는 것이 좋습니다.

원본
→ order_item

계산식
→ SUM(quantity × sale_price)

갱신 시점
→ 주문항목 변경과 동일 트랜잭션

검증 방법
→ 원본 합계와 정기 비교

복구 방법
→ order_item에서 재계산

이렇게 해야 시간이 지나도 이 값의 역할을 이해할 수 있습니다.


34장. 원본이 두 개가 되면 데이터 정합성을 유지하기 어렵다#

가장 위험한 구조는 이것입니다.

orders.total_amount
도 원본

order_item
도 원본

두 값을 모두 자유롭게 수정할 수 있다면 어느 것이 진짜인지 알 수 없습니다.

좋은 반정규화 설계에서는 보통 하나를 원본으로 지정합니다.

order_item
→ 원본

orders.total_amount
→ 파생 사본

그러면 불일치 시 어떤 데이터를 기준으로 복구할지 명확합니다.


35장. 트리거를 사용한다고 문제가 자동으로 해결되지는 않는다#

DB 트리거로 주문항목 변경 시 주문 총액을 갱신할 수 있습니다.

장점은 애플리케이션이 합계 갱신을 빠뜨릴 가능성을 줄일 수 있다는 것입니다.

하지만 다음도 고려해야 합니다.

트리거 로직의 복잡도

대량 변경 시 성능

디버깅 난이도

재처리와 운영 도구

동시성

DBMS 의존성

즉 트리거는 하나의 구현 도구일 뿐입니다.

반정규화의 일관성 문제 자체를 없애지는 않습니다.


36장. 물질화 뷰도 상황에 따라 좋은 선택이 될 수 있다#

자주 사용하는 복잡한 집계 결과를 DBMS가 지원하는 물질화 뷰에 저장할 수도 있습니다.

개념적으로 다음과 같습니다.

원본 테이블
   ↓
집계 쿼리
   ↓
물질화된 결과

일반 뷰와 달리 결과가 실제로 저장되므로 조회가 빨라질 수 있습니다.

하지만 새로 고침 정책을 정해야 합니다.

즉시 갱신

주기적 갱신

수동 갱신

따라서 물질화 뷰도 결국 저장된 사본을 언제 최신 상태로 만들 것인가라는 문제를 가집니다.


37장. 반정규화의 핵심은 허용 가능한 불일치 시간이다#

모든 시스템이 즉시 일관성을 요구하는 것은 아닙니다.

예를 들어 결제 금액 화면은 틀리면 안 됩니다.

허용 지연
≈ 0

반면 분석 대시보드는 5분 정도 늦어도 괜찮을 수 있습니다.

허용 지연
= 5분

야간 경영 보고서는 하루 전 데이터까지 사용하기도 합니다.

허용 지연
= 1일

허용 가능한 지연 시간이 다르면 적합한 동기화 방식도 달라집니다.


38장. 현재값이 중요한 곳과 통계값이 중요한 곳을 구분하자#

주문 결제 화면:

현재 주문 금액이 정확해야 함

실시간 재고:

현재 재고가 정확해야 함

월별 매출 대시보드:

몇 분 지연 가능

인기 상품 랭킹:

수 분 또는 수십 분 지연 가능

같은 데이터라도 사용 목적에 따라 반정규화 전략이 달라질 수 있습니다.


39장. 반정규화 전후 성능을 같은 조건에서 비교해야 한다#

다음과 같은 테스트 결과가 있다고 가정해 보겠습니다.

구조 95백분위 응답 추가 쓰기
정규화 기본 구조 420ms 없음
인덱스 개선 230ms 인덱스 유지
상품명 중복 210ms 상품명 변경 시 대량 갱신
전체 화면 캐시 80ms 캐시 무효화

80ms가 가장 빠르다고 해서 무조건 마지막 방식을 선택하는 것은 아닙니다.

다음도 확인해야 합니다.

캐시가 잘못될 확률

갱신 지연

쓰기량 증가

장애 복구

구현 복잡도

운영 비용

성능은 단일 숫자로 평가하기 어렵습니다.


40장. 읽기 비용과 쓰기 비용을 같은 기간으로 비교하자#

상품명 중복으로 조회 한 건당 20ms를 줄였다고 하겠습니다.

하루 조회가 1,000만 건이라면 상당한 효과일 수 있습니다.

반면 하루 조회가 100건뿐이라면 중복 컬럼을 추가하는 의미가 거의 없을 수 있습니다.

동시에 상품명 변경 때마다 수십만 행을 수정해야 한다면 쓰기 비용은 커집니다.

따라서 다음을 함께 비교해야 합니다.

하루 읽기 횟수 × 절약된 읽기 비용

하루 변경 횟수 × 추가된 쓰기 비용

여기에 장애 복구와 검증 비용까지 포함해야 합니다.


41장. 반정규화된 데이터가 틀어졌을 때 자동으로 찾을 수 있어야 한다#

예를 들어 주문 합계 불일치 건수를 운영 지표로 만들 수 있습니다.

반정규화 합계 불일치 건수

정상 상태:

0건

문제가 발생하면:

27건

즉시 재계산 작업을 수행할 수 있습니다.

반정규화를 운영한다면 단순히 값만 저장하는 것이 아니라 불일치를 관찰하는 체계도 함께 두는 것이 좋습니다.


42장. 재처리해도 같은 결과가 나오는 구조가 중요하다#

배치나 이벤트 처리가 실패하면 다시 실행해야 할 수 있습니다.

이때 같은 작업을 두 번 실행했다고 결과가 두 배가 되면 위험합니다.

예를 들어:

월매출 +100,000

이라는 명령을 다시 실행하면:

+100,000
+100,000

이 될 수 있습니다.

가능하다면 다음처럼 원본에서 다시 계산하는 방식을 사용할 수 있습니다.

10월 매출을 원본 주문에서 다시 계산
↓
830,000원으로 설정

이 방식은 같은 계산을 여러 번 해도 최종 결과가 같아집니다.

재처리와 복구를 생각하면 매우 중요한 특성입니다.


43장. 반정규화가 적합한 상황#

다음 조건이 여러 개 겹친다면 반정규화를 검토할 가치가 있습니다.

  • 동일한 조회가 매우 자주 실행된다.
  • 조회 비용이 실제 병목으로 측정된다.
  • 인덱스나 쿼리 개선만으로 목표를 만족하기 어렵다.
  • 중복되는 값의 변경 빈도가 낮거나 관리 가능하다.
  • 동기화 규칙을 명확하게 정의할 수 있다.
  • 원본과 사본을 구분할 수 있다.
  • 불일치를 발견하고 재계산할 수 있다.
  • 허용 가능한 데이터 지연 시간이 정의되어 있다.

44장. 반정규화를 피해야 할 상황#

반대로 다음과 같은 상태라면 먼저 다른 해결책을 검토하는 편이 좋습니다.

느리다는 느낌만 있고 측정한 적이 없다.

적절한 인덱스도 아직 없다.

실행 계획을 확인하지 않았다.

중복 값이 매우 자주 바뀐다.

원본 데이터가 무엇인지 정하지 않았다.

불일치가 생겼을 때 복구할 방법이 없다.

즉시 일관성이 필요한데 비동기 방식만 고려한다.

중복의 의미가 스냅샷인지 캐시인지 구분하지 않았다.

이 상태에서 반정규화를 하면 성능 문제보다 데이터 정합성 문제가 더 커질 수 있습니다.


45장. 반정규화 설계 문서에는 무엇을 적어야 할까#

예를 들어 주문 합계를 저장한다면 다음처럼 정리할 수 있습니다.

항목 내용
사본 orders.total_amount
원본 order_item
계산식 유효 주문항목 금액의 합
갱신 사건 추가, 삭제, 수량 변경, 할인, 반품
갱신 방식 동일 트랜잭션
허용 지연 없음
검증 원본 합계와 저장 합계 비교
복구 주문ID 기준 재계산

비동기 집계라면 다음 정보도 필요할 수 있습니다.

마지막 처리 이벤트ID

마지막 반영 버전

집계 기준 시각

재처리 시작 위치

반정규화 데이터 자체보다 그 값을 유지하는 계약이 더 중요할 수 있습니다.


46장. 정규화 구조와 반정규화 구조 비교#

정규화 구조는 다음과 같습니다.

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
        string customer_id FK
        date order_date
    }

    PRODUCT {
        string product_id PK
        string product_name
    }

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

반정규화된 주문 조회용 구조는 다음처럼 확장될 수 있습니다.

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
        string customer_id FK
        string customer_name_cache
        decimal total_amount
        date order_date
    }

    PRODUCT {
        string product_id PK
        string product_name
    }

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

여기서 세 가지 컬럼의 의미는 모두 다릅니다.

customer_name_cache
→ 현재 고객명의 사본

total_amount
→ 주문항목에서 계산한 파생값

product_name_snapshot
→ 주문 당시 상품명의 기록

겉으로는 모두 중복처럼 보여도 갱신 규칙은 서로 다릅니다.


47장. 반정규화 판단 절차#

반정규화를 검토할 때는 다음 흐름으로 판단할 수 있습니다.

1단계. 정상적인 논리 모델을 먼저 만든다#

원본 데이터와 책임을 명확하게 합니다.

2단계. 실제 병목을 측정한다#

응답 시간과 실행 계획, 호출 빈도를 확인합니다.

3단계. 중복 없는 해결책을 먼저 시험한다#

인덱스, 쿼리 변경, 조회 범위 조정 등을 확인합니다.

4단계. 반정규화 후보를 정한다#

어떤 값을 중복하거나 미리 계산할지 결정합니다.

5단계. 의미를 정의한다#

스냅샷인지 현재값 캐시인지 요약값인지 정합니다.

6단계. 원본을 지정한다#

불일치가 생겼을 때 어떤 데이터를 기준으로 복구할지 결정합니다.

7단계. 갱신 방식을 정한다#

동기, 비동기, 배치 등의 방식을 선택합니다.

8단계. 실패와 재처리를 시험한다#

중복 이벤트, 순서 역전, 동시 수정, 부분 실패를 확인합니다.

9단계. 검증 쿼리를 만든다#

원본과 사본의 차이를 찾을 수 있어야 합니다.

10단계. 적용 후 다시 측정한다#

읽기 성능뿐 아니라 쓰기량과 불일치 건수도 함께 확인합니다.


48장. 핵심 정리#

반정규화는 정규화를 실패한 설계로 되돌리는 작업이 아닙니다.

측정된 읽기 성능 문제를 해결하기 위해 중복과 사전 계산을 의도적으로 도입하는 설계 선택입니다.

대표적인 방식은 다음과 같습니다.

중복 컬럼
파생 컬럼
요약 테이블
테이블 통합
중복 관계
현재·이력 분리

하지만 값을 하나 복사하는 순간 새로운 책임도 생깁니다.

누가 원본인가?

사본은 언제 갱신되는가?

얼마나 늦어도 되는가?

동시에 수정되면 어떻게 되는가?

실패하면 어떻게 재처리하는가?

값이 틀어진 사실을 어떻게 발견하는가?

원본에서 다시 계산할 수 있는가?

특히 같은 값의 복사라도 의미를 구분해야 합니다.

주문 당시 상품명
→ 과거 사실이므로 유지

현재 상품명 캐시
→ 원본 변경 시 갱신

주문 총액
→ 원천 주문항목에서 계산된 파생값

월별 매출
→ 정해진 기준 시각과 상태 조건으로 계산한 요약값

반정규화에서 가장 위험한 상태는 데이터가 중복되어 있다는 사실 자체가 아닙니다.

어느 값이 진짜인지 아무도 설명할 수 없는 상태입니다.

따라서 좋은 반정규화 설계는 항상 원본과 사본의 관계가 명확해야 하고, 사본이 틀어졌을 때 이를 발견하고 복구할 수 있어야 합니다.

결국 반정규화의 판단 기준은 단순합니다.

조회에서 얻는 성능 이득이 추가되는 쓰기·동기화·검증·복구 비용보다 충분히 큰가?

그리고 그 판단은 감이 아니라 실제 측정값으로 내려야 합니다.

이 페이지의 목차