안동민 개발노트

본문 시작

역정규화와 집계 데이터

캐시된 게시글 조회수 합계의 불일치를 재현하고 트랜잭션 동기화·비동기 집계·원본 재계산의 비용과 신선도를 비교합니다.

역정규화는 정규화가 틀렸다는 뜻이 아니라 측정된 읽기 병목을 줄이기 위해 파생 사실을 복제하는 결정입니다.

복제 순간부터 원본, 갱신 주체, 기준 시각, 오류 복구, 일치 검사 쿼리가 필요합니다.

프로필 카드가 회원별 공개 게시글의 총 조회수를 매번 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 경로 하나만 후크하면 처음에는 맞다가 운영 중 불일치가 누적됩니다.

캐시와 원본 합계가 갈라진 트랜잭션 찾기

  1. 원본 정의 — 공개 상태·삭제 정책·시간대 범위를 SUM 조건에 명시합니다.
  2. 변경 경로 — INSERT·UPDATE·DELETE·복구 가져오기를 모두 나열합니다.
  3. 기준 시각 — 요약이 어느 원본 시각까지 반영했는지 저장합니다.
  4. 재구축 — 요약을 버리고 원본만으로 다시 만들 수 있는지 확인합니다.

재생 가능한 파생 모델

요약 한 행의 후보 키는 member_id이고 total_views뿐 아니라 published_count, source_cutoff_at, rebuilt_at을 둡니다.

평균만 저장하지 않고 합계·건수를 저장해야 재집계할 수 있습니다.

실시간 강한 일관성이 필요하고 변경량이 작으면 같은 트랜잭션, 약간의 지연을 허용하면 아웃박스/이벤트 작업자, 대량 분석이면 배치 재생성이 적합합니다.

어떤 방식도 정기 원본 일치 검사를 대체하지 않습니다.

역정규화에 기준 시각과 원본을 붙이기

  1. SLA — 화면이 허용하는 신선도 지연을 수치로 정합니다.
  2. 쓰기 비용 — 모든 게시글 변경이 요약 경합 집중 행을 잠그는지 봅니다.
  3. 재처리 — event_id·버전으로 중복 적용을 막습니다.
  4. 정답 기준 — 분쟁과 복구에서는 정규 원본을 원본으로 둡니다.

멱등 요약 재생성

먼저 정확한 재생성 쿼리를 만든 뒤 증분 최적화를 추가합니다.

고정 원본 세 행의 집계 기여

공개 상태와 cutoff 조건이 각 게시글을 집계에 포함하는지 확인합니다.

고정 원본 세 행의 집계 기여
원본 게시글상태 · 공개 시각건수 · 조회수 기여
게시글 1 · 회원 1PUBLISHED · 7월 10일1건 · 50회
게시글 2 · 회원 1PUBLISHED · 7월 11일1건 · 40회
게시글 3 · 회원 2DRAFT · NULL0건 · 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회로 덮어씁니다.

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 refreshed

0과 행 없음의 API 의미를 정하고, 삭제된 회원의 요약은 FK ON DELETE CASCADE로 함께 제거합니다.

원문의 VALUES(열) 참조는 MySQL 8.4에서 지원되지만 폐기 예정 문법입니다. INSERT ... SELECT에서는 집계 SELECT를 파생 테이블로 감싼 뒤 열 별칭을 UPDATE에서 참조하는 대안을 사용할 수 있습니다. VALUES/SET 뒤의 행 별칭 문법을 SELECT 뒤에 그대로 붙이지 않습니다.

핵심은 누적 더하기가 아니라 원본 절대값 재계산입니다.

불일치 주입 뒤 재계산으로 복구하기

  1. 불일치 주입 — 요약 UPDATE를 빼고 원본 한 행만 추가합니다.
  2. 일치 검사 — 원본-요약 차이를 회원별로 찾습니다.
  3. 재생성 — 원본을 고정한 채 같은 기준 시각으로 두 번 실행해 집계값이 같고 rebuilt_at만 갱신되는지 확인합니다.
  4. 지연 이벤트 — 기준 시각 이전 게시글이 늦게 들어올 때 과거 구간 재계산 정책을 검토합니다.

원본과 요약 비교

MySQL에는 완전 외부 JOIN이 없으므로 회원 기준 LEFT JOIN으로 0건까지 포함해 차이를 찾습니다.

member별 drift 진단
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 경과 시간, 불일치 행 건수, 재생성 소요 시간을 운영 지표로 둡니다.

재생성은 작은 배치와 일관된 원본 읽기 범위를 정해 실행하고 오류가 난 회원 범위를 재시작할 커서를 기록합니다.

원본 수정·삭제·재처리 실험

  1. 수정 — PUBLISHED 게시글 조회수를 40에서 45로 바꾸고 불일치를 봅니다.
  2. 상태 — PUBLISHED→DELETED가 집계에서 빠지는지 확인합니다.
  3. 삭제 — 소프트 삭제 조건을 원본 정의에 넣고 일치 여부를 확인합니다.
  4. 재처리 — 같은 이벤트/재생성을 두 번 적용해 값이 늘지 않는지 봅니다.

마지막 문서는 지금까지의 정규 원본, 타입, 인덱스, 요약 규칙을 의존성 순서의 통합 DDL과 시드 데이터·위반·EXPLAIN 검증으로 묶습니다.


역정규화 도입 기준

판단 축확인할 질문
병목원본 쿼리가 실제 행 수와 지연 시간 예산을 넘는가?
원본파생값을 재생할 원본 데이터가 있는가?
신선도허용 지연과 기준 시각을 사용자에게 설명하는가?
멱등재시도·중복 이벤트가 값을 두 번 더하지 않는가?
복구불일치 검사와 전체 재생성 실행 절차가 있는가?

역정규화 열부터 추가하지 않고 원본 쿼리와 재생성을 먼저 완성합니다.

동기화 방법보다 정답 정의와 복구 가능성이 승인 조건입니다.


연습 문제

카테고리별 일일 공개 게시글 수·총 조회수 요약을 설계하세요.

같은 날짜를 재처리해도 중복되지 않고 0건 카테고리도 표시되어야 합니다.

해설과 예시 답안

후보 키 (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값 요약으로 생성되는지 확인합니다.


핵심 정리

  • 역정규화는 원본·기준 시각·갱신·복구 규칙을 동반합니다.
  • 누적 더하기보다 원본 절대값 재생성이 안전한 기준선입니다.
  • 합계·건수를 함께 저장해 재집계 가능성을 남깁니다.
  • 불일치 쿼리와 멱등 재생성을 운영 실행 절차로 둡니다.

다음 문서에서는 회원 게시판 최종 DDL을 빈 데이터베이스에서 실행하고 무결성·쿼리 계획까지 인수 검증합니다.