<?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>Time Zone on jyukki's Blog</title><link>https://jyukki.com/tags/time-zone/</link><description>Recent content in Time Zone on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Sun, 13 Sep 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/time-zone/index.xml" rel="self" type="application/rss+xml"/><item><title>2026 개발 트렌드: Node.js 26의 Temporal 기본 활성화, Date 교체보다 시간 모델·호환성 경계를 먼저 정하자</title><link>https://jyukki.com/posts/2026-09-13-nodejs-temporal-time-model-migration-trend/</link><pubDate>Sun, 13 Sep 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/posts/2026-09-13-nodejs-temporal-time-model-migration-trend/</guid><description>Node.js 26에서 Temporal API가 기본 활성화된 흐름을 계기로, Date 치환을 목표로 삼지 않고 instant·local date·time zone·직렬화·구버전 runtime을 분리해 이행하는 실무 기준을 정리합니다.</description><content:encoded><![CDATA[<p>시간 버그는 대개 <code>Date</code> 생성자가 이상해서 생기지 않는다. 제품이 &ldquo;이 일이 <strong>언제</strong> 일어났는가&quot;와 &ldquo;사용자의 지역에서 <strong>몇 시에</strong> 실행돼야 하는가&quot;를 같은 문자열이나 timestamp에 넣는 순간 생긴다. <code>2026-11-01T01:30</code>은 미국 일부 지역에서 두 번 존재하고, 3월 어느 일요일의 <code>02:30</code>은 존재하지 않을 수 있다. 생일, 영업일 마감, 만료 시각, 이벤트 발생 시각도 모두 시간처럼 보이지만 같은 질문에 답하지 않는다.</p>
<p>이 구분이 지금 다시 중요해진 이유가 Node.js 26이다. <a href="https://nodejs.org/en/blog/release/v26.0.0">Node.js 26.0.0 릴리스</a>는 Temporal API가 기본 활성화됐다고 알렸고, <a href="https://nodejs.org/en/blog/release/v26.8.2">2026년 9월 9일의 26.8.2 Current 릴리스</a>는 이 흐름이 실험용 분기만의 이야기가 아님을 보여 준다. 다만 Node 26은 이 시점에 Current이며, 최신 LTS와 browser·edge·serverless·embedded runtime의 지원 범위는 별개다. &ldquo;Node에서 켜졌다&quot;는 것은 전면 교체 명령이 아니라 시간 모델과 호환성 표를 다시 점검하라는 신호다.</p>
<p>이 글은 <a href="/learning/deep-dive/deep-dive-timezone-i18n-handling/">Timezone·i18n 처리</a>, <a href="/learning/deep-dive/deep-dive-clock-skew-time-semantics-playbook/">Clock Skew와 시간 의미론</a>, <a href="/learning/deep-dive/deep-dive-business-calendar-cutoff-policy-playbook/">Business Calendar·Cutoff Policy</a>, <a href="/learning/deep-dive/deep-dive-scheduler-misfire-backfill-control-playbook/">Scheduler Misfire·Backfill 제어</a>를 JavaScript·Node 런타임 이행에 연결한다. API의 모양은 <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal">MDN Temporal 참조</a>로 확인할 수 있지만, 운영의 핵심은 메서드 암기가 아니라 데이터 의미와 경계 계약이다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>Temporal 타입이 <code>Date</code>와 달리 instant, local date, local date-time, zoned date-time을 나누는 이유를 이해합니다.</li>
<li>API·DB·queue에서 시간 값을 직렬화할 때 무엇을 보존하고 무엇을 추측하면 안 되는지 정리합니다.</li>
<li>Node 26 Current, 기존 LTS, browser, worker가 섞인 환경에서 작은 migration slice를 설계합니다.</li>
<li>4개 time zone, DST gap·overlap, 월말·윤년 fixture를 이용해 수치 기반 배포 gate를 만듭니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-시간에는-최소-네-가지-다른-의미가-있다">1) 시간에는 최소 네 가지 다른 의미가 있다</h3>
<p>Temporal의 장점은 <code>Date</code>보다 많은 기능이 아니라, 의미가 다른 시간을 다른 타입으로 적게 만든다는 데 있다.</p>
<table>
  <thead>
      <tr>
          <th>질문</th>
          <th>적합한 모델</th>
          <th>예시</th>
          <th>피해야 할 축약</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>정확히 언제 일어났나</td>
          <td><code>Temporal.Instant</code></td>
          <td>결제 승인, 로그 event time</td>
          <td>서버 local <code>Date</code> 문자열</td>
      </tr>
      <tr>
          <td>어느 지역에서 몇 시인가</td>
          <td><code>Temporal.ZonedDateTime</code></td>
          <td>서울 09:00 예약 실행</td>
          <td>offset만 있는 timestamp</td>
      </tr>
      <tr>
          <td>날짜만 필요한가</td>
          <td><code>Temporal.PlainDate</code></td>
          <td>생일, 휴일, 정산 기준일</td>
          <td>UTC 자정 Instant</td>
      </tr>
      <tr>
          <td>지역 없는 날짜·시각인가</td>
          <td><code>Temporal.PlainDateTime</code></td>
          <td>사용자가 입력한 2026-10-12 14:00</td>
          <td>time zone을 추측한 Date</td>
      </tr>
  </tbody>
</table>
<p><code>Instant</code>는 timeline 위의 한 점이다. 따라서 event 발생 시각, token 발급·만료 시각, DB audit timestamp처럼 전 세계에서 같은 순간을 가리켜야 하는 값에 적합하다. 보통 RFC 3339 UTC string (<code>2026-09-13T01:06:00Z</code>) 또는 DB의 <code>timestamptz</code>로 저장한다. 여기서 <code>timestamptz</code>는 원래 지역 이름을 보존하는 타입이 아니라 instant를 표현하는 PostgreSQL 타입이라는 점도 중요하다.</p>
<p>반면 &ldquo;매일 서울 시간 09:00에 보고서를 만든다&quot;는 instant가 아니다. <code>09:00</code>, <code>Asia/Seoul</code>, 반복 규칙, 휴일 정책, gap·overlap 해소 규칙이 함께 있어야 실행 시각을 계산할 수 있다. offset <code>+09:00</code>만 저장하면 해당 지역이 offset을 바꾸는 경우나 다른 지역을 지원할 때 규칙을 되살릴 수 없다. IANA time zone ID와 local time을 함께 갖는 모델이 필요하다.</p>
<h3 id="2-date-교체가-아니라-입력저장표시의-추측을-없앤다">2) Date 교체가 아니라 입력·저장·표시의 추측을 없앤다</h3>
<p>기존 JavaScript 코드에서 위험한 패턴은 값보다 parsing이다. <code>new Date(&quot;2026-09-13&quot;)</code>처럼 날짜만 든 문자열을 instant로 해석하거나, DB에서 받은 <code>timestamp without time zone</code>을 서버 process의 local zone으로 읽으면 개발 환경과 production의 결과가 다를 수 있다. <code>getMonth()</code>와 <code>getUTCMonth()</code>가 섞인 formatter도 월말에만 오류를 만들기 쉽다.</p>
<p>Temporal을 도입할 때는 외부 경계를 다음처럼 명시적으로 바꾼다.</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-ts" data-lang="ts"><span style="display:flex;"><span><span style="color:#6272a4">// event는 절대 시각: 항상 UTC instant로 parse하고 serialize한다.
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">const</span> occurredAt <span style="color:#ff79c6">=</span> Temporal.Instant.<span style="color:#ff79c6">from</span>(payload.occurredAt);
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">const</span> wireEvent <span style="color:#ff79c6">=</span> { occurredAt: <span style="color:#8be9fd">occurredAt.toString</span>() };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// 예약은 지역 시간과 zone을 별도 필드로 받는다.
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">const</span> localStart <span style="color:#ff79c6">=</span> Temporal.PlainDateTime.<span style="color:#ff79c6">from</span>(payload.localStart);
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">const</span> zoneId <span style="color:#ff79c6">=</span> payload.timeZone; <span style="color:#6272a4">// 예: Asia/Seoul, IANA ID validator로 검사
</span></span></span></code></pre></div><p>실제 API에서는 <code>localStart</code>, <code>timeZone</code>, <code>disambiguation</code>처럼 입력 필드를 분리하는 편이 낫다. overlap 시 더 이른 instant와 더 늦은 instant 중 어느 것을 고를지, gap 시 거절할지 다음 유효 시각으로 밀지의 정책은 라이브러리 기본값에 숨어 있으면 안 된다. 사용자에게 안내할 문구와 background scheduler의 동작도 이 선택을 공유해야 한다.</p>
<p>특히 JSON은 Temporal 객체의 타입 정보를 자동 보존하지 않는다. wire format에는 타입별 schema version을 둔다. 예를 들면 event는 <code>occurredAt: &quot;...Z&quot;</code>, 날짜는 <code>billingDate: &quot;2026-09-30&quot;</code>, 예약은 <code>localStart: &quot;2026-11-01T01:30&quot;</code>와 <code>timeZone: &quot;America/New_York&quot;</code>를 별도 필드로 보낸다. queue consumer, BI export, partner SDK가 받는 값도 같은 계약을 따라야 한다. 객체를 <code>JSON.stringify</code>했더니 어떻게 나오는지에 도메인 의미를 맡기면 runtime별 이행이 어려워진다.</p>
<h3 id="3-temporal은-timezone-database와-business-rule을-자동-해결하지-않는다">3) Temporal은 timezone database와 business rule을 자동 해결하지 않는다</h3>
<p>Temporal 타입이 명확해도 세 가지 문제는 남는다. 첫째, 시스템의 timezone data와 runtime 버전이 다르면 같은 IANA zone에 대한 과거·미래 계산이 다를 수 있다. 둘째, 업무일 마감은 time zone만으로 결정되지 않는다. 한국 공휴일, 고객사의 영업일, 17:00 cut-off, 다음 영업일 이월 규칙이 필요하다. 셋째, 시계가 맞지 않은 서버가 만든 <code>Instant</code>가 실제 발생 시각을 보장하지는 않는다.</p>
<p>그래서 <a href="/learning/deep-dive/deep-dive-clock-skew-time-semantics-playbook/">Clock Skew와 시간 의미론</a>처럼 <code>occurred_at</code>, <code>received_at</code>, <code>processed_at</code>을 서로 다른 사실로 남긴다. <a href="/learning/deep-dive/deep-dive-business-calendar-cutoff-policy-playbook/">Business Calendar·Cutoff Policy</a>처럼 정산·마감에는 calendar version과 정책 owner도 기록한다. Temporal은 잘못된 business rule을 고치는 도구가 아니라, rule이 어떤 종류의 시간을 입력으로 받는지 드러내는 도구다.</p>
<h3 id="4-node-26-기본-활성화와-배포-가능성은-같은-말이-아니다">4) Node 26 기본 활성화와 배포 가능성은 같은 말이 아니다</h3>
<p>Node 26이 Temporal을 기본 활성화했다고 해도 서비스의 실행 표면은 하나가 아니다. API server는 Node 26인데, shared validation package는 최신 LTS의 worker와 browser bundle에서도 실행될 수 있다. serverless provider의 runtime image, test runner, CLI, SSR, edge isolate, partner SDK까지 지원 범위가 다르다. 한 package에서 global <code>Temporal</code>을 바로 쓰면 지원하지 않는 target은 시작 시점에 실패할 수 있다.</p>
<p>따라서 첫 일은 runtime inventory다.</p>
<table>
  <thead>
      <tr>
          <th>표면</th>
          <th>확인 항목</th>
          <th>처음의 정책</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Node API/worker</td>
          <td>실제 production Node 버전과 <code>Temporal</code> 존재</td>
          <td>Node 26 canary에서 native 사용</td>
      </tr>
      <tr>
          <td>browser/SSR</td>
          <td>target browser와 bundle 변환</td>
          <td>필요한 화면만 지원 matrix 테스트</td>
      </tr>
      <tr>
          <td>edge/serverless</td>
          <td>provider runtime·ICU/timezone data</td>
          <td>별도 smoke, 지원 불명확하면 adapter 유지</td>
      </tr>
      <tr>
          <td>shared package</td>
          <td>import 시 global 접근 여부</td>
          <td>factory/adapter 뒤에 감추고 parse boundary만 노출</td>
      </tr>
      <tr>
          <td>partner/queue</td>
          <td>string schema와 parser version</td>
          <td>ISO/IANA wire contract 유지, 객체 전송 금지</td>
      </tr>
  </tbody>
</table>
<p>Node 26 Current은 migration 후보가 될 수 있지만, system-wide default 승격 기준은 제품의 LTS·platform policy를 따른다. 특히 browser polyfill을 넣어야 한다면 bundle 증가량과 start-up 비용을 측정한다. &ldquo;모든 Date를 없앤다&quot;는 목표보다 &ldquo;새 예약 기능에서 local time을 instant로 오해하지 않는다&quot;가 훨씬 검증 가능하다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-새-schedule-하나를-2주-shadow-mode로-이행한다">1) 새 schedule 하나를 2주 shadow mode로 이행한다</h3>
<p>처음 대상은 지역별 예약, 마감 계산, 사용자 입력한 날짜처럼 현재 오류 비용이 높은 신규 기능 하나가 좋다. event log나 이미 안정된 UTC-only API 전체를 한 번에 바꾸지 않는다. 기존 Date 계산과 새 Temporal 계산을 동시에 수행하되, 사용자에게는 기존 결과만 보이는 shadow mode로 2주 기록한다.</p>
<p>fixture는 최소 <code>Asia/Seoul</code>, <code>UTC</code>, <code>America/New_York</code>, <code>Europe/Berlin</code> 네 zone을 포함한다. 각 zone에서 평일, 월말, 윤년 2월 29일, DST gap, DST overlap을 검사한다. DST가 없는 Seoul만 통과하면 시간 모델을 검증한 것이 아니다. schedule이 gap에 걸리면 API는 <code>422</code>로 명시적 선택을 요구할지, 다음 유효 시각으로 옮길지 정하고, overlap은 earlier/later 선택을 receipt에 남긴다.</p>
<table>
  <thead>
      <tr>
          <th>지표</th>
          <th>2주 승격 기준</th>
          <th>중단·재설계 기준</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>기존·Temporal 결과 불일치</td>
          <td>정의된 정책 차이를 제외하고 0건</td>
          <td>설명 불가 불일치 1건</td>
      </tr>
      <tr>
          <td>시간 입력 validation 실패</td>
          <td>전체 생성의 0.5% 이하</td>
          <td>특정 locale/SDK에서 2% 이상</td>
      </tr>
      <tr>
          <td>schedule 실행 시각 오차</td>
          <td>목표 instant 대비 60초 이내 p99</td>
          <td>5분 이상 지연이 원인 불명</td>
      </tr>
      <tr>
          <td>parse/계산 p95</td>
          <td>기존 대비 +2ms 이내</td>
          <td>+10ms가 24시간 지속</td>
      </tr>
      <tr>
          <td>bundle 증가</td>
          <td>대상 페이지 +15KiB gzip 이내</td>
          <td>UX 이득 없이 예산 초과</td>
      </tr>
  </tbody>
</table>
<p>여기서 60초는 scheduler 운영 예시일 뿐이다. 결제 만료나 시장 마감은 더 좁은 예산이 필요하고, daily batch는 misfire 정책이 더 중요할 수 있다. 값보다 중요한 것은 어떤 결과를 정책 차이로 허용했고, 어떤 결과를 시간 오류로 보는지 사전에 적는 일이다.</p>
<h3 id="2-adapter를-통해-점진적으로-경계를-교체한다">2) adapter를 통해 점진적으로 경계를 교체한다</h3>
<p>공유 domain code에 <code>new Date()</code>를 흩뿌리는 대신, <code>parseEventInstant</code>, <code>parseLocalSchedule</code>, <code>formatForUser</code>, <code>serializeSchedule</code> 같은 좁은 adapter를 만든다. 초기는 adapter 내부에서 Date와 Temporal을 병행할 수 있다. 호출자는 <code>Date</code> 객체가 아니라 의미 있는 값을 받으므로, LTS worker가 남아 있어도 wire format과 validation contract는 먼저 통일할 수 있다.</p>
<p>DB migration도 type 이름보다 의미를 확인한다. audit event는 UTC instant 한 열이면 충분하지만, 예약은 <code>local_start</code>, <code>time_zone</code>, <code>disambiguation_policy</code>, 계산된 <code>next_run_at</code>를 분리할 수 있다. <code>next_run_at</code>만 저장하면 timezone rule이나 recurring policy가 바뀌었을 때 왜 그 instant가 나왔는지 재계산할 근거가 없다. 반대로 매번 재계산만 하면 이미 확정된 실행에 대한 audit이 약해진다. 미래의 규칙과 확정된 실행 receipt를 함께 저장하는 균형이 필요하다.</p>
<h3 id="3-api-문서와-observability를-시간-타입-기준으로-바꾼다">3) API 문서와 observability를 시간 타입 기준으로 바꾼다</h3>
<p>OpenAPI나 JSON Schema에서 <code>format: date-time</code> 하나로 모든 시간을 표현하지 않는다. event timestamp에는 UTC <code>date-time</code>과 <code>Z</code> requirement, 생일·정산일에는 <code>date</code>, 예약에는 local date-time과 IANA zone enum/validation을 분리한다. API description에는 offset만 허용하는지, <code>Z</code>만 허용하는지, local time의 gap·overlap 행동이 무엇인지 적는다.</p>
<p>관측도 같은 방향으로 바꾼다. 로그의 <code>event_time</code>, <code>received_time</code>, <code>scheduled_local_time</code>, <code>scheduled_zone</code>, <code>resolved_instant</code>를 구분하고 request ID와 함께 남긴다. <code>Date</code> 변환 실패를 단순 500으로 묻지 말고 zone, parser version, input shape를 저카디널리티로 집계한다. 단, 사용자 원문 시간 값이나 고객 식별자를 metric label에 넣어 cardinality와 개인정보 문제를 만들면 안 된다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li><strong>Temporal은 Date의 drop-in replacement가 아니다.</strong> 타입이 세분화된 만큼 기존 library의 <code>Date</code> adapter, ORM serializer, test helper를 명시적으로 연결해야 한다.</li>
<li><strong>UTC만 저장하면 모든 문제가 끝나지 않는다.</strong> event에는 맞지만 recurring local schedule·영업일·법정 마감에는 zone과 business rule이 필요하다.</li>
<li><strong>time zone ID는 offset보다 정보가 많지만 운영 의존성도 생긴다.</strong> runtime timezone data 버전과 재계산 정책을 관리해야 한다.</li>
<li><strong>polyfill은 호환성 해답이면서 bundle·startup 비용이다.</strong> server와 browser에 같은 전략을 강제하지 말고 target별로 측정한다.</li>
<li><strong>과거 데이터 재해석은 위험하다.</strong> 기존 <code>timestamp without time zone</code>이 어느 지역 기준인지 불명확하면 자동 migration보다 source별 가정, 표본 대조, 보정 log가 먼저다.</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="도입-체크리스트">도입 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 시간 field를 instant, local date, local date-time, zoned schedule, duration으로 분류했다.</li>
<li><input disabled="" type="checkbox"> API·queue·DB의 string schema에 UTC instant, IANA zone, gap/overlap 정책을 명시했다.</li>
<li><input disabled="" type="checkbox"> Node, browser, edge, worker, shared package의 runtime support matrix를 실제 CI target으로 확인했다.</li>
<li><input disabled="" type="checkbox"> 신규 schedule 하나에서 4개 zone·DST gap/overlap·월말·윤년 fixture를 통과시켰다.</li>
<li><input disabled="" type="checkbox"> 기존·Temporal 계산을 2주 shadow 비교하고 설명 불가 불일치 0건을 확인했다.</li>
<li><input disabled="" type="checkbox"> event time, received time, local schedule, resolved instant를 로그·감사 receipt에서 분리했다.</li>
</ul>
<h3 id="연습-매일-0900을-데이터-모델로-만들기">연습: &lsquo;매일 09:00&rsquo;을 데이터 모델로 만들기</h3>
<p>사용자가 뉴욕 시간으로 매일 09:00에 보고서를 받는 기능을 설계해 보자. <code>2026-11-01 01:30</code>처럼 overlap이 있는 입력과 3월 DST gap 입력을 넣어, API가 어떤 response를 돌려줄지 작성한다. <code>nextRunAt</code>만 저장하는 모델과 <code>localTime + timeZone + recurrence + disambiguationPolicy + resolvedInstant</code>를 저장하는 모델을 비교하고, 정책 변경 뒤 이미 예약된 실행을 재계산할지 기존 receipt를 보존할지 결정해 보자. 마지막으로 이 기능을 Node 26 API와 구버전 worker가 함께 처리할 때 adapter와 wire format을 어디에 둘지 그려 보자.</p>
<h2 id="마무리">마무리</h2>
<p>Node.js 26의 Temporal 기본 활성화는 JavaScript 서버가 시간 문제를 라이브러리 유틸리티로만 미룰 이유가 줄었다는 변화다. 그러나 성공 기준은 <code>Date</code> 호출 수가 0이 되는 것이 아니다. instant와 지역 시간, 날짜와 반복 규칙, 계산 결과와 원본 의도를 분리하고, 지원하지 않는 runtime에도 같은 wire contract를 제공하는 것이다. 작은 schedule 한 곳에서 정책과 fixture를 먼저 고정하면 Temporal은 타입 교체가 아니라 시간 오류를 줄이는 설계 도구가 된다.</p>
]]></content:encoded></item></channel></rss>