date-fns·dayjs를 걷어낸 자리에 Temporal API가 들어오면 타임존 버그가 사라지는 구조적 이유
5년 동안 Node.js 백엔드를 짜오면서 날짜·시간 관련 이슈는 늘 뒷맛이 개운치 않은 영역이었습니다. DST 전환 이후 스케줄러가 한 시간씩 어긋난다거나, new Date("2024-03-10")이 UTC로 파싱되면서 날짜가 하루 밀리는 상황. 결국 팀마다 date-fns 아니면 dayjs에 timezone 플러그인을 얹고, 그것도 모자라 각종 유틸 함수로 래핑하는 패턴을 쓰게 되더군요.
2026년 3월, TC39가 Temporal API를 Stage 4로 확정하면서 ECMAScript 2026 스펙에 공식 편입했습니다. 그리고 같은 해 5월, Node.js 26이 출시되면서 V8 14.6을 탑재해 플래그나 폴리필 없이 Temporal을 바로 쓸 수 있는 백엔드 환경이 열렸습니다. "언제쯤 쓸 수 있을까"가 아니라 "어떻게 마이그레이션할까"를 논의할 시점이 된 거죠.
이 글은 Node.js·TypeScript 백엔드 코드베이스에서 date-fns와 dayjs를 걷어내고 Temporal API로 전환하는 과정을 다룹니다. 단순한 API 소개가 아니라, 타임존 버그가 왜 생기는지, Temporal의 타입 체계가 그것을 어떻게 구조적으로 막는지, 그리고 실제 마이그레이션 순서를 같이 살펴볼 겁니다.
핵심 개념
Date 객체가 가진 세 가지 구조적 결함
Date는 올바르게 쓰는 것 자체가 어렵도록 설계되어 있습니다. 결함을 세 가지로 나눠보겠습니다.
첫째, 가변(mutable) 객체입니다. setHours(), setDate() 같은 메서드가 원본을 직접 변경합니다. 비동기 코드에서 여러 곳에 레퍼런스를 넘기다 보면, 어디선가 변이가 일어나도 추적하기 어렵습니다.
const deadline = new Date("2026-07-25");
function addWeek(d) {
d.setDate(d.getDate() + 7); // 원본이 바뀝니다
return d;
}
addWeek(deadline);
console.log(deadline); // 이미 2026-08-01이 되어버린 상태둘째, 파싱 규칙과 실제 구현 사이의 불일치 이력이 길게 남아 있습니다. ECMA-262 §21.4.3.2는 "2024-03-10" 형태의 date-only ISO 문자열을 UTC로 해석하도록 명시하고 있고, 현재의 주요 엔진들도 여기에 맞춰 동작합니다. 다만 과거에는 로컬 타임존으로 해석하는 구현이 존재했고, Date.parse의 그 밖의 입력 형식은 여전히 구현 정의(implementation-defined) 영역이 넓습니다. "언제 UTC고 언제 로컬인가"를 매번 확인해야 하는 인지 비용이 남아 있는 셈입니다.
셋째, DST 경계에서 산술이 틀립니다. 서머타임이 끝나는 날 +24h를 더하면 실제로 23시간 또는 25시간이 됩니다. +1day가 "벽시계 기준 다음 날 같은 시각"이어야 하는지, "정확히 86400초 뒤"여야 하는지를 Date는 구분하지 않습니다.
Temporal의 타입 체계 — 용도에 맞는 타입을 고르는 것이 핵심
Temporal은 하나의 거대한 날짜-시간 클래스 대신, 목적별로 분리된 불변 타입들의 집합입니다. 처음 접하면 어떤 타입을 골라야 할지 애매한데, 아래 결정 흐름 하나로 정리됩니다.
| 타입 | 용도 | 타임존 포함 |
|---|---|---|
Temporal.Instant |
UTC 기준 절대 시각, 타임스탬프 대체 | 없음 (UTC 기준 값) |
Temporal.PlainDate |
날짜만 (생일, 만료일, 공휴일) | 없음 |
Temporal.PlainDateTime |
날짜+시각, 타임존 없음 | 없음 |
Temporal.ZonedDateTime |
날짜+시각+타임존, DST 인식 산술 연산 | 있음 |
Temporal.Duration |
시간 간격 | N/A |
Stage 4 최종 스펙에서 타임존과 캘린더는 별도의 클래스가 아니라 문자열 식별자(예: "Asia/Seoul", "iso8601")로 다뤄집니다. 초안 단계에 존재하던 Temporal.TimeZone·Temporal.Calendar 클래스는 제거되었으니 이전 자료를 볼 때 주의가 필요합니다.
Plain* 타입과 ZonedDateTime의 분리가 핵심입니다. 타임존이 없는 값을 절대 시각으로 취급하는 실수를 타입 레벨에서 막아줍니다. TypeScript와 조합하면 컴파일 시점에 잡을 수 있습니다.
불변성 — 비동기 코드에서 안심하고 공유할 수 있는 이유
모든 Temporal 객체는 불변입니다. .add(), .subtract(), .with() 같은 연산은 항상 새 인스턴스를 반환합니다.
const start = Temporal.PlainDate.from("2026-07-25");
const end = start.add({ days: 30 }); // start는 그대로
console.log(start.toString()); // "2026-07-25"
console.log(end.toString()); // "2026-08-24"Promise 체인이나 async/await 경계를 넘어 날짜 객체를 공유할 때, 어디선가 .setDate() 같은 메서드가 원본을 변경할 걱정을 하지 않아도 됩니다.
DST 모호성을 코드로 명문화하는 방법
DST 전환에는 두 가지 특수한 순간이 존재합니다.
- 갭(gap): 봄철 시계가 앞으로 뛸 때 아예 존재하지 않게 되는 시각. 예를 들어 미국 동부 3월 두 번째 일요일 새벽 2:30은 건너뜁니다.
- 폴드(fold): 가을철 시계가 뒤로 돌 때 두 번 나타나는 시각. 유럽/런던 10월 마지막 일요일 새벽 1:30이 두 번 존재합니다.
기존 Date는 이 모호성을 암묵적으로 처리했고, 어떤 1:30인지 코드만 보고는 알 수 없었습니다. Temporal은 disambiguation 옵션으로 이 결정을 명시적으로 코드에 남깁니다.
const dt = Temporal.PlainDateTime.from("2026-10-25T01:30");
// 서머타임 종료 시 1:30이 두 번 나타나는 폴드 상황 (유럽/런던)
const earlier = dt.toZonedDateTime("Europe/London", { disambiguation: "earlier" });
const later = dt.toZonedDateTime("Europe/London", { disambiguation: "later" });
// 'reject'로 설정하면 모호한 시각에서 예외 발생
const strict = dt.toZonedDateTime("Europe/London", { disambiguation: "reject" });
disambiguation옵션 정리
'earlier': 폴드 시 이전 시각 선택'later': 폴드 시 이후 시각 선택'compatible': 갭은 앞으로 보정, 폴드는earlier적용 (기본값)'reject': 갭·폴드 모두 예외로 처리
코드 리뷰 때 "이 DST 처리 의도가 뭐야?"라는 질문이 사라집니다.
실전 적용
1. 서버 API의 타임스탬프 처리 — DB ↔ Temporal 경계
가장 흔한 패턴입니다. DB에는 UTC로 저장하고, 응답할 때는 사용자 타임존으로 변환하는 흐름입니다.
// DB에서 받은 UTC 타임스탬프 파싱
const instant = Temporal.Instant.from("2026-07-25T09:00:00Z");
// 서울 시각으로 변환
const seoul = instant.toZonedDateTimeISO("Asia/Seoul");
console.log(seoul.toString());
// "2026-07-25T18:00:00+09:00[Asia/Seoul]"
// 다음 날 자정 계산 — DST 자동 반영
const nextMidnight = seoul.startOfDay().add({ days: 1 });Prisma나 TypeORM 같은 ORM은 아직 Date 객체를 기대합니다. 경계에서 변환 래퍼를 두는 것을 권장합니다.
// ORM ↔ Temporal 경계 변환 유틸
function toInstant(date: Date): Temporal.Instant {
return Temporal.Instant.fromEpochMilliseconds(date.getTime());
}
function toDate(instant: Temporal.Instant): Date {
return new Date(instant.epochMilliseconds);
}2. DST를 넘나드는 반복 스케줄링
"매주 월요일 오전 9시"가 +7 * 24h와 다를 수 있다는 게 반복 스케줄링의 핵심입니다. DST 전환 주에 Date 기반으로 계산하면 9시가 아니라 8시나 10시가 됩니다. 뉴욕은 2026년 3월 8일 새벽 2시가 spring-forward 지점이므로, DST 전환 이전인 3월 1일을 기점으로 잡아야 경계를 넘는 효과가 드러납니다.
// 뉴욕 기준 EST 구간에 있는 3월 1일 오전 9시 (오프셋 -05:00)
const meeting = Temporal.ZonedDateTime.from({
timeZone: "America/New_York",
year: 2026, month: 3, day: 1,
hour: 9, minute: 0,
});
console.log(meeting.toString());
// "2026-03-01T09:00:00-05:00[America/New_York]"
// +7일 후 3월 8일은 이미 EDT (-04:00) 구간
// ZonedDateTime은 벽시계 기준으로 산술하므로 여전히 오전 9시가 유지됨
const nextWeek = meeting.add({ days: 7 });
console.log(nextWeek.toString());
// "2026-03-08T09:00:00-04:00[America/New_York]"동일한 상황을 Date.setDate(d.getDate() + 7)로 처리하면 UTC 기준 168시간을 더하기 때문에 3월 8일의 벽시계 시각은 9시가 아니라 10시가 되어버립니다.
3. 비즈니스 로직의 날짜 계산 — PlainDate로 타임존을 아예 배제
구독 만료일, 쿠폰 유효기간, 업무일 계산은 타임존이 필요 없습니다. PlainDate를 쓰면 타임존 관련 고민을 아예 할 필요가 없습니다.
// 구독 시작일부터 1개월 만료일
const start = Temporal.PlainDate.from("2026-07-25");
const expires = start.add({ months: 1 });
console.log(expires.toString()); // "2026-08-25"
// 오늘부터 만료까지 남은 일수
const today = Temporal.Now.plainDateISO();
const daysLeft = today.until(expires).days;
console.log(`만료까지 ${daysLeft}일 남음`);4. 멀티타임존 회의 조율
같은 Instant를 여러 타임존의 ZonedDateTime으로 표현하는 패턴입니다. 절대 시각은 하나이고, 표현만 다릅니다.
const seoulSlot = Temporal.ZonedDateTime.from("2026-07-25T10:00:00[Asia/Seoul]");
const nySlot = seoulSlot.withTimeZone("America/New_York");
const londonSlot = seoulSlot.withTimeZone("Europe/London");
// 같은 절대 시각을 각 지역 시각으로 표현
console.log(seoulSlot.toString()); // "2026-07-25T10:00:00+09:00[Asia/Seoul]"
console.log(nySlot.toString()); // "2026-07-24T21:00:00-04:00[America/New_York]"
console.log(londonSlot.toString()); // "2026-07-25T02:00:00+01:00[Europe/London]"5. date-fns·dayjs 코드를 Temporal로 바꾸는 패턴
// date-fns (Before)
import { addDays, format } from "date-fns";
const next = addDays(new Date("2026-07-25"), 7);
const str = format(next, "yyyy-MM-dd");
// Temporal (After)
const next = Temporal.PlainDate.from("2026-07-25").add({ days: 7 });
const str = next.toString(); // "2026-08-01"포맷팅이 필요한 경우 Temporal에는 format("yyyy-MM-dd HH:mm") 같은 편의 API가 없습니다. Intl.DateTimeFormat을 사용하되, ZonedDateTime은 toLocaleString()으로 직접 호출하는 편이 간결합니다.
const zdt = Temporal.ZonedDateTime.from("2026-07-25T18:30:00[Asia/Seoul]");
// 권장 — ZonedDateTime에 바로 로케일 옵션 전달
zdt.toLocaleString("ko-KR", {
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
// "2026. 07. 25. 오후 06:30"
// 재사용할 formatter가 필요하다면 Intl.DateTimeFormat 조합
const formatter = new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
year: "numeric", month: "2-digit", day: "2-digit",
hour: "2-digit", minute: "2-digit",
});
formatter.format(zdt); // Temporal 타입도 직접 인자로 지원장단점 분석
도입 전후 비교
| 항목 | 내용 | |
|---|---|---|
| ✅ | 의존성 제거 | date-fns·dayjs와 timezone·utc 플러그인, 사내 래퍼 유틸 계층 제거 가능 (tree-shaking 이후에도 반복적으로 늘어나는 date-fns import 관리 부담이 사라짐) |
| ✅ | 구조적 타임존 안전성 | Plain* vs ZonedDateTime 타입 분리로 타입 레벨에서 실수 방지 |
| ✅ | 불변성 | setHours/setDate 류의 사이드이펙트 버그 구조적 차단 |
| ✅ | DST 명시적 제어 | disambiguation 옵션으로 의도가 코드에 남음 |
| ✅ | IANA DB 자동 연동 | tzdata 번들 직접 관리 불필요, 엔진 업데이트로 해결 |
| ✅ | 나노초 정밀도 | Instant가 나노초 단위 지원 (Date는 밀리초) |
| ✅ | Node.js 26에서 즉시 사용 | 폴리필 없이 네이티브 지원 |
| ⚠️ | Safari 안정 버전 미지원 | 2026년 7월 기준 프론트엔드에는 폴리필 필요 |
| ⚠️ | 직렬화 형식 상호운용성 | ZonedDateTime의 RFC 9557 브라켓 표기를 외부 시스템이 파싱하지 못함 |
| ⚠️ | 포맷팅 내장 없음 | toLocaleString 또는 Intl.DateTimeFormat 조합 필요 |
| ⚠️ | ORM 호환성 | TypeORM·Prisma가 아직 Date 기대 → 경계 변환 레이어 필요 |
| ⚠️ | 학습 곡선 | 상황에 맞는 타입 선택에 초기 혼란 가능 |
date-fns는 tree-shaking을 지원하므로 addDays 하나만 import하면 실제 번들 증가는 수 KB 수준입니다. Temporal 도입의 진짜 이득은 KB 절감보다 의존성 그래프·플러그인·사내 래퍼 유틸 계층 자체를 걷어낼 수 있다는 점입니다.
실무에서 자주 마주치는 실수들
1. ZonedDateTime을 JSON 그대로 내보내는 경우
Temporal 타입들은 모두 toJSON()을 구현하고 있어서 JSON.stringify()에 그대로 넣어도 문자열로 직렬화됩니다. 문제는 어떤 형식으로 직렬화되는가입니다.
const instant = Temporal.Instant.from("2026-07-25T09:00:00Z");
JSON.stringify({ createdAt: instant });
// '{"createdAt":"2026-07-25T09:00:00Z"}' — ISO 8601, 표준적
const zdt = instant.toZonedDateTimeISO("Asia/Seoul");
JSON.stringify({ createdAt: zdt });
// '{"createdAt":"2026-07-25T18:00:00+09:00[Asia/Seoul]"}' — RFC 9557 브라켓 표기ZonedDateTime.toJSON()이 반환하는 [Asia/Seoul] 브라켓 형식은 RFC 9557에 정의된 확장 표기입니다. 대부분의 JSON 파서·ORM·외부 API·모바일 클라이언트가 아직 이 형식을 이해하지 못해서, 파싱 실패나 오해석으로 이어지기 쉽습니다.
// 외부 시스템과 주고받는 경계에서는 Instant로 정규화
JSON.stringify({ createdAt: zdt.toInstant().toString() });
// '{"createdAt":"2026-07-25T09:00:00Z"}'REST API 응답 레이어에서는 외부 경계에서 항상 Instant(또는 순수 ISO 8601 문자열)로 변환하는 규약을 정해두는 편이 안전합니다.
2. ORM 경계에서 Date 변환을 빠뜨리는 경우
// 잘못된 예시 — Prisma가 Temporal.Instant를 이해하지 못함
await prisma.event.create({
data: { scheduledAt: Temporal.Instant.from("2026-07-25T09:00:00Z") }
});
// 올바른 예시 — Date로 변환
await prisma.event.create({
data: {
scheduledAt: new Date(
Temporal.Instant.from("2026-07-25T09:00:00Z").epochMilliseconds
)
}
});3. PlainDateTime을 타임존 있는 값처럼 취급
PlainDateTime은 타임존 정보가 없는 벽시계 표현입니다. DB에 절대 시각으로 저장하려면 어느 지역의 벽시계인지를 반드시 함께 확정해야 합니다. 그렇지 않으면 "2026-07-25 14:00"이 서울인지 뉴욕인지 알 수 없어 저장 시점과 조회 시점의 해석이 어긋납니다.
// 잘못된 의도 — "서울 기준 오후 2시를 저장한다"고 생각했지만
// 타임존이 붙어있지 않아 절대 시각이 확정되지 않음
const dt = Temporal.PlainDateTime.from("2026-07-25T14:00:00");
// 올바른 방법 — 타임존을 명시해 벽시계 → 절대 시각으로 확정
const zdt = Temporal.ZonedDateTime.from({
timeZone: "Asia/Seoul",
year: 2026, month: 7, day: 25, hour: 14, minute: 0,
});
const instant = zdt.toInstant(); // DB에는 이 값을 UTC로 저장마치며
Temporal API는 날짜·시간 버그를 "더 신경 쓰자"가 아니라 "잘못 쓰기 어렵게 만들자"는 방향으로 설계되었습니다. 타임존 없는 값과 있는 값을 타입으로 분리하고, 불변 객체로 사이드이펙트를 차단하고, DST 처리 의도를 코드에 남기게 강제합니다.
Node.js 26(2026년 10월 LTS 진입 예정)이 기반이라면 지금 당장 시작할 수 있습니다. 전체 코드베이스를 한 번에 바꿀 필요는 없습니다. 아래 순서를 따라가면 위험을 낮추면서 점진적으로 확장할 수 있습니다.
- ORM 경계에 변환 유틸 함수(
toInstant/toDate)를 한 곳에 정의합니다. 이 어댑터가 없으면 이후 단계에서 매번epochMilliseconds왕복 코드가 흩어집니다. - 새로 작성하는 날짜·시간 코드부터
Temporal로 작성합니다. 기존 코드는 건드리지 않아도 됩니다. - DST 버그가 실제로 발생했거나 발생할 가능성이 높은 스케줄링·반복 일정 로직부터
ZonedDateTime으로 교체하면 효과를 가장 크게 체감할 수 있습니다. - 응답 직렬화 단계에서
Instant로 정규화하는 규약을 정해두면, 브라켓 표기로 인한 외부 시스템 호환성 문제를 예방할 수 있습니다.
Node.js 24 이하 환경이라면 temporal-polyfill(20KB 미만, spec-compliant, TypeScript 타입 포함)로 동일하게 시작할 수 있습니다.
참고 자료
- Temporal - MDN Web Docs
- TC39 Proposal Temporal (GitHub)
- TC39 Temporal 공식 문서
- TC39 Stage 4 Advances Temporal - Socket.dev
- Temporal Is Now Official - Bloomberg
- Temporal: The 9-Year Journey - Bloomberg JS Blog
- Node.js 26 Released: Temporal API Enabled by Default - InfoQ
- The History of Date in JavaScript - NodeSource
- Temporal API Is Finally in ECMAScript 2026: Replace date-fns and dayjs Today - jsmanifest
- Exploring Temporal API - Better Stack
- JavaScript Temporal in 2026 - Bryntum
- date-fns v4 vs Temporal API vs Day.js 2026 - PkgPulse
- temporal-polyfill (npm)
- Moving From Moment.js To The JS Temporal API - Smashing Magazine
- JavaScript Timezones: Dates, Temporal API & Libraries 2026 - Crosscheck
- RFC 9557 - Date and Time on the Internet: IXDTF Extensions