이중 시간 모델
소급 정정이 그 전의 지식 상태를 덮어쓰는 반례를 재현하고, 유효 기간과 기록 기간을 함께 가진 사실 테이블로 정정 전후의 지식을 모두 보존합니다. 유효 시각과 기록 기준 시각의 두 조건을 받는 시점 쿼리와 두 축의 대조로 모델을 검증합니다.
오전 9시부터 정오까지 게시된 제목이 실제로는 "B+Tree"였는데, 잘못 입력되었다는 것을 오후 3시에야 알았다고 합시다. "오전 10시의 게시글 제목은 무엇이었나"와 "오후 2시에 우리는 오전 10시의 제목을 무엇이라고 알고 있었나"는 답이 다르며, 시간 열 하나로는 두 질문을 함께 나타낼 수 없습니다.
유효 시간은 현실에서 사실이 효력을 가졌던 구간이고, 기록 시간은 데이터베이스가 그 버전을 사실로 갖고 있던 기간입니다. 두 축을 보존하는 이중 시간(bitemporal) 모델을 쓰면, 소급 정정 뒤에도 당시의 보고서가 왜 옛 제목을 썼는지 설명할 수 있습니다. 이 모델은 모든 테이블의 기본값이 아니라, 규제와 정산, 재현의 요구가 강한 사실에 골라서 적용합니다.
예제는 게시글의 제목을 이중 시간 사실로 다룹니다. 한 유효 구간에 대해 데이터베이스가 과거에 믿었던 버전과 지금 믿는 버전이 여러 기록 구간으로 쌓이며, 열린 기록 구간의 끝 system_to는 센티널 값으로 고정합니다. 소급 정정 프로시저는 지금의 지식 가운데 대상 유효 시각을 덮는 행 하나를 잠그고, 그 행의 기록 구간을 닫은 뒤 같은 유효 기간과 고친 값을 가진 새 지식 버전을 넣습니다. 원래 행의 제목과 유효 구간은 보존하고 system_to만 닫습니다.
소급 정정으로 사라지는 질문
유효 시작과 제목만 가진 테이블에서 제목을 고치면, 실제 적용 시각은 남지만 14시에 만든 보고서가 왜 오답을 썼는지는 재현할 수 없습니다. 현재 행에 corrected_at을 더해도 정정 전의 상태 자체가 없으므로 당시의 데이터베이스 상태는 되살릴 수 없습니다.
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시 당시 데이터베이스가 돌려준 값을 다시 묻는다.
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;title
인덱스 개요
title
B+Tree두 번째 조회는 지금의 정답만 돌려줄 뿐, 14시에 데이터베이스가 알고 있던 '인덱스 개요'는 다시 물을 수 없습니다. 업무의 유효 기간은 남았지만, 지식의 기간은 남지 않았습니다.
corrected_at은 변경 사건의 한 점일 뿐, 이전 버전이 사실로 기록되어 있던 구간을 나타내지 않습니다. 이전 상태와 [system_from, system_to)의 범위가 함께 있어야 특정 기록 시각을 조건으로 걸 수 있습니다. 유효 이력과 감사도 구분하는데, 행위자와 사유는 감사의 뜻을 주지만 두 시간 축으로 행을 고르는 규칙을 대신하지 않고, 반대로 이중 시간 행에 recorded_by가 있어도 승인의 절차와 변조의 방지가 완성되지는 않습니다.
두 질문은 SQL 매개변수의 이름으로도 구분합니다. p_valid_at은 현실에서 알고 싶은 순간이고, p_known_at은 데이터베이스 지식의 스냅샷을 볼 순간입니다. 둘을 모두 asOf라고 부르면 API의 호출자가 뒤바꾸기 쉬우므로, 쿼리와 인덱스의 문서에도 유효 시각과 기록 시각이라는 말을 그대로 유지합니다.
유효 시간과 기록 시간
한 행은 유효 기간과 기록 기간을 함께 가지며, 두 기간 모두 시작을 포함하고 끝을 제외하는 반개구간입니다. 알고 싶은 현실의 시각을 V, 그 일을 어느 시점의 기록으로 다시 볼지 정하는 시각을 T라고 하면, 고르는 행의 조건은 valid_from <= V < valid_to와 system_from <= T < system_to입니다. 정상적인 데이터라면 post_id와 V, T의 조합마다 행은 최대 하나입니다.
아래 SQL이 만들 첫 두 버전을 먼저 봅니다. 두 행은 모두 7월 15일 09:00부터 12:00 직전까지의 제목을 나타내며, 09:05에 처음 기록한 제목은 '인덱스 개요', 15:00에 'B+Tree'로 정정합니다. 유효 기간은 같고, 기록 기간만 15:00에서 나뉩니다.
같은 유효 시각을 두 기록 기준으로 읽기
그때 알던 제목을 읽는다. 새 기록이 계속 선택된다. 각 단계의 값과 전환 이유를 비교합니다.
BITEMPORAL FACT · VALID AT / KNOWN AT
같은 유효 시각을 두 기록 기준으로 읽기
유효 시각 V를 10:00에 두고 기록 기준 T를 14:00에서 16:00으로 바꿉니다. 15:00 제목 정정은 두 행의 유효 기간을 바꾸지 않습니다.
예시 시각은 2026-07-15 UTC 기준입니다. 막대 안의 점은 같은 V = 10:00 위치입니다. 모든 기간은 시작 포함·끝 제외이며 기록 기간별 행의 높이는 시간 길이에 비례하지 않습니다.
공유 경계 15:00에서는 새 기록을 고릅니다
T = 15:00은 기존 기록의 끝에서 제외되고 새 기록의 시작에 포함됩니다. 두 행의 유효 끝 12:00은 조회 범위에 포함하지 않습니다.
센티널은 9999-12-31 23:59:59.999999입니다. 입력한 기록 시각에 따른 두 버전이며 실제 COMMIT·MVCC 가시성 시각을 자동 재현하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 기록 14시부터 기록 16시까지 진행합니다.
- 열린 기록 구간의 끝은 무한대가 아니라 DATETIME 센티널 9999-12-31 23:59:59.999999이며 끝은 제외한다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기록 14시 그때 알던 제목을 읽는다. |
|
|
| 기록 15시 정정 경계를 포함하는 새 기록을 읽는다. |
|
|
| 기록 16시 새 기록이 계속 선택된다. |
|
|
같은 유효 시각을 두 기록 기준으로 읽기
그때 알던 제목을 읽는다. 새 기록이 계속 선택된다. 각 단계의 값과 전환 이유를 비교합니다.
BITEMPORAL FACT · VALID AT / KNOWN AT
같은 유효 시각을 두 기록 기준으로 읽기
유효 시각 V를 10:00에 두고 기록 기준 T를 14:00에서 16:00으로 바꿉니다. 15:00 제목 정정은 두 행의 유효 기간을 바꾸지 않습니다.
예시 시각은 2026-07-15 UTC 기준입니다. 막대 안의 점은 같은 V = 10:00 위치입니다. 모든 기간은 시작 포함·끝 제외이며 기록 기간별 행의 높이는 시간 길이에 비례하지 않습니다.
공유 경계 15:00에서는 새 기록을 고릅니다
T = 15:00은 기존 기록의 끝에서 제외되고 새 기록의 시작에 포함됩니다. 두 행의 유효 끝 12:00은 조회 범위에 포함하지 않습니다.
센티널은 9999-12-31 23:59:59.999999입니다. 입력한 기록 시각에 따른 두 버전이며 실제 COMMIT·MVCC 가시성 시각을 자동 재현하지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 기록 14시부터 기록 16시까지 진행합니다.
- 열린 기록 구간의 끝은 무한대가 아니라 DATETIME 센티널 9999-12-31 23:59:59.999999이며 끝은 제외한다.
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기록 14시 그때 알던 제목을 읽는다. |
|
|
| 기록 15시 정정 경계를 포함하는 새 기록을 읽는다. |
|
|
| 기록 16시 새 기록이 계속 선택된다. |
|
|
유효 시각 V를 10:00에 고정하고 기록 기준 T만 14:00에서 16:00으로 바꾸어 보면, 14:00은 정정 전의 기록 기간에 들어가 '인덱스 개요'를, 16:00은 정정 뒤의 기록 기간에 들어가 'B+Tree'를 고릅니다. 제목이 바뀌어도 두 행의 유효 끝은 모두 12:00입니다.
T가 정확히 15:00이면 기존 행의 끝은 제외되고 새 행의 시작은 포함되므로 새 행만 고릅니다. 반면 V가 12:00이거나 T가 첫 기록인 09:05보다 이르면 두 버전 가운데 고를 행이 없으므로, 행이 최대 하나라는 규칙이 언제나 하나를 돌려준다는 뜻은 아닙니다. 두 질문이 같은 행을 고른다고 읽었다면 기록 기간도 비교했는지, 15:00에 두 행이 모두 골라진다고 읽었다면 끝의 비교가 <인지 확인합니다.
실습의 system_from은 매개변수로 지정한 기록 시각입니다. 운영에서 CURRENT_TIMESTAMP(6)를 쓰더라도 그 값은 문장이나 프로시저 호출이 시작된 시각일 뿐, 실제의 COMMIT이나 MVCC의 가시성 시각이 아닙니다. 따라서 이 모델은 정해 둔 기록 시각에 따른 과거의 지식을 조회하며, 동시 트랜잭션을 포함한 실제 데이터베이스의 과거 가시성까지 재현하려면 기록 시각을 발행하는 규칙과 별도의 기록이 필요합니다.
오전 9시부터 정오라는 현실의 범위가 맞고 제목만 잘못 알았다면, valid_from과 valid_to는 그대로 복사합니다. 기존 행의 system_to를 오후 3시로 닫고 새 행의 system_from을 같은 시각으로 열면, 두 지식 버전이 15시라는 시각을 공유합니다.
MySQL은 SQL 표준의 시스템 버전 테이블 문법을 제공하지 않으므로 명시적인 DDL과 프로시저를 씁니다. 열린 끝의 센티널은 9999-12-31 23:59:59로 둡니다. 공식 문서는 소수점 이하를 포함한 DATETIME의 최댓값을 9999-12-31 23:59:59.499999로 밝히고 있어(DATETIME의 범위), 그 범위 안의 값을 고른 것입니다. 센티널의 정밀도와 연결의 time_zone은 마이그레이션에서도 고정합니다.
이중 시간 버전의 기록
생성 열 open_system_slot은 같은 게시글과 valid_from에 열린 기록 버전을 하나만 허용합니다. 서로 다른 유효 구간끼리의 겹침은 별도의 대조로 감시하며, 정정 프로시저는 지금의 지식 가운데 대상 유효 시각을 덮는 행을 FOR UPDATE로 가져옵니다. @post_id는 12장 4편의 시드 게시글 1입니다.
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',
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', 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',
@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'
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',
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, '교재 원본을 대조해 오전 게시글 제목을 소급 정정'
);
SELECT fact_id, title, system_from, system_to, open_system_slot
FROM post_title_fact
WHERE post_id = @post_id
ORDER BY fact_id;fact_id | title | system_from | system_to | open_system_slot
1 | 인덱스 개요 | 2026-07-15 09:05:00.000000 | 2026-07-15 15:00:00.000000 | NULL
2 | B+Tree | 2026-07-15 15:00:00.000000 | 9999-12-31 23:59:59.000000 | 1두 행의 유효 구간은 모두 09:00부터 12:00까지로 같고, 기록 구간이 15시에서 나뉘었습니다. 열린 기록 버전은 2 하나뿐입니다.
SELECT ... INTO가 두 행을 돌려주면 프로시저는 오류로 멈추는데, 이는 지금의 지식에서 유효 구간이 이미 겹쳤다는 신호입니다. LIMIT 1로 아무 행이나 고르기보다 오염을 드러내는 편이 안전합니다. p_recorded_at이 기존 system_from보다 커야 UPDATE가 한 행을 바꾸므로, 기록 시각을 되돌리는 정정은 knowledge time must increase로 거부됩니다. 운영에서는 데이터베이스의 현재 시각을 쓰고 NTP의 불일치를 감시하며, 데이터 이관처럼 과거의 기록 시각을 재현해야 한다면 별도의 마이그레이션 역할과 검증 목록을 둡니다.
지금의 정답만 필요한 일반 화면은 system_to의 센티널 조건만 붙이고, 감사의 재현 화면만 p_known_at을 받습니다. 모든 조회에 두 축을 강요하면 인덱스의 폭과 API의 복잡도가 커지므로, 쓰임새별 뷰를 제공합니다.
두 시간 축의 조회
두 시점 쿼리는 V를 10:00으로 같게 두고 T만 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'
AND b.system_to = '9999-12-31 23:59:59'
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;fact_id | title
1 | 인덱스 개요
fact_id | title
2 | B+Tree같은 유효 시각 10:00이라도, 14시의 지식으로는 '인덱스 개요'를, 16시의 지식으로는 'B+Tree'를 고릅니다. 두 겹침 쿼리는 빈 결과입니다. 첫 쿼리는 지금의 기록 행끼리 유효 구간을 비교하고, 두 번째는 유효 구간이 정확히 같은 행끼리 기록 구간을 비교하므로, 모든 과거 행의 두 축 겹침을 빠짐없이 찾는 쿼리는 아닙니다.
인덱스는 post_id와 valid_from을 앞에 두었으며, 실제 실행 계획과 끝 조건의 평가 비용은 첫 시점 조회 앞에 EXPLAIN ANALYZE를 붙여 데이터가 충분할 때 확인합니다. 사실의 수가 커지면 현재 뷰와 보관을 나눌 수 있지만, 기록 이력을 옮긴 뒤에도 두 시점 쿼리가 같은 결과를 내도록 UNION 뷰와 보존 기준 시각을 검증합니다.
정답 제목뿐 아니라 실제 게시의 종료가 11시였다고 밝혀지면 valid_to도 고쳐야 합니다. 이때도 지금의 지식 행을 닫고, 고친 유효 범위를 가진 새 기록 버전을 넣으며, 이웃한 현재 사실과 겹치거나 빈 구간이 생기지 않는지 같은 트랜잭션에서 검사합니다(연습 문제 1).
이중 시간을 적용할 곳
태그의 이름이나 화면의 정렬까지 이중 시간으로 만들 필요는 없습니다. 정산에 쓰인 게시 기간이나 신고 처리의 상태, 공개 정책의 적용 결과처럼 과거의 운영 보고서를 재현해야 하는 사실에 집중하고, 나머지는 스냅샷이나 감사만으로 충분한지 먼저 검토합니다.
보고서를 실행할 때는 유효 기준 시각과 기록 기준 시각, 쿼리의 버전을 함께 저장합니다. 데이터가 소급 정정된 뒤 같은 report_id를 다시 실행할 때 기록 기준 시각에 원래 값을 넣으면 당시의 출력이 재현되고, 최신의 정답 보고서는 기록 기준 시각을 현재로 두어 새로 발행합니다.
| 따져 볼 것 | 질문 |
|---|---|
| 소급 | 현실의 과거 사실이 나중에 정정될 수 있는가 |
| 재현 | 당시 시스템이 알던 값으로 보고서를 다시 만들어야 하는가 |
| 빈도 | 정정의 비율과 사실마다 쌓이는 기록 버전의 수를 측정했는가 |
| 질의 | valid_at과 known_at을 나누어 받는 실제 API가 있는가 |
| 보존 | 두 축 이력의 보관과 개인정보의 정책이 정해졌는가 |
소급 정정과 과거 지식의 재현이 둘 다 요구될 때 이중 시간 모델이 가치가 있고, 최신 정답만 필요하다면 스냅샷 이력이 더 단순합니다. 도입을 결정할 때는 재현해야 할 보고서의 목록과 예상되는 버전의 배수, 두 기준 시각을 누가 정하는지를 기록합니다.
정리
- 유효 시간은 현실의 효력 구간이고, 기록 시간은 데이터베이스가 그 버전을 사실로 믿었던 구간입니다.
- 두 시각 매개변수를 함께 적용하면, 소급 정정 이전에 보고서가 읽었던 값까지 재현할 수 있습니다.
- 정정은 이전의 기록 구간을 닫고 새 지식 행을 더하며, 유효 구간은 필요할 때만 바꿉니다.
- 열린 끝의 센티널은 문서가 밝힌 DATETIME의 범위 안에서 고릅니다.
- 이중 시간의 비용은 재현의 가치가 큰 사실에만 골라서 부담합니다.
연습 문제
1. 유효 종료의 추가 정정
오전 구간의 valid_to가 실제로는 11시였다는 추가 정정을 17시에 기록해 보세요. 15시에 만든 지식 행을 닫고 valid_to가 11시인 새 기록 버전을 더해야 하며, 16시를 기준으로 한 쿼리는 정오의 종료를, 18시를 기준으로 한 쿼리는 11시의 종료를 보여야 합니다.
해설 보기
제목을 정정할 때처럼 지금의 기록 행을 닫되, 새 행의 valid_to를 바꿉니다. 아래 코드는 지금의 유효 구간 하나를 줄이는 성공 경로이며, 별도의 겹침 조회나 오류 처리기는 넣지 않았으므로 수동으로 실행하다 오류가 나면 바로 멈추고 ROLLBACK합니다. 일반적인 범위 변경에는 다른 현재 사실과의 겹침과 빈 구간의 검사도 필요합니다.
START TRANSACTION;
SET @knowledge_change = '2026-07-15 17:00:00.000000';
SELECT fact_id INTO @current_fact
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'
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',
@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;title | valid_to
B+Tree | 2026-07-15 12:00:00.000000
title | valid_to
B+Tree | 2026-07-15 11:00:00.00000015시의 지식 행 2는 기록 구간이 [15:00, 17:00)으로 닫히고, 새 행 3이 17시부터 열립니다. 첫 두 버전의 제목과 유효 구간은 그대로 남으므로, 16시의 지식은 정오의 종료를, 18시의 지식은 11시의 종료를 보여 줍니다.
기록 시각마다 달라지는 게시글 제목의 유효 구간
첫 기록을 선택한다. 유효 종료 정정 뒤의 기록을 읽는다. 각 단계의 값과 전환 이유를 비교합니다.
BITEMPORAL FACT · VALID AT / KNOWN AT
같은 유효 시각도 기록 시각에 따라 제목과 종료가 달라진다
7월 15일 유효 시각을 가로축에 놓고 기록 구간별로 당시 지식을 비교합니다. 15시 제목 정정 후에도 유효 끝은 정오이며, 17시 추가 정정 때 끝이 11시로 바뀝니다.
모든 구간은 시작 포함·끝 제외입니다. 행의 높이는 기록 시간 길이에 비례하지 않습니다. 센티널은 9999-12-31 23:59:59.999999이며 이 기록 시각은 실제 COMMIT/MVCC 가시성 시각을 자동 재현하지 않습니다.
유효 10:00 고정 · 기록 시점만 바꾸기
known 14:00 → 인덱스 개요 · valid_to 12:00
known 16:00 → B+Tree · valid_to 12:00
known 18:00 → B+Tree · valid_to 11:00
유효·기록의 두 반개구간 조건을 함께 적용합니다. 예제 UNIQUE는 같은 valid_from의 열린 버전만 제한하며 임의 유효 겹침 전체를 막지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 14시 지식부터 18시 지식까지 진행합니다.
- 열린 기록 구간의 끝은 무한대가 아니라 DATETIME 센티널 9999-12-31 23:59:59.999999이며 끝은 제외한다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 같은 7월 15일의 유효 시각을 가로축으로 비교합니다. 행은 기록 시각 구간이며 세로 길이는 시간 길이에 비례하지 않습니다. 모든 구간은 시작을 포함하고 끝을 제외합니다. 마지막 기록 구간의 끝은 원문 DATETIME 센티널입니다. |
| 설명·조건 | 유효·기록의 두 반개구간 조건을 함께 적용합니다. 예제 UNIQUE는 같은 valid_from의 열린 버전만 제한하며 임의의 유효 구간 겹침 전체를 막지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 14시 지식 첫 기록을 선택한다. |
|
|
| 16시 지식 두 번째 버전의 제목 정정을 읽는다. |
|
|
| 18시 지식 유효 종료 정정 뒤의 기록을 읽는다. |
|
|
기록 시각마다 달라지는 게시글 제목의 유효 구간
첫 기록을 선택한다. 유효 종료 정정 뒤의 기록을 읽는다. 각 단계의 값과 전환 이유를 비교합니다.
BITEMPORAL FACT · VALID AT / KNOWN AT
같은 유효 시각도 기록 시각에 따라 제목과 종료가 달라진다
7월 15일 유효 시각을 가로축에 놓고 기록 구간별로 당시 지식을 비교합니다. 15시 제목 정정 후에도 유효 끝은 정오이며, 17시 추가 정정 때 끝이 11시로 바뀝니다.
모든 구간은 시작 포함·끝 제외입니다. 행의 높이는 기록 시간 길이에 비례하지 않습니다. 센티널은 9999-12-31 23:59:59.999999이며 이 기록 시각은 실제 COMMIT/MVCC 가시성 시각을 자동 재현하지 않습니다.
유효 10:00 고정 · 기록 시점만 바꾸기
known 14:00 → 인덱스 개요 · valid_to 12:00
known 16:00 → B+Tree · valid_to 12:00
known 18:00 → B+Tree · valid_to 11:00
유효·기록의 두 반개구간 조건을 함께 적용합니다. 예제 UNIQUE는 같은 valid_from의 열린 버전만 제한하며 임의 유효 겹침 전체를 막지 않습니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 14시 지식부터 18시 지식까지 진행합니다.
- 열린 기록 구간의 끝은 무한대가 아니라 DATETIME 센티널 9999-12-31 23:59:59.999999이며 끝은 제외한다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | 같은 7월 15일의 유효 시각을 가로축으로 비교합니다. 행은 기록 시각 구간이며 세로 길이는 시간 길이에 비례하지 않습니다. 모든 구간은 시작을 포함하고 끝을 제외합니다. 마지막 기록 구간의 끝은 원문 DATETIME 센티널입니다. |
| 설명·조건 | 유효·기록의 두 반개구간 조건을 함께 적용합니다. 예제 UNIQUE는 같은 valid_from의 열린 버전만 제한하며 임의의 유효 구간 겹침 전체를 막지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 14시 지식 첫 기록을 선택한다. |
|
|
| 16시 지식 두 번째 버전의 제목 정정을 읽는다. |
|
|
| 18시 지식 유효 종료 정정 뒤의 기록을 읽는다. |
|
|
지금의 기록 행끼리의 유효 겹침과, 같은 유효 구간의 기록 구간 교차를 확인하는 두 쿼리는 여전히 빈 결과입니다. 행 1과 2는 유효 구간이 같지만 기록 구간이 15시에서 맞닿을 뿐 겹치지 않고, 행 3은 유효 끝이 달라 두 번째 쿼리의 비교 대상이 아닙니다.
2. 어느 행을 고르는가
연습 문제 1까지 마친 상태에서, 다음 (V, T) 조합은 각각 어느 행을 고르나요? (10:00, 09:00), (10:00, 15:00), (11:30, 18:00), (11:30, 16:00).
해설 보기
| V | T | 고르는 행 | 이유 |
|---|---|---|---|
| 10:00 | 09:00 | 없음 | 첫 기록 09:05보다 이르다 |
| 10:00 | 15:00 | 행 2, B+Tree | 행 1의 기록 끝 15:00은 제외되고 행 2의 시작은 포함된다 |
| 11:30 | 18:00 | 없음 | 18시의 지식인 행 3은 유효 구간이 11:00에 끝난다 |
| 11:30 | 16:00 | 행 2, B+Tree | 16시의 지식인 행 2는 유효 구간이 12:00까지다 |
마지막 두 줄이 이중 시간 모델의 핵심입니다. 같은 11시 30분에 대해, 16시에는 게시 중이었다고 알고 있었고 18시에는 이미 끝났다고 알고 있습니다.
다음 글 소프트 삭제부터는 16장으로 넘어가, 삭제 표시와 다시 실행할 수 있는 집계로 현재 조회와 복구의 범위를 이어 갑니다.