역정규화와 집계 데이터
캐시된 게시글 조회수 합계의 불일치를 재현하고 트랜잭션 동기화·비동기 집계·원본 재계산의 비용과 신선도를 비교합니다.
역정규화는 정규화가 틀렸다는 뜻이 아니라 측정된 읽기 병목을 줄이기 위해 파생 사실을 복제하는 결정입니다.
복제 순간부터 원본, 갱신 주체, 기준 시각, 오류 복구, 일치 검사 쿼리가 필요합니다.
프로필 카드가 회원별 공개 게시글의 총 조회수를 매번 SUM해 느려졌다고 가정합니다.
member_post_summary에 total_views를 저장하지만 새 게시글 INSERT와 요약 UPDATE가 다른 트랜잭션이면 불일치가 생깁니다.
원본과 요약의 분리 갱신
게시글 INSERT는 성공하고 요약 UPDATE 전에 작업자가 죽습니다.
화면은 조회수 50, 원본 SUM은 90을 보여 어느 숫자가 사실인지 알 수 없습니다.
캐시 열만 보면 오류가 드러나지 않습니다.
DROP TABLE IF EXISTS summary_drift_demo;
DROP TABLE IF EXISTS post_summary_drift_demo;
CREATE TABLE post_summary_drift_demo (
post_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
author_id BIGINT UNSIGNED NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL
);
CREATE TABLE summary_drift_demo (
member_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
total_views BIGINT UNSIGNED NOT NULL
);
INSERT INTO post_summary_drift_demo
(author_id, view_count, status)
VALUES (1, 50, 'PUBLISHED');
INSERT INTO summary_drift_demo VALUES (1, 50);
INSERT INTO post_summary_drift_demo
(author_id, view_count, status)
VALUES (1, 40, 'PUBLISHED');
-- 여기서 process가 종료되어 아래 동기화 문장은 실행되지 않았다고 가정한다.
-- UPDATE summary_drift_demo
-- SET total_views = total_views + 40
-- WHERE member_id = 1;
SELECT
SUM(s.view_count) AS source_views,
summary.total_views AS cached_views,
summary.total_views - SUM(s.view_count) AS drift_views
FROM post_summary_drift_demo AS s
JOIN summary_drift_demo AS summary ON summary.member_id = s.author_id
WHERE s.status = 'PUBLISHED'
GROUP BY summary.member_id, summary.total_views;첫 구문은 자동 커밋되어 원본 조회수에 40이 늘었지만 요약은 이전 50에 머뭅니다.
오류 실행 결과source SUM(view_count): 90
cached total_views: 50
drift_views: -40
SQL constraint error: none파생값은 검사·FK만으로 원본 SUM과 같음을 강제할 수 없습니다.
같은 트랜잭션 동기화, 이벤트 기반 비동기 갱신, 주기 재계산 중 하나를 고르고 오류 복구를 설계해야 합니다.
상태 변경·조회수 수정·삭제도 합계에 영향을 줍니다.
INSERT 경로 하나만 후크하면 처음에는 맞다가 운영 중 불일치가 누적됩니다.
캐시와 원본 합계가 갈라진 트랜잭션 찾기
- 원본 정의 — 공개 상태·삭제 정책·시간대 범위를 SUM 조건에 명시합니다.
- 변경 경로 — INSERT·UPDATE·DELETE·복구 가져오기를 모두 나열합니다.
- 기준 시각 — 요약이 어느 원본 시각까지 반영했는지 저장합니다.
- 재구축 — 요약을 버리고 원본만으로 다시 만들 수 있는지 확인합니다.
재생 가능한 파생 모델
요약 한 행의 후보 키는 member_id이고 total_views뿐 아니라 published_count, source_cutoff_at, rebuilt_at을 둡니다.
평균만 저장하지 않고 합계·건수를 저장해야 재집계할 수 있습니다.
실시간 강한 일관성이 필요하고 변경량이 작으면 같은 트랜잭션, 약간의 지연을 허용하면 아웃박스/이벤트 작업자, 대량 분석이면 배치 재생성이 적합합니다.
어떤 방식도 정기 원본 일치 검사를 대체하지 않습니다.
역정규화에 기준 시각과 원본을 붙이기
- SLA — 화면이 허용하는 신선도 지연을 수치로 정합니다.
- 쓰기 비용 — 모든 게시글 변경이 요약 경합 집중 행을 잠그는지 봅니다.
- 재처리 —
event_id·버전으로 중복 적용을 막습니다. - 정답 기준 — 분쟁과 복구에서는 정규 원본을 원본으로 둡니다.
멱등 요약 재생성
먼저 정확한 재생성 쿼리를 만든 뒤 증분 최적화를 추가합니다.
INSERT ... ON DUPLICATE KEY UPDATE로 같은 기준 시각 재실행 결과를 고정합니다.
DROP TABLE IF EXISTS member_post_summary_demo;
DROP TABLE IF EXISTS summary_post_fixture;
DROP TABLE IF EXISTS summary_member_fixture;
CREATE TABLE summary_member_fixture (
id BIGINT UNSIGNED NOT NULL,
name VARCHAR(80) NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE summary_post_fixture (
id BIGINT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
published_at DATETIME(6) NULL,
PRIMARY KEY (id),
FOREIGN KEY (author_id) REFERENCES summary_member_fixture (id)
);
INSERT INTO summary_member_fixture (id, name)
VALUES (1, '안동민'), (2, '김소라');
INSERT INTO summary_post_fixture
(id, author_id, view_count, status, published_at)
VALUES
(1, 1, 50, 'PUBLISHED', '2026-07-10 10:00:00'),
(2, 1, 40, 'PUBLISHED', '2026-07-11 11:00:00'),
(3, 2, 20, 'DRAFT', NULL);
CREATE TABLE member_post_summary_demo (
member_id BIGINT UNSIGNED PRIMARY KEY,
published_count INT UNSIGNED NOT NULL DEFAULT 0,
total_views BIGINT UNSIGNED NOT NULL DEFAULT 0,
source_cutoff_at DATETIME(6) NOT NULL,
rebuilt_at DATETIME(6) NOT NULL,
CONSTRAINT fk_summary_demo_member FOREIGN KEY (member_id)
REFERENCES summary_member_fixture (id)
ON DELETE CASCADE
);
-- 재구축 전 잘못 남은 캐시를 의도적으로 넣는다.
INSERT INTO member_post_summary_demo
(member_id, published_count, total_views, source_cutoff_at, rebuilt_at)
VALUES
(1, 1, 50, '2026-07-15 00:00:00', '2026-07-14 00:00:00'),
(2, 9, 999, '2026-07-15 00:00:00', '2026-07-14 00:00:00');
INSERT INTO member_post_summary_demo
(member_id, published_count, total_views, source_cutoff_at, rebuilt_at)
SELECT
m.id AS member_id,
COUNT(p.id) AS published_count,
COALESCE(SUM(p.view_count), 0) AS total_views,
'2026-07-15 00:00:00.000000' AS source_cutoff_at,
CURRENT_TIMESTAMP(6) AS rebuilt_at
FROM summary_member_fixture AS m
LEFT JOIN summary_post_fixture AS p
ON p.author_id = m.id
AND p.status = 'PUBLISHED'
AND p.published_at < '2026-07-15 00:00:00.000000'
GROUP BY m.id
ON DUPLICATE KEY UPDATE
published_count = VALUES(published_count),
total_views = VALUES(total_views),
source_cutoff_at = VALUES(source_cutoff_at),
rebuilt_at = VALUES(rebuilt_at);
SELECT member_id, published_count, total_views
FROM member_post_summary_demo
ORDER BY member_id;같은 기준 시각으로 여러 번 실행해도 원본 집계와 같은 값으로 덮어써 누적 중복이 없습니다.
개선 실행 결과source published_count: 2
summary published_count: 2
source total_views: 90
summary total_views: 90
member 2 source/summary: 0 / 0
drift rows after rebuild: 0
same rebuild rerun: values unchanged고정 fixture의 회원 2는 공개 게시글이 0건이지만 회원을 기준으로 LEFT JOIN하므로 기존의 잘못된 9건·999회가 0건·0회로 교정됩니다.
0과 행 없음의 API 의미를 정하고, 삭제된 회원의 요약은 FK ON DELETE CASCADE로 함께 제거합니다.
VALUES() 별칭 문법은 MySQL 버전 경고를 확인하고 현재 8.4 권장 INSERT ... SELECT ... AS new ON DUPLICATE KEY UPDATE 형태로 조정할 수 있습니다.
핵심은 누적 더하기가 아니라 원본 절대값 재계산입니다.
불일치 주입 뒤 재계산으로 복구하기
- 불일치 주입 — 요약 UPDATE를 빼고 원본 한 행만 추가합니다.
- 일치 검사 — 원본-요약 차이를 회원별로 찾습니다.
- 재생성 — 같은 기준 시각을 두 번 실행해 값이 같음을 확인합니다.
- 지연 이벤트 — 기준 시각 이전 게시글이 늦게 들어올 때 과거 구간 재계산 정책을 검토합니다.
원본과 요약 비교
MySQL에는 완전 외부 JOIN이 없으므로 회원 기준 LEFT JOIN으로 0건까지 포함해 차이를 찾습니다.
WITH source_total AS (
SELECT m.id AS member_id,
COUNT(p.id) AS published_count,
COALESCE(SUM(p.view_count), 0) AS total_views
FROM summary_member_fixture AS m
LEFT JOIN summary_post_fixture AS p
ON p.author_id = m.id
AND p.status = 'PUBLISHED'
AND p.published_at < '2026-07-15 00:00:00'
GROUP BY m.id
)
SELECT src.member_id,
src.total_views AS source_views,
COALESCE(sumry.total_views, 0) AS cached_views
FROM source_total AS src
LEFT JOIN member_post_summary_demo AS sumry
ON sumry.member_id = src.member_id
WHERE src.published_count <> COALESCE(sumry.published_count, 0)
OR src.total_views <> COALESCE(sumry.total_views, 0);앞의 분리 커밋 오류 표에서는 회원 1이 90 대 50으로 어긋납니다.
고정 fixture를 절대값으로 재생성한 뒤 이 진단은 0행입니다.
불일치 쿼리 자체를 배치 지표로 사용합니다.
조회·동기화 전략 비교
| 선택 | 얻는 효과 | 감수할 비용 | 적합한 조건 |
|---|---|---|---|
| 쿼리-시간 SUM | 항상 최신 원본 | 반복 집계 비용 | 데이터량·호출량이 작을 때 |
| 같은 트랜잭션 카운터 | 즉시 일치 | 경합 집중 행·모든 경로 결합 | 강한 신선도가 필요할 때 |
| 이벤트 집계 | 쓰기 분리·확장 | 지연·중복 처리 | 초 단위 지연을 허용할 때 |
| 배치 재생성 | 단순 복구·일치 검사 | 오래된 값 | 주기 신선도로 충분할 때 |
집계 행의 정체성과 신선도
증분 카운터는 같은 회원 요약 행에 쓰기가 집중될 수 있습니다.
시간 버킷·이벤트 추가 후 비동기 집계·분할된 카운터를 검토하지만 읽기 SLA가 실제로 요구할 때만 복잡도를 추가합니다.
지연 이벤트와 수정 삭제를 처리하려면 단순 조회수 증가 이벤트만으로 부족합니다.
원본 버전 또는 수정 이벤트, 영향받은 버킷 재생성이 필요합니다.
0건·지연 이벤트·시간대·반올림 예외
- 공개 게시글 0건은 SUM NULL이므로 건수와 COALESCE 정책을 명시합니다.
- 사용자 시간대 일별 통계는 로컬 경계를 UTC로 변환합니다.
- 평균만 저장하면 재집계할 수 없으므로 분자·분모를 보존합니다.
- 원본 물리 삭제 뒤 감사 통계를 재현할 수 있는지 보존 정책을 확인합니다.
일치 검사 작업과 재생성 실행 절차
요약 지연, source_cutoff_at 경과 시간, 불일치 행 건수, 재생성 소요 시간을 운영 지표로 둡니다.
재생성은 작은 배치와 고정 기준 시각 스냅샷으로 실행하고 오류가 난 회원 범위를 재시작할 커서를 기록합니다.
원본 수정·삭제·재처리 실험
- 수정 — PUBLISHED 게시글 조회수를 40에서 45로 바꾸고 불일치를 봅니다.
- 상태 — PUBLISHED→DELETED가 집계에서 빠지는지 확인합니다.
- 삭제 — 소프트 삭제 조건을 원본 정의에 넣고 일치 여부를 확인합니다.
- 재처리 — 같은 이벤트/재생성을 두 번 적용해 값이 늘지 않는지 봅니다.
마지막 문서는 지금까지의 정규 원본, 타입, 인덱스, 요약 규칙을 의존성 순서의 통합 DDL과 시드 데이터·위반·EXPLAIN 검증으로 묶습니다.
역정규화 도입 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 병목 | 원본 쿼리가 실제 행 수와 지연 시간 예산을 넘는가? |
| 원본 | 파생값을 재생할 원본 데이터가 있는가? |
| 신선도 | 허용 지연과 기준 시각을 사용자에게 설명하는가? |
| 멱등 | 재시도·중복 이벤트가 값을 두 번 더하지 않는가? |
| 복구 | 불일치 검사와 전체 재생성 실행 절차가 있는가? |
역정규화 열부터 추가하지 않고 원본 쿼리와 재생성을 먼저 완성합니다.
동기화 방법보다 정답 정의와 복구 가능성이 승인 조건입니다.
연습 문제
카테고리별 일일 공개 게시글 수·총 조회수 요약을 설계하세요.
같은 날짜를 재처리해도 중복되지 않고 0건 카테고리도 표시되어야 합니다.
해설과 예시 답안
후보 키 (category_id, summary_date)와 건수·합계와 기준 시각을 두고 카테고리×날짜 기준 LEFT JOIN 집계를 절대값 UPSERT합니다.
CREATE TABLE category_daily_summary (
category_id BIGINT UNSIGNED NOT NULL,
summary_date DATE NOT NULL,
published_count INT UNSIGNED NOT NULL,
total_views BIGINT UNSIGNED NOT NULL,
source_cutoff_at DATETIME(6) NOT NULL,
PRIMARY KEY (category_id, summary_date),
FOREIGN KEY (category_id) REFERENCES categories (id)
);같은 날짜 재생성 두 번의 행 수·합계가 같고 게시글 없는 카테고리도 0행이 아니라 0값 요약으로 생성되는지 확인합니다.
핵심 정리
- 역정규화는 원본·기준 시각·갱신·복구 규칙을 동반합니다.
- 누적 더하기보다 원본 절대값 재생성이 안전한 기준선입니다.
- 합계·건수를 함께 저장해 재집계 가능성을 남깁니다.
- 불일치 쿼리와 멱등 재생성을 운영 실행 절차로 둡니다.
다음 문서에서는 회원 게시판 최종 DDL을 빈 데이터베이스에서 실행하고 무결성·쿼리 계획까지 인수 검증합니다.