본문 바로가기

일별 통계 재계산

다시 실행하면 합계가 두 배가 되는 가산 UPSERT를 재현하고, 원래 데이터를 기준 시각까지 다시 세어 날짜 구간을 원자적으로 교체하는 재계산 프로시저를 만듭니다. 일별 통계로 주와 월, 연의 보고서를 만들고, 확정된 과거와 오늘의 원본을 합치는 조회도 다룹니다.

집계 작업이 성공 응답을 보내기 직전에 연결이 끊기면, 스케줄러는 같은 날짜를 다시 실행합니다. 통계 행에 새 합계를 더하는 방식이라면 같은 원본이 두 번 반영됩니다. 재시도는 예외가 아니라 분산 실행의 정상 경로이므로, 결과가 호출의 횟수에 따라 달라져서는 안 됩니다.

회원 게시판의 일별 통계는 posts에서 다시 만들 수 있는 파생 데이터입니다. 한 날짜의 published_count와 total_views를 원래 데이터에서 집계값으로 계산해 대상 구간을 교체하면, 두 번째 실행도 같은 행 집합에 도달합니다. 집계의 단위는 author_id와 UTC의 stat_date이고, 게시 시각이 기준 시각보다 이른 현재의 PUBLISHED 게시글을 포함합니다.

기준 시각은 게시 시각을 거르는 조건일 뿐입니다. 그 뒤에 바뀐 조회수나 상태, 소급해 넣은 행을 이전 값으로 되돌리는 MVCC 스냅샷의 식별자가 아니므로, 같은 기준 시각만으로 과거 실행의 결과가 재현되지는 않습니다. 재계산 프로시저는 임시 테이블에서 새 집합을 다 만든 뒤, 트랜잭션 안에서 대상 날짜를 DELETE하고 집계 결과를 INSERT합니다. 긴 원본 SELECT를 대상의 잠금을 잡은 채 실행하지 않도록, 계산과 교체의 단계를 나눕니다.

누적 UPSERT의 재시도 오류

첫 실행은 커밋되었지만 작업자가 성공 응답을 받지 못하면, 같은 SELECT 결과가 다시 들어옵니다. 건수 = 건수 + 새 건수는 원본 사건이 이미 처리되었는지를 구분하지 않으므로 숫자가 배로 늘어납니다.

동일 날짜를 두 번 실행하면 조회수 120이 되는 가산 집계
DROP TABLE IF EXISTS daily_post_stats_bad;
CREATE TABLE daily_post_stats_bad (
  author_id BIGINT UNSIGNED NOT NULL,
  stat_date DATE NOT NULL,
  published_count INT UNSIGNED NOT NULL,
  total_views BIGINT UNSIGNED NOT NULL,
  PRIMARY KEY (author_id, stat_date)
) ENGINE=InnoDB;

INSERT INTO daily_post_stats_bad VALUES (1, '2026-07-15', 1, 60) AS new
ON DUPLICATE KEY UPDATE
  published_count = published_count + new.published_count,
  total_views = total_views + new.total_views;

-- 응답 손실 뒤 같은 배치를 재시도한다.
INSERT INTO daily_post_stats_bad VALUES (1, '2026-07-15', 1, 60) AS new
ON DUPLICATE KEY UPDATE
  published_count = published_count + new.published_count,
  total_views = total_views + new.total_views;

SELECT * FROM daily_post_stats_bad;
author_id | stat_date  | published_count | total_views
1         | 2026-07-15 | 2               | 120

원본은 조회수 60인 게시글 하나뿐인데, 두 번의 실행으로 대상은 2건과 120회가 되었습니다. 새 행의 값은 8장 2편처럼 VALUES 절 뒤의 행 별칭 new로 참조했습니다.

가산 갱신이 안전하려면, 각 원본 사건이 정확히 한 번만 더해졌다는 원장이 있어야 합니다. 날짜 단위의 단순한 배치에는 그런 기록이 없고, 결과 행에 last_run_at만 남겨도 커밋 응답을 잃은 작업자는 어느 실행이 반영되었는지 확정하기 어렵습니다.

전체 재계산은 원본을 다시 읽는 비용을 치르지만 복구가 단순합니다. 같은 날짜 범위의 현재 원본을 다시 읽어 대상의 값을 맞출 수 있으며, 같은 결과를 기대하려면 포함되는 원본의 행과 값도 같아야 합니다. 일별 통계처럼 다시 세기 쉬운 날짜 범위라면 이 성질의 운영 가치가 큽니다.

기준 시각이 없는 집계는 실행 중에 움직이는 원본을 읽습니다. 작업의 시작과 끝 사이에 새로 게시된 글이 들어오면 날짜마다 서로 다른 시점의 집합을 볼 수 있으므로, p_cutoff를 고정하고 published_at이 그보다 이른 행만 포함해 이번 실행의 범위를 명시합니다. 나머지는 다음 실행이 가져갑니다.

집계 단위와 기준 시각

기본 키 (author_id, stat_date)는 통계 행 하나의 업무 단위를 나타냅니다. published_count와 total_views는 저장해 둔 증가분이 아니라, 실행할 때 읽은 원본에 p_cutoff 조건을 적용한 집계의 결과이며, rebuild_run_id와 source_cutoff_at은 그 값이 어떤 실행 조건에서 만들어졌는지 설명합니다.

임시 집계에서 오류가 나면 대상은 아직 바뀌지 않습니다. 계산을 마친 뒤 DELETE와 INSERT를 한 트랜잭션으로 커밋하므로, READ COMMITTED와 REPEATABLE READ의 일관된 읽기는 커밋 전의 중간 삭제를 보지 않습니다. READ UNCOMMITTED는 이 보장에 들지 않으며, 같은 날짜 범위의 재계산은 바깥에서 직렬화해야 오래전에 계산한 임시 결과가 나중에 덮어쓰는 경합을 피할 수 있습니다.

이 예제는 게시글이 없는 날짜를 저장하지 않으며, 달력 보고서는 날짜 차원과 LEFT JOIN해 0을 보여 줍니다. 모든 회원과 날짜의 조합을 미리 만들면 행의 수가 급증하고, 새 회원마다 백필이 필요해집니다. 또 VALUES(col)을 쓰는 UPSERT는 폐기 예정이므로, 이 설계는 덧셈 UPSERT 자체를 없애고 임시 결과를 그대로 INSERT합니다.

날짜별 집계값 재계산

대상에는 원본의 기준 시각과 실행의 UUID를 저장합니다. 프로시저는 매개변수를 검증한 뒤 임시 테이블을 만들고 게시된 글을 GROUP BY하며, 대상의 변경은 계산이 성공한 뒤에 시작합니다. 게시된 글이 한 번도 조회되지 않았을 수 있으므로 total_views의 0은 허용하고, 게시 건수가 양수인지만 검사합니다.

12장 4편의 daily_post_stats는 이 글의 스키마로 바꿉니다. 그 테이블을 참조하는 다른 테이블은 없으므로 지우고 다시 만들 수 있으며, SQL은 진행 중인 다른 트랜잭션이 없는 연결에서 차례로 실행합니다.

멱등 일별 통계 DDL과 전체 재계산 프로시저
DROP PROCEDURE IF EXISTS rebuild_daily_post_stats;
DROP TABLE IF EXISTS daily_post_stats;
CREATE TABLE daily_post_stats (
  author_id BIGINT UNSIGNED NOT NULL,
  stat_date DATE NOT NULL,
  published_count INT UNSIGNED NOT NULL,
  total_views BIGINT UNSIGNED NOT NULL,
  source_cutoff_at DATETIME(6) NOT NULL,
  rebuild_run_id BINARY(16) NOT NULL,
  rebuilt_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (author_id, stat_date),
  CONSTRAINT fk_daily_stat_member FOREIGN KEY (author_id)
    REFERENCES members (id) ON DELETE RESTRICT,
  CONSTRAINT chk_daily_stat_values CHECK (published_count > 0),
  INDEX ix_daily_stat_date (stat_date, author_id),
  INDEX ix_daily_stat_cutoff (source_cutoff_at)
) ENGINE=InnoDB;

DELIMITER //
CREATE PROCEDURE rebuild_daily_post_stats(
  IN p_from DATE,
  IN p_to DATE,
  IN p_cutoff DATETIME(6),
  IN p_run BINARY(16)
)
BEGIN
  DECLARE EXIT HANDLER FOR SQLEXCEPTION
  BEGIN
    ROLLBACK;
    DROP TEMPORARY TABLE IF EXISTS next_daily_stat;
    RESIGNAL;
  END;

  IF p_from IS NULL OR p_to IS NULL OR p_cutoff IS NULL OR p_run IS NULL
     OR p_to <= p_from OR p_cutoff < CAST(p_to AS DATETIME) THEN
    SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'invalid rebuild boundary';
  END IF;

  DROP TEMPORARY TABLE IF EXISTS next_daily_stat;
  CREATE TEMPORARY TABLE next_daily_stat (
    author_id BIGINT UNSIGNED NOT NULL,
    stat_date DATE NOT NULL,
    published_count INT UNSIGNED NOT NULL,
    total_views BIGINT UNSIGNED NOT NULL,
    PRIMARY KEY (author_id, stat_date)
  ) ENGINE=InnoDB;

  INSERT INTO next_daily_stat
  SELECT author_id, DATE(published_at), COUNT(*), SUM(view_count)
  FROM posts
  WHERE status = 'PUBLISHED'
    AND published_at >= p_from AND published_at < p_to
    AND published_at < p_cutoff
  GROUP BY author_id, DATE(published_at);

  START TRANSACTION;
  DELETE FROM daily_post_stats
  WHERE stat_date >= p_from AND stat_date < p_to;
  INSERT INTO daily_post_stats
    (author_id, stat_date, published_count, total_views,
     source_cutoff_at, rebuild_run_id)
  SELECT author_id, stat_date, published_count, total_views, p_cutoff, p_run
  FROM next_daily_stat;
  COMMIT;
  DROP TEMPORARY TABLE next_daily_stat;
END//
DELIMITER ;

SET @post_id = (
  SELECT id FROM posts
  WHERE status = 'PUBLISHED'
  ORDER BY id LIMIT 1
);
SET @author_id = (SELECT author_id FROM posts WHERE id = @post_id);
UPDATE posts
SET view_count = 95,
    created_at = '2026-07-15 08:00:00',
    published_at = '2026-07-15 09:00:00'
WHERE id = @post_id;

SET @run_a = UUID_TO_BIN('88888888-8888-4888-8888-888888888888');
CALL rebuild_daily_post_stats('2026-07-15', '2026-07-16', '2026-07-16 01:00:00', @run_a);
SET @checksum_a = (
  SELECT COALESCE(SUM(published_count * 1000003 + total_views), 0)
  FROM daily_post_stats WHERE stat_date = '2026-07-15'
);
CALL rebuild_daily_post_stats('2026-07-15', '2026-07-16', '2026-07-16 01:00:00', @run_a);
SET @checksum_b = (
  SELECT COALESCE(SUM(published_count * 1000003 + total_views), 0)
  FROM daily_post_stats WHERE stat_date = '2026-07-15'
);

SELECT author_id, stat_date, published_count, total_views, source_cutoff_at
FROM daily_post_stats
ORDER BY author_id, stat_date;

SELECT @checksum_a AS first_checksum, @checksum_b AS second_checksum;
author_id | stat_date  | published_count | total_views | source_cutoff_at
1         | 2026-07-15 | 1               | 95          | 2026-07-16 01:00:00.000000

first_checksum | second_checksum
1000098        | 1000098

posts에는 12장 4편의 시드 게시글 하나뿐이고, 그 글을 7월 15일 09:00에 조회수 95로 게시한 것으로 맞추었습니다. 두 번 실행해도 통계는 1건과 95회로 같고, 체크섬도 1 × 1000003 + 95 = 1000098로 같습니다. 작성자 회원 1은 16장 2편에서 익명화되었지만 행은 남아 있으므로 통계의 외래 키는 그대로 이어집니다.

여러 게시글이 있다면 체크섬의 숫자는 달라지고, 체크섬이 같다는 것만으로 행마다 같다고 증명되지도 않습니다(연습 문제 2). 새 실행 UUID를 쓰면 rebuild_run_id도 바뀌므로, 값의 멱등성과 실행 정보의 동등성을 구분합니다. 실행 이력을 별도의 테이블로 나누는 설계도 이 차이를 고려해야 합니다.

전체 재계산에서 같은 값과 달라지는 값

임시 합계가 완성되어야 교체를 시작한다. 같은 입력을 다시 교체한다. 각 단계의 값과 전환 이유를 비교합니다.

DAILY REBUILD · ABSOLUTE REPLACE / RETRY

같은 원본은 같은 합계, 정정된 원본은 새 절대합으로 교체한다

게시글 한 건이 95회인 fixture를 같은 조건으로 재실행하면 1건·95회입니다. 20회 정정 뒤에는 원본 75회를 다시 계산하며 이전 합계에 더하거나 두 번 빼지 않습니다.

계산이 끝난 집합만 한 트랜잭션으로 교체한다다섯노드네간선. 날짜/cutoff현재원본집계와임시값완성은대상교체트랜잭션밖이다. 그뒤DELETE,INSERT,COMMIT은동일영역이다. 계산실패전대상은그대로이고교체중SQL예외는원처리기가롤백한다. cutoff는과거스냅샷식별자가아니며동일날짜실행직렬화는별도다. 양쪽같은경계.대상 교체 트랜잭션현재 원본 집계날짜 · cutoff 조건임시 절대값 완성계산 실패 → 대상 보존대상 날짜 DELETE새 절대값 INSERTCOMMIT
계산이 끝난 집합만 한 트랜잭션으로 교체한다다섯노드네간선. 날짜/cutoff현재원본집계와임시값완성은대상교체트랜잭션밖이다. 그뒤DELETE,INSERT,COMMIT은동일영역이다. 계산실패전대상은그대로이고교체중SQL예외는원처리기가롤백한다. cutoff는과거스냅샷식별자가아니며동일날짜실행직렬화는별도다. 양쪽같은경계.대상 교체현재 원본 집계날짜 · cutoff 조건임시 절대값 완성계산 실패는기존 대상 보존대상 날짜 DELETE새 절대값 INSERTCOMMIT
절대 재계산에서 같은 값과 달라지는 값
다시 읽는 원본건수·조회수 결과행과 실행 정보
같은 행·같은 값같은 절대 합계rebuilt_at은 다시 기록
원본 조회수 정정정정된 전체 합계이전 합계에 더하지 않음
원본에서 키가 사라짐그 키의 기여 없음날짜 교체에서 대상 행도 제거
같은 cutoff·변경된 원본새로 읽은 원본에 따라 달라짐cutoff만으로 과거 상태 복원 불가
같은 행·같은 값
건수·조회수 결과: 같은 절대 합계
행과 실행 정보: rebuilt_at은 다시 기록
원본 조회수 정정
건수·조회수 결과: 정정된 전체 합계
행과 실행 정보: 이전 합계에 더하지 않음
원본에서 키가 사라짐
건수·조회수 결과: 그 키의 기여 없음
행과 실행 정보: 날짜 교체에서 대상 행도 제거
같은 cutoff·변경된 원본
건수·조회수 결과: 새로 읽은 원본에 따라 달라짐
행과 실행 정보: cutoff만으로 과거 상태 복원 불가

먼저 계산 · 날짜 집합을 함께 교체

임시 집계 완료 → START TRANSACTION

대상 날짜 DELETE → 새 절대값 INSERT → COMMIT

같은 95회 원본 두 실행의 checksum = 1,000,098

체크섬 일치만으로 행별 동등성을 증명하지 않습니다. 같은 cutoff도 원본 변경을 고정하지 않으며 rebuilt_at은 다시 기록됩니다. 같은 날짜 재빌드는 외부 직렬화가 필요하고, 0건 키는 날짜 교체에서 제거됩니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 먼저 계산부터 재실행까지 진행합니다.
전체 재계산에서 같은 값과 달라지는 값
다시 읽는 원본건수·조회 수 결과행과 실행 정보
같은 행·같은 값같은 전체 합계rebuilt_at은 다시 기록
원본 조회 수 정정정정된 전체 합계이전 합계에 더하지 않음
원본에서 키가 사라짐그 키의 기여 없음날짜 교체에서 대상 행도 제거
같은 cutoff·변경된 원본새로 읽은 원본에 따라 달라짐cutoff만으로 과거 상태 복원 불가
관계도의 보충 설명
전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목전체 재계산에서 같은 값과 달라지는 값
제목·표기같은 원본은 같은 합계, 정정된 원본은 새 전체 합계로 교체한다
관계 설명다섯 노드, 네 간선. 날짜/cutoff 현재 원본 집계와 임시값 완성은 대상 교체 트랜잭션 밖이다. 그 뒤 DELETE, INSERT, COMMIT은 동일 영역이다. 계산 실패 시 대상은 그대로이고 교체 중 SQL 예외는 원 처리기가 롤백한다. cutoff는 과거 스냅샷 식별자가 아니며 동일 날짜 실행 직렬화는 별도다. 양쪽은 같은 경계를 나타낸다.
노드·선 표기임시 집계값 완성
노드·선 표기새 집계값 INSERT
표의 열건수·조회 수 결과
표의 값같은 전체 합계
표의 열원본 조회 수 정정
표의 값같은 행·같은 값. 건수·조회 수 결과: 같은 전체 합계. 행과 실행 정보: rebuilt_at은 다시 기록. 원본 조회 수 정정. 건수·조회 수 결과: 정정된 전체 합계. 행과 실행 정보: 이전 합계에 더하지 않음. 원본에서 키가 사라짐. 건수·조회 수 결과: 그 키의 기여 없음. 행과 실행 정보: 날짜 교체에서 대상 행도 제거. 같은 cutoff·변경된 원본. 건수·조회 수 결과: 새로 읽은 원본에 따라 달라짐. 행과 실행 정보: cutoff만으로 과거 상태 복원 불가.
설명·조건대상 날짜 DELETE → 새 집계값 INSERT → COMMIT
예의 값과 변화
관찰·상황값·보충 정보변화·이유
먼저 계산

임시 합계가 완성되어야 교체를 시작한다.

  • 집계·배치 기록 1: post95
  • 집계·배치 기록 2: count1/views95
  • 집계·배치 기록 3: checksum1000098
  • 계산 실패면 대상 유지
대상 교체 트랜잭션

해당 날짜의 대상 삭제와 삽입을 묶는다.

  • 집계·배치 기록 1: DELETE date
  • 집계·배치 기록 2: INSERT absolute1/95
  • 중간 오류면 ROLLBACK
COMMIT

완성된 합계를 확정한다.

  • 집계·배치 기록 1: 1/95
  • 집계·배치 기록 2: rebuilt_at 기록
  • 날짜별 실행 직렬화 전제
재실행

같은 입력을 다시 교체한다.

  • 집계·배치 기록 1: 1/95 유지
  • 집계·배치 기록 2: rebuilt_at 갱신
  • 정정 -20이면 75; 같은 cutoff가 원본을 동결하지 않음

전체 재계산에서 같은 값과 달라지는 값

임시 합계가 완성되어야 교체를 시작한다. 같은 입력을 다시 교체한다. 각 단계의 값과 전환 이유를 비교합니다.

DAILY REBUILD · ABSOLUTE REPLACE / RETRY

같은 원본은 같은 합계, 정정된 원본은 새 절대합으로 교체한다

게시글 한 건이 95회인 fixture를 같은 조건으로 재실행하면 1건·95회입니다. 20회 정정 뒤에는 원본 75회를 다시 계산하며 이전 합계에 더하거나 두 번 빼지 않습니다.

계산이 끝난 집합만 한 트랜잭션으로 교체한다다섯노드네간선. 날짜/cutoff현재원본집계와임시값완성은대상교체트랜잭션밖이다. 그뒤DELETE,INSERT,COMMIT은동일영역이다. 계산실패전대상은그대로이고교체중SQL예외는원처리기가롤백한다. cutoff는과거스냅샷식별자가아니며동일날짜실행직렬화는별도다. 양쪽같은경계.대상 교체 트랜잭션현재 원본 집계날짜 · cutoff 조건임시 절대값 완성계산 실패 → 대상 보존대상 날짜 DELETE새 절대값 INSERTCOMMIT
계산이 끝난 집합만 한 트랜잭션으로 교체한다다섯노드네간선. 날짜/cutoff현재원본집계와임시값완성은대상교체트랜잭션밖이다. 그뒤DELETE,INSERT,COMMIT은동일영역이다. 계산실패전대상은그대로이고교체중SQL예외는원처리기가롤백한다. cutoff는과거스냅샷식별자가아니며동일날짜실행직렬화는별도다. 양쪽같은경계.대상 교체현재 원본 집계날짜 · cutoff 조건임시 절대값 완성계산 실패는기존 대상 보존대상 날짜 DELETE새 절대값 INSERTCOMMIT
절대 재계산에서 같은 값과 달라지는 값
다시 읽는 원본건수·조회수 결과행과 실행 정보
같은 행·같은 값같은 절대 합계rebuilt_at은 다시 기록
원본 조회수 정정정정된 전체 합계이전 합계에 더하지 않음
원본에서 키가 사라짐그 키의 기여 없음날짜 교체에서 대상 행도 제거
같은 cutoff·변경된 원본새로 읽은 원본에 따라 달라짐cutoff만으로 과거 상태 복원 불가
같은 행·같은 값
건수·조회수 결과: 같은 절대 합계
행과 실행 정보: rebuilt_at은 다시 기록
원본 조회수 정정
건수·조회수 결과: 정정된 전체 합계
행과 실행 정보: 이전 합계에 더하지 않음
원본에서 키가 사라짐
건수·조회수 결과: 그 키의 기여 없음
행과 실행 정보: 날짜 교체에서 대상 행도 제거
같은 cutoff·변경된 원본
건수·조회수 결과: 새로 읽은 원본에 따라 달라짐
행과 실행 정보: cutoff만으로 과거 상태 복원 불가

먼저 계산 · 날짜 집합을 함께 교체

임시 집계 완료 → START TRANSACTION

대상 날짜 DELETE → 새 절대값 INSERT → COMMIT

같은 95회 원본 두 실행의 checksum = 1,000,098

체크섬 일치만으로 행별 동등성을 증명하지 않습니다. 같은 cutoff도 원본 변경을 고정하지 않으며 rebuilt_at은 다시 기록됩니다. 같은 날짜 재빌드는 외부 직렬화가 필요하고, 0건 키는 날짜 교체에서 제거됩니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 먼저 계산부터 재실행까지 진행합니다.
전체 재계산에서 같은 값과 달라지는 값
다시 읽는 원본건수·조회 수 결과행과 실행 정보
같은 행·같은 값같은 전체 합계rebuilt_at은 다시 기록
원본 조회 수 정정정정된 전체 합계이전 합계에 더하지 않음
원본에서 키가 사라짐그 키의 기여 없음날짜 교체에서 대상 행도 제거
같은 cutoff·변경된 원본새로 읽은 원본에 따라 달라짐cutoff만으로 과거 상태 복원 불가

관계도의 보충 설명

전체 도표의 표기·관계·조건
구분전체 도표의 내용
제목전체 재계산에서 같은 값과 달라지는 값
제목·표기같은 원본은 같은 합계, 정정된 원본은 새 전체 합계로 교체한다
관계 설명다섯 노드, 네 간선. 날짜/cutoff 현재 원본 집계와 임시값 완성은 대상 교체 트랜잭션 밖이다. 그 뒤 DELETE, INSERT, COMMIT은 동일 영역이다. 계산 실패 시 대상은 그대로이고 교체 중 SQL 예외는 원 처리기가 롤백한다. cutoff는 과거 스냅샷 식별자가 아니며 동일 날짜 실행 직렬화는 별도다. 양쪽은 같은 경계를 나타낸다.
노드·선 표기임시 집계값 완성
노드·선 표기새 집계값 INSERT
표의 열건수·조회 수 결과
표의 값같은 전체 합계
표의 열원본 조회 수 정정
표의 값같은 행·같은 값. 건수·조회 수 결과: 같은 전체 합계. 행과 실행 정보: rebuilt_at은 다시 기록. 원본 조회 수 정정. 건수·조회 수 결과: 정정된 전체 합계. 행과 실행 정보: 이전 합계에 더하지 않음. 원본에서 키가 사라짐. 건수·조회 수 결과: 그 키의 기여 없음. 행과 실행 정보: 날짜 교체에서 대상 행도 제거. 같은 cutoff·변경된 원본. 건수·조회 수 결과: 새로 읽은 원본에 따라 달라짐. 행과 실행 정보: cutoff만으로 과거 상태 복원 불가.
설명·조건대상 날짜 DELETE → 새 집계값 INSERT → COMMIT
예의 값과 변화
관찰·상황값·보충 정보변화·이유
먼저 계산

임시 합계가 완성되어야 교체를 시작한다.

  • 집계·배치 기록 1: post95
  • 집계·배치 기록 2: count1/views95
  • 집계·배치 기록 3: checksum1000098
  • 계산 실패면 대상 유지
대상 교체 트랜잭션

해당 날짜의 대상 삭제와 삽입을 묶는다.

  • 집계·배치 기록 1: DELETE date
  • 집계·배치 기록 2: INSERT absolute1/95
  • 중간 오류면 ROLLBACK
COMMIT

완성된 합계를 확정한다.

  • 집계·배치 기록 1: 1/95
  • 집계·배치 기록 2: rebuilt_at 기록
  • 날짜별 실행 직렬화 전제
재실행

같은 입력을 다시 교체한다.

  • 집계·배치 기록 1: 1/95 유지
  • 집계·배치 기록 2: rebuilt_at 갱신
  • 정정 -20이면 75; 같은 cutoff가 원본을 동결하지 않음

DELETE의 범위는 ix_daily_stat_date로 좁힙니다. 큰 월 단위의 재계산은 하루나 일주일 단위의 트랜잭션으로 나누어 언두 로그와 복제본의 지연을 관리하며, 각 구간은 집계값 전체를 독립적으로 교체할 수 있어야 합니다. 과거 게시글의 조회수가 정정되면 해당 stat_date를 다시 실행하고, 기준 시각을 새로 올려 계산한 집계값이 이전 합계를 빼는 변화량의 계산 없이 정답을 덮습니다. 바뀐 날짜의 큐는 16장 4편에서 다룹니다.

일별 통계에서 주와 월, 연 통계 계산하기

일별 통계는 원본 사건보다 훨씬 작으면서도 여러 기간 보고서의 공통 재료가 됩니다. 월별 테이블을 따로 만들기 전에, 일별 행을 합치는 비용이 충분히 작은지 먼저 측정합니다. 보통은 일별 통계 하나로 주와 월, 연을 만들어야 정정의 경로와 저장 구조가 단순합니다.

일별 통계를 주·월·연 단위로 다시 집계
-- 월요일을 한 주의 시작으로 사용한다.
SELECT
  author_id,
  DATE_SUB(stat_date, INTERVAL WEEKDAY(stat_date) DAY) AS week_start,
  SUM(published_count) AS published_count,
  SUM(total_views) AS total_views
FROM daily_post_stats
GROUP BY author_id,
         DATE_SUB(stat_date, INTERVAL WEEKDAY(stat_date) DAY)
ORDER BY author_id, week_start;

SELECT
  author_id,
  DATE_SUB(stat_date, INTERVAL DAYOFMONTH(stat_date) - 1 DAY) AS month_start,
  SUM(published_count) AS published_count,
  SUM(total_views) AS total_views
FROM daily_post_stats
GROUP BY author_id,
         DATE_SUB(stat_date, INTERVAL DAYOFMONTH(stat_date) - 1 DAY)
ORDER BY author_id, month_start;

SELECT
  author_id,
  MAKEDATE(YEAR(stat_date), 1) AS year_start,
  SUM(published_count) AS published_count,
  SUM(total_views) AS total_views
FROM daily_post_stats
GROUP BY author_id, MAKEDATE(YEAR(stat_date), 1)
ORDER BY author_id, year_start;
author_id | week_start | published_count | total_views
1         | 2026-07-13 | 1               | 95

author_id | month_start | published_count | total_views
1         | 2026-07-01  | 1               | 95

author_id | year_start | published_count | total_views
1         | 2026-01-01 | 1               | 95

2026-07-15는 수요일이므로 WEEKDAY가 2를 돌려주고, 주의 시작은 그 주 월요일인 7월 13일입니다. 일별 행이 하나뿐이라 세 보고서의 합계는 모두 같습니다.

주의 시작 요일과 사용자의 시간대는 보고서 정의의 일부입니다. ISO 주차나 회사의 회계연도, 지역별 하루가 필요하다면 날짜 문자열을 잘라 묶지 말고, 달력 차원에 week_start나 fiscal_period, 시간대의 규칙을 둡니다. 일별 행이 수억 건으로 커지거나 월 보고서의 시간 목표가 엄격할 때만 월별 요약을 더하고, 월 합계가 일별 합계와 맞는지 계속 확인합니다.

확정된 과거와 오늘의 원본을 합치는 조회

야간 배치가 어제까지의 일별 통계를 확정했다면, 오늘의 행은 아직 대상에 없을 수 있습니다. 그렇다고 전체 원본을 다시 집계할 필요는 없으며, [보고서 시작, 오늘)은 일별 통계에서, [오늘, 현재 기준 시각)만 원본에서 계산해 두 집합을 합칩니다.

과거 일별 통계와 오늘 원본을 합친 보고서
SET @report_from = DATE('2026-07-01');
SET @today = DATE('2026-07-15');
SET @live_cutoff = TIMESTAMP('2026-07-15 14:00:00');

WITH historical AS (
  SELECT author_id,
         SUM(published_count) AS published_count,
         SUM(total_views) AS total_views
  FROM daily_post_stats
  WHERE stat_date >= @report_from
    AND stat_date < @today
  GROUP BY author_id
), today_live AS (
  SELECT author_id,
         COUNT(*) AS published_count,
         SUM(view_count) AS total_views
  FROM posts
  WHERE status = 'PUBLISHED'
    AND published_at >= @today
    AND published_at < DATE_ADD(@today, INTERVAL 1 DAY)
    AND published_at < @live_cutoff
  GROUP BY author_id
), combined AS (
  SELECT * FROM historical
  UNION ALL
  SELECT * FROM today_live
)
SELECT author_id,
       SUM(published_count) AS published_count,
       SUM(total_views) AS total_views
FROM combined
GROUP BY author_id
ORDER BY author_id;
author_id | published_count | total_views
1         | 1               | 95

이 예제에서는 7월 1일부터 14일까지의 일별 통계가 없으므로 historical은 비어 있고, 오늘 09:00에 게시한 글 하나가 today_live에서 더해집니다. 15일의 일별 행은 이미 있지만 stat_date < @today 조건 때문에 과거 쪽에 들어가지 않아 두 번 더해지지 않습니다.

두 구간이 겹치면 오늘의 값이 두 번 더해지고, 사이가 비면 일부 게시글이 빠집니다. 그래서 과거 대상의 마지막 source_cutoff_at과 오늘의 시작 시각, 실시간 기준 시각을 결과의 정보에 함께 남기고, 오늘의 통계가 이미 발행된 뒤에도 같은 혼합 SQL을 쓴다면 대상의 범위를 반드시 stat_date < @today로 제한합니다.

DELETE·INSERT와 집계값을 대체하는 UPSERT 선택하기

지금의 프로시저는 DELETE한 뒤 INSERT하여 대상 날짜 전체를 교체하므로, 원본에서 사라진 키도 대상에서 함께 지워집니다. 날짜 범위가 작고 재실행이 드물다면 가장 이해하기 쉽습니다. 반면 대상 키의 대부분이 그대로이고 일부 집계값만 바뀐다면, UPSERT로 인덱스의 변경 횟수를 줄일 수 있습니다.

아래는 집계값을 더하지 않고 대체하는 UPSERT입니다. 원본과 맞지 않는 3건과 180회를 일부러 넣으므로, 트랜잭션 안에서 두 번 실행해 결과를 보고 ROLLBACK합니다.

기존 합계에 더하지 않는 집계값을 대체하는 UPSERT
SET @run_b = UUID_TO_BIN('99999999-9999-4999-8999-999999999999');

START TRANSACTION;

INSERT INTO daily_post_stats (
  author_id, stat_date, published_count, total_views,
  source_cutoff_at, rebuild_run_id
)
VALUES (
  1, '2026-07-15', 3, 180,
  '2026-07-16 01:00:00', @run_b
) AS incoming
ON DUPLICATE KEY UPDATE
  published_count = incoming.published_count,
  total_views = incoming.total_views,
  source_cutoff_at = incoming.source_cutoff_at,
  rebuild_run_id = incoming.rebuild_run_id,
  rebuilt_at = CURRENT_TIMESTAMP(6);

INSERT INTO daily_post_stats (
  author_id, stat_date, published_count, total_views,
  source_cutoff_at, rebuild_run_id
)
VALUES (
  1, '2026-07-15', 3, 180,
  '2026-07-16 01:00:00', @run_b
) AS incoming
ON DUPLICATE KEY UPDATE
  published_count = incoming.published_count,
  total_views = incoming.total_views,
  source_cutoff_at = incoming.source_cutoff_at,
  rebuild_run_id = incoming.rebuild_run_id,
  rebuilt_at = CURRENT_TIMESTAMP(6);

SELECT published_count, total_views
FROM daily_post_stats
WHERE author_id = 1 AND stat_date = '2026-07-15';

ROLLBACK;
published_count | total_views
3               | 180

같은 입력을 두 번 실행해도 3건과 180회로 유지됩니다. total_views = total_views + incoming.total_views처럼 기존 값에 더했다면 재시도 때 360회가 되어 멱등성을 잃었을 것입니다. ROLLBACK했으므로 대상은 다시 1건과 95회입니다.

집계값을 대체하는 UPSERT도, 원본에서 완전히 사라진 대상 전용의 행을 지우지는 못하므로 별도의 안티 조인 삭제나 범위의 교체가 필요합니다.

방식장점주의할 점알맞은 경우
범위 DELETE 후 INSERT결과 집합을 통째로 바꾸어 오래된 행을 지운다인덱스와 언두, 리두 로그의 비용작은 날짜 구간 전체의 재생성
집계값을 대체하는 UPSERT있는 키의 변경량이 작다대상 전용 행을 따로 지워야 한다일부 키만 자주 바뀔 때
증가분을 더하는 UPSERT단순한 이벤트 합산이 빠르다중복 소비와 정정, 재시도에 약하다정확히 한 번의 처리가 따로 확인될 때만

어느 방식을 쓰든, 같은 기준 시각의 원본 집계와 대상을 양방향으로 비교하는 절차는 남겨 둡니다. 성능의 차이는 데이터의 크기와 보조 인덱스의 수, 정정의 비율에 따라 달라지므로, 대표 날짜로 리두 로그와 실행 시간, 잠금의 범위를 함께 측정합니다.

더 깊이: UPSERT가 돌려주는 행 수와 UNIQUE 키가 여럿일 때

INSERT ... ON DUPLICATE KEY UPDATE의 영향받은 행 수는 행마다 다르게 셉니다. 새 행을 넣으면 1, 기존 행을 바꾸면 2, 기존 행을 지금과 같은 값으로 두면 0입니다. 그래서 이 숫자로 첫 실행이 새 행을 만들었는지 기존 행을 고쳤는지 구별할 수 있지만, 위의 UPSERT는 rebuilt_at을 실행할 때마다 새 시각으로 바꾸므로 같은 집계값으로 다시 실행해도 2로 셉니다. 또 연결에 CLIENT_FOUND_ROWS를 켠 드라이버는 같은 값으로 둔 행도 1로 세므로, 이 숫자로 재실행을 판단하려면 연결 설정부터 확인합니다.

겹침을 판단하는 키가 여럿인 테이블에서는 더 조심해야 합니다. MySQL 8.4 매뉴얼에 따르면 새 행이 서로 다른 기존 행의 두 UNIQUE 키와 동시에 겹칠 때 MySQL은 그 가운데 한 행만 갱신하며, 매뉴얼은 UNIQUE 인덱스가 여럿인 테이블에는 이 절을 쓰지 않는 편이 좋다고 권합니다. 기본 키나 UNIQUE 키가 둘 이상인 테이블에 대한 이 문장과 INSERT ... SELECT ... ON DUPLICATE KEY UPDATE는 문장 기반 복제에서 안전하지 않은 문장으로 표시됩니다. 이 글의 daily_post_stats는 기본 키 (author_id, stat_date) 하나로만 겹침을 판단하므로, 어느 행을 갱신할지가 하나로 정해집니다.

새 행을 참조하는 VALUES(열) 함수는 사용 중단되었으므로, 이 글처럼 VALUES (...) AS incoming 뒤에 행 별칭을 붙여 incoming.total_views로 씁니다. 별칭 뒤에 AS incoming(a, b, ...)처럼 열 별칭을 함께 적으면, 갱신식에서 열 이름만으로 새 값을 가리킬 수도 있습니다.

재호출과 원본 수정의 검증

같은 실행의 재호출 결과를 비교한 뒤, 대상과 원본을 같은 집계 단위로 묶은 CTE가 차이 행을 돌려주지 않는지 확인합니다. 첫 줄은 앞에서 같은 날짜를 두 번 재계산하고 각각 구해 둔 @checksum_a와 @checksum_b를 비교해, 같으면 1을 돌려줍니다. CTE의 differences는 두 방향의 LEFT JOIN을 UNION ALL로 합칩니다. 위쪽은 원본에는 있는데 대상에 없거나 값이 다른 행을, 아래쪽은 원본에는 없는데 대상에만 남은 행을 찾고, 마지막 쿼리는 기본 키가 되어야 할 (author_id, stat_date)가 겹친 행을 찾습니다.

일별 통계 체크섬과 양방향 차집합
SELECT @checksum_a = @checksum_b AS retry_same_result;

WITH source_value AS (
  SELECT author_id, DATE(published_at) AS stat_date,
         COUNT(*) AS published_count, SUM(view_count) AS total_views
  FROM posts
  WHERE status = 'PUBLISHED'
    AND published_at >= '2026-07-15' AND published_at < '2026-07-16'
    AND published_at < '2026-07-16 01:00:00'
  GROUP BY author_id, DATE(published_at)
), differences AS (
  SELECT s.author_id, s.stat_date
  FROM source_value AS s
  LEFT JOIN daily_post_stats AS d
    ON d.author_id = s.author_id AND d.stat_date = s.stat_date
  WHERE d.author_id IS NULL
     OR d.published_count <> s.published_count
     OR d.total_views <> s.total_views
  UNION ALL
  SELECT d.author_id, d.stat_date
  FROM daily_post_stats AS d
  LEFT JOIN source_value AS s
    ON s.author_id = d.author_id AND s.stat_date = d.stat_date
  WHERE d.stat_date = '2026-07-15' AND s.author_id IS NULL
)
SELECT * FROM differences;

SELECT author_id, stat_date FROM daily_post_stats
GROUP BY author_id, stat_date HAVING COUNT(*) > 1;
retry_same_result
1

재호출의 결과가 같고, 차이와 기본 키 중복의 검사는 모두 빈 결과입니다. 대상 전용의 행도 함께 확인하는 것은, 원본이 0건이 된 날짜라면 DELETE 뒤에 새 행이 들어오지 않아 사라지는 것이 올바르기 때문입니다.

보고서는 여러 날짜 행의 source_cutoff_at이 같은지도 확인합니다. 범위의 재계산이 일부만 끝났다면 서로 다른 기준 시각이 섞일 수 있으므로, 월 보고서는 완료된 실행의 목록이 가리키는 기준 시각 이하의 행만 쓰거나 스냅샷 테이블로 발행합니다.

임시 테이블이 메모리의 한도를 넘을 수 있어 ENGINE=InnoDB를 명시했습니다. 임시 집계를 적재하다 디스크가 부족해지면 대상의 DELETE 전이므로 기존 통계가 보존되고, 대상의 INSERT 중에 공간이 부족해지면 처리기의 롤백 범위에 들어갑니다.

사용자의 지역 날짜를 집계 단위로 삼는다면 회원의 시간대 스냅샷이 필요합니다. 현재 프로필의 시간대를 과거 게시글에 적용하면 여행이나 설정 변경으로 날짜가 옮겨 가므로, 게시글에 display_timezone을 기록하고 CONVERT_TZ의 결과를 원본의 집계 단위로 씁니다.

따져 볼 것질문
범위한 번에 다시 읽을 원본의 날짜가 운영에서 허용되는 양인가
정정과거 사건의 값이 바뀌거나 삭제되는 일이 잦은가
재시도작업자가 같은 구간을 중복해서 실행할 수 있는가
신선도허용하는 지연을 정하고 기준 시각을 보고서에 드러낼 수 있는가
일치 확인원본과 대상을 같은 집계 단위로 비교하는 쿼리가 있는가

작은 날짜 범위를 쉽게 다시 읽을 수 있고 정정이 있다면, 집계값 전체를 대체하는 방법이 단순합니다. 초당 대량의 사건을 바로 반영해야 한다면 이벤트 원장과 증분 기여의 모델이 필요하지만, 그때도 원본 전체를 정기적으로 다시 계산해 대조하는 경로는 남겨 둡니다.

정리

  • 가산 UPSERT는 처리 원장 없이 재시도되면 같은 원본을 다시 더합니다.
  • 전체 재계산은 원본과 기준 시각의 함수로 대상의 날짜 구간을 교체합니다.
  • 임시 계산을 마친 뒤 DELETE와 INSERT를 한 트랜잭션으로 발행합니다.
  • 일별 통계 하나로 주와 월, 연의 보고서를 만들고, 확정된 과거와 오늘의 원본은 겹치지 않게 합칩니다.
  • 재호출의 체크섬과 양방향 집계 단위의 대조가 멱등성을 확인합니다.

연습 문제

1. 조회수 정정 뒤의 재계산

7월 15일 게시글 한 건의 조회수가 20회 줄었다고 하고, 새 기준 시각으로 다시 계산해 보세요. 대상에서 20을 빼는 UPDATE를 쓰지 말고, 원본 GROUP BY의 결과 전체로 교체해야 합니다.

해설 보기

원본을 고치는 트랜잭션을 먼저 커밋한 뒤, 같은 날짜만 프로시저에 넘깁니다. 새 실행 UUID와 더 늦은 기준 시각으로 집계값을 다시 발행합니다.

UPDATE posts
SET view_count = view_count - 20
WHERE id = @post_id AND view_count > 20;

SET @run_b = UUID_TO_BIN('99999999-9999-4999-8999-999999999999');
CALL rebuild_daily_post_stats('2026-07-15', '2026-07-16', '2026-07-16 02:00:00', @run_b);

SELECT d.total_views, s.source_views, d.total_views = s.source_views AS matches
FROM daily_post_stats AS d
JOIN (
  SELECT author_id, DATE(published_at) AS stat_date, SUM(view_count) AS source_views
  FROM posts
  WHERE status = 'PUBLISHED'
    AND published_at >= '2026-07-15' AND published_at < '2026-07-16'
    AND published_at < '2026-07-16 02:00:00'
  GROUP BY author_id, DATE(published_at)
) AS s ON s.author_id = d.author_id AND s.stat_date = d.stat_date;
total_views | source_views | matches
75          | 75           | 1

조회수 95에서 20이 빠진 75가 원본과 대상에 똑같이 나옵니다. 대상의 변화량은 원본의 변화량과 같지만, SQL은 차이를 계산해 더하지 않았습니다. 같은 @run_b로 다시 호출해도 원본이 그대로이므로 total_views는 75에서 더 줄지 않습니다.

2. 체크섬이 같으면 같은 보고서인가

두 회원이 모두 있고 날짜도 같다고 합시다. 원본 집계는 회원 A에게 1건과 95회가 있는데, 대상은 잘못해서 회원 B에게 같은 건수와 조회수를 저장했습니다. SUM(published_count × 1000003 + total_views)만 비교하면 같은 값이 나올까요? 본문의 양방향 차집합은 무엇을 더 확인하나요?

해설 보기

양쪽 모두 1 × 1000003 + 95 = 1000098입니다. 이 체크섬은 author_id를 식에 넣지 않으므로 회원이 달라도 같습니다. 원본에서 대상으로의 조인은 A 키의 누락을, 대상에서 원본으로의 조인은 B 키의 여분을 찾아 차이 행 두 개를 돌려줍니다. 전체 숫자가 맞는지와, 그 숫자가 어느 회원과 날짜의 것인지는 따로 검사해야 합니다.

다음 글 마이크로배치 집계에서는 날짜 전체를 다시 읽는 일을 줄이면서도 가산 불일치를 만들지 않는 마이크로배치의 체크포인트를 설계합니다.