본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
선택 심화 · 16장 : 데이터 수명과 집계

일별 통계 재계산

재실행 때 합계가 두 배가 되는 가산 UPSERT를 버리고 원본 집합을 다시 세어 날짜 구간을 원자 교체합니다.

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

회원 게시판의 일별 통계는 posts에서 다시 만들 수 있는 파생 데이터입니다. 한 날짜의 published_counttotal_views를 원본에서 절대값으로 계산하고 대상 구간을 교체하면 두 번째 실행도 같은 행 집합에 수렴합니다.

집계 단위는 author_id와 UTC stat_date입니다. 원본 기준 시각보다 먼저 게시 시각이 확정된 PUBLISHED 게시글만 포함합니다. 기준 시각을 행에 기록해 두 보고서가 서로 다른 원본 스냅샷을 섞지 않게 합니다.

재빌드 프로시저는 임시 테이블에서 새 집합을 완성한 뒤 트랜잭션 안에서 대상 날짜를 DELETE하고 절대값 행 수를 INSERT합니다. 긴 원본 SELECT를 대상 잠금을 잡은 채 실행하지 않도록 계산과 교체 단계를 구분합니다.


누적 UPSERT의 재시도 오류

첫 실행이 커밋됐지만 작업자가 성공을 못 받으면 같은 SELECT 결과가 다시 들어옵니다. 건수=건수+VALUES(건수)는 원본 사건의 처리 여부를 구분하지 않으므로 숫자가 배수로 증가합니다.

동일 날짜를 두 번 실행하면 조회수 120이 되는 additive 집계
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)
ON DUPLICATE KEY UPDATE
 published_count=published_count+VALUES(published_count),
 total_views=total_views+VALUES(total_views);

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

원본에는 조회수 60인 게시글 하나뿐이지만 대상은 두 번 더해진 값을 보입니다.

오류 실행 결과
source published posts=1
source views=60
target published_count=2
target total_views=120
retry count=2

가산 갱신이 안전하려면 각 원본 이벤트가 정확히 한 번만 더해졌다는 원장이 필요합니다. 단순 날짜 배치에는 그런 증거가 없습니다. 결과 행에 last_run_at만 기록해도 커밋 응답을 잃은 작업자가 어느 실행이 반영됐는지 확정하기 어렵습니다.

절대 재계산은 원본 스캔 비용을 치르지만 복구가 단순합니다. 잘못된 대상을 삭제하고 같은 기준 시각으로 다시 돌리면 정답으로 돌아옵니다. 일별 통계처럼 범위 제한 범위를 다시 세기 쉬운 경우에는 이 성질이 운영 가치가 큽니다.

기준 시각이 없는 집계는 실행 중 움직이는 원본을 읽는다

작업 시작과 끝 사이 새 게시된 게시글이 들어오면 날짜마다 서로 다른 시점의 집합을 볼 수 있습니다. p_cutoff를 고정하고 published_at이 그보다 작은 행만 포함해 이번 실행의 경계를 명시합니다. 다음 실행이 나머지를 가져갑니다.


집계 단위와 기준 시각

PK(author_id, stat_date)는 한 통계 행의 업무 단위를 나타냅니다. published_counttotal_views는 저장된 증가분이 아니라 p_cutoff에서의 함수 결과입니다. rebuild_run_idsource_cutoff_at은 값이 어떤 실행 조건에서 만들어졌는지 설명합니다.

임시 집계에 오류가 있으면 대상은 아직 바뀌지 않습니다. 계산을 마친 뒤 DELETE와 INSERT를 하나의 트랜잭션으로 커밋하므로 읽기 주체는 격리 수준에 따라 이전 또는 새 집합을 보되 빈 중간 상태를 관찰하지 않습니다.

0건 날짜를 행으로 저장할지 결정하기

이 예제는 게시글이 없는 날짜를 저장하지 않습니다. 달력 보고서는 날짜 차원과 LEFT JOIN해 0을 표시합니다. 모든 회원×날짜 조합을 미리 만들면 행 수가 급증하고 새로운 회원 백필이 필요해집니다.

MySQL 8.4 메모: VALUES(col)을 사용하는 UPSERT 표현은 최신 버전에서 사용 중단 예정 경고 대상입니다. 이 설계는 덧셈 UPSERT 자체를 제거하고 임시 결과를 그대로 INSERT합니다.


날짜별 절대값 재계산

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

멱등 daily stat DDL과 absolute rebuild procedure
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_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,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');

두 호출 뒤 행 수와 체크섬이 같고 원본 일치 검사는 차이를 반환하지 않습니다.

개선 실행 결과
first checksum=1000098
second checksum=1000098
checksum equality=true
source-target mismatch rows=0
additive drift=0

같은 실행 UUID를 재사용해도 결과는 값 기준으로 같습니다. 새로운 실행 UUID로 동일 기준 시각을 실행하면 rebuild_run_id만 바뀝니다. 완전한 바이트 동등성이 필요한 요구라면 실행 목록을 별도 테이블에 두고 대상에는 기준 시각만 저장합니다.

DELETE 범위는 ix_daily_stat_date로 좁힙니다. 큰 월 단위 재빌드는 하루 또는 주 단위 트랜잭션으로 분할해 언두 로그와 복제본 지연을 관리합니다. 각 구간은 독립적으로 절대값 대체가 가능해야 합니다.

지연 수정을 흡수하는 방법

과거 게시글 조회수가 정정되면 해당 stat_date를 다시 실행합니다. 원본 기준 시각을 새 시각으로 올린 절대값 재생성은 이전 합계를 빼는 변화량 계산 없이 정답을 덮습니다. 변경된 날짜 큐는 ch16-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, YEAR(stat_date)
ORDER BY author_id, year_start;

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

확정된 과거와 오늘 원본을 합치는 하이브리드 조회

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

과거 일별 통계와 오늘 원본을 합친 보고서
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;

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

DELETE·INSERT와 절대값 UPSERT 선택하기

현재 프로시저의 DELETEINSERT는 대상 날짜 전체를 교체하므로 원본에서 사라진 키도 대상에서 함께 제거됩니다. 날짜 범위가 작고 재실행이 드물면 가장 이해하기 쉽습니다. 반면 대상 키 대부분이 그대로이고 일부 절대값만 바뀐다면 UPSERT로 인덱스 변경 빈도를 줄일 수 있습니다.

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

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);

같은 입력을 두 번 실행해도 게시글 3건·조회수 180회로 유지됩니다. total_views = total_views + incoming.total_views처럼 기존 대상에 더하면 재시도 때 조회수 360회가 되어 멱등성을 잃습니다. 절대값 UPSERT도 원본에서 완전히 사라진 대상-전용 행을 자동 삭제하지 못하므로 별도 안티-조인 삭제나 범위 교체가 필요합니다.

방식장점주의할 점적합한 상황
범위 DELETEINSERT결과 집합을 통째로 교체해 오래된 행 제거인덱스·언두 로그·리두 로그 비용작은 날짜 파티션 전체 재생성
절대값 UPSERT존재하는 키의 변경량이 작음대상-전용 행 별도 제거일부 키만 자주 바뀜
증가분 덧셈 UPSERT단순 이벤트 합산이 빠름중복 소비·정정·재시도에 취약정확히 한 번 처리가 별도로 확인될 때만

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


재호출과 원본 수정 검증

첫 예제 데이터는 같은 실행을 두 번 호출합니다. 다음에는 원본 조회수를 바꾸고 새 기준 시각으로 해당 날짜를 재빌드합니다. 대상과 원본을 동일 집계 단위로 집계한 CTE가 차이 행을 반환하지 않아야 합니다.

daily stat checksum과 양방향 차집합
SELECT @checksum_a=@checksum_b AS retry_same_result;

WITH source_value AS(
 SELECT author_id,DATE(published_at) stat_date,COUNT(*) published_count,SUM(view_count) 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 s LEFT JOIN daily_post_stats 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 d LEFT JOIN source_value 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입니다. 차이와 PK 중복 검사는 0행입니다. 원본 한 건의 view_count를 변경한 뒤 재빌드하면 대상 합계가 새 절대합과 같아지고 이전 실행 값을 더하지 않습니다.

일치 검사에서 대상-전용 행도 확인해야 합니다. 원본이 0건이 된 날짜는 DELETE 뒤 새 행이 들어오지 않으므로 사라지는 것이 올바릅니다.

읽기 주체가 기준 시각을 선택하는 법

보고서는 여러 날짜 행의 source_cutoff_at이 같은지 확인합니다. 범위 재빌드가 부분 완료됐다면 서로 다른 기준 시각이 섞일 수 있습니다. 월 보고서는 완료된 실행 목록이 가리키는 기준 시각 이하 행만 사용하거나 스냅샷 테이블로 발행합니다.

임시 테이블 크기와 오류 위치

임시 테이블이 메모리 한도를 넘을 수 있어 ENGINE=InnoDB를 명시했습니다. 디스크 공간 부족은 대상 DELETE 전에 발생하므로 기존 통계가 보존됩니다. 대상 트랜잭션에서 오류가 발생하면 처리기가 롤백합니다.

시간대별 하루

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


전체 재계산과 증분 집계

판단 축확인할 질문
범위한 번에 다시 읽을 원본 날짜가 운영 허용량인가?
정정과거 사건의 값이나 삭제가 자주 발생하는가?
재시도작업자가 동일 구간을 중복 실행할 수 있는가?
신선도허용 지연 시간을 정하고 기준 시각을 보고서에 노출할 수 있는가?
일치 검사원본과 대상을 같은 집계 단위로 비교하는 쿼리가 있는가?

작은 날짜 범위를 쉽게 다시 읽고 정정이 있다면 절대값 대체가 단순합니다. 초당 대규모 사건을 즉시 반영해야 하면 이벤트 원장과 증분 기여 모델이 필요합니다. 그래도 정기 전체 정기 일치 검사 경로는 남깁니다.


연습 문제

2026-07-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 matches
FROM daily_post_stats d JOIN(
 SELECT author_id,DATE(published_at) stat_date,SUM(view_count) 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)
) s ON s.author_id=d.author_id AND s.stat_date=d.stat_date;

일치는 모든 행에서 1입니다. 대상 변화량은 원본 변화량과 같지만 SQL은 차이를 더하지 않았습니다. 같은 @run_b 호출을 반복해도 total_views가 더 줄지 않습니다.


핵심 정리

  • 가산 UPSERT는 처리 원장 없이 재시도되면 같은 원본을 다시 더합니다.
  • 절대 재빌드는 원본과 기준 시각의 함수로 대상 날짜 구간을 교체합니다.
  • 임시 계산이 끝난 뒤 DELETE와 INSERT를 한 트랜잭션으로 발행합니다.
  • 양방향 집계 단위 일치 검사와 체크섬 재호출이 멱등성을 확인합니다.

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