변경 이력 모델
UPDATE가 덮어써 사라지는 게시글의 과거 상태를 재현하고, 추적 열과 변화량 로그, 전체 스냅샷이 답할 수 있는 질문을 비교합니다. 현재 테이블과 반개구간 스냅샷 이력을 한 트랜잭션으로 갱신하고, 특정 시각의 상태를 복원하는 쿼리로 검증합니다.
이 장은 과거 상태의 복원과 감사, 소급 정정이 실제 요구일 때 읽는 선택 심화입니다. 8장의 트랜잭션과 잠금, 12장의 최종 모델을 이해했다면 시작할 수 있습니다.
이 장에서 쓰는 말은 다섯 가지입니다. 현재 테이블은 최신 값을, 스냅샷(snapshot)은 변경 한 번 뒤의 전체 상태를, 감사 이벤트는 누가 왜 바꾸었는지를 답합니다. 유효 시간(valid time)은 현실에서 효력이 있던 때를, 기록 시간(transaction time)은 데이터베이스가 그 사실을 알고 있던 때를 답합니다.
게시글의 제목을 고치는 UPDATE는 짧지만, 실행하기 직전의 제목과 조회수, 상태를 묻는 순간 현재 테이블만으로는 답할 수 없습니다. 마지막 값이 맞다는 것과 과거가 필요 없다는 것은 서로 다른 판단입니다. 화면은 최신 게시글을 자주 읽으므로 모든 버전을 한 테이블에 섞으면 외래 키와 인덱스의 조건이 복잡해지고, 반대로 현재 행만 유지하면 신고 처리의 분쟁이나 운영의 정정, 통계의 재계산에서 당시의 상태를 확인할 수 없습니다.
그래서 이 글은 현재를 안정된 참조 대상으로 두고, 복원하기로 정한 속성의 변경 후 스냅샷을 별도의 수명으로 보관합니다. 실습은 다시 board_lab에서 12장 4편의 posts를 대상으로 합니다. posts.id는 바뀌지 않고 제목과 view_count, status가 정정될 수 있으며, 스냅샷은 변화량을 누적하지 않고 고른 속성의 값을 버전마다 복사합니다. 예제는 제목과 조회수, 상태, 작성자, 요청 키, 시각을 보존하고, 본문과 카테고리, 낙관적 잠금 버전까지 복제하지는 않습니다. 시각은 UTC로 고정하며, 첫 스냅샷은 2026-07-15 00:00, 정정은 정오에 발행합니다.
UPDATE로 사라지는 과거 상태
updated_at은 마지막으로 바뀐 시각만 알려 줍니다. 누가 무엇을 덮었는지, 두 열이 같은 요청에서 함께 바뀌었는지는 나타내지 못합니다. 변화량 로그도 기록의 누락과 타입 변환에 약하며, 특히 문자열인 old_value는 NULL과 숫자, 시간의 뜻을 복원하는 코드에 떠넘깁니다.
USE board_lab;
DROP TABLE IF EXISTS post_delta_bad;
DROP TABLE IF EXISTS post_current_bad;
CREATE TABLE post_current_bad (
post_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(160) NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (post_id)
) ENGINE=InnoDB;
CREATE TABLE post_delta_bad (
delta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
column_name VARCHAR(40) NOT NULL,
old_value VARCHAR(255) NULL,
changed_at DATETIME(6) NOT NULL,
PRIMARY KEY (delta_id)
) ENGINE=InnoDB;
INSERT INTO post_current_bad VALUES
(7001, 'B+Tree 초안', 60, 'DRAFT', '2026-07-15 09:00:00');
INSERT INTO post_delta_bad
(post_id, column_name, old_value, changed_at)
VALUES (7001, 'title', 'B+Tree 초안', '2026-07-15 10:00:00');
UPDATE post_current_bad
SET title = 'B+Tree와 covering index',
view_count = 90,
updated_at = '2026-07-15 10:00:00'
WHERE post_id = 7001;
UPDATE post_current_bad
SET view_count = 75,
status = 'PUBLISHED',
updated_at = '2026-07-15 11:00:00'
WHERE post_id = 7001;
SELECT * FROM post_current_bad;
SELECT column_name, old_value FROM post_delta_bad ORDER BY delta_id;post_id | title | view_count | status | updated_at
7001 | B+Tree와 covering index | 75 | PUBLISHED | 2026-07-15 11:00:00.000000
column_name | old_value
title | B+Tree 초안마지막 상태는 읽히지만, 10시 30분의 조회수와 상태는 두 테이블만으로 정할 수 없습니다. 스크립트의 UPDATE 순서를 아는 사람은 그때가 조회수 90에 DRAFT였다고 추론할 수 있지만, 현재 행과 제목의 변화량에는 그 조회수와 상태의 과거 값이 없습니다. 스크립트를 모르는 복원 프로그램은 같은 결과를 다시 계산할 수 없습니다.
문제의 원인은 이력 행의 단위가 "변경 한 번 뒤의 완전한 게시글"이 아니라 "우연히 기록한 열 하나"라는 데 있습니다. 현재에서 사라진 값을 다른 저장소가 모두 갖고 있지 않으면, 과거에 대한 질의는 추측이 됩니다.
추적 열부터 이력 모델까지
모든 변경에 완전한 스냅샷이 필요한 것은 아니므로, 먼저 어떤 질문에 답해야 하는지 정하고 가장 단순한 방법부터 검토합니다. 생성과 수정의 시각, 작업자, 변경 사유, 들어온 시스템을 현재 행에 남기면 마지막 변경을 누가 했는지는 빠르게 찾을 수 있습니다.
DROP TABLE IF EXISTS post_tracking_demo;
CREATE TABLE post_tracking_demo (
post_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
title VARCHAR(160) NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
created_at DATETIME(6) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
updated_at DATETIME(6) NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
update_reason VARCHAR(240) NOT NULL,
source_system VARCHAR(40) NOT NULL
) ENGINE = InnoDB;
INSERT INTO post_tracking_demo VALUES (
7001, 'B+Tree 초안', 60,
'2026-07-15 09:00:00', 1,
'2026-07-15 09:00:00', 1,
'최초 등록', 'WEB'
);
UPDATE post_tracking_demo
SET title = 'B+Tree와 covering index',
view_count = 90,
updated_at = '2026-07-15 10:00:00',
updated_by = 2,
update_reason = '게시글 조회수 정정',
source_system = 'ADMIN'
WHERE post_id = 7001;
SELECT title, view_count, updated_at,
updated_by, update_reason, source_system
FROM post_tracking_demo;title | view_count | updated_at | updated_by | update_reason | source_system
B+Tree와 covering index | 90 | 2026-07-15 10:00:00.000000 | 2 | 게시글 조회수 정정 | ADMIN추적 열은 "누가 마지막으로 바꾸었는가"에는 답하지만, 고치기 전의 view_count 60이나 그 전의 전체 상태에는 답하지 못합니다. 방식마다 잘 답하는 질문이 다릅니다.
| 방식 | 잘 답하는 질문 | 장점 | 한계 |
|---|---|---|---|
| 현재 행의 추적 열 | 마지막 수정의 시각과 작업자, 사유 | 구현과 조회가 단순하다 | 이전 상태를 복원하지 못한다 |
previous_value 열 | 바로 직전의 값 하나 | 작은 설정값에 간단하다 | 변경이 쌓이면 더 오래된 값이 사라진다 |
| 한 테이블에 모든 버전 | 특정 시점의 상태 | 한 구조에서 이력을 조회한다 | 현재 행 조건과 외래 키, UNIQUE, 인덱스가 복잡해진다 |
| 별도의 전체 행 스냅샷 | 현재 조회와 과거 복원 모두 | 복원 쿼리가 단순하고 타입이 유지된다 | 저장의 중복과 두 테이블의 원자적 갱신이 필요하다 |
| 열 단위의 변경 로그 | 무엇이 바뀌었는지의 검색 | 변경량이 작고 감사 검색이 쉽다 | 전체 상태의 복원이 어렵고 값의 타입 표현이 복잡하다 |
| 공통 감사 로그 | 여러 도메인의 활동 검색 | 작업자와 요청의 흐름을 모은다 | 도메인별 시점 복원과 제약이 약하다 |
실무에서는 한 가지만 고집하지 않고 역할을 나눕니다. 현재 테이블에는 created_at, updated_at 같은 기본 추적 열을 두고, 과거 상태를 복원해야 하는 핵심 데이터에는 전체 스냅샷을 둡니다. 누가 왜 바꾸었는지를 입증해야 한다면 15장 3편의 추가 전용 감사 이벤트를 함께 기록하며, 애플리케이션 로그는 보존 기간과 트랜잭션의 단위가 달라 데이터의 이력을 대신하지 못합니다.
전체 스냅샷을 고를 때도 모든 테이블을 복제하지는 않습니다. 권한과 게시글 상태, 노출 정책처럼 과거 시점의 결정을 재현해야 하는 데이터와, 쉽게 다시 만들 수 있는 캐시나 일시적인 계산값을 구분합니다. 스냅샷에 생기는 중복은 바뀌지 않을 읽기 상태를 의도적으로 보존한 결과이며, 대신 valid_from과 valid_to, version_no를 두어 어느 구간에 어떤 복사본이 유효했는지 검증할 수 있어야 합니다.
스냅샷에 담을 열은 화면이 아니라 복원해야 할 질문에서 고릅니다. 제목만 감사 대상이라고 생각해 제목만 복사하면 통계를 정정할 때 조회수가 다시 빠지고, author_id와 request_key 같은 관계도 당시의 분류를 보존해야 한다면 함께 담습니다. 거대한 본문이나 첨부처럼 수명이 따로인 객체는 ID와, 그때 보여 준 표시 이름의 스냅샷을 구분합니다.
현재 모델과 이력 모델
posts는 "지금 참조할 게시글" 한 건을 제공하므로, 태그 연결과 통계 원본의 외래 키는 안정된 현재의 기본 키를 향합니다. post_revision은 "특정 시각에 보였던 게시글"을 version_no와 반개구간(half-open interval)으로 제공합니다. 시작은 포함하고 끝은 제외하는 [valid_from, valid_to)를 쓰면, 구간이 바뀌는 시각이 두 버전에 동시에 포함되지 않습니다.
정오 경계는 버전 1을 닫고 버전 2를 연다
현재 posts에서 복사한 초기 v1을 정오에 닫고 조회 수 100의 v2를 연다. 시작 포함·끝 제외로 한 이력을 고른다.
POST SNAPSHOT · CURRENT / HISTORY / HALF OPEN
정오 경계는 버전 1을 닫고 버전 2를 연다
동일 post_id의 첫 스냅샷은 7월 15일 00:00에 시작합니다. 정오 정정은 기존 구간 종료·현재 UPDATE·새 스냅샷 INSERT를 하나의 트랜잭션으로 묶습니다.
버전 1 · 닫힌 구간
[00:00, 12:00)
선택한 초기 posts 속성의 스냅샷
10:30 조회 → 버전 1
버전 2 · 열린 구간
[12:00, 끝 미지정)
제목 snapshot 이력 실습 · 조회수 100
12:00 조회 → 버전 2
열린 스냅샷 하나 · 현재 참조는 같은 ID
valid_to NULL → current_slot 1
UNIQUE(post_id, current_slot) → 열린 행 최대 1개
닫힌 행의 current_slot NULL → 여러 과거 버전 허용
최대 한 행 제약은 반드시 한 행 존재까지 보장하지 않습니다. 초기화 대상의 열린 행 수와 현재 값 일치는 별도로 점검합니다. 이 예제는 본문·카테고리 등 전체 posts 열을 복제하지 않으며 다른 쓰기도 같은 이력 규칙을 따라야 합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 12시 이전부터 경계 조회까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 시작을 포함하고 끝을 제외합니다. 정오 12시는 버전 1에서 제외되고 열린 버전 2에 포함됩니다. 점선은 현재 posts와 열린 스냅샷의 제목/조회 수 일치 검사 관계이며 FK나 데이터 복사 화살표가 아닙니다. 처음 12시간은 종료된 이력 구간이고 이후 길이는 미지정이라 축을 생략합니다. 원 프로시저의 정정은 세 변경을 한 트랜잭션으로 묶습니다. |
| 노드·선 표기 | 현재 제목·조회 수 |
| 설명·조건 | 버전 1 · 종료된 이력 |
| 설명·조건 | 버전 2 · 진행 중인 이력 |
| 설명·조건 | 제목 snapshot 이력 실습 · 조회 수 100 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 12시 이전 현재 posts의 초기 속성을 복사한 v1이 현재 버전이다. 초기 조회 수는 고정하지 않는다. |
|
|
| 12시 갱신 기존 종료와 새 시작을 한 트랜잭션에 둔다. |
|
|
| 경계 조회 시작 포함·끝 제외로 한 이력을 고른다. |
|
|
정오 경계는 버전 1을 닫고 버전 2를 연다
현재 posts에서 복사한 초기 v1을 정오에 닫고 조회 수 100의 v2를 연다. 시작 포함·끝 제외로 한 이력을 고른다.
POST SNAPSHOT · CURRENT / HISTORY / HALF OPEN
정오 경계는 버전 1을 닫고 버전 2를 연다
동일 post_id의 첫 스냅샷은 7월 15일 00:00에 시작합니다. 정오 정정은 기존 구간 종료·현재 UPDATE·새 스냅샷 INSERT를 하나의 트랜잭션으로 묶습니다.
버전 1 · 닫힌 구간
[00:00, 12:00)
선택한 초기 posts 속성의 스냅샷
10:30 조회 → 버전 1
버전 2 · 열린 구간
[12:00, 끝 미지정)
제목 snapshot 이력 실습 · 조회수 100
12:00 조회 → 버전 2
열린 스냅샷 하나 · 현재 참조는 같은 ID
valid_to NULL → current_slot 1
UNIQUE(post_id, current_slot) → 열린 행 최대 1개
닫힌 행의 current_slot NULL → 여러 과거 버전 허용
최대 한 행 제약은 반드시 한 행 존재까지 보장하지 않습니다. 초기화 대상의 열린 행 수와 현재 값 일치는 별도로 점검합니다. 이 예제는 본문·카테고리 등 전체 posts 열을 복제하지 않으며 다른 쓰기도 같은 이력 규칙을 따라야 합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 12시 이전부터 경계 조회까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 시작을 포함하고 끝을 제외합니다. 정오 12시는 버전 1에서 제외되고 열린 버전 2에 포함됩니다. 점선은 현재 posts와 열린 스냅샷의 제목/조회 수 일치 검사 관계이며 FK나 데이터 복사 화살표가 아닙니다. 처음 12시간은 종료된 이력 구간이고 이후 길이는 미지정이라 축을 생략합니다. 원 프로시저의 정정은 세 변경을 한 트랜잭션으로 묶습니다. |
| 노드·선 표기 | 현재 제목·조회 수 |
| 설명·조건 | 버전 1 · 종료된 이력 |
| 설명·조건 | 버전 2 · 진행 중인 이력 |
| 설명·조건 | 제목 snapshot 이력 실습 · 조회 수 100 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 12시 이전 현재 posts의 초기 속성을 복사한 v1이 현재 버전이다. 초기 조회 수는 고정하지 않는다. |
|
|
| 12시 갱신 기존 종료와 새 시작을 한 트랜잭션에 둔다. |
|
|
| 경계 조회 시작 포함·끝 제외로 한 이력을 고른다. |
|
|
열려 있는 최신 구간만 valid_to가 NULL입니다. 생성 열 current_slot은 열린 행에서 1, 닫힌 행에서 NULL이 되는데, UNIQUE는 NULL을 서로 다른 값으로 취급하므로 UNIQUE (post_id, current_slot)은 닫힌 버전 여러 개를 허용하면서 열린 버전은 하나로 제한합니다. 경쟁하는 두 쓰기가 동시에 열린 행을 만들려 해도 이 UNIQUE가 하나를 거부합니다.
변경 프로시저는 세 가지를 지킵니다. 기존의 열린 스냅샷을 잠근 뒤에만 version_no를 늘리고, 정정 시각은 열린 구간의 시작보다 커야 하며, 현재 행의 UPDATE와 이전 구간의 종료, 새 스냅샷의 INSERT가 하나의 커밋이어야 합니다. SQL 예외가 나면 처리기가 트랜잭션 전체를 되돌리고, 현재 행을 바꾸는 다른 쓰기 경로도 같은 이력 발행 규칙을 따라야 현재와 스냅샷이 일치합니다.
아래 프로시저는 START TRANSACTION부터 COMMIT까지 직접 실행하므로, CALL은 진행 중인 다른 트랜잭션 밖에서 합니다. 이미 시작한 업무 트랜잭션 안에서 부르면 MySQL의 START TRANSACTION이 그 트랜잭션을 암묵적으로 커밋하므로, 중첩된 작은 트랜잭션처럼 쓸 수 없습니다(암묵적 커밋을 일으키는 문장).
스냅샷 버전의 기록
아래 DDL은 스냅샷의 복합 기본 키와 현재의 유일성, 유효 구간의 검사를 정의합니다. 첫 스냅샷을 만든 뒤, 프로시저가 잠금부터 커밋까지 수행합니다. @post_id는 12장 4편의 시드 게시글 1입니다.
DROP PROCEDURE IF EXISTS revise_post_revision;
DROP TABLE IF EXISTS post_revision;
CREATE TABLE post_revision (
post_id BIGINT UNSIGNED NOT NULL,
version_no INT UNSIGNED NOT NULL,
author_id BIGINT UNSIGNED NOT NULL,
request_key VARCHAR(64) NOT NULL,
title VARCHAR(160) NOT NULL,
view_count BIGINT UNSIGNED NOT NULL,
status VARCHAR(16) NOT NULL,
created_at DATETIME(6) NOT NULL,
published_at DATETIME(6) NULL,
valid_from DATETIME(6) NOT NULL,
valid_to DATETIME(6) NULL,
change_reason VARCHAR(240) NOT NULL,
current_slot TINYINT GENERATED ALWAYS AS
(IF(valid_to IS NULL, 1, NULL)) STORED,
PRIMARY KEY (post_id, version_no),
CONSTRAINT uq_post_revision_current UNIQUE (post_id, current_slot),
CONSTRAINT fk_post_revision_current FOREIGN KEY (post_id)
REFERENCES posts (id) ON DELETE RESTRICT,
CONSTRAINT chk_post_revision_window
CHECK (valid_to IS NULL OR valid_to > valid_from),
CONSTRAINT chk_post_revision_views
CHECK (view_count <= 1000000000),
INDEX ix_post_revision_asof (post_id, valid_from, valid_to)
) ENGINE=InnoDB;
SET @post_id = (SELECT id FROM posts ORDER BY id LIMIT 1);
SET @author_id = (SELECT author_id FROM posts WHERE id = @post_id);
INSERT INTO post_revision
(post_id, version_no, author_id, request_key, title,
view_count, status, created_at, published_at,
valid_from, valid_to, change_reason)
SELECT id, 1, author_id, request_key, title,
view_count, status, created_at, published_at,
'2026-07-15 00:00:00.000000', NULL, 'post revision 시작'
FROM posts
WHERE id = @post_id;
DELIMITER //
CREATE PROCEDURE revise_post_revision (
IN p_post_id BIGINT UNSIGNED,
IN p_effective_at DATETIME(6),
IN p_title VARCHAR(160),
IN p_view_count BIGINT UNSIGNED,
IN p_reason VARCHAR(240)
)
BEGIN
DECLARE v_version INT UNSIGNED;
DECLARE v_opened_at DATETIME(6);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
SELECT version_no + 1, valid_from
INTO v_version, v_opened_at
FROM post_revision
WHERE post_id = p_post_id AND valid_to IS NULL
FOR UPDATE;
IF v_opened_at IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'open snapshot not found';
END IF;
IF p_effective_at <= v_opened_at OR p_view_count > 1000000000 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'invalid snapshot boundary';
END IF;
UPDATE post_revision
SET valid_to = p_effective_at
WHERE post_id = p_post_id AND valid_to IS NULL;
UPDATE posts
SET title = p_title, view_count = p_view_count
WHERE id = p_post_id;
INSERT INTO post_revision
(post_id, version_no, author_id, request_key, title,
view_count, status, created_at, published_at,
valid_from, valid_to, change_reason)
SELECT id, v_version, author_id, request_key, title,
view_count, status, created_at, published_at,
p_effective_at, NULL, p_reason
FROM posts
WHERE id = p_post_id;
COMMIT;
END//
DELIMITER ;
CALL revise_post_revision(
@post_id, '2026-07-15 12:00:00.000000',
'snapshot 이력 실습', 100, '영수증과 대조해 제목과 조회수를 정정'
);
SELECT version_no, title, view_count,
valid_from, valid_to, current_slot
FROM post_revision
WHERE post_id = @post_id
ORDER BY version_no;version_no | title | view_count | valid_from | valid_to | current_slot
1 | 회원 게시판 최종 모델 | 0 | 2026-07-15 00:00:00.000000 | 2026-07-15 12:00:00.000000 | NULL
2 | snapshot 이력 실습 | 100 | 2026-07-15 12:00:00.000000 | NULL | 1첫 스냅샷은 정확히 정오에 닫히고, 같은 시각에 두 번째 스냅샷이 열렸습니다. 열린 행은 하나뿐이며, 현재 행 posts도 같은 커밋에서 새 제목과 조회수 100이 되었습니다.
외래 키의 ON DELETE RESTRICT는 스냅샷이 참조하는 현재 게시글의 삭제를 막지만, 자식인 스냅샷 자체의 DELETE는 막지 않으므로 별도의 권한과 보존 정책이 필요합니다. 이력의 보존 기간이 끝났다면 업무의 승인과 별도의 보관 절차를 거쳐야 하며, 부모를 지운 부수 효과로 사라져서는 안 됩니다.
version_no는 사용자에게 보이는 수정의 횟수이고, 유효 시각의 정렬 키는 valid_from입니다. 둘을 하나로 취급하면 과거 시각의 수정이나 데이터 이관에서 문제가 생기므로, 이 글은 앞으로 나아가는 발행만 허용하고 소급 정정은 15장 4편의 두 시간 축 모델로 넘깁니다.
특정 시점의 복원과 구간 검증
정상 결과는 특정 시각의 선택과 열린 행의 수, 구간의 겹침, 현재와 열린 스냅샷의 차이라는 네 개의 독립된 쿼리로 확인합니다. 하나의 복잡한 보고서로 합치면 어느 규칙이 깨졌는지 알기 어려우므로, 시점 조회는 고른 버전을, 나머지는 위반한 행을 돌려주게 씁니다.
시점 조회는 valid_from <= 시각이고 valid_to가 NULL이거나 시각보다 큰 행을 고릅니다. COALESCE(valid_to, '9999-12-31')처럼 문자열 상수로 열린 끝을 대신하면 결과 타입이 문자열이 되어 문자열 비교로 바뀌므로, IS NULL의 조건으로 직접 씁니다. 현재와의 일치 검사는 NULL에 안전한 비교 <=>로 published_at의 NULL까지 정확히 비교합니다.
SET @as_of = '2026-07-15 10:30:00.000000';
SELECT version_no, title, view_count, status
FROM post_revision
WHERE post_id = @post_id
AND valid_from <= @as_of
AND (valid_to IS NULL OR @as_of < valid_to);
SELECT p.id AS post_id
FROM posts AS p
LEFT JOIN post_revision AS h
ON h.post_id = p.id AND h.valid_to IS NULL
WHERE p.id = @post_id
GROUP BY p.id
HAVING COUNT(h.version_no) <> 1;
SELECT a.post_id, a.version_no, b.version_no
FROM post_revision AS a
JOIN post_revision AS b
ON b.post_id = a.post_id AND b.version_no > a.version_no
WHERE (b.valid_to IS NULL OR a.valid_from < b.valid_to)
AND (a.valid_to IS NULL OR b.valid_from < a.valid_to);
SELECT s.id AS post_id
FROM posts AS s
JOIN post_revision AS h
ON h.post_id = s.id AND h.valid_to IS NULL
WHERE NOT (s.title <=> h.title)
OR NOT (s.view_count <=> h.view_count)
OR NOT (s.status <=> h.status)
OR NOT (s.published_at <=> h.published_at);version_no | title | view_count | status
1 | 회원 게시판 최종 모델 | 0 | PUBLISHED10시 30분의 조회는 정오 전의 버전 1 한 행을 돌려주고, 뒤의 세 감사 쿼리는 모두 빈 결과입니다. 겹침 검사도 열린 끝을 IS NULL의 조건으로 다루며, 마지막 쿼리는 고른 네 속성만 대조할 뿐 다른 속성까지 확인하지는 않습니다.
정정 프로시저의 UPDATE와 스냅샷 INSERT 사이에서 오류가 나면, 처리기가 롤백해 열린 스냅샷이 그대로 남습니다. 이력이 커질수록 게시글 하나의 시간선 조회와 기간별 보관이 주된 작업이 되므로, 시점 조회가 ix_post_revision_asof의 post_id와 valid_from 범위를 쓰는지 EXPLAIN으로 확인합니다.
전체 스냅샷의 중복을 허용한 대가로, 복원 쿼리는 변화량을 누적해 계산할 필요 없이 한 행만 읽습니다. 스키마가 바뀌면 스냅샷의 열을 넓히고 새 쓰기 주체를 배포한 뒤 과거 행을 채웁니다. 반대로 상태가 절대 수정되지 않는 추가 전용 이벤트라면 별도의 현재 테이블이 필요 없을 수 있고, 현재 행을 참조하는 외래 키가 많고 최신 조회의 비율이 높다면 분리가 단순합니다. 판단의 기준은 유행하는 패턴이 아니라 읽기의 단위와 참조의 수명입니다.
운영 중에 불일치가 보이면, 쓰기 주체를 잠시 한 경로로 제한하고 현재와 열린 스냅샷의 차이 목록을 먼저 저장합니다. 현재 값을 무조건 이력에 덮어쓰면 어느 쪽이 맞는지 판단할 자료가 사라지므로, change_reason과 배포 기록을 대조한 뒤 승인된 방향으로 한 건씩 복구합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 참조 | 다른 테이블이 현재의 post_id를 안정적으로 참조해야 하는가 |
| 복원 | 운영자가 과거 한 시점의 완전한 행을 자주 재현하는가 |
| 쓰기 | 동시 정정이 있어 열린 스냅샷 하나를 데이터베이스가 보장해야 하는가 |
| 보존 | 이력의 삭제 기간과 현재 행의 삭제 수명이 서로 다른가 |
복원과 참조가 모두 필요하면 두 테이블의 분리가 유력한 후보입니다. 과거가 필요 없다고 확정할 수 있고 외부 원장이 이미 완전한 스냅샷을 갖고 있다면 중복 보관을 피할 수 있으며, 어느 쪽이든 결정의 기록에 보존 기간과 복구 담당자까지 적습니다.
정리
- 현재 행의 마지막 값과 일부 열의 변화량만으로는 과거의 완전한 게시글을 재현할 수 없습니다.
- 추적 열은 마지막 변경의 주체를, 전체 스냅샷은 과거 시점의 상태를 답합니다.
- 전체 스냅샷은 반개구간의 유효 기간과 함께 저장할 때 의도한 중복이 됩니다.
- 생성 열
current_slot과 UNIQUE가 열린 버전을 하나로 제한합니다. - 현재의 변경과 이력의 발행은 같은 트랜잭션에서 함께 성공하거나 함께 취소됩니다.
연습 문제
1. 두 번째 정정과 같은 시각의 재발행
정오에 만든 버전 2를 오후 1시에 다시 정정해 보세요. 그 뒤 12시 30분의 조회는 버전 2를, 13시 30분의 조회는 버전 3을 돌려주어야 하고, 같은 시각으로 다시 발행하려는 요청은 거부되어야 합니다.
해설 보기
기존 프로시저가 열린 구간의 시작보다 큰 시각만 허용하므로 그대로 다시 쓸 수 있습니다. 발행한 뒤 두 시각의 조회와 같은 시각의 재발행을 확인합니다.
CALL revise_post_revision(
@post_id, '2026-07-15 13:00:00.000000',
'snapshot 복원 검증', 110, '두 번째 정정으로 구간 전환을 검사'
);
SELECT version_no, title
FROM post_revision
WHERE post_id = @post_id
AND valid_from <= '2026-07-15 12:30:00'
AND (valid_to IS NULL OR '2026-07-15 12:30:00' < valid_to);
SELECT version_no, title
FROM post_revision
WHERE post_id = @post_id
AND valid_from <= '2026-07-15 13:30:00'
AND (valid_to IS NULL OR '2026-07-15 13:30:00' < valid_to);
CALL revise_post_revision(
@post_id, '2026-07-15 13:00:00.000000',
'거부 대상', 10, '같은 시각의 중복 발행을 확인'
);version_no | title
2 | snapshot 이력 실습
version_no | title
3 | snapshot 복원 검증
ERROR 1644 (45000): invalid snapshot boundary12시 30분은 버전 2의 구간 [12:00, 13:00) 안이고, 13시 30분은 열린 버전 3의 구간 안입니다. 마지막 호출은 열린 구간의 시작 13:00과 같은 시각이라 거부되며, 처리기가 롤백하므로 버전은 3개에서 늘지 않습니다.
2. 추적 열로 답할 수 있는 질문
post_tracking_demo만 있다면 다음 질문 가운데 답할 수 있는 것은 무엇인가요? 마지막으로 바꾼 사람, 바꾸기 전의 조회수, 9시 30분의 제목, 마지막 변경이 들어온 시스템.
해설 보기
마지막으로 바꾼 사람(회원 2)과 들어온 시스템(ADMIN)은 현재 행의 추적 열에 있으므로 답할 수 있습니다. 바꾸기 전의 조회수 60과 9시 30분의 제목 'B+Tree 초안'은 UPDATE가 덮어써 행에 남아 있지 않으므로 답할 수 없습니다. 스크립트를 읽은 사람은 알지만 데이터만으로는 알 수 없으며, 이런 질문이 필요하다면 스냅샷이나 감사 이벤트를 함께 둡니다.
다음 글 유효 기간 모델에서는 업무상의 유효 기간 자체가 겹칠 수 있는 예약 노출 정책을 모델링합니다.