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