마이크로배치 집계
변화량을 일별 합계에 바로 더하는 소비자가 재소비와 정정에서 어긋나는 것을 재현하고, 게시글별 최신 기여값과 영향받은 날짜의 재합산, 체크포인트를 한 트랜잭션으로 묶는 마이크로배치를 만듭니다. 늦게 도착한 낮은 버전과 취소 이벤트도 안전하게 처리합니다.
날짜 전체를 매번 다시 읽는 방식은 단순하지만, 원본이 커지면 비용이 큽니다. 그렇다고 이벤트의 조회수를 일별 합계에 바로 더하면, 같은 이벤트의 재전송과 게시 시각의 정정, 날짜의 이동마다 빼기와 더하기의 순서를 관리해야 합니다. 체크포인트의 숫자만 저장해도, 대상의 반영과 따로 커밋하면 유실이나 중복이 생깁니다.
마이크로배치(micro-batch)의 안전한 단위는 이벤트의 증가분이 아니라 "각 게시글이 지금의 통계에 기여하는 전체 값"입니다. post_id별 기여값을 최신 source_version으로 덮고, 바뀐 회원과 날짜의 키만 기여값 전체에서 다시 합산하며, 체크포인트와 기여값, 일별 대상을 한 트랜잭션으로 함께 전진시킵니다.
post_change_event는 추가 전용으로 운용할 이벤트 큐이지만, 아래 DDL에는 UPDATE와 DELETE를 거부하는 트리거나 권한 설정이 없습니다. 게시된 글은 건수 1과 조회수를, 취소되거나 삭제된 글은 건수 0과 조회수 0을 싣고, 같은 게시글의 정정은 source_version을 올립니다. 소비자의 이름별 체크포인트는 마지막으로 끝낸 event_id를 저장하며, 배치는 다음 N개 이벤트의 상한을 정한 뒤 그 범위에서 게시글별 가장 최신의 이벤트만 씁니다.
실습은 이벤트의 INSERT가 차례로 커밋된 상태에서 소비합니다. AUTO_INCREMENT는 커밋의 순서를 보장하지 않으므로, 여러 생산자가 동시에 쓰면 낮은 ID가 늦게 커밋되어 체크포인트가 그 이벤트를 건너뛸 수 있습니다. 운영에 적용하려면 소비 상한 이전의 생산이 모두 확정되었다는 별도의 발행 규칙이 필요하며, 이 예제는 그 규칙을 구현하지 않습니다. 중간 버전을 건너뛰어도 기여값 전체가 최종 상태를 나타내므로 결과는 같습니다.
집계와 체크포인트를 따로 저장하면
일별 UPDATE가 커밋된 뒤 체크포인트의 UPDATE 전에 장애가 나면 같은 이벤트가 다시 더해지고, 순서를 바꾸면 체크포인트만 전진하고 합계가 빠질 수 있습니다. 두 테이블을 한 트랜잭션에 넣어도, 조회수가 90으로 정정되었다는 이벤트를 +90으로 처리하면 기존의 +60을 지우지 못합니다.
DROP TABLE IF EXISTS final_stat_delta_bad;
CREATE TABLE final_stat_delta_bad (
author_id BIGINT UNSIGNED,
stat_date DATE,
total_views BIGINT UNSIGNED,
PRIMARY KEY (author_id, stat_date)
) ENGINE=InnoDB;
INSERT INTO final_stat_delta_bad VALUES (1, '2026-07-15', 60);
-- 이벤트 101을 처리한 뒤 응답을 잃어 같은 내용을 다시 소비한다.
UPDATE final_stat_delta_bad SET total_views = total_views + 60
WHERE author_id = 1 AND stat_date = '2026-07-15';
-- 실제 게시글은 조회수 90으로 정정되었지만 소비자는 90을 다시 더한다.
UPDATE final_stat_delta_bad SET total_views = total_views + 90
WHERE author_id = 1 AND stat_date = '2026-07-15';
SELECT * FROM final_stat_delta_bad;author_id | stat_date | total_views
1 | 2026-07-15 | 210게시글 하나의 지금의 기여는 조회수 90인데, 대상에는 210이 남았습니다. 재소비가 60을 더 얹었고, 정정은 이전의 60을 90으로 바꾸지 않고 90을 더했습니다.
변화량이 정확하려면 이전의 기여를 알고 새 값과의 차이를 한 번만 적용해야 하는데, 소비자가 그 상태를 갖지 않으면 단순한 덧셈으로는 수정과 삭제를 처리할 수 없습니다. 메시지 브로커가 정확히 한 번의 전달을 보장한다 해도, 데이터베이스의 커밋 응답을 잃는 지점은 남습니다.
게시글별 기여값 테이블은 이 문제를 업무의 집계 단위로 바꿉니다. source_version이 더 큰 이벤트만 게시글의 행을 교체하고, 일별 합계는 기여값을 SUM한 결과이므로 이벤트가 다시 와도 값이 늘지 않습니다. 게시글의 published_at이나 작성자가 바뀌면 기여가 다른 날짜로 옮겨 가므로, 이전의 회원과 날짜도 영향받은 키에 넣어야 이전 날짜에서 값이 빠집니다.
체크포인트와 원자성
v_from은 저장된 체크포인트이고, v_to는 이번에 고른 마지막 이벤트의 ID입니다. 트랜잭션이 성공하면 기여값과 영향받은 일별 행, 체크포인트가 한 커밋에서 확정되어, 체크포인트는 v_to까지 전진하고 기여값은 창 안에서 채택한 최고 버전의 event_id를 기록합니다. 롤백되면 체크포인트는 v_from에 머물고 나머지도 직전 커밋의 상태로 남으므로, 연결의 응답을 잃고 다시 호출해도 체크포인트로 어디까지 처리했는지 판단할 수 있습니다.
배치 안에 같은 게시글의 이벤트가 여러 건이면 가장 큰 source_version을 고르고, 버전이 같다면 가장 큰 event_id를 고르는 방어적인 규칙을 둡니다. 원장은 유일 제약으로 같은 게시글과 버전의 중복을 먼저 막지만, 재생성의 입력을 합칠 때도 같은 정렬 규칙을 쓸 수 있습니다. 순서를 벗어나 늦게 도착한 낮은 버전은 체크포인트에는 포함되지만, 기여값을 되돌리지 않습니다.
대상은 기여값의 캐시이지 이벤트의 원장이 아닙니다. 이벤트의 보존 기간이 끝나도 기여값은 원본의 지금 상태를 나타내야 하므로, 장애를 복구할 때 posts에서 기여값 전체를 다시 만들 수 있어야 하고, 일별 행은 기여값에서 다시 만들어집니다. 폐기 예정인 VALUES() UPSERT를 피하려고, 임시 테이블 next_contribution과 명시적인 UPDATE, INSERT의 두 단계를 쓰며, 두 단계는 같은 트랜잭션과 기본 키의 잠금 안에 있습니다.
최신 기여값 기반 집계
DDL은 이벤트의 업무 버전 유일성과 기여값의 기본 키, 소비자의 체크포인트를 정의합니다. 프로시저는 임시 테이블 두 개를 쓰는데, next_contribution은 최종적으로 적용할 이벤트 내용을, affected_stat_key는 이전과 새 날짜를 담습니다. 공개된 글도 조회수가 0일 수 있으므로, published_count = 1의 분기는 total_views 0부터 허용합니다.
DROP PROCEDURE IF EXISTS process_post_stats_microbatch;
DROP TABLE IF EXISTS daily_post_stats_incremental;
DROP TABLE IF EXISTS post_stat_contribution;
DROP TABLE IF EXISTS final_stat_checkpoint;
DROP TABLE IF EXISTS post_change_event;
CREATE TABLE post_change_event (
event_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id BIGINT UNSIGNED NOT NULL,
source_version INT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
stat_date DATE NOT NULL,
published_count TINYINT UNSIGNED NOT NULL,
total_views BIGINT UNSIGNED NOT NULL,
produced_at DATETIME(6) NOT NULL,
CONSTRAINT uq_stat_event_version UNIQUE (post_id, source_version),
CONSTRAINT chk_stat_event_value CHECK (
(published_count = 0 AND total_views = 0)
OR (published_count = 1 AND total_views <= 1000000000)
),
INDEX ix_stat_event_stream (event_id)
) ENGINE=InnoDB;
CREATE TABLE post_stat_contribution (
post_id BIGINT UNSIGNED PRIMARY KEY,
source_version INT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
stat_date DATE NOT NULL,
published_count TINYINT UNSIGNED NOT NULL,
total_views BIGINT UNSIGNED NOT NULL,
last_event_id BIGINT UNSIGNED NOT NULL,
INDEX ix_contribution_day (author_id, stat_date)
) ENGINE=InnoDB;
CREATE TABLE daily_post_stats_incremental (
author_id BIGINT UNSIGNED NOT NULL,
stat_date DATE NOT NULL,
published_count INT UNSIGNED NOT NULL,
total_views BIGINT UNSIGNED NOT NULL,
rebuilt_from_event BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (author_id, stat_date)
) ENGINE=InnoDB;
CREATE TABLE final_stat_checkpoint (
consumer_name VARCHAR(80) PRIMARY KEY,
last_event_id BIGINT UNSIGNED NOT NULL,
updated_at DATETIME(6) NOT NULL
) ENGINE=InnoDB;
INSERT INTO final_stat_checkpoint VALUES ('daily-stat-v1', 0, '2026-07-16 00:00:00');
DELIMITER //
CREATE PROCEDURE process_post_stats_microbatch(
IN p_consumer VARCHAR(80),
IN p_limit INT UNSIGNED
)
proc: BEGIN
DECLARE v_from BIGINT UNSIGNED;
DECLARE v_to BIGINT UNSIGNED;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
DROP TEMPORARY TABLE IF EXISTS next_contribution;
DROP TEMPORARY TABLE IF EXISTS affected_stat_key;
RESIGNAL;
END;
IF p_consumer IS NULL OR p_limit IS NULL OR p_limit = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'invalid consumer or batch limit';
END IF;
START TRANSACTION;
SELECT last_event_id INTO v_from
FROM final_stat_checkpoint
WHERE consumer_name = p_consumer
FOR UPDATE;
IF v_from IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'consumer checkpoint not registered';
END IF;
SELECT MAX(event_id) INTO v_to
FROM (
SELECT event_id FROM post_change_event
WHERE event_id > v_from
ORDER BY event_id
LIMIT p_limit
) AS batch_ids;
IF v_to IS NULL THEN
COMMIT;
SELECT v_from AS checkpoint, 'EMPTY' AS outcome;
LEAVE proc;
END IF;
CREATE TEMPORARY TABLE next_contribution (
post_id BIGINT UNSIGNED PRIMARY KEY,
source_version INT UNSIGNED,
author_id BIGINT UNSIGNED,
stat_date DATE,
published_count TINYINT UNSIGNED,
total_views BIGINT UNSIGNED,
event_id BIGINT UNSIGNED
) ENGINE=InnoDB;
INSERT INTO next_contribution
(post_id, source_version, author_id, stat_date,
published_count, total_views, event_id)
SELECT post_id, source_version, author_id, stat_date,
published_count, total_views, event_id
FROM (
SELECT e.*,
ROW_NUMBER() OVER (
PARTITION BY e.post_id
ORDER BY e.source_version DESC, e.event_id DESC
) AS version_rank
FROM post_change_event AS e
WHERE e.event_id > v_from AND e.event_id <= v_to
) AS ranked
WHERE version_rank = 1;
CREATE TEMPORARY TABLE affected_stat_key (
author_id BIGINT UNSIGNED,
stat_date DATE,
PRIMARY KEY (author_id, stat_date)
) ENGINE=InnoDB;
INSERT IGNORE INTO affected_stat_key
SELECT c.author_id, c.stat_date
FROM post_stat_contribution AS c
JOIN next_contribution AS n ON n.post_id = c.post_id
WHERE n.source_version > c.source_version;
INSERT IGNORE INTO affected_stat_key
SELECT n.author_id, n.stat_date
FROM next_contribution AS n
LEFT JOIN post_stat_contribution AS c ON c.post_id = n.post_id
WHERE c.post_id IS NULL OR n.source_version > c.source_version;
UPDATE post_stat_contribution AS c
JOIN next_contribution AS n ON n.post_id = c.post_id
SET c.source_version = n.source_version,
c.author_id = n.author_id,
c.stat_date = n.stat_date,
c.published_count = n.published_count,
c.total_views = n.total_views,
c.last_event_id = n.event_id
WHERE n.source_version > c.source_version;
INSERT INTO post_stat_contribution
(post_id, source_version, author_id, stat_date,
published_count, total_views, last_event_id)
SELECT n.post_id, n.source_version, n.author_id, n.stat_date,
n.published_count, n.total_views, n.event_id
FROM next_contribution AS n
LEFT JOIN post_stat_contribution AS c ON c.post_id = n.post_id
WHERE c.post_id IS NULL;
DELETE d FROM daily_post_stats_incremental AS d
JOIN affected_stat_key AS a
ON a.author_id = d.author_id AND a.stat_date = d.stat_date;
INSERT INTO daily_post_stats_incremental
SELECT c.author_id, c.stat_date, SUM(c.published_count), SUM(c.total_views), v_to
FROM post_stat_contribution AS c
JOIN affected_stat_key AS a
ON a.author_id = c.author_id AND a.stat_date = c.stat_date
GROUP BY c.author_id, c.stat_date
HAVING SUM(c.published_count) > 0;
UPDATE final_stat_checkpoint
SET last_event_id = v_to, updated_at = CURRENT_TIMESTAMP(6)
WHERE consumer_name = p_consumer;
COMMIT;
DROP TEMPORARY TABLE next_contribution;
DROP TEMPORARY TABLE affected_stat_key;
SELECT v_to AS checkpoint, 'APPLIED' AS outcome;
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);
INSERT INTO post_change_event
(post_id, source_version, author_id, stat_date,
published_count, total_views, produced_at)
VALUES (@post_id, 1, @author_id, '2026-07-15', 1, 60, '2026-07-16 01:00:00');
CALL process_post_stats_microbatch('daily-stat-v1', 100);
CALL process_post_stats_microbatch('daily-stat-v1', 100);
SELECT post_id, source_version, stat_date, total_views, last_event_id
FROM post_stat_contribution;
SELECT author_id, stat_date, published_count, total_views, rebuilt_from_event
FROM daily_post_stats_incremental;checkpoint | outcome
1 | APPLIED
checkpoint | outcome
1 | EMPTY
post_id | source_version | stat_date | total_views | last_event_id
1 | 1 | 2026-07-15 | 60 | 1
author_id | stat_date | published_count | total_views | rebuilt_from_event
1 | 2026-07-15 | 1 | 60 | 1첫 배치는 이벤트 1 하나를 읽어 체크포인트와 기여값, 일별 행을 함께 바꾸었고, 곧바로 다시 부른 배치는 새 이벤트가 없어 EMPTY로 끝났습니다. 재호출이 아무것도 더하지 않으므로 일별 합계는 60 그대로입니다.
DELETE에 해당하는 이벤트도 기여값의 행을 없애지 않고 0의 값으로 남깁니다. 그래야 오래된 이벤트가 다시 와도 source_version을 비교할 수 있으며, 이 삭제 표식은 보존 기간이 지난 뒤 전체 재생성과 함께 정리합니다. 생산자의 버그를 숨기지 않도록, event_id의 순서와 source_version이 거꾸로 된 경우는 별도의 쿼리로 경고합니다.
p_limit은 정확성이 아니라 운영의 비용을 조절합니다. 배치의 크기를 바꾸어도 최종 기여값과 일별 값은 같아야 하며, 큰 배치는 처리량을 높이는 대신 체크포인트 행의 잠금과 언두 로그를 길게 만듭니다. 처리의 지연과 트랜잭션의 시간, 영향받은 키의 수를 함께 지켜봅니다.
정정과 이동, 응답 손실의 검증
버전 3은 조회수를 90으로 바꾸면서 다음 날짜로 옮기고, 그 뒤에 더 낮은 버전 2가 늦게 도착합니다. 한 배치가 두 이벤트를 함께 읽어도 가장 높은 source_version인 3을 적용해야 하고, 같은 프로시저를 다시 부르면 EMPTY가 되어야 합니다.
INSERT INTO post_change_event
(post_id, source_version, author_id, stat_date,
published_count, total_views, produced_at)
VALUES
(@post_id, 3, @author_id, '2026-07-16', 1, 90, '2026-07-16 03:00:00'),
(@post_id, 2, @author_id, '2026-07-15', 1, 90, '2026-07-16 02:00:00');
CALL process_post_stats_microbatch('daily-stat-v1', 100);
CALL process_post_stats_microbatch('daily-stat-v1', 100);
SELECT author_id, stat_date, published_count, total_views, rebuilt_from_event
FROM daily_post_stats_incremental;
WITH expected AS (
SELECT author_id, stat_date,
SUM(published_count) AS published_count,
SUM(total_views) AS total_views
FROM post_stat_contribution
GROUP BY author_id, stat_date
HAVING SUM(published_count) > 0
)
SELECT e.author_id, e.stat_date
FROM expected AS e
LEFT JOIN daily_post_stats_incremental AS d
ON d.author_id = e.author_id AND d.stat_date = e.stat_date
WHERE d.author_id IS NULL
OR d.published_count <> e.published_count
OR d.total_views <> e.total_views
UNION ALL
SELECT d.author_id, d.stat_date
FROM daily_post_stats_incremental AS d
LEFT JOIN expected AS e
ON e.author_id = d.author_id AND e.stat_date = d.stat_date
WHERE e.author_id IS NULL;
SELECT e.post_id, MAX(e.source_version) AS expected_version,
c.source_version AS actual_version
FROM post_change_event AS e
JOIN post_stat_contribution AS c ON c.post_id = e.post_id
GROUP BY e.post_id, c.source_version
HAVING c.source_version <> MAX(e.source_version);
SELECT earlier.event_id AS higher_version_event,
later.event_id AS late_lower_version_event
FROM post_change_event AS earlier
JOIN post_change_event AS later
ON later.post_id = earlier.post_id AND later.event_id > earlier.event_id
WHERE later.source_version < earlier.source_version;checkpoint | outcome
3 | APPLIED
checkpoint | outcome
3 | EMPTY
author_id | stat_date | published_count | total_views | rebuilt_from_event
1 | 2026-07-16 | 1 | 90 | 3
higher_version_event | late_lower_version_event
2 | 3두 이벤트는 차례로 2와 3의 id를 받았고, 배치는 둘을 함께 읽어 버전 3인 이벤트 2를 채택했습니다. 이전 날짜 7월 15일과 새 날짜 7월 16일이 모두 영향받은 키가 되어, 15일의 행은 지워지고 16일에 조회수 90의 행이 생겼습니다. 늦게 온 버전 2는 체크포인트 3에는 포함되었지만 기여값을 되돌리지 않았습니다.
기여값과 일별 캐시의 대조, 최고 버전의 불일치 검사는 빈 결과입니다. 첫 대조는 기여값과 일별 캐시만 비교하므로 posts 원본이나 이벤트 생산의 누락까지 증명하지 않고, 두 번째의 INNER JOIN은 기여값 행이 아예 없는 게시글을 놓치므로 전체의 누락 검사는 별도의 외부 조인이 필요합니다. 마지막 도착 순서의 검사는 버전이 거꾸로 온 한 쌍을 경고로 돌려줍니다.
연습 문제의 취소 이벤트까지 포함한 상태의 변화는 다음과 같습니다.
원문 이벤트가 바꾸는 기여값과 날짜 합계
첫 이벤트가 반영돼 있다. 0 기여를 남기고 날짜 대상을 제거한다. 각 단계의 값과 전환 이유를 비교합니다.
MICROBATCH · LATEST CONTRIBUTION / AFFECTED KEYS
늦게 온 버전 2보다 버전 3을 유지하고 날짜 양쪽을 재집계한다
이벤트 2·3은 버전 3·2 순서로 도착합니다. 최고 source_version을 기여값에 적용하고 이전 7/15와 새 7/16 키를 모두 다시 SUM하며 체크포인트도 같은 커밋에서 전진합니다.
| 처리 입력 | 게시글 최신 기여 | 일별 결과 | 체크포인트 |
|---|---|---|---|
| 이벤트 1 · 버전 1 | 7/15 · 건수 1 · 조회수 60 | 7/15 → 60 | 1 |
| 이벤트 2·3 · 버전 3·2 | 높은 버전 3: 7/16 · 1 · 90 | 7/15 제거, 7/16 → 90 | 3 |
| 같은 프로시저 재호출 | 변경 없음 | 변경 없음 · EMPTY | 3 유지 |
| 이벤트 4 · 버전 4 취소 | 7/16 · 건수 0 · 조회수 0 | 7/16 행 제거 | 4 |
- 이벤트 1 · 버전 1
- 게시글 최신 기여: 7/15 · 건수 1 · 조회수 60일별 결과: 7/15 → 60체크포인트: 1
- 이벤트 2·3 · 버전 3·2
- 게시글 최신 기여: 높은 버전 3: 7/16 · 1 · 90일별 결과: 7/15 제거, 7/16 → 90체크포인트: 3
- 같은 프로시저 재호출
- 게시글 최신 기여: 변경 없음일별 결과: 변경 없음 · EMPTY체크포인트: 3 유지
- 이벤트 4 · 버전 4 취소
- 게시글 최신 기여: 7/16 · 건수 0 · 조회수 0일별 결과: 7/16 행 제거체크포인트: 4
취소 버전 4 · 대상은 제거하고 버전 표식은 유지
post_stat_contribution → version 4 · 건수 0 · 조회수 0
7/16 대상 행 제거 → checkpoint 4 · 중복 INSERT 1062
0 기여 표식을 남겨 늦은 낮은 버전이 되살리지 못하게 합니다. 이벤트는 순차 커밋 fixture이며 AUTO_INCREMENT는 생산 커밋 순서를 보장하지 않습니다. 소비 상한 이전 생산 완료 규칙과 posts 원본·생산 누락 대사는 별도로 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 기존 기여부터 취소 버전 4까지 진행합니다.
| 처리 입력 | 게시글 최신 기여 | 일별 결과 | 체크포인트 |
|---|---|---|---|
| 이벤트 1 · 버전 1 | 7/15 · 건수 1 · 조회 수 60 | 7/15 → 60 | 1 |
| 이벤트 2·3 · 버전 3·2 | 높은 버전 3: 7/16 · 1 · 90 | 7/15 제거, 7/16 → 90 | 3 |
| 같은 프로시저 재호출 | 변경 없음 | 변경 없음 · EMPTY | 3 유지 |
| 이벤트 4 · 버전 4 취소 | 7/16 · 건수 0 · 조회 수 0 | 7/16 행 제거 | 4 |
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 여섯 노드, 다섯 간선. 원 fixture의 두 번째 배치에서 이벤트 2,3 중 높은 버전 3을 고르고, 기존 기여의 7/15와 새 기여의 7/16을 영향 키로 합친다. 기존 키 수집은 기여값 교체 전이다. 그 뒤 기여값을 90회로 교체하고 두 키 SUM으로 7/15 행을 제거하고 7/16 90회를 발행하며 체크포인트 3을 같은 트랜잭션으로 전진한다. 한 게시글만 있는 원 fixture의 가정이다. 모바일의 이전 키 간선은 새 이벤트 노드를 통과하지 않는다. |
| 표의 값 | 7/15 · 건수 1 · 조회 수 60 |
| 표의 값 | 7/16 · 건수 0 · 조회 수 0 |
| 표의 값 | 이벤트 1 · 버전 1. 게시글 최신 기여: 7/15 · 건수 1 · 조회 수 60. 일별 결과: 7/15 → 60. 체크포인트: 1. 이벤트 2·3 · 버전 3·2. 게시글 최신 기여: 높은 버전 3: 7/16 · 1 · 90. 일별 결과: 7/15 제거, 7/16 → 90. 체크포인트: 3. 같은 프로시저 재호출. 게시글 최신 기여: 변경 없음. 일별 결과: 변경 없음 · EMPTY. 체크포인트: 3 유지. 이벤트 4 · 버전 4 취소. 게시글 최신 기여: 7/16 · 건수 0 · 조회 수 0. 일별 결과: 7/16 행 제거. 체크포인트: 4. |
| 설명·조건 | post_stat_contribution → version 4 · 건수 0 · 조회 수 0 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기존 기여 첫 이벤트가 반영돼 있다. |
|
|
| 창과 영향 키 버전 3을 고르고 양쪽 날짜를 기억한다. |
|
|
| 같은 커밋 기여 교체와 전체 SUM을 확정한다. |
|
|
| 재호출 EMPTY 저장된 소비 위치부터 다시 읽는다. |
|
|
| 취소 버전 4 0 기여를 남기고 날짜 대상을 제거한다. |
|
|
원문 이벤트가 바꾸는 기여값과 날짜 합계
첫 이벤트가 반영돼 있다. 0 기여를 남기고 날짜 대상을 제거한다. 각 단계의 값과 전환 이유를 비교합니다.
MICROBATCH · LATEST CONTRIBUTION / AFFECTED KEYS
늦게 온 버전 2보다 버전 3을 유지하고 날짜 양쪽을 재집계한다
이벤트 2·3은 버전 3·2 순서로 도착합니다. 최고 source_version을 기여값에 적용하고 이전 7/15와 새 7/16 키를 모두 다시 SUM하며 체크포인트도 같은 커밋에서 전진합니다.
| 처리 입력 | 게시글 최신 기여 | 일별 결과 | 체크포인트 |
|---|---|---|---|
| 이벤트 1 · 버전 1 | 7/15 · 건수 1 · 조회수 60 | 7/15 → 60 | 1 |
| 이벤트 2·3 · 버전 3·2 | 높은 버전 3: 7/16 · 1 · 90 | 7/15 제거, 7/16 → 90 | 3 |
| 같은 프로시저 재호출 | 변경 없음 | 변경 없음 · EMPTY | 3 유지 |
| 이벤트 4 · 버전 4 취소 | 7/16 · 건수 0 · 조회수 0 | 7/16 행 제거 | 4 |
- 이벤트 1 · 버전 1
- 게시글 최신 기여: 7/15 · 건수 1 · 조회수 60일별 결과: 7/15 → 60체크포인트: 1
- 이벤트 2·3 · 버전 3·2
- 게시글 최신 기여: 높은 버전 3: 7/16 · 1 · 90일별 결과: 7/15 제거, 7/16 → 90체크포인트: 3
- 같은 프로시저 재호출
- 게시글 최신 기여: 변경 없음일별 결과: 변경 없음 · EMPTY체크포인트: 3 유지
- 이벤트 4 · 버전 4 취소
- 게시글 최신 기여: 7/16 · 건수 0 · 조회수 0일별 결과: 7/16 행 제거체크포인트: 4
취소 버전 4 · 대상은 제거하고 버전 표식은 유지
post_stat_contribution → version 4 · 건수 0 · 조회수 0
7/16 대상 행 제거 → checkpoint 4 · 중복 INSERT 1062
0 기여 표식을 남겨 늦은 낮은 버전이 되살리지 못하게 합니다. 이벤트는 순차 커밋 fixture이며 AUTO_INCREMENT는 생산 커밋 순서를 보장하지 않습니다. 소비 상한 이전 생산 완료 규칙과 posts 원본·생산 누락 대사는 별도로 필요합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 기존 기여부터 취소 버전 4까지 진행합니다.
| 처리 입력 | 게시글 최신 기여 | 일별 결과 | 체크포인트 |
|---|---|---|---|
| 이벤트 1 · 버전 1 | 7/15 · 건수 1 · 조회 수 60 | 7/15 → 60 | 1 |
| 이벤트 2·3 · 버전 3·2 | 높은 버전 3: 7/16 · 1 · 90 | 7/15 제거, 7/16 → 90 | 3 |
| 같은 프로시저 재호출 | 변경 없음 | 변경 없음 · EMPTY | 3 유지 |
| 이벤트 4 · 버전 4 취소 | 7/16 · 건수 0 · 조회 수 0 | 7/16 행 제거 | 4 |
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 여섯 노드, 다섯 간선. 원 fixture의 두 번째 배치에서 이벤트 2,3 중 높은 버전 3을 고르고, 기존 기여의 7/15와 새 기여의 7/16을 영향 키로 합친다. 기존 키 수집은 기여값 교체 전이다. 그 뒤 기여값을 90회로 교체하고 두 키 SUM으로 7/15 행을 제거하고 7/16 90회를 발행하며 체크포인트 3을 같은 트랜잭션으로 전진한다. 한 게시글만 있는 원 fixture의 가정이다. 모바일의 이전 키 간선은 새 이벤트 노드를 통과하지 않는다. |
| 표의 값 | 7/15 · 건수 1 · 조회 수 60 |
| 표의 값 | 7/16 · 건수 0 · 조회 수 0 |
| 표의 값 | 이벤트 1 · 버전 1. 게시글 최신 기여: 7/15 · 건수 1 · 조회 수 60. 일별 결과: 7/15 → 60. 체크포인트: 1. 이벤트 2·3 · 버전 3·2. 게시글 최신 기여: 높은 버전 3: 7/16 · 1 · 90. 일별 결과: 7/15 제거, 7/16 → 90. 체크포인트: 3. 같은 프로시저 재호출. 게시글 최신 기여: 변경 없음. 일별 결과: 변경 없음 · EMPTY. 체크포인트: 3 유지. 이벤트 4 · 버전 4 취소. 게시글 최신 기여: 7/16 · 건수 0 · 조회 수 0. 일별 결과: 7/16 행 제거. 체크포인트: 4. |
| 설명·조건 | post_stat_contribution → version 4 · 건수 0 · 조회 수 0 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기존 기여 첫 이벤트가 반영돼 있다. |
|
|
| 창과 영향 키 버전 3을 고르고 양쪽 날짜를 기억한다. |
|
|
| 같은 커밋 기여 교체와 전체 SUM을 확정한다. |
|
|
| 재호출 EMPTY 저장된 소비 위치부터 다시 읽는다. |
|
|
| 취소 버전 4 0 기여를 남기고 날짜 대상을 제거한다. |
|
|
커밋 뒤에 응답을 잃은 경우, 다음 호출은 저장된 체크포인트부터 진행하도록 설계했습니다. SQL 예외가 나면 처리기가 체크포인트와 대상의 변경을 함께 롤백합니다. 위의 실습은 차례로 부르는 호출만 보여 줄 뿐, 연결 끊김이나 강제 오류를 실제로 주입한 검증은 아닙니다.
운영의 규칙
주간 통계와 알림이 같은 이벤트를 읽어도 각자 consumer_name별 체크포인트를 가집니다. 다만 위 프로시저는 기여값과 일별 대상의 테이블을 한 벌만 쓰므로, 같은 대상에는 같은 consumer_name을 써야 체크포인트 잠금으로 직렬화됩니다. 독립된 소비자는 대상 테이블과 처리 경로까지 나누어야 하며, 이름만 바꾼 호출이 독립을 보장하지는 않습니다. 이벤트의 보존 기간은, 필수 소비자들의 가장 작은 체크포인트보다 앞선 행만 지우도록 계산합니다.
CHECK는 통과했지만 업무상 해석할 수 없는 이벤트가 있으면 배치가 계속 롤백될 수 있습니다. 그런 이벤트를 오류 보관 테이블로 옮기고 체크포인트를 넘길지, 운영자가 원본을 고칠 때까지 멈출지 중요도에 따라 정하며, 조용히 건너뛰면 통계를 잃습니다.
기여값은 캐시이므로 posts에서 전체를 다시 만드는 유지보수 프로시저가 필요합니다. 재생성한 결과의 체크섬이 지금의 기여값과 다르면, 이벤트 생산자의 누락이나 소비자의 버그를 조사합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 비용 | 날짜 전체의 재계산이 정해진 시간 안에 끝나지 않는가 |
| 변경 | 수정과 삭제, 날짜 이동의 이벤트를 생산자가 버전과 함께 발행하는가 |
| 원자성 | 체크포인트와 두 대상 테이블을 같은 트랜잭션에 둘 수 있는가 |
| 지연 | 이벤트의 지연과 가장 오래된 미처리 이벤트의 경과 시간을 측정하는가 |
| 복구 | 원본에서 기여값 전체를 다시 만드는 경로가 있는가 |
날짜 전체의 재계산이 충분히 싸다면 16장 3편의 방식이 더 읽기 쉽습니다. 원본의 규모 때문에 증분이 필요할 때도 변화량을 바로 더하지 말고 게시글의 기여값을 두며, 설계에는 이벤트의 보존 기간과 전체 대조의 주기를 포함합니다.
정리
- 증분 이벤트를 합계에 바로 더하면 재소비와 정정에서 불일치가 생깁니다.
- 게시글별 최신 기여값은 게시 건수와 조회수의 전체 상태를 보존합니다.
- 이전과 새 날짜의 키를 다시 합산하고, 체크포인트까지 한 트랜잭션으로 커밋합니다.
- 늦게 도착한 낮은 버전은 체크포인트에만 포함되고 기여값을 되돌리지 않습니다.
- 배치의 크기와 재호출의 횟수는 최종 대상의 값을 바꾸지 않아야 합니다.
연습 문제
1. 배치 크기 1로 같은 입력 읽기
세 이벤트가 이미 차례로 커밋되어 있고, 체크포인트 0과 빈 기여값, 빈 일별 대상에서 새로 시작한다고 합시다. p_limit = 1로 세 번 부르면 체크포인트와 기여값의 버전, 날짜, 조회수는 매번 어떻게 되나요? 마지막 기여값의 last_event_id는 최종 체크포인트와 같아야 하나요?
해설 보기
| 호출 | 읽은 이벤트 | 체크포인트 | 기여값 | 일별 대상 |
|---|---|---|---|---|
| 1 | 이벤트 1, 버전 1 | 1 | 버전 1, 7/15, 60회 | 7/15에 60회 |
| 2 | 이벤트 2, 버전 3 | 2 | 버전 3, 7/16, 90회 | 7/15를 지우고 7/16에 90회 |
| 3 | 이벤트 3, 버전 2 | 3 | 바뀌지 않음 | 바뀌지 않음 |
셋째 호출은 낮은 버전 2를 소비하되 기여값을 바꾸지 않아 체크포인트만 3으로 옮깁니다. 그래서 기여값의 last_event_id는 마지막으로 채택한 이벤트 2로, 최종 체크포인트 3과 같지 않습니다.
두 이벤트를 한 번에 읽은 본문과 최종 건수, 조회수는 같지만 실행 정보까지 같지는 않습니다. 셋째 호출은 영향받은 키가 비어 일별 행을 다시 쓰지 않으므로 그 행의 rebuilt_from_event는 2에 머물지만, 본문에서는 3입니다. 소비 상한과 채택한 버전, 실제로 다시 합산한 실행은 서로 다른 정보입니다.
2. 취소 이벤트
본문의 뒤를 이어, 버전 4의 취소 이벤트(건수 0, 조회수 0)를 넣고 처리해 보세요. 7월 16일의 일별 행이 없어져야 하고, 같은 취소 이벤트를 다시 넣으려 하면 uq_stat_event_version이 거부해야 합니다.
해설 보기
취소도 최신 기여값 전체를 나타냅니다. 처리기는 게시글의 행을 0으로 바꾼 뒤 영향받은 날짜를 지우고, HAVING 조건 때문에 새 일별 행을 만들지 않습니다.
INSERT INTO post_change_event
(post_id, source_version, author_id, stat_date,
published_count, total_views, produced_at)
VALUES (@post_id, 4, @author_id, '2026-07-16', 0, 0, '2026-07-16 04:00:00');
CALL process_post_stats_microbatch('daily-stat-v1', 100);
SELECT * FROM daily_post_stats_incremental
WHERE author_id = @author_id AND stat_date = '2026-07-16';
INSERT INTO post_change_event
(post_id, source_version, author_id, stat_date,
published_count, total_views, produced_at)
VALUES (@post_id, 4, @author_id, '2026-07-16', 0, 0, '2026-07-16 04:01:00');checkpoint | outcome
4 | APPLIED
ERROR 1062 (23000): Duplicate entry '1-4' for key 'post_change_event.uq_stat_event_version'일별 행을 찾는 SELECT는 빈 결과이고, 기여값에는 버전 4와 조회수 0의 삭제 표식이 남습니다. 같은 버전의 두 번째 INSERT는 대상을 건드리기 전에 유일 제약의 오류로 끝납니다.
다음 글 구체 테이블 상속부터는 17장으로 넘어가, 서로 다른 종류의 게시글을 관계형 스키마에 나타내는 상속 전략과 EAV의 쓰임을 비교합니다.