이중 시간BitemporalSQL:2011시간 테이블PostgreSQL 18PostgreSQL 19MariaDBXTDBFOR PORTION OFWITHOUT OVERLAPS시스템 버전 테이블Graphiti도커실습
시간을 기억하는 SQL — PostgreSQL 18·MariaDB·XTDB로 직접 짜 보는 이중 시간 테이블
Graphiti의 두 개의 시계는 1980년대 데이터베이스 연구에서 왔습니다. 그 뿌리가 2026년의 SQL 데이터베이스에서는 얼마나 자랐는지, PostgreSQL 18·19 베타, MariaDB 11.8·12.3, XTDB 2.1을 도커로 띄워 같은 급여·요금제 시나리오를 넣어 봤습니다. MariaDB는 표준의 이중 시간 기능을 거의 전부 갖췄고, PostgreSQL 18은 기간이 겹치지 않게 막는 키까지만 있습니다. 기간 일부만 고치는 FOR PORTION OF는 PostgreSQL 19 개발판에 들어갔다가 9월 15일에 빠졌는데, 그 이유인 '동시 수정 유실'을 직접 재현했습니다. XTDB는 과거 시각으로 기록하려는 트랜잭션을 거부했고, MariaDB는 기본 설정에서 기록 시각을 되돌리자 이력 한 줄이 조용히 사라졌습니다. 고객 1만 명 규모의 적재·질의 측정과 인터랙티브 4개를 곁들였습니다.
이 블로그는 지난 두 글에서 Graphiti의 시간 그래프를 코드로 읽고한국어 대화로 실측했습니다. 두 글 모두 같은 출발점을 짚었습니다. 사실마다 세상의 시간(언제 참이었나)과 기록의 시간(언제 알게 됐나)을 따로 적는 이중 시간(bitemporal) 모델은 1985~1986년 스노드그래스(Snodgrass)와 안(Ahn)의 데이터베이스 연구에서 나왔고, 2011년에는 SQL 표준(SQL:2011)에 들어갔다는 것입니다.
그렇다면 15년이 지난 2026년, 우리가 매일 쓰는 SQL 데이터베이스는 이 표준을 얼마나 따라왔을까요? 이번에는 언어 모델 없이, 데이터베이스만 도커로 띄워 직접 확인했습니다.
1
MariaDB는 거의 다 있고, PostgreSQL은 절반
MariaDB 11.8·12.3은 유효 기간, 겹침 금지 키, 기간 일부 수정(FOR PORTION OF), 자동 이력(시스템 버전), 기록 시점 질의를 모두 기본으로 지원했습니다. PostgreSQL 18은 겹침 금지 키와 시간 외래 키까지만 있고, 기록 시간은 트리거로 직접 만들어야 했습니다(56줄).
2
PostgreSQL 19의 FOR PORTION OF는 들어왔다 나갔다
19 베타3에는 있던 문법이 베타4에서 사라졌습니다. 2026년 9월 15일에 되돌려졌는데, 이유는 두 세션이 동시에 기간을 고치면 한쪽 변경 일부가 소리 없이 사라지는 문제였습니다. 이 현상을 베타3에서 그대로 재현했습니다. 같은 경쟁을 MariaDB에서는 재현하지 못했습니다.
3
기록의 시간을 지키는 방식이 다르다
XTDB 2.1은 이미 기록된 시각보다 과거로 기록하려는 트랜잭션을 거부했습니다. MariaDB는 기본 설정에서 세션 시각을 되돌리면 받아들였고, 그 결과 이력 한 줄이 흔적 없이 사라졌습니다. 서버를 --secure-timestamp=YES로 띄우면 막힙니다.
모든 명령과 결과는 2026년 10월 11일, 이 글에 적은 버전의 도커 이미지에서 그대로 옮겼습니다.
1. SQL:2011 한 장 요약
표준의 이중 시간 기능은 몇 개의 낱말로 정리됩니다. IBM의 Kulkarni와 Michels가 2012년 SIGMOD Record에 쓴 해설이 가장 짧고 정확한 안내서입니다.
개념
표준 문법
뜻
기간(period)
PERIOD FOR p (start_col, end_col)
새 데이터 타입이 아니라 열 두 개에 붙인 이름. 시작 포함·끝 제외 [start, end)
애플리케이션 시간
사용자 정의 기간 하나
세상의 시간(유효 시간). 앱이 값을 넣는다
시스템 시간
PERIOD FOR SYSTEM_TIME + WITH SYSTEM VERSIONING
기록의 시간(트랜잭션 시간). DB가 자동으로 채우고 사용자는 못 바꾼다
겹침 금지 키
PRIMARY KEY (id, p WITHOUT OVERLAPS)
같은 id의 유효 기간이 겹치지 않게
시간 외래 키
FOREIGN KEY (id, PERIOD p) REFERENCES ...
자식 행의 기간 내내 부모가 존재해야(여러 부모 행에 걸쳐도 됨)
기간 일부 수정
UPDATE t FOR PORTION OF p FROM a TO b SET ...
겹치는 행을 둘·셋으로 쪼개 그 구간만 바꾼다
기록 시점 질의
FOR SYSTEM_TIME AS OF t
“그때 DB에 저장돼 있던 대로” 읽는다
두 가지를 기억해 두면 뒤가 편합니다. 첫째, 표준은 기간을 타입으로 만들지 않았습니다. JDBC·ODBC 같은 모든 연결 계층이 새 타입을 지원해야 하는 부담을 피하려고, 기존 날짜 열 두 개에 이름만 붙였습니다. 둘째, 표준에는 유효 시간에 대한 AS OF 질의 문법이 없습니다. 유효 시간은 평범한 WHERE 조건으로 묻습니다. MariaDB가 이 기능 요청을 “표준이 명시적으로 배제했다”며 닫은 것도 그래서입니다. 두 기능 모두 표준의 선택(optional) 기능이라, 데이터베이스마다 지원 범위가 제각각입니다.
시나리오는 둘입니다. 하나는 마틴 파울러의 「Bitemporal History」(2021)에 나오는 급여 소급 예시를 한국어로 옮긴 것이고, 다른 하나는 Graphiti 이론편의 이중 시간 평면 위젯에 쓴 한빛상사 요금제 이야기입니다. 위젯에서 그림으로만 보여 준 질의를 이번에는 진짜 SQL로 던집니다.
김지훈 님의 월급은 2026년 1월 1일부터 300만 원입니다. 2월 28일 급여일에는 300만 원이 나갔습니다. 그런데 3월 15일, 인사팀이 “2월 15일부터 350만 원으로 올랐다”고 알려 옵니다. 4월 20일에는 “4월 한 달은 무급 휴직”이라는 정정이 들어옵니다.
이 회사가 답해야 할 질문은 둘입니다. “2월 28일에 우리는 얼마를 줘야 한다고 알고 있었나?”(당시 결정의 근거)와 “지금 아는 바로는 2월 20일 월급이 얼마였나?”(소급 정산의 근거)입니다.
MariaDB: 표준 문법 그대로
MariaDB는 한 테이블에 두 기간을 모두 붙일 수 있습니다. 기록 시각은 원래 DB가 실제 시각으로 채우지만, 실습을 위해 세션 시각(@@timestamp)을 그날로 맞춰 가며 넣었습니다. MariaDB 문서가 시연용으로 소개하는 방법입니다(6절에서 이 방법의 위험을 따로 다룹니다).
sql
CREATE TABLE salaries (
emp VARCHAR(20), monthly INT,
valid_from DATE, valid_to DATE,
PERIODFOR valid_period(valid_from, valid_to),
UNIQUE (emp, valid_period WITHOUTOVERLAPS)
) WITHSYSTEMVERSIONING;
SET @@timestamp= UNIX_TIMESTAMP('2026-01-01 09:00:00');
INSERT INTO salaries VALUES ('김지훈', 3000000, '2026-01-01', '9999-12-31');
SET @@timestamp= UNIX_TIMESTAMP('2026-03-15 09:00:00'); -- 소급 인상을 알게 된 날UPDATE salaries FORPORTIONOF valid_period FROM'2026-02-15'TO'9999-12-31'SET monthly =3500000WHERE emp ='김지훈';
SET @@timestamp= UNIX_TIMESTAMP('2026-04-20 09:00:00'); -- 무급 휴직을 알게 된 날DELETEFROM salaries FORPORTIONOF valid_period FROM'2026-04-01'TO'2026-05-01'WHERE emp ='김지훈';
결과를 그대로 옮깁니다.
MariaDB 11.8.9 출력
-- 지금 아는 바 (현재 행)
김지훈 | 3000000 | 2026-01-01 | 2026-02-15
김지훈 | 3500000 | 2026-02-15 | 2026-04-01
김지훈 | 3500000 | 2026-05-01 | 9999-12-31
-- 2월 28일 급여일에 시스템이 알던 2월 20일 월급
SELECT monthly FROM salaries FOR SYSTEM_TIME AS OF TIMESTAMP '2026-02-28 18:00:00'
WHERE '2026-02-20' >= valid_from AND '2026-02-20' < valid_to; 3000000
-- 지금 아는 바로 2월 20일 월급 3500000
UPDATE ... FOR PORTION OF 한 줄이 행 하나를 둘로 갈랐고(2월 15일 앞은 300만 원 그대로), DELETE ... FOR PORTION OF 한 줄이 가운데 4월만 도려내 행을 둘로 나눴습니다. 그리고 같은 2월 20일에 대해 기록 시점을 어디로 잡느냐에 따라 300만 원과 350만 원이라는 서로 다른 답이 나옵니다. 둘 다 맞는 답입니다. 앞의 것은 “그날 우리가 왜 300만 원을 줬는가”를, 뒤의 것은 “지금 얼마를 더 줘야 하는가”를 설명합니다.
전체 이력(FOR SYSTEM_TIME ALL)을 보면 DB가 무엇을 쌓았는지 보입니다.
monthly
valid_from ~ valid_to
ROW_START ~ ROW_END (기록의 시간)
3000000
2026-01-01 ~ 9999-12-31
2026-01-01 09:00 ~ 2026-03-15 09:00
3000000
2026-01-01 ~ 2026-02-15
2026-03-15 09:00 ~ 2106-02-07 06:28:15
3500000
2026-02-15 ~ 9999-12-31
2026-03-15 09:00 ~ 2026-04-20 09:00
3500000
2026-02-15 ~ 2026-04-01
2026-04-20 09:00 ~ 2106-02-07 06:28:15
3500000
2026-05-01 ~ 9999-12-31
2026-04-20 09:00 ~ 2106-02-07 06:28:15
‘현재’ 행의 ROW_END는 2106-02-07입니다. MariaDB 11.5부터 TIMESTAMP 범위가 32비트 부호 없는 정수 끝까지 넓어져, 예전 문서의 2038년 대신 2106년이 찍힙니다.
XTDB: 모든 테이블이 처음부터 이중 시간
XTDB 2는 테이블을 만들 때 아무것도 선언하지 않아도 모든 행이 _valid_from·_valid_to·_system_from·_system_to 네 열을 갖습니다. 지난 글들의 Graphiti 엣지가 갖던 네 타임스탬프와 정확히 대응합니다. 과거 데이터를 옮겨 넣을 때는 트랜잭션에 기록 시각을 지정할 수 있습니다.
sql
BEGIN READ WRITE WITH (SYSTEM_TIME=TIMESTAMP'2026-01-01 09:00:00Z');
INSERT INTO salaries (_id, emp, monthly, _valid_from) VALUES (1, '김지훈', 3000000, DATE'2026-01-01');
COMMIT;
BEGIN READ WRITE WITH (SYSTEM_TIME=TIMESTAMP'2026-03-15 09:00:00Z');
UPDATE salaries FORPORTIONOF VALID_TIME FROMDATE'2026-02-15'TONULLSET monthly =3500000WHERE _id =1;
COMMIT;
BEGIN READ WRITE WITH (SYSTEM_TIME=TIMESTAMP'2026-04-20 09:00:00Z');
DELETEFROM salaries FORPORTIONOF VALID_TIME FROMDATE'2026-04-01'TODATE'2026-05-01'WHERE _id =1;
COMMIT;
-- 급여일의 시스템이 알던 2월 20일 월급: 두 시간축을 모두 고정한다SELECT monthly FROM salaries
FORSYSTEM_TIMEASOFTIMESTAMP'2026-02-28 18:00:00Z'FOR VALID_TIME ASOFDATE'2026-02-20'; -- 3000000SELECT monthly FROM salaries FOR VALID_TIME ASOFDATE'2026-02-20'; -- 3500000
답은 MariaDB와 같았습니다(300만 원, 350만 원). 다른 점은 문법입니다. XTDB는 표준에 없는 FOR VALID_TIME AS OF를 제공해, 두 시간축을 같은 모양의 구절로 고정합니다. 그리고 아무 조건 없이 SELECT하면 “지금 아는 바로, 지금 유효한 행”만 돌려줍니다. 처음 써 보면 “1~2월 행은 어디 갔지?” 싶은데, FOR VALID_TIME ALL을 붙여야 지난 기간까지 나옵니다.
아래에서 같은 종류의 명령을 차례로 실행해 보세요. 겹치는 행이 어떻게 갈라지는지, 세 DB의 문법이 어떻게 다른지 함께 보입니다.
4. 시나리오 2: 지난 글의 위젯을 진짜 SQL로
Graphiti 이론편의 3부에서 한빛상사의 요금제 이야기로 이중 시간 평면을 그렸습니다. 1월 1일 Basic으로 시작, 3월 1일 Pro로 바꿨지만 3월 15일에야 알려 줌, 6월 30일 Basic으로 돌아갔지만 7월 3일에야 알려 줌. 그 위젯이 던진 네 질문을 세 DB에 그대로 물었습니다.
질문
기록 시점
유효 시점
MariaDB
XTDB
PostgreSQL 18(트리거)
Q1 지금 요금제
지금
지금
Basic
Basic
Basic
Q2 3/10 청구서는 왜 Basic이었나
3/10
3/10
Basic
Basic
Basic
Q3 지금 보면 3/10의 요금제
지금
3/10
Pro
Pro
Pro
Q4 5/1의 시스템이 본 8/1
5/1
8/1
Pro
Pro
Pro
세 DB의 답이 위젯의 그림과 모두 같았습니다. 흥미로운 것은 답이 아니라 이력을 저장한 모양입니다. MariaDB(와 같은 방식으로 만든 PostgreSQL 트리거)는 행이 바뀔 때마다 행 전체의 옛 버전을 닫고 새 버전을 넣습니다. XTDB는 바뀐 부분만 닫습니다. 두 방식 모두 행 다섯 개로 같은 평면을 빈틈없이 덮지만, 자르는 방향이 다릅니다.
어느 쪽이 낫다기보다 쓰임새가 다릅니다. 행 전체를 복사하는 방식은 “그때 테이블에 저장돼 있던 행”을 원형 그대로 꺼낼 수 있어 감사 기록과 잘 맞습니다. 바뀐 부분만 닫는 방식은 같은 사실이 여러 번 다시 쓰여도 조각이 덜 늘어납니다. 7절의 측정에서 저장 공간 차이를 봅니다.
5. PostgreSQL: 키는 생겼고, 나머지는 손으로
18: 기간이 겹치지 않게 막는 키
PostgreSQL 18(2025년 9월)은 Paul Jungwirth가 오래 다듬은 시간 제약을 처음 받아들였습니다. 표준과 다른 점은 기간을 열 두 개가 아니라 범위 타입 열 하나(daterange, tstzrange)로 표현한다는 것입니다. 정수·문자열 열과 섞어 키를 만들려면 btree_gist 확장이 필요합니다.
sql
CREATE EXTENSION btree_gist;
CREATE TABLE plans (
customer text, plan text, valid_at daterange,
PRIMARY KEY (customer, valid_at WITHOUTOVERLAPS)
);
INSERT INTO plans VALUES ('한빛상사', 'Basic', '[2026-01-01, 2026-03-01)');
INSERT INTO plans VALUES ('한빛상사', 'Pro', '[2026-03-01, 2026-06-30)');
INSERT INTO plans VALUES ('한빛상사', 'Basic', '[2026-06-30,)');
INSERT INTO plans VALUES ('한빛상사', 'Enterprise', '[2026-05-01, 2026-08-01)'); -- 겹친다CREATE TABLE invoice_lines (
customer text, billed_for daterange, amount int,
FOREIGN KEY (customer, PERIOD billed_for) REFERENCES plans (customer, PERIOD valid_at)
);
INSERT INTO invoice_lines VALUES ('한빛상사', '[2026-02-01, 2026-04-01)', 30000); -- 두 요금제 행에 걸침INSERT INTO invoice_lines VALUES ('한빛상사', '[2025-12-01, 2026-01-15)', 10000); -- 요금제가 없던 기간
INSERT 0 1 ← 2~4월 청구: Basic 행과 Pro 행이 이어서 덮으므로 통과 ERROR: insert or update on table "invoice_lines" violates foreign key constraint "invoice_lines_customer_billed_for_fkey"
DETAIL: Key (customer, billed_for)=(한빛상사, [2025-12-01,2026-01-15)) is not present in table "plans".
시간 외래 키가 특히 쓸모 있습니다. 청구 기간(2~4월)이 요금제 행 하나에 담기지 않고 Basic과 Pro 두 행에 걸쳐 있어도 통과시키고, 요금제가 아예 없던 2025년 12월이 끼면 거부합니다. “청구한 기간에는 반드시 계약이 있어야 한다”는 규칙을 DB가 지켜 줍니다. 다만 참조 동작은 NO ACTION만 되고 CASCADE 같은 연쇄 동작은 아직 없습니다.
PostgreSQL에는 시스템 버전 테이블이 없습니다. PostgreSQL 19 문서에 새로 생긴 ‘시간 테이블’ 절도 “시스템 시간은 현재 지원하지 않으며, 트리거나 외부 확장으로 흉내 낼 수 있다”고 적습니다. 그래서 직접 만들었습니다. 현재 행에 기록 기간 열(sys_period)을 두고, 수정·삭제 때마다 옛 행을 이력 테이블로 옮기는 트리거입니다.
sql
CREATE TABLE plans (
customer text, plan text, valid_at daterange,
sys_period tstzrange NOT NULLDEFAULT tstzrange(now(), NULL),
PRIMARY KEY (customer, valid_at WITHOUTOVERLAPS)
);
CREATE TABLE plans_history (LIKE plans);
CREATEFUNCTION plans_versioning() RETURNStriggerLANGUAGE plpgsql AS $$
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THENINSERT INTO plans_history VALUES
(OLD.customer, OLD.plan, OLD.valid_at, tstzrange(lower(OLD.sys_period), bt_now()));
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
NEW.sys_period := tstzrange(bt_now(), NULL);
RETURNNEW;
END IF;
RETURNOLD;
END $$;
CREATETRIGGER plans_versioning BEFORE INSERTORUPDATEORDELETEON plans
FOREACHROWEXECUTEFUNCTION plans_versioning();
CREATEVIEW plans_all ASSELECT*FROM plans UNIONALLSELECT*FROM plans_history;
-- bt_now()는 실습용으로 세션 설정값을 '지금'으로 쓰는 함수
FOR PORTION OF가 없으니 “3월 1일부터 Pro로”도 함수로 직접 잘라야 했습니다. 여기서 한 번 실패했습니다. 새 조각을 먼저 넣고 옛 행을 나중에 줄였더니, 넣는 순간 옛 행과 기간이 겹쳐 시간 키가 거부했습니다. 옛 행을 먼저 끊고, 남은 조각을 나중에 넣어야 합니다. 표준의 FOR PORTION OF가 한 문장으로 해 주는 일을 손으로 하면 이런 순서까지 신경 써야 합니다. 다 만들고 나니 4절의 네 질문에 MariaDB와 같은 답을 냈고, 이력 행도 MariaDB와 똑같은 다섯 줄이었습니다. 실습용 함수와 뷰까지 합쳐 SQL 56줄이었습니다.
직접 만들기 싫다면 확장이 있습니다. temporal_tables(트리거 기반, C로 작성)와 Vik Fearing의 periods(PERIOD와 시스템 버전 문법을 함수로 흉내 냄)가 대표적인데, periods는 README 기준으로 PostgreSQL 15까지 호환을 적고 있어 최신 버전에서는 직접 확인이 필요합니다.
19: 들어왔다 나간 FOR PORTION OF
PostgreSQL 19 개발판에는 2026년 4월 1일 UPDATE/DELETE ... FOR PORTION OF가 들어갔습니다. 같은 Paul Jungwirth의 작업이고 Peter Eisentraut가 커밋했습니다. 같은 스크립트를 두 베타에 돌렸습니다.
버전
UPDATE salaries FOR PORTION OF valid_at FROM '2026-02-15' TO NULL ...
베타3과 베타4 사이에 무슨 일이 있었을까요. 9월 10일, Andres Freund가 이 기능의 결함을 짚었습니다. 기본 격리 수준인 READ COMMITTED에서 두 트랜잭션이 겹치는 기간을 동시에 고치면, 뒤의 트랜잭션은 앞 트랜잭션이 고친 행만 다시 읽고 앞 트랜잭션이 새로 넣은 남은 조각은 다시 찾지 않습니다. 그래서 뒤의 변경 일부가 사라집니다. 그는 “변경 유실은 어떤 경우에도 받아들일 수 없다”고 썼고, Eisentraut는 9월 15일 이 기능과 후속 수정 22개를 함께 되돌렸습니다. 남은 조각을 계산하는 도우미 함수와 문서의 ‘시간 테이블’ 절은 19에 남았습니다.
글로만 읽고 넘어가기 아까워 직접 재현했습니다. 세션 A가 3~5월 월급을 바꾸고 커밋하기 전에 2초를 쉬는 동안, 세션 B가 4~8월 부서를 바꾸려고 A를 기다리게 했습니다.
베타3의 READ COMMITTED에서 B가 바꾸려던 6~8월이 통째로 빠졌습니다. 두 세션 모두 오류나 경고 없이 커밋됐습니다. 격리 수준을 REPEATABLE READ나 SERIALIZABLE로 올리면 B가 could not serialize access due to concurrent update로 실패해, 앱이 다시 시도할 기회가 생깁니다.
같은 경쟁을 MariaDB 11.8에도 걸었습니다. READ COMMITTED와 기본값인 REPEATABLE READ 모두 두 변경이 다 반영된 올바른 결과(행 다섯 개)가 나왔고, SERIALIZABLE에서는 B가 ERROR 1020 Record has changed since last read로 실패했습니다. PostgreSQL 측 테스트 명세에는 “MariaDB도 READ COMMITTED에서 잘못 동작한다”는 메모가 있었지만, 이 시나리오에서는 재현되지 않았습니다. 다른 조건에서 나타나는 문제일 수 있어, 이 결과를 “MariaDB는 안전하다”로 읽지는 않기를 권합니다.
이 일은 Graphiti 실측에서 본 것과 같은 교훈을 줍니다. 시간을 다루는 연산은 한 행을 여러 행으로 바꾸는 연산이고, 그래서 동시성 문제가 일반 UPDATE보다 훨씬 미묘합니다. Graphiti 실측에서 한 사용자의 메시지만큼은 순서대로 넣어야 했던 것도 같은 맥락입니다.
이중 시간 모델에서 두 시계는 성격이 다릅니다. 세상의 시간(유효 시간)은 틀릴 수 있고 고칠 수 있어야 합니다. 소급 인상처럼요. 기록의 시간은 반대로 고칠 수 없어야 의미가 있습니다. “3월 10일에 시스템은 무엇을 알고 있었나”라는 질문의 답이 나중에 바뀐다면 감사 기록이 아니니까요. 그래서 실습 중 기록 시각을 일부러 과거로 돌려 봤습니다.
XTDB: 과거로는 기록할 수 없다
XTDB에서 이미 7월 3일까지 기록한 상태로, 2월 1일 시각의 트랜잭션을 시도했습니다.
XTDB 2.1.0
BEGIN READ WRITE WITH (SYSTEM_TIME = TIMESTAMP '2026-02-01 09:00:00Z');
INSERT INTO plans (_id, plan) VALUES ('누리랩스', 'Pro');
COMMIT; ERROR: specified system-time older than current tx
DETAIL: {"tx-key":{"system-time":"2026-02-01T09:00Z"}, "latest-completed-tx":{"system-time":"2026-07-03T09:00Z"}, "code":"invalid-system-time"}
기록 시각은 DB 전체에서 앞으로만 흐릅니다. 과거 데이터를 옮겨 넣을 때 시각을 지정할 수는 있지만, 지금까지 기록된 가장 늦은 시각보다 앞설 수는 없습니다. 실습 중 이것 때문에 컨테이너를 두 번 새로 띄워야 했습니다. 2026년 시나리오를 넣은 뒤에는 2025년 이력을 넣을 수 없었기 때문입니다. 귀찮지만, 바로 이 귀찮음이 보증입니다. 기록을 영구히 지워야 하는 경우(개인정보 삭제 요청 등)를 위해서는 ERASE라는 별도 명령이 있습니다.
MariaDB: 기본 설정에서는 되돌릴 수 있고, 그러면 이력이 사라진다
MariaDB는 세션 시각을 바꾸면 시스템 버전 테이블의 기록 시각도 따라갑니다. 3절의 실습이 그 기능을 썼습니다. 그렇다면 이미 7월 3일까지 기록된 요금제 테이블에서, 세션 시각을 2월 1일로 되돌려 행 하나를 고치면 어떻게 될까요?
sql
SET @@timestamp= UNIX_TIMESTAMP('2026-02-01 09:00:00');
UPDATE plans SET plan ='Trial'WHERE customer ='한빛상사'AND valid_from ='2026-06-30';
plan
유효 기간
ROW_START ~ ROW_END
비고
Basic
01-01 ~ 9999
01-01 ~ 03-15
Trial
06-30 ~ 9999
02-01 ~ (현재)
2월 1일에 기록됐다고 적힘
Basic
01-01 ~ 03-01
03-15 ~ (현재)
Pro
03-01 ~ 9999
03-15 ~ 07-03
Pro
03-01 ~ 06-30
07-03 ~ (현재)
7월 3일에 기록된 “6월 30일부터 Basic” 버전이 이력에서 사라졌다
갱신은 오류 없이 끝났습니다. 그런데 이력을 다시 보면, 7월 3일에 기록된 “6월 30일부터 Basic” 버전이 흔적 없이 없어졌고, 대신 “Trial”이 2월 1일부터 기록돼 있었던 것으로 나옵니다. 옛 버전을 닫으려면 끝 시각(2월 1일)이 시작 시각(7월 3일)보다 앞서야 하니, 그런 버전은 남기지 않은 것입니다. 이제 이 테이블은 “2월 1일에 시스템은 6월 30일부터 Trial이라고 알고 있었다”는, 실제로는 한 번도 없었던 과거를 답합니다.
이 동작은 서버 변수 secure_timestamp가 기본값 NO이기 때문입니다. 어떤 사용자든 세션 시각을 바꿀 수 있다는 뜻입니다. 서버를 --secure-timestamp=YES로 띄우자 같은 SET @@timestamp가 거부됐습니다.
MariaDB 11.8, --secure-timestamp=YES
ERROR 1290 (HY000): The MariaDB server is running with the --secure-timestamp=YES option so it cannot execute this statement
감사 목적으로 시스템 버전 테이블을 쓴다면 이 설정부터 확인해야 합니다(SUPER나 복제 권한에만 허용하는 중간 값도 있습니다). 과거 데이터를 옮겨 넣는 동안만 열어 두고 닫는 식으로 운영할 수 있습니다.
Graphiti는?
Graphiti 실측에서 본 대로, Graphiti의 기록 시각(created_at, expired_at)은 언제나 수집을 실행한 실제 시각이라 과거로 되돌릴 방법 자체가 없습니다. 대신 Graphiti는 엣지를 제자리에서 고쳐 씁니다. 끝 날짜가 바뀌어도 이전 값은 남지 않고, 언어 모델이 잘못 닫은 사실을 사람이 다시 열면 그 정정의 흔적도 남지 않습니다. 기록 시각은 되돌릴 수 없지만, 기록 내용은 덮어쓸 수 있는 셈입니다. Zep이 2026년 9월 「에이전트 기억 오염 방어」에서 “출처가 주장한 시각과 시스템이 받아들인 시각을 따로 남기라”고 한 것도, 기록의 시간이 감사와 보안의 마지막 버팀목이기 때문입니다. 이 주제는 다음 글(기억 오염)에서 이어서 다룹니다.
7. 얼마나 무거운가: 고객 1만 명, 사건 13만 건
기능이 맞게 돌아가는 것과 쓸 만한 속도로 돌아가는 것은 다른 문제입니다. 고객마다 가입 1건과 요금제 변경 12건(한 달에 한 번꼴, 실제 변경 뒤 0~20일 늦게 기록)을 만들어, 모든 사건을 기록 시각 순서대로 세 DB에 넣었습니다. PostgreSQL은 5절의 트리거 방식, MariaDB는 FOR PORTION OF, XTDB는 하루치 사건을 그날 기록 시각의 트랜잭션 하나로 묶어 넣었습니다(XTDB는 기록 시각이 앞으로만 가므로 순서가 필수입니다).
질의는 세 가지입니다. 한 고객의 지금 요금제, 한 고객의 “기록 시점·유효 시점을 모두 고정한” 요금제, 그리고 전체 고객 중 “5월 20일의 시스템이 본 5월 15일에 Pro였던 고객 수”입니다. 세 DB 모두 같은 답(고객 2천 명일 때 1,480명, 1만 명일 때 7,403명)을 냈습니다.
항목
PostgreSQL 18 (트리거)
MariaDB 11.8
XTDB 2.1
고객 2,000명 · 사건 26,000건
적재 시간
44.2초
38.9초
74.8초
저장된 행
현재 26,000 + 이력 24,000
현재 26,000 + 이력 24,000
조각 50,000
한 고객 · 지금
0.56ms
0.80ms
1.77ms
한 고객 · 두 시점 고정
0.66ms
0.88ms
1.71ms
전체 집계 · 두 시점 고정
4.0ms
8.5ms
5.4ms
고객 10,000명 · 사건 130,000건
적재 시간
278.7초
249.7초
848.2초
저장 크기
30.1MB (테이블+인덱스)
30.6MB (데이터+인덱스)
52MB (데이터 디렉터리)
한 고객 · 지금
0.80ms
0.84ms
5.02ms
한 고객 · 두 시점 고정
0.91ms
0.78ms
7.20ms
전체 집계 · 두 시점 고정
15.5ms
38.5ms
21.5ms
질의 시간은 30회 반복의 중앙값. 적재는 사건 하나당 SQL 한 번(묶음 없음). 고객 1만 명 측정은 세 DB를 같은 가상 머신(colima, CPU 14개)에서 동시에 돌렸으므로 서로 자원을 나눠 썼습니다. 절대값보다 자릿수를 보세요.
읽을 것은 세 가지입니다.
이 규모에서는 셋 다 충분히 빠릅니다. 한 고객을 묻는 질의는 모두 한 자릿수 밀리초, 1만 명 전체를 두 시점으로 집계해도 40ms 안쪽입니다. 시간 기능을 쓴다고 질의가 갑자기 느려지지는 않습니다.
이력은 변경 수만큼 늘어납니다. 저장 방식은 달라도 세 DB 모두 사건 13만 건에 행(또는 조각) 25만 개를 남겼습니다. 끝 기간이 열린 변경 한 번이 ‘옛 버전 하나 + 새 버전 하나’를 만들기 때문입니다. 고객 1만 명의 1년치가 30~52MB였으니, 변경이 잦은 테이블이라면 MariaDB의 이력 파티션 같은 정리 수단을 미리 정해 두는 편이 좋습니다.
XTDB의 적재는 규모가 커지자 눈에 띄게 느려졌습니다. 고객이 다섯 배가 되자 적재 시간은 열한 배가 됐습니다. 이번 측정은 행 하나씩 넣는 가장 단순한 방식이었고 세 DB가 자원을 나눠 썼으므로, 대량 이관이 필요하다면 XTDB의 묶음 적재 방식을 따로 확인해야 합니다. 반대로 PostgreSQL의 트리거 구현은 손이 많이 가는 대신 성능 면에서는 불리하지 않았습니다.
8. 다른 데이터베이스들은
이번에 도커로 돌린 셋 말고도 시간 기능을 가진 DB는 많습니다. 공식 문서로 확인한 것만 한 줄씩 적습니다.
Db2 10
2010년(z/OS), 2012년(LUW). 시스템 시간과 ‘비즈니스 시간’(유효 시간)을 모두 지원한 상용 DB. BUSINESS_TIME WITHOUT OVERLAPS, FOR PORTION OF BUSINESS_TIME까지 SQL:2011과 같은 방식이다. 표준 해설을 쓴 Kulkarni와 Michels가 IBM 사람이다.
Teradata 13.10
Db2와 같은 2010년 10월. 다만 문법은 표준이 아니라 TSQL2 계열(VALIDTIME, SEQUENCED)로 시작했다. 그래서 “최초의 SQL:2011 구현”이라는 말은 조심해서 써야 한다.
Oracle
기록 시간은 2001년 9i의 Flashback Query(AS OF TIMESTAMP)부터. 12c(2013)의 Temporal Validity가 PERIOD FOR로 유효 시간을 더했고, AS OF PERIOD FOR라는 유효 시점 질의 문법도 있다.
SQL Server 2016
시스템 버전 테이블만. 별도 이력 테이블을 지정하고 FOR SYSTEM_TIME AS OF / CONTAINED IN / ALL로 묻는다. 유효 시간 기능은 없다. 문서 예제가 기록 시간 열을 ValidFrom/ValidTo라고 불러 헷갈리기 쉽다.
Datomic
모든 사실이 트랜잭션 시각을 갖고 as-of, history로 과거를 본다. 유효 시간은 없어서 직접 속성으로 모델링해야 한다. 2023년 4월부터 무료(바이너리 Apache 2.0, 오픈소스는 아님).
‘타임 트래블’들
Apache Iceberg(TIMESTAMP AS OF), BigQuery(FOR SYSTEM_TIME AS OF, 2~7일), DuckLake(AT (VERSION => 3))의 시간 여행은 모두 기록의 시간만 다룬다. 테이블 스냅숏을 되돌려 보는 기능이지, “언제 참이었나”를 저장하는 기능이 아니다. 반대로 Flink의 시간 조인은 FOR SYSTEM_TIME AS OF라는 이름으로 사건 시간(유효 시간)을 쓰니, 이름만 보고 판단하면 안 된다.
정리하면 아래 표와 같습니다. 굵은 테두리는 이 글에서 직접 돌려 본 칸입니다.
9. Graphiti와 나란히 놓기
이 시리즈의 출발점으로 돌아가겠습니다. Graphiti의 네 타임스탬프와 SQL:2011의 두 기간은 같은 생각의 두 구현입니다.
개념
SQL:2011 / MariaDB
XTDB 2
Graphiti
세상의 시간
애플리케이션 기간 PERIOD FOR p
_valid_from / _valid_to
valid_at / invalid_at
기록의 시간
ROW_START / ROW_END
_system_from / _system_to
created_at / expired_at
누가 기간을 닫나
앱이 FOR PORTION OF로 명시
앱이 FOR PORTION OF VALID_TIME로 명시
언어 모델이 모순을 판정하면 코드가 닫음
겹침을 누가 막나
DB 제약(WITHOUT OVERLAPS)
DB가 구조로 보장
없음(판정 모델의 몫)
옛 버전
이력 행으로 보존
닫힌 조각으로 보존
엣지를 제자리에서 고침(이전 값 없음)
입력
정형 데이터
정형 데이터(JSON 문서 포함)
대화·문서 같은 비정형 데이터
이 표가 두 기술의 역할 분담을 말해 줍니다. 정형 데이터(계약, 요금제, 급여, 주소 변경 신청서)는 이중 시간 DB가 훨씬 정확하고 쌉니다. 기간을 닫는 일을 앱이 명시하고, 겹침은 DB가 막고, 동시성은 격리 수준이 지킵니다. 이번 실험에서 고객 1만 명의 사건 13만 건을 넣고 묻는 데 언어 모델은 한 번도 필요하지 않았습니다.
비정형 데이터(“저 지난달에 이사했어요”)는 이야기가 다릅니다. 무엇이 무엇을 대체하는지, 그 날짜가 언제인지를 문장에서 읽어 내야 하고, 그 일을 Graphiti는 언어 모델에게 맡깁니다. 실측에서 본 대로 그 판단은 아직 자주 틀립니다.
그래서 현실적인 설계는 둘을 섞는 것입니다. 대화에서 뽑은 사실은 Graphiti에 두되, 그중 계약·요금제·주소처럼 정답이 정형 시스템에 있는 사실은 이중 시간 DB를 원본으로 삼고 그래프는 참조만 하는 식입니다. 언어 모델이 “요금제가 바뀐 것 같다”고 판단하면, 그래프의 엣지를 닫는 대신 정형 DB에 FOR PORTION OF 갱신 요청을 보내고 사람이나 규칙이 확인하게 할 수도 있습니다.
10. 실무 체크리스트
1. 무엇이 필요한가
“지금 값”만 필요하면 시간 테이블이 필요 없다. “그때 값”(유효 시간)이 필요하면 애플리케이션 기간을, “그때 우리가 알던 값”(감사)이 필요하면 시스템 버전을 쓴다. 둘 다면 이중 시간.
2. PostgreSQL
18의 WITHOUT OVERLAPS 키와 PERIOD 외래 키는 바로 쓸 만하다(btree_gist 필요). 기록 시간은 트리거나 확장으로 만든다. 기간 일부 수정은 직접 쪼개되, 옛 행을 먼저 줄이고 조각을 나중에 넣는다. 19에서도 FOR PORTION OF는 없다.
3. MariaDB
이중 시간을 기본 기능으로 다 쓸 수 있다. 감사용이면 --secure-timestamp=YES(또는 SUPER/REPLICATION)로 기록 시각 조작을 막는다. 이력이 빨리 크면 PARTITION BY SYSTEM_TIME으로 나눈다. 시간 외래 키는 아직 없다.
4. XTDB
모든 테이블이 이중 시간이라 따로 설계할 것이 없다. 기본 조회가 ‘지금 유효한 행’만 돌려준다는 점, 조건 없는 UPDATE/DELETE가 ‘지금부터’에만 적용된다는 점(표준과 다름)을 팀에 알려 둔다. 과거 데이터 이관은 반드시 기록 시각 순서대로.
5. 동시성
기간 일부를 고치는 연산은 행을 쪼갠다. 같은 대상의 기간을 동시에 고칠 수 있다면 REPEATABLE READ 이상에서 실패-재시도를 쓰거나, 대상별로 직렬화한다.
6. 언어 모델과 섞을 때
정형 원본이 있는 사실은 이중 시간 DB를 원본으로, Graphiti는 대화에서만 알 수 있는 사실에. 언어 모델의 ‘닫자’는 판단은 곧바로 반영하지 말고 검토 대상으로.
마무리: 40년 된 장부, 아직 다 쓰이지 않았다
스노드그래스가 두 시계를 구분한 지 40년, SQL 표준이 이를 받아들인 지 15년이 지났지만, 가장 널리 쓰이는 오픈소스 DB인 PostgreSQL은 2026년에야 겹침 금지 키를 갖췄고, 기간 일부를 고치는 문장은 넣었다가 동시성 문제로 다시 뺐습니다. 반대로 MariaDB는 조용히 거의 전부를 구현해 두었고, XTDB는 아예 모든 테이블을 이중 시간으로 만들어 버렸습니다.
Graphiti를 읽으며 “사실에 유효기간을 적는다”는 생각이 새롭다고 느꼈다면, 그 생각의 절반은 이미 오래전에 데이터베이스 장부에 적혀 있었습니다. 새로운 것은 나머지 절반, 그 장부를 사람의 말에서 채우는 일입니다. 둘 중 어느 쪽을 써야 할지는 결국 하나의 질문으로 정해집니다. 정답이 이미 어딘가에 정형으로 있는가, 아니면 대화 속에만 있는가.
재현 정보
실험일: 2026-10-11 (KST), colima(CPU 14, 메모리 24GB) 위 도커