Temporal API와 TypeScript 타입으로 DST-safe 반복 스케줄러 만들기
새벽 2시, 슬랙 알림이 쏟아진다. 일간 집계 작업이 정각에 실행되지 않았다. 로그를 뒤지면 작업은 돌았다 — 그런데 한 시간 일찍. 서머타임(DST) 전환일이었다. 원인은 뻔하다: 반복 작업의 다음 실행 시각을 UTC 밀리초로 계산했고, 그 계산은 타임존 전환을 모른다.
이 글은 그 버그를 TypeScript 타입 시스템 수준에서 구조적으로 검출하는 방법을 다룬다. TC39의 Temporal API — 2026년 3월 Stage 4로 승격되어 ECMAScript 2026에 정식 편입된 — 는 DST 처리를 개발자의 암묵적 지식이 아니라 API 타입 분리의 책임으로 끌어올린다. ZonedDateTime과 PlainDateTime이 서로 다른 타입이므로, TypeScript로 타입을 명시적으로 선언하면 타임존 컨텍스트를 잃는 순간을 정적 타입 검사에서 잡아낼 수 있다. 순수 JavaScript에서는 컴파일 단계가 없으니 이 이점은 런타임 TypeError로 대체된다.
타겟 독자는 두 부류다. Node.js 26 이상에서 네이티브로 쓰는 경우, 그리고 Bun이나 Node.js 24 이하처럼 @js-temporal/polyfill을 함께 얹어야 하는 경우. 후자는 번들 크기와 초기 로드 비용이 붙는다는 점을 미리 염두에 두자.
왜 지금, 왜 Date가 아닌가
기존 Date 객체의 구조적 결함
Date는 내부적으로 UTC 밀리초 정수 하나를 저장한다. 타임존이라는 개념이 없다. getHours()는 시스템 로컬 타임존을 암묵적으로 사용하고, toISOString()은 항상 UTC로 직렬화한다. 뮤터블하기 때문에 함수 인자로 넘긴 Date 객체를 내부에서 수정하면 호출자가 모르는 사이 상태가 바뀐다.
반복 스케줄러에서 이것이 치명적인 이유는 간단하다. "매일 오전 9시 Asia/Seoul"을 UTC 밀리초로 고정해두면, 한국 표준시(UTC+9)는 DST가 없으니 괜찮지만, "매일 오전 8시 America/New_York"을 같은 방식으로 구현하면 EDT(UTC-4)와 EST(UTC-5) 전환 구간에서 작업이 7시 또는 9시에 실행된다.
Temporal API의 설계 철학
TC39 공식 제안 저장소를 보면 Temporal이 해결하려는 세 가지 문제가 명확하다: 가변성, DST 무시, 타임존 불명확. 이를 위해 API는 "무엇을 표현하는가"에 따라 타입을 분리한다.
| 타입 | 표현 대상 | 타임존 | DST 인식 |
|---|---|---|---|
Temporal.Instant |
절대 시각(에포크 기준) | 없음 | 해당 없음 |
Temporal.ZonedDateTime |
특정 타임존의 벽시계 시각 | 포함 | 완전 자동 |
Temporal.PlainDateTime |
타임존 없는 달력+시각 | 없음 | 불가 |
Temporal.PlainDate |
날짜만 | 없음 | 불가 |
ZonedDateTime은 Instant(언제)와 PlainDateTime(몇 시처럼 보이는가)을 동시에 보유한다. "베를린 시각 내일 오전 9시"를 구할 때 단순히 86,400초를 더하는 게 아니라, 해당 타임존에서 실제로 내일 9시가 되는 Instant를 찾아낸다.
2026년 기준 런타임 지원 현황
Chrome 144 (2026년 2월)부터 플래그 없이 기본 탑재됐고, Node.js 26 (2026년 5월)은 V8 14.6을 얹으며 --harmony-temporal 플래그 없이 활성화됐다. Firefox 139(2025년 5월)도 정식 지원한다. Node.js 24 이하라면 @js-temporal/polyfill(TC39 팀 공식 관리)이나 temporal-polyfill(경량 대체)을 사용할 수 있다. Bun은 2026년 7월 현재 내부 통합이 진행 중이므로 여전히 폴리필이 필요하다.
스케줄러 설계의 핵심: 의도와 실행 시각의 분리
솔직히 처음 이 개념을 접했을 때 "그게 그거 아닌가?"라고 생각했다. 하지만 아니다.
실행 의도(intent) 는 "Asia/Seoul 타임존에서 매일 오전 9시 0분에 실행"이라는 규칙이다. 타임존 식별자와 벽시계 시각으로 표현된다. 이것은 변하지 않는다.
실제 실행 시각(moment) 은 "2026-03-29에는 UTC 00:00:00에 실행"처럼 구체적인 Instant다. 이것은 매 반복마다 ZonedDateTime을 통해 새로 계산된다.
UTC 밀리초만 저장하는 방식은 이 두 가지를 혼동한다. DST 전환이 일어나면 의도(9시)와 실행 시각이 어긋난다.
기본 구현
import { Temporal } from '@js-temporal/polyfill'; // Node.js 26에서는 불필요
type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
function nextOccurrence(
wallHour: number,
wallMinute: number,
tz: string,
disambiguation: Disambiguation = 'reject',
): Temporal.ZonedDateTime {
const now = Temporal.Now.zonedDateTimeISO(tz);
const build = (date: Temporal.PlainDate) =>
Temporal.ZonedDateTime.from(
{
timeZone: tz,
year: date.year,
month: date.month,
day: date.day,
hour: wallHour,
minute: wallMinute,
second: 0,
millisecond: 0,
},
{ disambiguation },
);
const today = now.toPlainDate();
const candidate = build(today);
return Temporal.ZonedDateTime.compare(candidate, now) > 0
? candidate
: build(today.add({ days: 1 }));
}
function scheduleRecurring(
wallHour: number,
wallMinute: number,
tz: string,
job: () => void | Promise<void>,
): void {
const next = nextOccurrence(wallHour, wallMinute, tz);
// Duration.total()은 number를 반환하므로 별도 변환 불필요
const delayMs = next.toInstant()
.since(Temporal.Now.instant())
.total('milliseconds');
setTimeout(async () => {
try {
await job();
} catch (err) {
// job 예외로 재귀 예약이 끊기면 스케줄이 조용히 사라진다
console.error('scheduled job failed:', err);
} finally {
scheduleRecurring(wallHour, wallMinute, tz, job);
}
}, delayMs);
}
// 사용 예: Asia/Seoul 오전 9시마다 실행
scheduleRecurring(9, 0, 'Asia/Seoul', () => {
console.log('일간 집계 작업 실행');
});두 가지가 핵심이다.
첫째, 후보 생성에 ZonedDateTime.from() + disambiguation을 사용한다. withPlainTime()은 disambiguation 옵션을 받지 않고 내부적으로 'compatible'을 쓰기 때문에, 예를 들어 Europe/London의 spring-forward 날에 01:30을 스케줄하면 아무 경고 없이 02:30으로 밀린다. 도입부에서 극화한 "모르는 사이 한 시간 어긋난" 상황과 정확히 같은 구조다. 이 함수는 기본값을 'reject'로 두어, 존재하지 않거나 모호한 시각이면 즉시 RangeError를 던진다. 호출자가 의식적으로 다른 정책을 골라야 한다.
둘째, 하루를 넘길 때 ZonedDateTime.add({ days: 1 }) 대신 PlainDate.add({ days: 1 })로 날짜를 옮긴 뒤 다시 from()으로 재구성한다. 이렇게 하면 두 번째 후보에도 동일한 disambiguation 정책이 적용된다.
Oslo 예시로 하루 덧셈이 실제 시간 간격과 다르다는 것을 확인해보자.
const oslo = Temporal.ZonedDateTime.from(
'2026-03-29T00:00:00+01:00[Europe/Oslo]',
);
const nextDay = oslo.add({ days: 1 });
// 결과: 2026-03-30T00:00:00+02:00[Europe/Oslo]
// 벽시계로는 하루이지만 실제 경과는 23시간 (서머타임으로 1시간 단축)
console.log(nextDay.offset); // '+02:00'DST 전환 시나리오 다루기
Spring-forward: 존재하지 않는 시각
UK 서머타임이 시작되는 2026년 3월 29일, 01:00 GMT가 02:00 BST로 점프한다. 01:30은 존재하지 않는다. 이 시각에 예약된 작업은 어떻게 될까?
// 01:30은 이 날 존재하지 않는 시각
const scheduled = Temporal.ZonedDateTime.from(
{
timeZone: 'Europe/London',
year: 2026, month: 3, day: 29,
hour: 1, minute: 30,
},
{ disambiguation: 'compatible' }, // 기본값: spring-forward 시 이후 오프셋 선택
);
console.log(scheduled.toString());
// 결과: 2026-03-29T02:30:00+01:00[Europe/London]
console.log(scheduled.offset); // '+01:00' (BST)disambiguation: 'compatible'이 기본값이지만 의도를 코드에 명시하는 것이 훨씬 낫다. 6개월 뒤 자신이 보더라도, 동료가 보더라도 "아, 이 시각이 모호할 수 있구나"가 즉시 읽힌다.
Fall-back: 중복되는 시각
미국 동부 서머타임 해제일(2026년 11월 1일), 02:00이 01:00으로 돌아간다. 01:30이 EDT(UTC-4)와 EST(UTC-5) 두 번 존재한다. 어느 01:30인가?
// EDT 기준 01:30 (서머타임 해제 전, UTC 05:30)
const edt = Temporal.ZonedDateTime.from(
{
timeZone: 'America/New_York',
year: 2026, month: 11, day: 1,
hour: 1, minute: 30,
},
{ disambiguation: 'earlier' },
);
// EST 기준 01:30 (서머타임 해제 후, UTC 06:30)
const est = Temporal.ZonedDateTime.from(
{
timeZone: 'America/New_York',
year: 2026, month: 11, day: 1,
hour: 1, minute: 30,
},
{ disambiguation: 'later' },
);
console.log(edt.offset); // '-04:00'
console.log(est.offset); // '-05:00'금융·배치 시스템에서 이 차이는 실제 돈과 연결된다. Bloomberg가 Temporal API 설계에 깊이 관여한 것도 이 맥락에서다.
disambiguation 선택 기준
reject는 "모호한 시각이 절대 들어오면 안 되는" 엄격한 시스템에서 유용하다. 예외를 잡아 사용자에게 알리거나 작업을 건너뛸 수 있다. 스케줄러의 기본값으로 두면 "숨은 스킵/시프트"를 코드 리뷰 단계로 끌어낼 수 있다.
PlainDateTime의 함정과 타입 시스템 활용
toPlainDateTime()을 호출하는 순간 타임존 컨텍스트가 완전히 사라진다.
const seoulNow = Temporal.Now.zonedDateTimeISO('Asia/Seoul');
const plain = seoulNow.toPlainDateTime(); // 타임존 정보 소실
// 이 이후의 연산은 DST를 전혀 모른다
const nextDay = plain.add({ days: 1 }); // PlainDateTime — DST 고려 없음TypeScript에서 이것이 강력한 이유는 타입을 명시적으로 선언하는 코드에서 두 타입이 대입 호환되지 않는다는 점이다. 스케줄러 내부에서 실수로 PlainDateTime을 중간 저장소로 쓰려 하면 정적 타입 검사가 잡아낸다.
function getInstant(dt: Temporal.ZonedDateTime): Temporal.Instant {
return dt.toInstant();
}
const plain: Temporal.PlainDateTime = seoulNow.toPlainDateTime();
getInstant(plain);
// TypeScript 에러:
// Argument of type 'Temporal.PlainDateTime' is not assignable to
// parameter of type 'Temporal.ZonedDateTime'.에러 메시지는 "메서드가 없어서"가 아니라 "파라미터 타입 불일치"에서 발생한다. 즉, PlainDateTime이 ZonedDateTime 자리에 들어갈 수 있는 서브타입이 아니라는 사실을 TypeScript가 구조적으로 판단한다. 순수 JavaScript로 같은 코드를 돌리면 plain.toInstant is not a function 형태의 런타임 TypeError가 대신 발생한다.
BullMQ와 연동하기
단일 프로세스 setTimeout 기반 스케줄러는 프로세스가 재시작되면 작업을 잃는다. Redis 기반 분산 큐인 BullMQ를 쓰는 경우, Temporal로 다음 실행 시각을 계산해 delay로 넘길 수 있다.
import { Queue } from 'bullmq';
import { Temporal } from '@js-temporal/polyfill';
const queue = new Queue('daily-jobs', { connection: { host: 'localhost' } });
async function enqueueNextRun(
wallHour: number,
wallMinute: number,
tz: string,
): Promise<void> {
const next = nextOccurrence(wallHour, wallMinute, tz);
const delayMs = next.toInstant()
.since(Temporal.Now.instant())
.total('milliseconds');
await queue.add(
'aggregate',
{ scheduledFor: next.toString() }, // ISO 8601 확장 형식으로 직렬화
{ delay: delayMs },
);
}BullMQ의 repeat.tz 필드도 DST-aware 반복을 지원하지만, Temporal으로 직접 계산하면 disambiguation 옵션처럼 세밀한 제어가 가능하다.
next.toString()이 생성하는 문자열은 2026-03-29T09:00:00+09:00[Asia/Seoul] 형태다. 기존 Date.toISOString()의 2026-03-29T00:00:00.000Z와 다르므로 DB 스키마나 API 응답 포맷을 조정해야 한다.
트레이드오프
라이브러리 선택 비교
| Temporal (네이티브) | date-fns v4 + @date-fns/tz | Luxon | |
|---|---|---|---|
| 번들 크기 | 0KB (네이티브 환경 기준, 폴리필 사용 시 수십 KB) | ~13KB | ~23KB |
| TypeScript | 내장 타입 | TypeScript-first | 내장 타입 |
| DST 처리 | 완전 자동 | @date-fns/tz 필요 | 완전 지원 |
| Node.js 26 | 폴리필 불필요 | 항상 가능 | 항상 가능 |
| 상태 | ES2026 표준 | 현실적 기본값 | 레거시 유지보수 |
실무에서 자주 겪는 함정
PlainDateTime을 스케줄러 중간 상태로 쓰는 실수. withPlainTime()이 반환하는 것은 ZonedDateTime이다. 그런데 어디선가 toPlainDateTime()을 거치면 이후 .add({ days: 1 })은 DST를 무시한다. TypeScript 타입을 명시적으로 선언하면 이 경로를 정적 타입 검사에서 차단할 수 있다.
withPlainTime()의 조용한 disambiguation. 위에서 강조했듯이, 이 메서드는 옵션을 받지 않고 'compatible'을 쓴다. 스케줄러 후보 생성 경로에서 사용하면 spring-forward 날의 존재하지 않는 시각이 소리 없이 시프트된다. ZonedDateTime.from()으로 명시적으로 재구성하는 편이 안전하다.
Temporal(API)과 Temporal.io(워크플로 플랫폼) 혼동. 검색하면 두 가지가 섞여 나온다. Temporal.io는 Cadence 파생 워크플로 오케스트레이션 플랫폼으로 JavaScript Temporal API와 무관하다.
UTC 고정이 더 나은 케이스. heartbeat, rate-limit 같이 초 단위 고정 간격이 필요한 작업은 타임존 복잡도를 도입할 이유가 없다. Temporal.Instant를 직접 써서 UTC 기반으로 처리하는 쪽이 단순하고 예측 가능하다.
직렬화 호환성. ZonedDateTime.toString()의 출력은 기존 DB나 API가 기대하는 형식과 다를 수 있다. toInstant().toString()(UTC ISO 8601)이나 epochMilliseconds를 함께 저장하는 패턴을 고려할 수 있다.
지금 당신의 스케줄러를 진단하는 체크리스트
기존 코드가 DST 버그를 안고 있는지 다음 항목으로 자가 진단해 보자. 하나라도 해당되면 다음 서머타임 전환일이 오기 전에 손을 봐두는 편이 낫다.
- 저장 형식: 다음 실행 시각을 UTC 밀리초(
Date.now() + interval)로만 저장하고 있는가? 그렇다면 벽시계 의도가 소실됐다는 뜻이다. 규칙(타임존 + 벽시계 시각)과 계산 결과(Instant)를 별도로 저장하고, 매 실행 후 규칙에서 다시 계산해야 한다. - 하루 덧셈:
+ 86_400_000이나date.setDate(date.getDate() + 1)로 다음 날을 만들고 있는가? DST 전환일에는 하루가 23시간 또는 25시간이 된다.ZonedDateTime.add({ days: 1 })또는PlainDate.add({ days: 1 })로 옮겨야 벽시계 시각이 보존된다. - 모호한 시각 처리: 벽시계 시각을 코드에서 만드는 지점에서
disambiguation을 명시하고 있는가? 명시하지 않으면 spring-forward에서 조용히 밀리고, fall-back에서 어느 오프셋인지 결정론적이지 않다. 기본값을'reject'로 두면 리뷰 단계에서 사고를 예방할 수 있다. - 타입 경계: 스케줄러 내부 함수 시그니처가
Temporal.ZonedDateTime을 명시적으로 요구하는가, 아니면any나PlainDateTime이 뒤섞여 흐르는가? 명시적 선언이 없으면 TypeScript가 잡아줄 여지도 사라진다. - 테스트 커버리지: DST 전환일(3월 마지막 일요일, 11월 첫 일요일 등)을 픽스처로 두고 테스트하는가? 프로덕션 시각을
Temporal.Now에 주입할 수 있는 구조라면 이런 테스트는 몇 줄이면 짤 수 있다.
Node.js 26 환경이라면 지금 당장 폴리필 없이 시작할 수 있다. Bun이나 Node.js 24 이하 환경이라면 @js-temporal/polyfill로 시작해도 API는 동일하다. 새로 짜는 스케줄러 로직이 있다면, new Date() 대신 Temporal.Now.zonedDateTimeISO(tz)에서 시작해 보자. 새벽 2시 알람 대신 정적 타입 검사 실패 메시지를 받는 쪽이 훨씬 낫다.
참고 자료
- Temporal - JavaScript (MDN Web Docs)
- Temporal.ZonedDateTime (MDN Web Docs)
- tc39/proposal-temporal (GitHub)
- TC39 Advances Temporal to Stage 4 (Socket.dev)
- Chrome 144 Ships Temporal API (InfoQ)
- JavaScript Temporal in 2026 (Bryntum Blog)
- Temporal Is Now Official (Bloomberg LP)
- Exploring Temporal API (Better Stack)
- The DST Bugs That Only Show Up at 2 A.M. (Medium)
- date-fns v4 vs Temporal API vs Day.js (PkgPulse)
- Temporal API: ZonedDateTime Docs (tc39.es)
- @js-temporal/polyfill (npm)
- Node.js 26 & Temporal History (NodeSource)