<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Temporal Data on jyukki's Blog</title><link>https://jyukki.com/tags/temporal-data/</link><description>Recent content in Temporal Data on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-kr</language><lastBuildDate>Mon, 03 Aug 2026 11:30:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/temporal-data/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Bitemporal Effective-Dated Records, 가격·권한·계약 이력을 현재처럼 조회하는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-bitemporal-effective-dated-records-playbook/</link><pubDate>Mon, 03 Aug 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-bitemporal-effective-dated-records-playbook/</guid><description>가격, 권한, 약관, 플랜처럼 과거 기준 조회와 정정 이력이 필요한 데이터를 bitemporal/effective-dated record로 설계하는 기준을 정리합니다.</description><content:encoded><![CDATA[<p>많은 백엔드 테이블은 현재값 중심으로 시작합니다. <code>users.plan = 'PRO'</code>, <code>products.price = 12000</code>, <code>roles.name = 'ADMIN'</code>처럼 한 row에 최신 상태를 저장하고 변경되면 UPDATE합니다. 작은 서비스에서는 이 방식이 빠르고 단순합니다. 문제는 시간이 비즈니스 의미를 갖기 시작할 때 생깁니다. 2026년 8월 3일에 주문을 다시 계산해야 하는데 &ldquo;2026년 7월 15일 오전 10시에 사용자가 봤던 가격&quot;을 알아야 하거나, 과거 권한 사고를 조사하면서 &ldquo;그 시점에 운영자가 어떤 tenant에 접근할 수 있었는가&quot;를 재현해야 하는 상황입니다.</p>
<p>현재값을 덮어쓴 테이블은 이런 질문에 약합니다. 감사 로그가 있더라도 로그는 사건을 설명하는 자료이지, 애플리케이션이 같은 기준으로 다시 계산할 수 있는 도메인 모델이 아닐 수 있습니다. 그래서 가격, 수수료, 약관, 구독 플랜, 권한 정책, 세금 규칙처럼 시간이 핵심인 데이터는 effective-dated record 또는 bitemporal 모델로 설계하는 편이 안전합니다.</p>
<p>이 글은 <a href="/learning/deep-dive/deep-dive-operational-state-machine-design/">Operational State Machine</a>, <a href="/learning/deep-dive/deep-dive-tamper-evident-audit-log-playbook/">Tamper-Evident Audit Log</a>, <a href="/learning/deep-dive/deep-dive-data-retention-deletion-architecture/">Data Retention/Deletion Architecture</a>, <a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation Ledger Pipeline</a>과 이어집니다. 핵심은 이력 테이블을 많이 만들자는 이야기가 아닙니다. <strong>과거 시점의 판단을 같은 코드와 같은 데이터로 다시 설명할 수 있게 만들자</strong>는 것입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>현재값 UPDATE, audit log, 상태 이력, effective-dated record, bitemporal record의 역할을 구분합니다.</li>
<li>valid time과 transaction time을 분리해 &ldquo;그때 효력이 있었던 값&quot;과 &ldquo;그때 시스템이 알고 있던 값&quot;을 따로 조회하는 법을 배웁니다.</li>
<li>가격·권한·계약 데이터에서 interval overlap, gap, backdated correction을 막는 실무 기준을 잡습니다.</li>
<li>모든 테이블에 시간 모델을 붙이는 과설계를 피하고, 비용 대비 효과가 큰 도메인부터 적용하는 기준을 가져갑니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-현재값-update는-과거-판단을-지운다">1) 현재값 UPDATE는 과거 판단을 지운다</h3>
<p>가장 흔한 가격 테이블을 생각해 봅시다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">CREATE</span> <span style="color:#ff79c6">TABLE</span> product_price (
</span></span><span style="display:flex;"><span>  product_id <span style="color:#8be9fd;font-style:italic">bigint</span> <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  price_cents <span style="color:#8be9fd;font-style:italic">bigint</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  currency <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  updated_at timestamptz <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>이 구조는 현재 가격을 빠르게 읽기 좋습니다. 하지만 가격이 바뀐 뒤에는 과거 주문, 환불, 정산, 고객 문의를 같은 기준으로 재현하기 어렵습니다. &ldquo;주문 당시 가격은 order_line에 복사해 두면 되지 않나&quot;라고 할 수 있습니다. 주문 금액만 보면 맞습니다. 하지만 가격 정책 자체가 왜 그렇게 적용됐는지, 특정 쿠폰과 세금 규칙이 그 시점에 유효했는지, 약관 개정 전후 판단이 맞는지는 별도 근거가 필요합니다.</p>
<p>현재값 테이블은 아래 질문에 약합니다.</p>
<table>
  <thead>
      <tr>
          <th>질문</th>
          <th>현재값 UPDATE의 한계</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>7월 15일 10시에 유효한 가격은?</td>
          <td>이전 값이 사라짐</td>
      </tr>
      <tr>
          <td>7월 20일에 시스템이 알고 있던 7월 15일 가격은?</td>
          <td>사후 정정 전후를 구분 못함</td>
      </tr>
      <tr>
          <td>운영자가 당시 어떤 tenant 권한을 가졌나?</td>
          <td>권한 변경 이력과 현재 권한이 섞임</td>
      </tr>
      <tr>
          <td>과거 계산을 오늘 다시 실행하면 같은 결과가 나오나?</td>
          <td>정책·코드·데이터 버전이 바뀌었을 수 있음</td>
      </tr>
  </tbody>
</table>
<p>그래서 시간 기반 데이터는 &ldquo;최신값&quot;과 &ldquo;판단 근거&quot;를 분리해야 합니다. 현재 화면은 projection으로 빠르게 제공하고, 판단 근거는 append-only 또는 bitemporal 원장에 남기는 구조가 현실적입니다.</p>
<h3 id="2-valid-time과-transaction-time은-다르다">2) valid time과 transaction time은 다르다</h3>
<p>effective-dated record의 첫 단계는 valid time입니다. valid time은 비즈니스 세계에서 값이 효력을 가진 기간입니다. 예를 들어 <code>2026-08-01 00:00</code>부터 새 가격이 적용됐다면, 그 가격의 <code>effective_from</code>은 8월 1일입니다.</p>
<p>transaction time은 시스템이 그 사실을 기록하고 알고 있었던 시간입니다. 예를 들어 담당자가 8월 3일에 &ldquo;사실 8월 1일부터 적용됐어야 하는 가격&quot;을 뒤늦게 등록했다면 valid time은 8월 1일이고 transaction time은 8월 3일입니다. 이 차이가 중요합니다.</p>
<table>
  <thead>
      <tr>
          <th>시간 축</th>
          <th>질문</th>
          <th>대표 컬럼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>valid time</td>
          <td>비즈니스상 언제 효력이 있었나</td>
          <td><code>effective_from</code>, <code>effective_to</code></td>
      </tr>
      <tr>
          <td>transaction time</td>
          <td>시스템이 언제 이 사실을 알았나</td>
          <td><code>recorded_from</code>, <code>recorded_to</code></td>
      </tr>
      <tr>
          <td>event time</td>
          <td>변경 요청이나 외부 이벤트가 언제 발생했나</td>
          <td><code>event_occurred_at</code></td>
      </tr>
      <tr>
          <td>processing time</td>
          <td>worker가 언제 처리했나</td>
          <td><code>processed_at</code></td>
      </tr>
  </tbody>
</table>
<p>bitemporal 모델은 valid time과 transaction time을 모두 저장합니다. 그러면 두 종류의 as-of query가 가능해집니다.</p>
<ul>
<li>&ldquo;2026년 8월 1일에 유효했던 가격을 오늘 기준으로 알려줘.&rdquo;</li>
<li>&ldquo;2026년 8월 2일 당시 시스템이 알고 있던 8월 1일 가격을 알려줘.&rdquo;</li>
</ul>
<p>두 질문의 답이 다를 수 있습니다. 사후 정정, 지연 수집, 정책 오류 수정이 있었기 때문입니다. 감사나 정산에서는 이 차이를 설명할 수 있어야 합니다.</p>
<h3 id="3-interval-overlap은-데이터-버그가-아니라-비즈니스-사고다">3) interval overlap은 데이터 버그가 아니라 비즈니스 사고다</h3>
<p>effective-dated 테이블에서 가장 위험한 버그는 같은 키에 같은 시간 구간이 두 번 존재하는 것입니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>product A
</span></span><span style="display:flex;"><span>  2026-08-01 00:00 ~ 2026-09-01 00:00 : 12000원
</span></span><span style="display:flex;"><span>  2026-08-15 00:00 ~ 2026-10-01 00:00 : 13000원
</span></span></code></pre></div><p>8월 20일 가격은 무엇일까요? 쿼리 조건에 따라 12000원이 나오거나 13000원이 나올 수 있습니다. 더 나쁜 것은 둘 다 나와 애플리케이션이 임의로 첫 row를 선택하는 경우입니다. 가격, 권한, 약관, 세금 규칙에서 이런 overlap은 단순 데이터 오류가 아닙니다. 고객 청구, 접근 통제, 감사 증거가 흔들립니다.</p>
<p>초기 기준은 보수적으로 잡습니다.</p>
<table>
  <thead>
      <tr>
          <th>지표</th>
          <th>권장 기준</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>같은 business key의 valid interval overlap</td>
          <td>0건, 배포 차단</td>
      </tr>
      <tr>
          <td>current row 개수</td>
          <td>key당 정확히 1건</td>
      </tr>
      <tr>
          <td>gap 허용 여부</td>
          <td>가격·권한은 기본 0건, 프로모션은 명시적 gap 가능</td>
      </tr>
      <tr>
          <td>backdated correction</td>
          <td>ticket/reason/approver 필수</td>
      </tr>
      <tr>
          <td>correction rate</td>
          <td>주간 0.5% 초과 시 정책 또는 입력 UX 점검</td>
      </tr>
      <tr>
          <td>as-of query p95</td>
          <td>현재값 조회 대비 2배 이상이면 projection 검토</td>
      </tr>
  </tbody>
</table>
<p>PostgreSQL을 쓴다면 range type과 exclusion constraint를 검토할 수 있습니다. 모든 팀이 처음부터 복잡한 제약을 넣을 필요는 없지만, 최소한 application test와 daily integrity job으로 overlap 0건은 지켜야 합니다.</p>
<h3 id="4-정정은-update가-아니라-새-사실의-기록이다">4) 정정은 UPDATE가 아니라 새 사실의 기록이다</h3>
<p>시간 모델에서 과거 값을 고치는 일은 흔합니다. 세금 코드가 잘못 들어갔거나, 파트너 수수료율 파일이 늦게 도착했거나, 운영자가 날짜를 잘못 선택했을 수 있습니다. 이때 과거 row를 UPDATE로 고치면 &ldquo;처음부터 그렇게 알고 있었다&quot;는 기록이 됩니다. 실제로는 그렇지 않습니다.</p>
<p>권장 흐름은 아래에 가깝습니다.</p>
<ol>
<li>기존 row의 <code>recorded_to</code>를 닫는다.</li>
<li>같은 valid interval 또는 조정된 valid interval로 새 row를 추가한다.</li>
<li><code>correction_reason</code>, <code>source_event_id</code>, <code>approved_by</code>, <code>ticket_id</code>를 남긴다.</li>
<li>영향을 받는 주문·정산·권한 판정을 reconciliation 대상으로 보낸다.</li>
</ol>
<p>이 방식은 저장공간을 더 쓰지만, 나중에 &ldquo;언제 누가 무엇을 정정했는가&quot;를 설명할 수 있습니다. <a href="/learning/deep-dive/deep-dive-tamper-evident-audit-log-playbook/">Tamper-Evident Audit Log</a>와 다른 점은 bitemporal 원장은 도메인 계산의 입력이고, audit log는 변경 행위의 증거라는 점입니다. 둘은 경쟁하지 않습니다. 함께 있어야 합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-테이블은-원장과-현재-projection으로-나눈다">1) 테이블은 원장과 현재 projection으로 나눈다</h3>
<p>가격 정책을 예로 들면 원장은 아래처럼 설계할 수 있습니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">CREATE</span> <span style="color:#ff79c6">TABLE</span> product_price_policy (
</span></span><span style="display:flex;"><span>  price_policy_id bigserial <span style="color:#ff79c6">PRIMARY</span> <span style="color:#ff79c6">KEY</span>,
</span></span><span style="display:flex;"><span>  product_id <span style="color:#8be9fd;font-style:italic">bigint</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  currency <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  price_cents <span style="color:#8be9fd;font-style:italic">bigint</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  effective_from timestamptz <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  effective_to timestamptz,
</span></span><span style="display:flex;"><span>  recorded_from timestamptz <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span> <span style="color:#ff79c6">DEFAULT</span> now(),
</span></span><span style="display:flex;"><span>  recorded_to timestamptz,
</span></span><span style="display:flex;"><span>  source_type <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  source_id <span style="color:#8be9fd;font-style:italic">text</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>,
</span></span><span style="display:flex;"><span>  correction_reason <span style="color:#8be9fd;font-style:italic">text</span>,
</span></span><span style="display:flex;"><span>  approved_by <span style="color:#8be9fd;font-style:italic">text</span>,
</span></span><span style="display:flex;"><span>  created_at timestamptz <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span> <span style="color:#ff79c6">DEFAULT</span> now(),
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">CHECK</span> (effective_to <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span> <span style="color:#ff79c6">OR</span> effective_from <span style="color:#ff79c6">&lt;</span> effective_to),
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">CHECK</span> (recorded_to <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span> <span style="color:#ff79c6">OR</span> recorded_from <span style="color:#ff79c6">&lt;</span> recorded_to)
</span></span><span style="display:flex;"><span>);
</span></span></code></pre></div><p>운영 API가 매번 이 테이블을 복잡하게 조회하면 비용이 커질 수 있습니다. 그래서 현재값 projection을 별도로 둡니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">CREATE</span> <span style="color:#ff79c6">VIEW</span> current_product_price <span style="color:#ff79c6">AS</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">FROM</span> product_price_policy
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> effective_from <span style="color:#ff79c6">&lt;=</span> now()
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> (effective_to <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span> <span style="color:#ff79c6">OR</span> now() <span style="color:#ff79c6">&lt;</span> effective_to)
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> recorded_to <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span>;
</span></span></code></pre></div><p>트래픽이 크면 view 대신 materialized projection이나 별도 current table을 둡니다. 중요한 것은 projection을 원본으로 착각하지 않는 것입니다. projection은 빠른 조회용이고, 원장은 판단 근거입니다.</p>
<h3 id="2-쓰기-api는-구간-닫기--새-구간-추가를-하나의-명령으로-제공한다">2) 쓰기 API는 &ldquo;구간 닫기 + 새 구간 추가&quot;를 하나의 명령으로 제공한다</h3>
<p>개발자가 직접 row를 만지게 두면 interval 사고가 납니다. 도메인 명령을 명확히 둡니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">change_price_policy</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">product_id</span>: <span style="color:#bd93f9">1001</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">new_price_cents</span>: <span style="color:#bd93f9">130000</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">effective_from</span>: <span style="color:#f1fa8c">&#34;2026-09-01T00:00:00+09:00&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">reason_code</span>: <span style="color:#f1fa8c">&#34;QUARTERLY_PRICE_CHANGE&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">ticket_id</span>: <span style="color:#f1fa8c">&#34;BILLING-2412&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">validation</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;same product/currency interval overlap must be 0&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;effective_from is not more than 30 days in the past unless approved&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;current price projection rebuild succeeds&#34;</span>
</span></span></code></pre></div><p>실무 기준은 아래처럼 시작할 수 있습니다.</p>
<ul>
<li>미래 적용 예약은 90일 이내만 일반 승인으로 허용한다.</li>
<li>과거 적용은 7일 이내면 owner 승인, 7일 초과면 정산·감사 승인까지 요구한다.</li>
<li>금전 영향이 100만원 이상이면 reconciliation plan을 먼저 만든다.</li>
<li>같은 product/currency에 open-ended current row가 2건 이상이면 write를 차단한다.</li>
<li>정책 변경 후 5분 안에 current projection과 원장 count 검증을 실행한다.</li>
</ul>
<p>이 숫자는 도메인마다 달라집니다. 중요한 것은 &ldquo;날짜만 바꾸는 작은 수정&quot;처럼 보이는 작업을 운영 명령으로 취급하는 것입니다.</p>
<h3 id="3-as-of-query를-제품-기능처럼-테스트한다">3) as-of query를 제품 기능처럼 테스트한다</h3>
<p>bitemporal 모델은 조회가 어려워지기 때문에 테스트가 없으면 금방 깨집니다. 기본 쿼리 패턴을 라이브러리나 repository method로 고정합니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">SELECT</span> <span style="color:#ff79c6">*</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">FROM</span> product_price_policy
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> product_id <span style="color:#ff79c6">=</span> :product_id
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> currency <span style="color:#ff79c6">=</span> :currency
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> effective_from <span style="color:#ff79c6">&lt;=</span> :business_time
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> (:business_time <span style="color:#ff79c6">&lt;</span> effective_to <span style="color:#ff79c6">OR</span> effective_to <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span>)
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> recorded_from <span style="color:#ff79c6">&lt;=</span> :system_time
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> (:system_time <span style="color:#ff79c6">&lt;</span> recorded_to <span style="color:#ff79c6">OR</span> recorded_to <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NULL</span>)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">ORDER</span> <span style="color:#ff79c6">BY</span> recorded_from <span style="color:#ff79c6">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">LIMIT</span> <span style="color:#bd93f9">1</span>;
</span></span></code></pre></div><p>테스트 케이스는 최소 5개가 필요합니다.</p>
<ol>
<li>현재 가격 조회</li>
<li>미래 예약 가격이 현재 조회에 나오지 않음</li>
<li>과거 시점 가격 조회</li>
<li>backdated correction 전 system time으로 조회하면 정정 전 값 반환</li>
<li>backdated correction 후 system time으로 조회하면 정정 후 값 반환</li>
</ol>
<p>이 테스트가 없으면 팀은 valid time과 transaction time을 섞기 시작합니다. 섞이는 순간 bitemporal 모델은 복잡하기만 하고 믿을 수 없는 구조가 됩니다.</p>
<h3 id="4-변경-후-reconciliation-범위를-계산한다">4) 변경 후 reconciliation 범위를 계산한다</h3>
<p>과거 유효기간을 정정하면 이미 생성된 파생 데이터가 틀릴 수 있습니다. 주문 금액, 정산 원장, 권한 grant, 세금 리포트, 고객 고지 내역이 영향을 받을 수 있습니다. 그래서 정정 명령은 영향 범위를 계산해야 합니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-yaml" data-lang="yaml"><span style="display:flex;"><span><span style="color:#ff79c6">correction_impact</span>:
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">target</span>: <span style="color:#f1fa8c">&#34;product_price_policy&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">valid_interval</span>: <span style="color:#f1fa8c">&#34;2026-08-01..2026-08-10&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">affected_orders</span>: <span style="color:#bd93f9">1842</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">estimated_amount_delta</span>: <span style="color:#bd93f9">3720000</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">downstream</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;order_line_snapshot&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;settlement_ledger&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;refund_calculator&#34;</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">action</span>:
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;sample 30 orders&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;run reconciliation batch&#34;</span>
</span></span><span style="display:flex;"><span>    - <span style="color:#f1fa8c">&#34;manual approval before customer-visible adjustment&#34;</span>
</span></span></code></pre></div><p><a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation Ledger Pipeline</a>이 여기서 필요합니다. bitemporal 테이블에 정정 이력이 있다는 사실만으로 운영이 끝나지 않습니다. 정정이 실제 비즈니스 결과에 어떤 차이를 만들었는지 대조해야 합니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, bitemporal 모델은 조회 복잡도를 늘립니다. 대부분의 화면은 현재값만 필요합니다. 모든 요청에서 as-of query를 직접 실행하면 latency와 개발 난이도가 올라갑니다. 원장과 projection을 분리하고, current path에는 단순한 인덱스를 제공하는 편이 좋습니다.</p>
<p>둘째, 저장공간과 인덱스 비용이 늘어납니다. 가격 정책처럼 변경 빈도가 낮은 데이터는 부담이 작지만, 권한 decision이나 세션 상태처럼 초당 변경이 많은 데이터에 그대로 적용하면 비용이 커집니다. 고빈도 이벤트는 event log나 state machine history로 두고, bitemporal record는 정책·계약처럼 변경 빈도보다 설명 책임이 큰 데이터에 우선 적용합니다.</p>
<p>셋째, 시간대와 시계 기준을 가볍게 보면 안 됩니다. <code>effective_from</code>이 사용자의 현지 날짜 기준인지, 회사 정책 기준 KST인지, UTC timestamp인지 명확히 해야 합니다. 만료 시각과 예약 적용은 <a href="/learning/deep-dive/deep-dive-clock-skew-time-semantics-playbook/">Clock Skew 시간 의미론</a>과 연결됩니다. 서버 간 clock drift가 30초 이상이면 예약 적용과 만료 처리에서 보수 모드로 들어가는 기준을 둘 만합니다.</p>
<p>넷째, 개인정보와 삭제 요구가 섞이면 더 조심해야 합니다. 이력 원장은 오래 보관하고 싶지만, 개인정보 원문을 영원히 남기면 <a href="/learning/deep-dive/deep-dive-data-retention-deletion-architecture/">Data Retention/Deletion Architecture</a>의 요구와 충돌합니다. 원장에는 내부 ID, digest, 정책 버전, 금액, 권한 범위처럼 계산에 필요한 최소값을 남기고, 원문 개인정보는 별도 보관 정책을 따르는 편이 안전합니다.</p>
<p>다섯째, 감사 로그를 bitemporal 원장으로 대체하지 마세요. 원장은 &ldquo;어떤 값이 언제 유효했는가&quot;를 말하고, 감사 로그는 &ldquo;누가 어떤 권한으로 왜 바꿨는가&quot;를 말합니다. 권한 변경, 가격 정정, 약관 소급 적용은 두 기록이 모두 필요합니다.</p>
<p>의사결정 우선순위는 <strong>사후 재현 가능성 &gt; 구간 무결성 &gt; 현재 조회 성능 &gt; 저장 비용 &gt; 구현 단순성</strong>입니다. 가격이나 권한처럼 틀렸을 때 설명 비용이 큰 데이터에서는 구현 단순성보다 재현 가능성이 먼저입니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<ul>
<li><input disabled="" type="checkbox"> 과거 기준 조회가 필요한 엔티티를 가격, 권한, 계약, 정산, 세금, 약관으로 분류했다.</li>
<li><input disabled="" type="checkbox"> 각 엔티티에 valid time과 transaction time이 모두 필요한지, valid time만으로 충분한지 결정했다.</li>
<li><input disabled="" type="checkbox"> 같은 business key에서 effective interval overlap이 0건임을 DB 제약 또는 daily job으로 검증한다.</li>
<li><input disabled="" type="checkbox"> 현재값 projection과 bitemporal 원장의 역할이 분리되어 있다.</li>
<li><input disabled="" type="checkbox"> backdated correction에는 reason code, ticket id, 승인자, 영향 범위가 남는다.</li>
<li><input disabled="" type="checkbox"> as-of query 테스트가 현재, 과거, 미래 예약, 정정 전, 정정 후를 모두 포함한다.</li>
<li><input disabled="" type="checkbox"> 정정 후 파생 데이터 reconciliation 경로가 있다.</li>
<li><input disabled="" type="checkbox"> 개인정보 원문을 원장에 장기 보관하지 않도록 retention 정책을 확인했다.</li>
</ul>
<p>연습으로 현재 서비스의 &ldquo;가격 변경&rdquo; 또는 &ldquo;관리자 권한 부여&rdquo; 중 하나를 골라 보세요. 먼저 현재값 테이블로만 설계했을 때 답할 수 없는 질문 5개를 적습니다. 그다음 <code>effective_from</code>, <code>effective_to</code>, <code>recorded_from</code>, <code>recorded_to</code>, <code>source_id</code>, <code>correction_reason</code>을 가진 원장 row 예시를 3개 만듭니다. 마지막으로 &ldquo;2026-08-01 기준 값&quot;과 &ldquo;2026-08-02 당시 시스템이 알고 있던 2026-08-01 기준 값&quot;을 각각 조회하는 SQL을 작성해 보세요. 두 답이 달라질 수 있는 예시까지 만들면 시간 모델의 감각이 잡힙니다.</p>
<p>오늘의 결론은 단순합니다. 시간이 중요한 데이터는 현재값만으로 충분하지 않습니다. 좋은 백엔드 데이터 모델은 최신 상태를 빠르게 보여주는 동시에, 과거의 판단을 같은 기준으로 다시 설명할 수 있어야 합니다.</p>
]]></content:encoded></item></channel></rss>