이중 시간 모델
소급 정정 전후의 지식 상태를 보존하고 두 시간 조건을 받는 이중 시간 기준 시점 쿼리를 완성합니다.
오전 9시부터 정오까지 게시된 제목이 실제로는 “B+Tree”였지만 오후 3시에야 잘못 입력된 사실을 알았다고 합시다. “오전 10시의 게시글은 무엇이었나?”와 “오후 2시에 우리는 오전 10시를 무엇이라고 알고 있었나?”는 답이 다릅니다. 시간 열 하나는 이 두 질문을 동시에 표현하지 못합니다.
유효 시간은 현실에서 사실이 효력을 가진 구간입니다. 기록 시간은 DB가 그 버전을 사실로 보유한 기간입니다. 두 축을 보존하면 소급 정정 뒤에도 당시 보고서가 왜 옛 제목을 사용했는지 설명할 수 있습니다. 이 모델은 모든 테이블의 기본값이 아니라 규제, 정산, 재현 요구가 강한 사실에 선택적으로 적용합니다.
최종 회원 게시판의 변경 추적 표본으로 게시글 제목 사실을 사용합니다.
한 유효 구간에 대해 DB가 과거에 믿었던 버전과 현재 믿는 버전이 여러 시스템 구간으로 쌓입니다.
system_to의 열린 끝은 9999-12-31 센티널로 고정합니다.
소급 수정 프로시저는 “현재 DB 지식 중 p_valid_at을 덮는 행” 한 건을 잠급니다.
그 행의 시스템 기간을 닫고 같은 유효 기간과 수정된 값을 가진 새 지식 버전을 넣습니다.
원래 행을 UPDATE로 덮지 않습니다.
소급 정정으로 사라지는 질문
단일 effective_from과 제목을 가진 테이블에서 제목을 고치면 실제 적용 시각은 남을 수 있습니다.
하지만 14시에 생성한 보고서가 왜 오답을 썼는지 재현할 수 없습니다.
corrected_at을 현재 행에 추가해도 정정 전 상태 자체가 없으므로 당시 DB 상태는 복원되지 않습니다.
DROP TABLE IF EXISTS post_title_fact_bad;
CREATE TABLE post_title_fact_bad (
post_id BIGINT UNSIGNED NOT NULL,
effective_from DATETIME(6) NOT NULL,
effective_to DATETIME(6) NOT NULL,
title VARCHAR(160) NOT NULL,
corrected_at DATETIME(6) NULL,
PRIMARY KEY (post_id, effective_from)
) ENGINE=InnoDB;
INSERT INTO post_title_fact_bad VALUES
(7001, '2026-07-15 09:00:00', '2026-07-15 12:00:00',
'인덱스 개요', NULL);
-- 오후 2시 보고서는 이 값을 사용했다.
SELECT title FROM post_title_fact_bad
WHERE post_id = 7001
AND '2026-07-15 10:00:00' >= effective_from
AND '2026-07-15 10:00:00' < effective_to;
-- 오후 3시에 소급 정정하면서 이전 지식을 덮는다.
UPDATE post_title_fact_bad
SET title = 'B+Tree', corrected_at = '2026-07-15 15:00:00'
WHERE post_id = 7001 AND effective_from = '2026-07-15 09:00:00';
-- 오후 2시 당시 DB가 반환했던 payload를 다시 묻는다.
SELECT title FROM post_title_fact_bad
WHERE post_id = 7001
AND '2026-07-15 10:00:00' >= effective_from
AND '2026-07-15 10:00:00' < effective_to;두 번째 조회는 현재 정답만 반환하며 14시의 지식 상태는 사라졌습니다.
오류 실행 결과title used by report at 14:00=인덱스 개요
title returned after correction=B+Tree
queryable previous database belief=0 rows
business-effective period retained=yes
knowledge period retained=nocorrected_at은 변경 사건의 한 점일 뿐 이전 버전이 유효했던 시스템 구간을 나타내지 않습니다.
이전 상태와 [system_from, system_to) 범위가 함께 있어야 특정 트랜잭션 시각을 조건으로 걸 수 있습니다.
유효 이력과 감사도 구분해야 합니다.
행위자와 사유는 감사 의미를 제공하지만 두 시점 축으로 행을 선택하는 규칙을 대신하지 않습니다.
반대로 이중 시간 행에 recorded_by가 있어도 승인 작업 흐름과 변조 방지가 완성되는 것은 아닙니다.
두 질문을 SQL 매개변수 이름으로 구분하기
p_valid_at은 현실에서 알고 싶은 순간, p_known_at은 DB 지식 스냅샷의 순간입니다.
둘을 모두 asOf라고 부르면 API 호출자가 뒤바꿉니다.
쿼리와 인덱스 문서에도 유효/기록 시점이라는 용어를 유지합니다.
사실 시간과 기록 시간
한 행은 유효 기간과 시스템 기간 두 반개구간을 갖습니다.
이중 시간 기준 시점 조건은 네 비교식입니다.
V는 유효 시작 이상이고 끝보다 작으며, T도 시스템 시작 이상이고 끝보다 작은 행을 찾습니다.
정상 데이터라면 post_id, V, T 조합당 최대 한 건입니다.
system_from은 애플리케이션이 보내는 값이 아니라 DB가 기록을 수락한 시각이어야 합니다.
실습은 재현 가능한 예제 데이터를 위해 매개변수로 받지만 운영 프로시저에서는 CURRENT_TIMESTAMP(6)를 내부에서 얻고 권한을 제한합니다.
소급 정정은 유효 구간을 바꾸지 않고 시스템 버전을 추가한다
오전 9시~정오라는 현실 범위가 맞고 제목만 잘못 알았다면 valid_from/to는 그대로 복사합니다.
기존 행의 system_to를 오후 3시로 닫고 새 행의 system_from을 같은 시각으로 엽니다.
두 지식 버전이 시스템 경계를 공유합니다.
MySQL 8.4 메모: 시스템-버전이 있는 테이블 문법을 자동 제공하지 않으므로 명시적 DDL과 프로시저를 사용합니다. 센티널 DATETIME의 정밀도와 게시글
time_zone을 마이그레이션에 고정합니다.
이중 시간 버전 기록
생성된 구간은 같은 게시글과 valid_from에 열린 시스템 버전 하나만 허용합니다.
임의 유효 겹침은 별도 일치 검사로 감시합니다.
수정 프로시저는 현재 지식 중 대상 유효 시점을 덮는 행을 FOR UPDATE로 가져옵니다.
DROP PROCEDURE IF EXISTS correct_post_fact_bitemporal;
DROP TABLE IF EXISTS post_title_fact;
CREATE TABLE post_title_fact (
fact_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
post_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(160) NOT NULL,
valid_from DATETIME(6) NOT NULL,
valid_to DATETIME(6) NOT NULL,
system_from DATETIME(6) NOT NULL,
system_to DATETIME(6) NOT NULL
DEFAULT '9999-12-31 23:59:59.999999',
recorded_by BIGINT UNSIGNED NOT NULL,
correction_reason VARCHAR(240) NOT NULL,
open_system_slot TINYINT GENERATED ALWAYS AS (
IF(system_to = '9999-12-31 23:59:59.999999', 1, NULL)
) STORED,
PRIMARY KEY (fact_id),
CONSTRAINT uq_post_fact_open_version
UNIQUE (post_id, valid_from, open_system_slot),
CONSTRAINT fk_post_fact_post FOREIGN KEY (post_id)
REFERENCES posts (id) ON DELETE RESTRICT,
CONSTRAINT fk_post_fact_actor FOREIGN KEY (recorded_by)
REFERENCES members (id) ON DELETE RESTRICT,
CONSTRAINT chk_post_fact_valid CHECK (valid_to > valid_from),
CONSTRAINT chk_post_fact_system CHECK (system_to > system_from),
INDEX ix_post_fact_bitemporal
(post_id, valid_from, valid_to, system_from, system_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_title_fact
(post_id, title, valid_from, valid_to,
system_from, system_to, recorded_by, correction_reason)
VALUES
(@post_id, '인덱스 개요',
'2026-07-15 09:00:00', '2026-07-15 12:00:00',
'2026-07-15 09:05:00', '9999-12-31 23:59:59.999999',
@author_id, '최초 게시글 제목 입력');
DELIMITER //
CREATE PROCEDURE correct_post_fact_bitemporal (
IN p_post_id BIGINT UNSIGNED,
IN p_valid_at DATETIME(6),
IN p_recorded_at DATETIME(6),
IN p_new_title VARCHAR(160),
IN p_actor_id BIGINT UNSIGNED,
IN p_reason VARCHAR(240)
)
BEGIN
DECLARE v_fact_id BIGINT UNSIGNED;
DECLARE v_valid_from DATETIME(6);
DECLARE v_valid_to DATETIME(6);
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN ROLLBACK; RESIGNAL; END;
START TRANSACTION;
SELECT fact_id, valid_from, valid_to
INTO v_fact_id, v_valid_from, v_valid_to
FROM post_title_fact
WHERE post_id = p_post_id
AND p_valid_at >= valid_from
AND p_valid_at < valid_to
AND system_to = '9999-12-31 23:59:59.999999'
FOR UPDATE;
IF v_fact_id IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'current belief not found';
END IF;
IF CHAR_LENGTH(TRIM(p_reason)) < 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'correction reason too short';
END IF;
UPDATE post_title_fact
SET system_to = p_recorded_at
WHERE fact_id = v_fact_id
AND p_recorded_at > system_from;
IF ROW_COUNT() <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'knowledge time must increase';
END IF;
INSERT INTO post_title_fact
(post_id, title, valid_from, valid_to,
system_from, system_to, recorded_by, correction_reason)
VALUES
(p_post_id, p_new_title, v_valid_from, v_valid_to,
p_recorded_at, '9999-12-31 23:59:59.999999',
p_actor_id, p_reason);
COMMIT;
END//
DELIMITER ;
CALL correct_post_fact_bitemporal(
@post_id, '2026-07-15 10:00:00', '2026-07-15 15:00:00',
'B+Tree', @author_id, '교재 원본을 대조해 오전 게시글 제목을 소급 정정'
);유효 범위는 같고 시스템 경계 15시를 기준으로 두 지식 버전이 나뉩니다.
개선 실행 결과facts for valid slice=2
open system versions=1
belief at valid 10:00 / known 14:00=인덱스 개요
belief at valid 10:00 / known 16:00=B+Tree
system overlap pairs=0SELECT INTO가 두 행을 반환하면 프로시저는 오류로 중단됩니다. 이는 현재 지식의 유효 구간이 이미 겹쳤다는 신호입니다. LIMIT 1로 임의 선택하지 않고 오염을 드러내는 편이 안전합니다.
현재 정답만 필요한 일반 화면은 system_to 센티널 조건을 붙입니다.
감사 재현 화면만 p_known_at을 받습니다.
모든 조회에 두 축을 강요하면 인덱스 폭과 API 복잡도가 커지므로 사용 사례별 뷰를 제공합니다.
시스템 시계를 되돌릴 수 없게 하는 조건
p_recorded_at이 기존 system_from보다 커야 UPDATE가 한 건을 바꿉니다.
운영에서는 DB 현재 시각을 사용하고 NTP 불일치를 감시합니다.
데이터 이관처럼 과거 시스템 시간을 재현해야 한다면 별도 마이그레이션 역할과 검증 목록을 둡니다.
두 시간 축의 조회
두 기준 시점 쿼리는 p_valid_at을 같게 두고 p_known_at만 14시와 16시로 바꿉니다.
그 다음 현재 시스템 행끼리 유효 겹침이 없는지, 같은 유효 구간의 시스템 기간이 교차하지 않는지 확인합니다.
SET @valid_at = '2026-07-15 10:00:00.000000';
SELECT fact_id, title
FROM post_title_fact
WHERE post_id = @post_id
AND @valid_at >= valid_from AND @valid_at < valid_to
AND '2026-07-15 14:00:00' >= system_from
AND '2026-07-15 14:00:00' < system_to;
SELECT fact_id, title
FROM post_title_fact
WHERE post_id = @post_id
AND @valid_at >= valid_from AND @valid_at < valid_to
AND '2026-07-15 16:00:00' >= system_from
AND '2026-07-15 16:00:00' < system_to;
SELECT a.fact_id, b.fact_id
FROM post_title_fact AS a
JOIN post_title_fact AS b
ON b.post_id = a.post_id AND b.fact_id > a.fact_id
WHERE a.system_to = '9999-12-31 23:59:59.999999'
AND b.system_to = '9999-12-31 23:59:59.999999'
AND a.valid_from < b.valid_to
AND b.valid_from < a.valid_to;
SELECT a.fact_id, b.fact_id
FROM post_title_fact AS a
JOIN post_title_fact AS b
ON b.post_id = a.post_id
AND b.valid_from = a.valid_from
AND b.valid_to = a.valid_to
AND b.fact_id > a.fact_id
WHERE a.system_from < b.system_to
AND b.system_from < a.system_to;
EXPLAIN ANALYZE
SELECT title
FROM post_title_fact
WHERE post_id = @post_id
AND @valid_at >= valid_from AND @valid_at < valid_to
AND '2026-07-15 14:00:00' >= system_from
AND '2026-07-15 14:00:00' < system_to;첫 결과는 인덱스 개요, 두 번째 결과는 B+Tree입니다.
유효와 시스템 겹침 감사는 모두 0행입니다.
실행 계획은 post_id와 valid_from 선두 범위를 사용하고 남은 끝값 조건을 평가합니다.
사실 수가 커지면 현재 뷰와 보관을 분리할 수 있습니다. 다만 시스템 이력을 옮긴 뒤에도 두 시각 쿼리가 같은 결과를 내도록 UNION 뷰와 보존 기준 시각을 검증해야 합니다.
유효 구간 자체가 잘못된 경우
정답 제목뿐 아니라 실제 게시 종료가 11시였다고 밝혀지면 valid_to 수정도 필요합니다.
기존 현재 인식을 닫고 수정된 유효 범위를 가진 새 시스템 버전을 넣습니다.
인접 현재 사실과 겹침 또는 누락 구간이 생기는지 같은 트랜잭션에서 검사합니다.
최종 회원 게시판에서 두 시간 모델을 제한할 곳
단순 태그 이름이나 UI 정렬까지 이중 시간으로 만들 필요는 없습니다. 정산에 사용된 게시 기간, 신고 처리 상태, 공개 정책 적용 결과처럼 과거 운영 보고서를 재현해야 하는 사실에 집중합니다. 나머지는 스냅샷 또는 감사만으로 충분한지 먼저 검토합니다.
보고서 재현 목록
보고서 실행 시 유효 기준 시각, 기록 시점 기준 시각, 쿼리 버전을 함께 저장합니다.
데이터가 소급 정정된 뒤 같은 report_id를 재생할 때 기록 시점 기준 시각을 원래 값으로 넣으면 당시 출력이 재현됩니다.
최신 정답 보고서는 기록 시점 기준 시각을 현재 시각으로 새로 발행합니다.
이중 시간 모델 도입 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 소급 | 현실의 과거 사실이 나중에 정정될 수 있는가? |
| 재현 | 당시 시스템이 알고 있던 값으로 보고서를 다시 만들어야 하는가? |
| 빈도 | 수정 비율과 사실당 시스템 버전 수를 측정했는가? |
| 질의 | valid_at과 known_at을 구분해서 받는 실제 API가 존재하는가? |
| 보존 | 두 축 이력의 보관과 개인정보 정책이 정의되었는가? |
소급 정정과 과거 지식 재현이 둘 다 요구될 때 이중 시간 모델이 가치가 있습니다. 최신 정답만 필요하면 스냅샷 이력이 더 단순합니다. 도입 결정서에는 재현해야 할 보고서 목록, 예상 버전 배수, 두 기준 시각의 소유자를 기록합니다.
연습 문제
오전 구간의 valid_to가 실제로는 11시였다는 추가 정정을 17시에 기록하세요.
기존 15시 지식 행을 닫고 valid_to=11시인 새 시스템 버전을 추가합니다.
16시 기록 시점 쿼리는 정오 종료를, 18시 기록 시점 쿼리는 11시 종료를 보여야 합니다.
해설과 예시 답안
제목을 수정할 때와 같은 방식으로 현재 시스템 행을 닫지만 신규 행의 valid_to를 바꿉니다.
새 범위가 다른 현재 사실과 겹치지 않는지 먼저 검사합니다.
원래 두 시스템 버전은 그대로 남습니다.
START TRANSACTION;
SET @knowledge_change = '2026-07-15 17:00:00.000000';
SET @current_fact = (
SELECT fact_id
FROM post_title_fact
WHERE post_id = @post_id
AND '2026-07-15 10:00:00' >= valid_from
AND '2026-07-15 10:00:00' < valid_to
AND system_to = '9999-12-31 23:59:59.999999'
FOR UPDATE
);
UPDATE post_title_fact
SET system_to = @knowledge_change
WHERE fact_id = @current_fact;
INSERT INTO post_title_fact
(post_id, title, valid_from, valid_to, system_from, system_to,
recorded_by, correction_reason)
SELECT post_id, title, valid_from, '2026-07-15 11:00:00',
@knowledge_change, '9999-12-31 23:59:59.999999',
@author_id, '운영 로그로 실제 valid_to를 추가 보정'
FROM post_title_fact
WHERE fact_id = @current_fact;
COMMIT;
SELECT title, valid_to
FROM post_title_fact
WHERE post_id = @post_id
AND '2026-07-15 10:00:00' >= valid_from
AND '2026-07-15 10:00:00' < valid_to
AND '2026-07-15 16:00:00' >= system_from
AND '2026-07-15 16:00:00' < system_to;
SELECT title, valid_to
FROM post_title_fact
WHERE post_id = @post_id
AND '2026-07-15 10:00:00' >= valid_from
AND '2026-07-15 10:00:00' < valid_to
AND '2026-07-15 18:00:00' >= system_from
AND '2026-07-15 18:00:00' < system_to;기록 시점 16시 결과의 valid_to는 12시이고 기록 시점 18시 결과는 11시입니다.
현재 시스템 유효 겹침 쿼리와 시스템 기간 교차 쿼리는 여전히 0행이어야 합니다.
핵심 정리
- 유효 시간은 현실의 효력 구간이고 기록 시간은 DB가 그 버전을 믿은 구간입니다.
- 두 시점 매개변수를 함께 적용하면 소급 정정 이전의 보고서 입력까지 재현할 수 있습니다.
- 수정은 이전 시스템 기간을 닫고 새 지식 행을 추가하며 유효 의미는 필요할 때만 바꿉니다.
- 이중 시간 비용은 재현 가치가 큰 사실에만 선택적으로 부담합니다.
다음 장에서는 삭제 표시와 재실행 가능한 집계를 통해 현재 조회와 복구 경계를 이어갑니다.