역정규화와 집계 데이터
캐시된 게시글 조회수 합계의 불일치를 재현하고 트랜잭션 동기화·비동기 집계·원본 재계산의 비용과 신선도를 비교합니다.
역정규화는 정규화가 틀렸다는 뜻이 아니라 측정된 읽기 병목을 줄이기 위해 파생 사실을 복제하는 결정입니다.
복제 순간부터 원본, 갱신 주체, 기준 시각, 오류 복구, 일치 검사 쿼리가 필요합니다.
프로필 카드가 회원별 공개 게시글의 총 조회수를 매번 SUM해 느려졌다고 가정합니다.
member_post_summary에 total_views를 저장하지만 새 게시글 INSERT와 요약 UPDATE가 다른 트랜잭션이면 불일치가 생깁니다.
원본과 요약의 분리 갱신
게시글 INSERT 뒤 요약 UPDATE 전에 작업자가 종료되는 상황을 가정합니다. 아래 SQL은 실제 프로세스를 종료하지 않고 요약 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을 더한 INSERT는 커밋되고, 주석 처리한 요약은 이전 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·버전으로 중복 적용을 막습니다. - 정답 기준 — 분쟁과 복구에서는 정규화된 원본을 판단 기준으로 둡니다.
멱등 요약 재생성
먼저 정확한 재생성 쿼리를 만든 뒤 증분 최적화를 추가합니다.
고정 원본 세 행의 집계 기여
7/15 cutoff(기준 시각)보다 앞선 공개 글만 포함한다. 같은 원본이면 업무 값은 같다. 각 단계의 값과 전환 이유를 비교합니다.
DERIVED DATA · ABSOLUTE REBUILD
공개 원본 50+40을 90으로 재계산하고 0건 회원도 남긴다
집계 조건은 PUBLISHED이며 published_at이 2026-07-15 00:00보다 이른 행입니다. 회원 기준 LEFT JOIN은 해당 게시글이 없는 회원도 0값 요약으로 복구합니다.
회원 1: 두 기여를 절대값으로 저장
회원 2: 제외된 글과 남겨야 할 요약
원본은 그대로 읽고 요약만 절대값으로 덮어씁니다. 같은 입력을 두 번 처리해도 기여를 두 배로 더하지 않습니다. 화살표는 각 입력이 집계와 요약에 반영되는 관계입니다.
| 원본 게시글 | 상태 · 공개 시각 | 건수 · 조회수 기여 |
|---|---|---|
| 게시글 1 · 회원 1 | PUBLISHED · 7월 10일 | 1건 · 50회 |
| 게시글 2 · 회원 1 | PUBLISHED · 7월 11일 | 1건 · 40회 |
| 게시글 3 · 회원 2 | DRAFT · NULL | 0건 · 0회 |
- 게시글 1 · 회원 1
- 상태 · 공개 시각: PUBLISHED · 7월 10일건수 · 조회수 기여: 1건 · 50회
- 게시글 2 · 회원 1
- 상태 · 공개 시각: PUBLISHED · 7월 11일건수 · 조회수 기여: 1건 · 40회
- 게시글 3 · 회원 2
- 상태 · 공개 시각: DRAFT · NULL건수 · 조회수 기여: 0건 · 0회
2026년 7월 15일 00:00 전의 공개 글을 소스에서 계산한 값입니다. 회원 기준 LEFT JOIN은 회원 2도 남겨 잘못된 요약 9건·999회를 0건·0회로 덮어씁니다.
회원 1 · 안동민
잘못된 캐시: 1건 · 50회
원본: 1+1 = 2건 · 50+40 = 90회
절대값 UPSERT → 2건 · 90회
회원 2 · 김소라
잘못된 캐시: 9건 · 999회
DRAFT의 조회수 20은 집계 제외
절대값 UPSERT → 0건 · 0회
같은 원본과 cutoff로 다시 실행해도 집계값을 더하지 않습니다. rebuilt_at은 실행 때마다 갱신되고, cutoff만 고정해도 나중 원본 수정까지 스냅샷으로 고정되지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 원본 선택부터 재실행까지 진행합니다.
| 원본 게시글 | 상태 · 공개 시각 | 건수 · 조회 수 기여 |
|---|---|---|
| 게시글 1 · 회원 1 | PUBLISHED · 7월 10일 | 1건 · 50회 |
| 게시글 2 · 회원 1 | PUBLISHED · 7월 11일 | 1건 · 40회 |
| 게시글 3 · 회원 2 | DRAFT · NULL | 0건 · 0회 |
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 설명·조건 | 집계 조건은 PUBLISHED이며 published_at이 2026-07-15 00:00보다 이른 행입니다. 회원 기준 LEFT JOIN은 해당 게시글이 없는 회원도 값이 0인 요약으로 복구합니다. |
| 제목·표기 | 회원 1: 두 기여를 집계값으로 저장 |
| 제목 | 회원 1의 원본 집계와 집계값 저장 |
| 관계 설명 | 회원 1의 공개 글 50과 40은 두 입력에서 집계 2건 90회로 합쳐지고, 집계값 UPSERT가 이 값으로 기존 캐시 1건 50회를 덮어씁니다. 화살표는 집계 데이터의 기여와 저장 방향이며 원본 두 행을 변경하는 요청이 아닙니다. |
| 제목 | 회원 2를 남기는 값이 0인 요약 복구 |
| 관계 설명 | 회원 2의 DRAFT 글은 공개 집계 조건에서 제외되어 기여 0입니다. 회원을 왼쪽 집합으로 남기고 빈 합계를 0으로 만들어 기존 9건 999회 캐시를 0건 0회로 덮어씁니다. DRAFT 글이나 회원 원본은 삭제하지 않습니다. |
| 설명·조건 | 원본은 그대로 읽고 요약만 집계값으로 덮어씁니다. 같은 입력을 두 번 처리해도 기여를 두 배로 더하지 않습니다. 화살표는 각 입력이 집계와 요약에 반영되는 관계입니다. |
| 표의 열 | 건수 · 조회 수 기여 |
| 표의 값 | 게시글 1 · 회원 1. 상태 · 공개 시각: PUBLISHED · 7월 10일. 건수 · 조회 수 기여: 1건 · 50회. 게시글 2 · 회원 1. 상태 · 공개 시각: PUBLISHED · 7월 11일. 건수 · 조회 수 기여: 1건 · 40회. 게시글 3 · 회원 2. 상태 · 공개 시각: DRAFT · NULL. 건수 · 조회 수 기여: 0건 · 0회. |
| 설명·조건 | 집계값 UPSERT → 2건 · 90회 |
| 설명·조건 | DRAFT의 조회 수 20은 집계 제외 |
| 설명·조건 | 집계값 UPSERT → 0건 · 0회 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원본 선택 7/15 cutoff(기준 시각)보다 앞선 공개 글만 포함한다. |
|
|
| 회원별 합계 기준 회원에서 LEFT JOIN한다. |
|
|
| 집계값 교체 기존 요약에 합계를 더하지 않는다. |
|
|
| 재실행 같은 원본이면 업무 값은 같다. |
|
|
고정 원본 세 행의 집계 기여
7/15 cutoff(기준 시각)보다 앞선 공개 글만 포함한다. 같은 원본이면 업무 값은 같다. 각 단계의 값과 전환 이유를 비교합니다.
DERIVED DATA · ABSOLUTE REBUILD
공개 원본 50+40을 90으로 재계산하고 0건 회원도 남긴다
집계 조건은 PUBLISHED이며 published_at이 2026-07-15 00:00보다 이른 행입니다. 회원 기준 LEFT JOIN은 해당 게시글이 없는 회원도 0값 요약으로 복구합니다.
회원 1: 두 기여를 절대값으로 저장
회원 2: 제외된 글과 남겨야 할 요약
원본은 그대로 읽고 요약만 절대값으로 덮어씁니다. 같은 입력을 두 번 처리해도 기여를 두 배로 더하지 않습니다. 화살표는 각 입력이 집계와 요약에 반영되는 관계입니다.
| 원본 게시글 | 상태 · 공개 시각 | 건수 · 조회수 기여 |
|---|---|---|
| 게시글 1 · 회원 1 | PUBLISHED · 7월 10일 | 1건 · 50회 |
| 게시글 2 · 회원 1 | PUBLISHED · 7월 11일 | 1건 · 40회 |
| 게시글 3 · 회원 2 | DRAFT · NULL | 0건 · 0회 |
- 게시글 1 · 회원 1
- 상태 · 공개 시각: PUBLISHED · 7월 10일건수 · 조회수 기여: 1건 · 50회
- 게시글 2 · 회원 1
- 상태 · 공개 시각: PUBLISHED · 7월 11일건수 · 조회수 기여: 1건 · 40회
- 게시글 3 · 회원 2
- 상태 · 공개 시각: DRAFT · NULL건수 · 조회수 기여: 0건 · 0회
2026년 7월 15일 00:00 전의 공개 글을 소스에서 계산한 값입니다. 회원 기준 LEFT JOIN은 회원 2도 남겨 잘못된 요약 9건·999회를 0건·0회로 덮어씁니다.
회원 1 · 안동민
잘못된 캐시: 1건 · 50회
원본: 1+1 = 2건 · 50+40 = 90회
절대값 UPSERT → 2건 · 90회
회원 2 · 김소라
잘못된 캐시: 9건 · 999회
DRAFT의 조회수 20은 집계 제외
절대값 UPSERT → 0건 · 0회
같은 원본과 cutoff로 다시 실행해도 집계값을 더하지 않습니다. rebuilt_at은 실행 때마다 갱신되고, cutoff만 고정해도 나중 원본 수정까지 스냅샷으로 고정되지는 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 원본 선택부터 재실행까지 진행합니다.
| 원본 게시글 | 상태 · 공개 시각 | 건수 · 조회 수 기여 |
|---|---|---|
| 게시글 1 · 회원 1 | PUBLISHED · 7월 10일 | 1건 · 50회 |
| 게시글 2 · 회원 1 | PUBLISHED · 7월 11일 | 1건 · 40회 |
| 게시글 3 · 회원 2 | DRAFT · NULL | 0건 · 0회 |
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 설명·조건 | 집계 조건은 PUBLISHED이며 published_at이 2026-07-15 00:00보다 이른 행입니다. 회원 기준 LEFT JOIN은 해당 게시글이 없는 회원도 값이 0인 요약으로 복구합니다. |
| 제목·표기 | 회원 1: 두 기여를 집계값으로 저장 |
| 제목 | 회원 1의 원본 집계와 집계값 저장 |
| 관계 설명 | 회원 1의 공개 글 50과 40은 두 입력에서 집계 2건 90회로 합쳐지고, 집계값 UPSERT가 이 값으로 기존 캐시 1건 50회를 덮어씁니다. 화살표는 집계 데이터의 기여와 저장 방향이며 원본 두 행을 변경하는 요청이 아닙니다. |
| 제목 | 회원 2를 남기는 값이 0인 요약 복구 |
| 관계 설명 | 회원 2의 DRAFT 글은 공개 집계 조건에서 제외되어 기여 0입니다. 회원을 왼쪽 집합으로 남기고 빈 합계를 0으로 만들어 기존 9건 999회 캐시를 0건 0회로 덮어씁니다. DRAFT 글이나 회원 원본은 삭제하지 않습니다. |
| 설명·조건 | 원본은 그대로 읽고 요약만 집계값으로 덮어씁니다. 같은 입력을 두 번 처리해도 기여를 두 배로 더하지 않습니다. 화살표는 각 입력이 집계와 요약에 반영되는 관계입니다. |
| 표의 열 | 건수 · 조회 수 기여 |
| 표의 값 | 게시글 1 · 회원 1. 상태 · 공개 시각: PUBLISHED · 7월 10일. 건수 · 조회 수 기여: 1건 · 50회. 게시글 2 · 회원 1. 상태 · 공개 시각: PUBLISHED · 7월 11일. 건수 · 조회 수 기여: 1건 · 40회. 게시글 3 · 회원 2. 상태 · 공개 시각: DRAFT · NULL. 건수 · 조회 수 기여: 0건 · 0회. |
| 설명·조건 | 집계값 UPSERT → 2건 · 90회 |
| 설명·조건 | DRAFT의 조회 수 20은 집계 제외 |
| 설명·조건 | 집계값 UPSERT → 0건 · 0회 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 원본 선택 7/15 cutoff(기준 시각)보다 앞선 공개 글만 포함한다. |
|
|
| 회원별 합계 기준 회원에서 LEFT JOIN한다. |
|
|
| 집계값 교체 기존 요약에 합계를 더하지 않는다. |
|
|
| 재실행 같은 원본이면 업무 값은 같다. |
|
|
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;같은 원본과 기준 시각으로 다시 실행하면 건수·조회수는 더해지지 않습니다. rebuilt_at은 실행 시각으로 바뀌므로 전체 행이 동일한 것은 아닙니다. 고정 cutoff만으로 수정 가능한 원본의 스냅샷을 만들지는 않습니다.
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 source + cutoff rerun: aggregate values unchanged; rebuilt_at refreshed0과 행 없음의 API 의미를 정하고, 삭제된 회원의 요약은 FK ON DELETE CASCADE로 함께 제거합니다.
원문의 VALUES(열) 참조는 MySQL 8.4에서 지원되지만 폐기 예정 문법입니다. INSERT ... SELECT에서는 집계 SELECT를 파생 테이블로 감싼 뒤 열 별칭을 UPDATE에서 참조하는 대안을 사용할 수 있습니다. VALUES/SET 뒤의 행 별칭 문법을 SELECT 뒤에 그대로 붙이지 않습니다.
핵심은 누적 더하기가 아니라 원본 기반 집계값 재계산입니다.
회원 1의 두 공개 행은 건수 1 + 1 = 2, 조회수 50 + 40 = 90을 기여합니다. 회원 2의 DRAFT 행은 JOIN 조건에서 제외되어 COUNT(p.id)=0, SUM(p.view_count)=NULL이 되고 COALESCE로 0을 저장합니다. LEFT JOIN이 남긴 회원 행 자체를 세는 COUNT(*)로 바꾸면 회원 2도 1건이 되므로 이 집계에 맞지 않습니다.
그 뒤 게시글 2의 조회수만 40에서 45로 고치면 같은 cutoff로 재생성해도 원본 합계는 95입니다. 요약을 90에 고정하는 것이 멱등성은 아닙니다. 원본이 같을 때 재처리로 중복 가산하지 않는 것과, 바뀐 원본을 다시 반영하는 것을 구분합니다.
불일치 주입 뒤 재계산으로 복구하기
- 불일치 주입 — 요약 UPDATE를 빼고 원본 한 행만 추가합니다.
- 일치 검사 — 원본-요약 차이를 회원별로 찾습니다.
- 재생성 — 원본을 고정한 채 같은 기준 시각으로 두 번 실행해 집계값이 같고
rebuilt_at만 갱신되는지 확인합니다. - 지연 이벤트 — 기준 시각 이전 게시글이 늦게 들어올 때 과거 구간 재계산 정책을 검토합니다.
원본과 요약 비교
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 sumry.member_id IS NULL
OR src.published_count <> COALESCE(sumry.published_count, 0)
OR src.total_views <> COALESCE(sumry.total_views, 0);앞의 분리 커밋 오류 표에서는 회원 1이 90 대 50으로 어긋납니다.
고정 fixture의 원본 집계값으로 재생성한 뒤 이 진단의 예상 결과는 0행입니다. 요약 행 자체가 빠진 회원은 집계가 0이어도 NULL 검사로 찾습니다.
불일치 쿼리 자체를 배치 지표로 사용합니다.
조회·동기화 전략 비교
- 조회 시점의 SUM — 해당 읽기 뷰의 원본. 감수할 비용은 반복 집계 비용, 적합한 조건은 데이터량·호출량이 작을 때.
- 같은 트랜잭션 카운터 — 즉시 일치. 감수할 비용은 경합 집중 행·모든 경로 결합, 적합한 조건은 강한 신선도가 필요할 때.
- 이벤트 집계 — 쓰기 분리·확장. 감수할 비용은 지연·중복 처리, 적합한 조건은 초 단위 지연을 허용할 때.
- 배치 재생성 — 단순 복구·일치 검사. 감수할 비용은 오래된 값, 적합한 조건은 주기 신선도로 충분할 때.
집계 행의 정체성과 신선도
증분 카운터는 같은 회원 요약 행에 쓰기가 집중될 수 있습니다.
시간 버킷·이벤트 추가 후 비동기 집계·분할된 카운터를 검토하지만 읽기 SLA가 실제로 요구할 때만 복잡도를 추가합니다.
지연 이벤트와 수정 삭제를 처리하려면 단순 조회수 증가 이벤트만으로 부족합니다.
원본 버전 또는 수정 이벤트, 영향받은 버킷 재생성이 필요합니다.
0건·지연 이벤트·시간대·반올림 예외
- 공개 게시글 0건은 SUM NULL이므로 건수와 COALESCE 정책을 명시합니다.
- 사용자 시간대 일별 통계는 로컬 경계를 UTC로 변환합니다.
- 평균만 저장하면 재집계할 수 없으므로 분자·분모를 보존합니다.
- 원본 물리 삭제 뒤 감사 통계를 재현할 수 있는지 보존 정책을 확인합니다.
일치 검사 작업과 재생성 실행 절차
요약 지연, source_cutoff_at 경과 시간, 불일치 행 건수, 재생성 소요 시간을 운영 지표로 둡니다.
재생성은 작은 배치와 일관된 원본 읽기 범위를 정해 실행하고 오류가 난 회원 범위를 재시작할 커서를 기록합니다.
원본 수정·삭제·재처리 실험
- 수정 — PUBLISHED 게시글 조회수를 40에서 45로 바꾸고 불일치를 봅니다.
- 상태 — PUBLISHED→DELETED가 집계에서 빠지는지 확인합니다.
- 삭제 — 소프트 삭제 조건을 원본 정의에 넣고 일치 여부를 확인합니다.
- 재처리 — 같은 이벤트/재생성을 두 번 적용해 값이 늘지 않는지 봅니다.
마지막 문서는 지금까지의 정규 원본, 타입, 인덱스, 요약 규칙을 의존성 순서의 통합 DDL과 시드 데이터·위반·EXPLAIN 검증으로 묶습니다.
역정규화 도입 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 병목 | 원본 쿼리가 실제 행 수와 지연 시간 예산을 넘는가? |
| 원본 | 파생값을 재생할 원본 데이터가 있는가? |
| 신선도 | 허용 지연과 기준 시각을 사용자에게 설명하는가? |
| 멱등 | 재시도·중복 이벤트가 값을 두 번 더하지 않는가? |
| 복구 | 불일치 검사와 전체 재생성 실행 절차가 있는가? |
역정규화 열부터 추가하지 않고 원본 쿼리와 재생성을 먼저 완성합니다.
동기화 방법보다 정답 정의와 복구 가능성이 승인 조건입니다.
연습 문제
카테고리별 일일 공개 게시글 수·총 조회수 요약을 설계하세요.
같은 날짜를 재처리해도 중복되지 않고 0건 카테고리도 표시되어야 합니다.
먼저 UTC 날짜 2026-07-14 한 개와 카테고리 1·2가 있다고 놓고 손으로 답을 계산하세요. 카테고리 1에는 그날 공개한 조회수 10·20의 글 두 개, 그날 DRAFT 글 한 개(조회수 999), 다음 날 00:00에 공개한 글 한 개(조회수 100)가 있습니다. 카테고리 2에는 글이 없습니다. 공개 시각 구간은 2026-07-14 00:00 <= published_at < 2026-07-15 00:00입니다. 두 요약 행의 건수·합계를 쓰고, 상태·시각 조건을 LEFT JOIN의 ON과 WHERE 중 어디에 둘지 판단하세요.
해설과 예시 답안
후보 키 (category_id, summary_date)와 건수·합계·기준 시각을 두고 카테고리×날짜 기준 LEFT JOIN 집계 결과 전체를 UPSERT합니다. 아래는 categories(id) 부모가 이미 존재한다는 전제의 저장 구조이며 집계·UPSERT SQL은 별도로 작성해야 합니다.
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값 요약으로 생성되는지 확인합니다.
위 조건에서는 카테고리 1이 2건·30회, 카테고리 2가 0건·0회이므로 요약은 두 행입니다. DRAFT 999와 다음 날 경계 100은 더하지 않습니다. 카테고리×날짜를 왼쪽 출발 집합으로 두고 글의 상태·시간 조건을 ON에서 적용해야 글 없는 조합도 남습니다. LEFT JOIN 뒤 WHERE에서 p.status='PUBLISHED'를 요구하면 NULL인 카테고리 2를 탈락시킵니다. 같은 입력을 두 번 UPSERT해도 4건·60회로 늘어나지 않는지, 조회수가 20인 글의 조회수를 25로 수정한 뒤 재생성하면 2건·35회로 바뀌는지도 독립적으로 확인합니다.
핵심 정리
- 역정규화는 원본·기준 시각·갱신·복구 규칙을 동반합니다.
- 누적 더하기보다 원본 기반 집계값 재생성이 안전한 기준선입니다.
- 합계·건수를 함께 저장해 재집계 가능성을 남깁니다.
- 불일치 쿼리와 멱등 재생성을 운영 실행 절차로 둡니다.
다음 문서에서는 회원 게시판 최종 DDL을 빈 데이터베이스에서 실행하고 무결성·쿼리 계획까지 인수 검증합니다.