--- id: db-bitemporal-data title: Bitemporal Data — valid time + transaction time category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [database, time, vibe-coding] tech_stack: { language: "SQL", applicable_to: ["Database"] } applied_in: [] aliases: [bitemporal, valid time, transaction time, system time, SCD, slowly changing dimension, history] --- # Bitemporal Data > "이 fact 가 valid 한 기간" + "이 fact 가 DB 에 있던 기간". **Bitemporal = 둘 다**. 회계 / 의료 / legal 가 필수. SCD (Slowly Changing Dimension) 의 generalization. ## 📖 핵심 개념 - Valid time (effective): 현실 의 fact 시점. - Transaction time (system): DB 안 시점. - 둘 다 = bitemporal. - "내가 1주 전에 알았던 사실 가 무엇" 가능. ## 💻 코드 패턴 ### 일반 (no time) ```sql CREATE TABLE customers ( id INT PRIMARY KEY, name VARCHAR, address VARCHAR ); UPDATE customers SET address = 'New' WHERE id = 1; -- → 옛 address 잃음. ``` ### Valid time (effective from / to) ```sql CREATE TABLE customers ( id INT, name VARCHAR, address VARCHAR, valid_from DATE NOT NULL, valid_to DATE NOT NULL DEFAULT '9999-12-31', PRIMARY KEY (id, valid_from) ); -- 변경 시 UPDATE customers SET valid_to = '2026-05-09' WHERE id = 1 AND valid_to = '9999-12-31'; INSERT INTO customers VALUES (1, 'Alice', 'New', '2026-05-09', '9999-12-31'); -- "2025-01-01 의 address?" SELECT address FROM customers WHERE id = 1 AND valid_from <= '2025-01-01' AND valid_to > '2025-01-01'; ``` → SCD Type 2. ### Transaction time (system) ```sql CREATE TABLE customers ( id INT, name VARCHAR, sys_from TIMESTAMP NOT NULL, sys_to TIMESTAMP NOT NULL DEFAULT '9999-12-31', PRIMARY KEY (id, sys_from) ); -- "1주 전 DB 에 있던 record?" SELECT * FROM customers WHERE id = 1 AND sys_from <= NOW() - INTERVAL '1 week' AND sys_to > NOW() - INTERVAL '1 week'; ``` → "내가 어제 본 거" — even if data 가 retro 변경. ### Bitemporal (둘 다) ```sql CREATE TABLE policies ( policy_id INT, premium DECIMAL, valid_from DATE, -- 현실 effective valid_to DATE DEFAULT '9999-12-31', sys_from TIMESTAMP, -- DB transaction sys_to TIMESTAMP DEFAULT '9999-12-31', PRIMARY KEY (policy_id, valid_from, sys_from) ); ``` ### Insert (bitemporal) ```sql -- 새 policy INSERT INTO policies (policy_id, premium, valid_from, valid_to, sys_from, sys_to) VALUES (1, 100, '2026-01-01', '9999-12-31', NOW(), '9999-12-31'); ``` ### Update (premium 가 retroactive 변경) ```sql -- 2026-05-09 가 발견: 2026-01-01 부터 premium 가 110 였다 (오타). -- 1. 옛 row close UPDATE policies SET sys_to = NOW() WHERE policy_id = 1 AND sys_to = '9999-12-31'; -- 2. 새 row (valid 가 1월 1일 부터) INSERT INTO policies VALUES (1, 110, '2026-01-01', '9999-12-31', NOW(), '9999-12-31'); ``` → "이 시점 에 우리 가 아는 premium" 가 query 가능. ### Query: As-of (valid + system) ```sql -- "2026-03-01 시점 의 policy. 1주 전 DB 의 view." SELECT premium FROM policies WHERE policy_id = 1 AND valid_from <= '2026-03-01' AND valid_to > '2026-03-01' AND sys_from <= NOW() - INTERVAL '1 week' AND sys_to > NOW() - INTERVAL '1 week'; ``` → 4 가지 query 가능: - 현재 valid + 현재 system (default) - 과거 valid + 현재 system (history) - 현재 valid + 과거 system ("when did we know?") - 과거 valid + 과거 system (full audit) ### SQL:2011 Temporal ```sql -- Postgres + extension (BTRIM 또는 system-versioned tables 가 옵션) CREATE TABLE customers (...) WITH SYSTEM VERSIONING; SELECT * FROM customers FOR SYSTEM_TIME AS OF TIMESTAMP '2026-05-01'; ``` → MariaDB / SQL Server / IBM DB2 가 native 지원. ### Use case: 보험 ``` 2026-01-01: Policy 시작, premium $100. 2026-03-01: Customer 가 새 car (premium $120). 2026-04-01: 발견 - 2026-01-01 부터 premium $110 였어야 함. 쿼리: - "2026-02-15 의 premium" = $100 ($110 retroactive 적용 안 했을 시 본 거). - "2026-02-15 의 premium (현재 truth)" = $110. → 보험 / regulatory 가 "we knew on date X" 답 필요. ``` ### Use case: 회계 ``` "2026-Q1 매출 = ?" - Q1 끝날 시점 답 = $1M. - 4월 의 retroactive correction 후 답 = $1.05M. → Audit: 우리 가 Q1 close 때 본 = $1M. 정정 후 = $1.05M. 둘 다 record 유지. ``` ### Pitfall: complexity ``` Bitemporal 가 어려움. - 매 update = 2-3 row - Index 가 복잡 (composite) - Query 가 복잡 → 진짜 필요할 때 만. 대부분 = SCD Type 2 (valid time 만) 충분. ``` ### Implementation: trigger ```sql CREATE OR REPLACE FUNCTION close_old_row() RETURNS TRIGGER AS $$ BEGIN UPDATE policies SET sys_to = NOW() WHERE policy_id = NEW.policy_id AND valid_from = NEW.valid_from AND sys_to = '9999-12-31'; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER policy_versioning BEFORE INSERT ON policies FOR EACH ROW EXECUTE FUNCTION close_old_row(); ``` ### XTDB (bitemporal native) ```clojure (xt/submit-tx node [[::xt/put {:xt/id :alice :address "New"} #inst "2026-05-09"]]) ; valid time (xt/q (xt/db node #inst "2026-05-08") '{:find [?addr] :where [[:alice :address ?addr]]}) ; ↑ as-of yesterday ``` → Bitemporal first-class. ### Datomic (similar) ```clojure (d/q '[:find ?addr :where [?e :customer/address ?addr]] (d/as-of db tx-id)) ``` → Immutable + time travel. ### Snapshot tables (simpler alternative) ```sql -- 매일 entire DB snapshot CREATE TABLE customers_snapshot_2026_05_09 AS SELECT * FROM customers; -- "2026-05-09 의 truth" SELECT * FROM customers_snapshot_2026_05_09; ``` → Storage 큼, query simple. ### Event sourcing (related) ``` Event log (append-only) → state. "2026-05-09 시점 의 state" = event log replay until then. → Bitemporal 의 different angle. ``` → [[Backend_Event_Sourcing]]. ### Audit log (simpler) ```sql CREATE TABLE customer_audit ( audit_id BIGSERIAL, customer_id INT, changed_at TIMESTAMP, old_data JSONB, new_data JSONB ); -- Trigger 가 매 변경 추가 ``` → Full bitemporal 가 X. "언제 무엇 변경" 만. ### When 진짜 bitemporal? ``` ✓ Insurance (regulatory) ✓ Banking / accounting ✓ Healthcare (EHR — patient history vs DB record) ✓ Legal (contracts vs amendments) ✓ ERP (price catalog history) ✗ 일반 web app ✗ E-commerce ✗ Internal tool ``` → "내가 X 시점 에 알았던 거?" 가 비즈니스 질문 일 때 만. ### Performance ``` Composite index: CREATE INDEX ON policies (policy_id, valid_from, valid_to, sys_from, sys_to); → Range query 빠름. Storage 가 일반 의 N x (N = 평균 history depth). ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | 일반 web | No history | | Audit log 필요 | Audit table / trigger | | 현재 + 과거 valid | SCD Type 2 (valid time only) | | 회계 / 보험 / 법 | Full bitemporal | | Event-driven | Event sourcing | | Functional / immutable | Datomic / XTDB | | Ad-hoc snapshot | Daily backup | ## ❌ 안티패턴 - **Bitemporal 없이 retroactive correction**: 옛 truth 잃음. - **Soft delete 만 + history 없음**: 변경 추적 안 됨. - **모든 table 가 bitemporal**: complexity. - **Index 없이**: query 가 full scan. - **Trigger error → silent**: data corrupt. - **`9999-12-31` 가 magic value**: NULL 보다 좋지만 명시 필요. ## 🤖 LLM 활용 힌트 - Bitemporal 진짜 필요 = 회계 / 법 / 의료. - 대부분 = valid time (SCD Type 2) 충분. - XTDB / Datomic 가 native 지원. - Audit log 가 cheap alternative. ## 🔗 관련 문서 - [[DB_Audit_Log_Patterns]] - [[Backend_Event_Sourcing]] - [[DB_Soft_Delete_Patterns]]