반정규화를 고려해야 하는 시점
반정규화를 고려해야 하는 시점
반정규화는 측정된 읽기 병목을 해결하기 위해 중복 데이터를 의도적으로 저장하는 선택이다. 원본, 갱신 방식, 불일치 복구 방법이 있어야 한다.
목차
- #문제가 되는 상황
- #반정규화는 중복의 책임을 사는 선택이다
- #먼저 병목을 측정한다
- #snapshot과 파생 값을 구분한다
- #같은 transaction에서 동기 갱신하기
- #Outbox 이벤트로 비동기 read model 만들기
- #주기적 재계산과 materialized view
- #불일치를 탐지하고 복구하기
- #write amplification과 lock 비용
- #도입과 제거를 단계적으로 진행한다
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
주문 목록에서 항목 수와 최종 금액을 보여 주기 위해 매번 수백만 건의 order_items를 집계한다면 읽기 비용이 커질 수 있다. 그래서 orders.item_count, orders.total_minor를 추가하면 조회는 빨라진다. 하지만 항목을 추가·삭제할 때 이 두 값을 항상 같이 갱신해야 하고, 한 번 어긋나면 어느 값이 맞는지 판단해야 한다.
반정규화는 중복을 허용해 읽기 비용을 줄이는 대신 쓰기·복구·관측 책임을 추가하는 선택이다. “JOIN은 느리다”는 추측으로 시작하기보다 측정된 query 하나와 필요한 일관성 수준에서 시작해야 한다.
주문 summary와 event 처리 코드는 반정규화 전략을 설명하기 위한 가상 예제다. 실제 매출·주문 데이터를 사용하지 않았다.
반정규화는 중복의 책임을 사는 선택이다
정규화된 원본은 다음과 같을 수 있다.
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
line_no SMALLINT NOT NULL,
quantity INT NOT NULL,
unit_price_minor BIGINT NOT NULL,
PRIMARY KEY (order_id, line_no),
FOREIGN KEY (order_id) REFERENCES orders(id)
);
목록에서 매번 집계한다.
SELECT
o.id,
o.status,
COUNT(oi.line_no) AS item_count,
COALESCE(SUM(oi.quantity * oi.unit_price_minor), 0) AS total_minor
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.status;
반정규화하면 summary를 미리 저장한다.
ALTER TABLE orders
ADD COLUMN item_count INT NOT NULL DEFAULT 0,
ADD COLUMN total_minor BIGINT NOT NULL DEFAULT 0;
이제 query는 간단하지만 다음 질문에 답해야 한다.
- 원본은
order_items인가, orders summary인가? - 어느 transaction 또는 event가 summary를 갱신하는가?
- 부분 실패로 값이 다르면 어떻게 알아내는가?
- 전체 값을 어떻게 다시 만들 수 있는가?
- 몇 초의 지연 또는 일시적 불일치를 허용하는가?
답이 없다면 성능을 얻은 것이 아니라 숨은 데이터 부채를 만든 것이다.
먼저 병목을 측정한다
반정규화 전에 다음을 확인한다.
- 느린 실제 SQL과 parameter를 찾는다.
- 실행 계획에서 읽은 행, join 방식, sort·temporary 작업을 본다.
- 필요한 index와 query shape를 점검한다.
- pagination과 과도한 응답 컬럼을 줄인다.
- application·DB cache hit와 p95/p99를 측정한다.
- 쓰기 빈도와 읽기 빈도를 함께 계산한다.
EXPLAIN ANALYZE
SELECT /* order list query */ ...;
index 하나로 병목이 해결된다면 중복 데이터와 복구 job을 추가할 이유가 없다. 반대로 한 사용자 요청이 복잡한 집계를 수십 번 반복하고 동일 결과를 수천 명이 읽는다면 미리 계산한 read model이 큰 이점을 줄 수 있다.
데이터가 많은 고객·월말 집계처럼 일부 요청만 느릴 수 있다. p95/p99, 읽은 row 수, CPU·I/O와 lock 대기를 함께 본다.
snapshot과 파생 값을 구분한다
모든 중복이 같은 성격은 아니다.
업무 snapshot
주문 당시 상품명과 가격은 이후 상품 master가 바뀌어도 유지해야 하는 독립 사실이다.
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
line_no SMALLINT NOT NULL,
product_id BIGINT NOT NULL,
product_name_at_order VARCHAR(200) NOT NULL,
unit_price_at_order BIGINT NOT NULL,
PRIMARY KEY (order_id, line_no)
);
이는 단순 성능 cache가 아니라 거래 기록의 의미다. 현재 product 이름과 자동 동기화하면 오히려 잘못된다.
파생 값
item_count, total_minor는 원본 항목으로 재계산할 수 있다. 불일치 시 원본을 기준으로 복구한다.
복제된 표시 값
주문 목록에 현재 고객 이름을 빠르게 보여 주려고 복제한다면 이름 변경을 언제 반영할지 정책이 필요하다. “주문 당시 이름”인지 “현재 이름 cache”인지 컬럼명과 문서에서 구분한다.
같은 transaction에서 동기 갱신하기
강한 일관성이 필요하고 원본과 summary가 같은 DB에 있다면 하나의 transaction에서 갱신한다.
await database.transaction(async (tx) => {
await tx.orderItems.insert({
orderId,
lineNo,
quantity,
unitPriceMinor,
});
await tx.orders.incrementSummary({
orderId,
itemCountDelta: 1,
totalDelta: quantity * unitPriceMinor,
});
});
SQL은 원자적 증가로 경쟁 조건을 줄인다.
UPDATE orders
SET item_count = item_count + 1,
total_minor = total_minor + :line_total
WHERE id = :order_id;
애플리케이션이 기존 값을 읽고 계산한 뒤 update하면 동시 요청의 증가분을 잃을 수 있다.
// 좋지 않은 예: 두 요청이 같은 itemCount를 읽을 수 있다.
order.itemCount += 1;
await orderRepository.save(order);
동기 갱신은 읽자마자 정확한 summary를 제공하지만 order row가 hot spot이 되어 lock 경합이 생길 수 있다. 항목 write가 매우 많다면 매번 같은 parent row를 갱신하는 비용을 측정한다.
Outbox 이벤트로 비동기 read model 만들기
검색·dashboard처럼 몇 초 지연을 허용한다면 비동기 read model을 별도로 둘 수 있다. 원본 transaction 안에 outbox event를 함께 기록한다.
await database.transaction(async (tx) => {
const item = await tx.orderItems.insert(command);
await tx.outbox.insert({
id: crypto.randomUUID(),
type: "order-item-added",
aggregateId: command.orderId,
payload: {
lineNo: item.lineNo,
quantity: item.quantity,
unitPriceMinor: item.unitPriceMinor,
},
});
});
sequenceDiagram
participant A as Order API
participant D as Primary DB
participant O as Outbox Relay
participant W as Summary Worker
participant R as Read Model
A->>D: item + outbox event 한 transaction
D-->>A: commit
O->>D: 미발행 event 조회
O-->>W: order-item-added
W->>R: summary 멱등 갱신메시지는 중복 전달될 수 있으므로 event ID를 저장해 멱등 처리한다.
CREATE TABLE processed_summary_events (
consumer VARCHAR(60) NOT NULL,
event_id CHAR(36) NOT NULL,
processed_at DATETIME NOT NULL,
PRIMARY KEY (consumer, event_id)
);
비동기 방식에서는 client가 write 직후 summary를 읽으면 이전 값을 볼 수 있다. UI가 “계산 중” 상태를 표시할지, command 응답의 계산 결과를 임시 반영할지 일관성 계약을 정한다.
주기적 재계산과 materialized view
실시간 정확성이 필요하지 않은 통계는 매 write마다 갱신하지 않고 일정 주기로 재계산할 수 있다.
INSERT INTO daily_order_metrics (
metric_date,
order_count,
revenue_minor,
calculated_at
)
SELECT
DATE(ordered_at),
COUNT(*),
SUM(total_minor),
CURRENT_TIMESTAMP
FROM orders
WHERE ordered_at >= :from
AND ordered_at < :to
GROUP BY DATE(ordered_at)
ON DUPLICATE KEY UPDATE
order_count = VALUES(order_count),
revenue_minor = VALUES(revenue_minor),
calculated_at = VALUES(calculated_at);
DB가 materialized view를 지원한다면 refresh 방식과 query rewrite를 활용할 수 있다. 지원하지 않아도 summary table과 job으로 같은 개념을 구현할 수 있다. 전체 refresh가 큰 lock과 I/O를 만들면 변경 범위만 incremental하게 계산하거나 새 테이블에 만든 뒤 swap한다.
job에는 기준 시각과 watermarks를 두고 늦게 도착한 데이터의 재계산 범위를 정한다.
불일치를 탐지하고 복구하기
파생 값은 언젠가 어긋날 수 있다고 가정하고 비교 query와 repair 경로를 만든다.
SELECT
o.id,
o.item_count AS stored_count,
COUNT(oi.line_no) AS actual_count,
o.total_minor AS stored_total,
COALESCE(SUM(oi.quantity * oi.unit_price_minor), 0) AS actual_total
FROM orders o
LEFT JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, o.item_count, o.total_minor
HAVING stored_count <> actual_count
OR stored_total <> actual_total;
전체 테이블 비교가 비싸면 최근 변경분, random sample, hash bucket으로 나눈 batch를 검사한다. mismatch 수, lag, 마지막 성공 rebuild 시각을 지표로 둔다.
복구 job은 원본에서 다시 계산해 summary를 덮어쓰고 실행 전후 건수를 기록한다.
UPDATE orders o
JOIN (
SELECT order_id,
COUNT(*) AS item_count,
SUM(quantity * unit_price_minor) AS total_minor
FROM order_items
WHERE order_id BETWEEN :from_id AND :to_id
GROUP BY order_id
) s ON s.order_id = o.id
SET o.item_count = s.item_count,
o.total_minor = s.total_minor;
항목이 0개인 주문도 올바르게 0으로 고쳐지는지 별도 처리해야 한다. 검증 query가 누락하는 edge case까지 테스트한다.
write amplification과 lock 비용
한 원본 변경이 search index, cache, summary, analytics table 등 여러 복제본을 갱신하면 write amplification이 커진다.
order item write 1회
→ orders summary update
→ outbox insert
→ Redis invalidation
→ search index update
→ daily metric update
각 복제본이 정말 필요한 query를 해결하는지, 갱신 실패가 원본 transaction을 막아야 하는지 구분한다. 모든 cache를 동기 transaction에 넣으면 외부 저장소 장애가 주문 쓰기 장애로 번진다. 반대로 모두 비동기로 만들면 read-your-writes가 깨진다.
event payload에 전체 entity snapshot을 넣으면 schema 변경과 개인정보 복제가 늘어난다. read model에 필요한 최소 데이터와 event version을 정한다.
도입과 제거를 단계적으로 진행한다
반정규화 컬럼을 바로 읽기 시작하지 않는다.
- nullable summary 컬럼 또는 새 read table을 추가한다.
- 새 write부터 dual-write 또는 event 갱신을 시작한다.
- 기존 데이터를 작은 batch로 backfill한다.
- 원본 계산과 summary를 shadow compare한다.
- 정확도와 latency가 기준을 통과하면 read를 전환한다.
- feature flag로 원본 query로 되돌릴 수 있게 한다.
- 오래된 경로와 임시 컬럼을 충분히 관찰한 뒤 제거한다.
성능 이점이 없거나 불일치 비용이 크다면 반정규화를 제거할 수 있어야 한다. 원본이 명확하고 read model이 재생성 가능하면 rollback이 단순해진다.
실전 점검 목록
- 느린 query와 p95/p99 병목을 실제로 측정했는가?
- index·query·pagination 개선을 먼저 검토했는가?
- 중복 값이 업무 snapshot인지 재생성 가능한 파생 값인지 구분했는가?
- source of truth와 허용 stale 시간이 명시되어 있는가?
- 동기 transaction, 비동기 outbox, 주기 계산 중 방식을 선택한 근거가 있는가?
- drift를 발견하는 query·지표와 repair job이 있는가?
- write amplification과 hot row lock 비용을 측정했는가?
- feature flag와 원본 query로 rollback할 수 있는가?
반정규화는 측정된 읽기 병목을 해결하기 위해 중복 데이터를 의도적으로 저장하는 선택이다. 원본, 갱신 방식, 불일치 복구 방법이 있어야 한다.
결론
반정규화는 측정된 읽기 병목을 줄이기 위해 중복 데이터의 갱신·불일치·복구 책임을 의도적으로 떠안는 선택이다. 업무 snapshot과 재생성 가능한 파생 값을 구분하고, 요구하는 일관성에 따라 동기 transaction·outbox 기반 read model·주기 재계산을 선택한다. source of truth, drift 탐지, repair job과 rollback 경로가 있어야 빠른 조회가 장기적인 데이터 불신으로 바뀌지 않는다.