- Published on
ES2024+ Temporal 타입추론 - 런타임·d.ts 정합
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버·클라이언트 모두에서 날짜/시간을 다루다 보면, 결국 문제는 두 가지로 수렴합니다.
- 런타임에는 분명
Temporal이 동작하는데 TypeScript가 타입을 못 잡거나 - 타입은 맞는 것 같은데 배포 환경에서
Temporal is not defined같은 런타임 오류가 터지는 것
ES2024+에서 Temporal 은 “표준화된 날짜/시간 API”라는 기대를 받고 있지만, 네이티브 지원 시점이 런타임별로 다르고, TS에서는 lib 설정과 폴리필 타입 선언이 섞이면서 타입추론이 깨지는 지점이 자주 생깁니다. 이 글은 그 간극(런타임·d.ts)을 줄이는 방법을 “타입추론” 관점에서 정리합니다.
관련해서 분산 워크플로우에서 시간 기반 재시도/중복처리를 다룬 경험이 있다면, Temporal(워크플로 엔진)과의 이름 충돌도 종종 등장합니다. 필요하면 Temporal로 분산 트랜잭션 재시도·중복처리 끝내기도 함께 참고하세요.
Temporal이 겪는 전형적인 불일치 시나리오
1) 타입은 있는데 런타임에 없다
TypeScript에서 lib 에 Temporal 타입을 포함하거나(혹은 폴리필 패키지가 d.ts 를 제공) IDE에서 자동완성이 되는데, 실제 실행 환경(Node, 브라우저, Edge 런타임 등)에서는 globalThis.Temporal 이 없는 경우입니다.
이때 코드는 컴파일되지만, 다음처럼 터집니다.
// 런타임에 Temporal이 없으면 여기서 바로 터짐
const now = Temporal.Now.instant();
2) 런타임에는 있는데 타입이 없다
반대로 최신 런타임(혹은 실험 플래그)에서는 Temporal 이 존재하는데, 프로젝트의 tsconfig.json 에서 관련 lib 가 빠져 있거나 선언 병합이 꼬여서 TS가 Cannot find name 'Temporal' 을 내는 경우입니다.
3) 폴리필 타입과 네이티브 타입이 섞여 추론이 흐려진다
폴리필이 제공하는 타입 선언과 표준 라이브러리 타입 선언이 동시에 들어오면, 어떤 환경에서는 Temporal.Instant 가 동일한 심볼로 보이지 않거나, 서로 다른 모듈/글로벌 선언으로 취급되어 브랜드 타입이 분리됩니다.
그 결과 Temporal.Instant 를 인자로 받는 함수에 instant 를 넣었는데도 “타입이 다르다”는 황당한 오류가 날 수 있습니다.
목표: 런타임·d.ts 를 하나의 진실로 만들기
Temporal은 날짜/시간 API라서, 한 번 어긋나면 장애가 조용히 누적됩니다(타임존, DST, 파싱 규칙, 직렬화 등). 따라서 아래 2가지를 동시에 만족시키는 구성이 필요합니다.
- 런타임:
globalThis.Temporal이 실제로 존재하고 동작할 것 - 타입: TS가 동일한
Temporal선언을 기준으로 추론하고, 빌드/테스트/배포에서 일관될 것
타입추론을 망치는 핵심: “글로벌 + 브랜드 + 오버로드”
Temporal 객체들은 대부분 불변(immutable)이고, 서로 다른 타입이 촘촘히 분리되어 있습니다.
Temporal.InstantTemporal.ZonedDateTimeTemporal.PlainDateTemporal.PlainDateTimeTemporal.Duration
이들은 구조적으로 비슷해 보여도, TS 타입 시스템에서는 보통 브랜드(명목) 타입처럼 설계됩니다. 그래서 “비슷한 shape”로는 대체가 안 됩니다.
또한 Temporal.*.from(...), Temporal.Now.*(...) 같은 API는 입력 타입이 넓고 오버로드가 많아, 인수 타입이 string | number | ... 로 퍼지는 순간 반환 타입 추론이 기대보다 약해집니다.
이 문제를 해결하려면 “런타임 폴리필 주입 방식”과 “타입 선언 소스”를 분리하지 말고 한 덩어리로 맞춰야 합니다.
권장 구성 1: 폴리필을 런타임에 주입하고 글로벌 타입을 고정
환경별 네이티브 지원이 불확실하다면, 가장 안전한 방식은 항상 폴리필을 로드하고 globalThis.Temporal 을 보장하는 겁니다.
아래 예시는 @js-temporal/polyfill 을 기준으로 설명합니다.
1) 런타임 부트스트랩(한 번만 실행)
// temporal-bootstrap.ts
import { Temporal } from '@js-temporal/polyfill';
// 전역에 주입(서버/브라우저 공통)
if (!('Temporal' in globalThis)) {
(globalThis as any).Temporal = Temporal;
}
export {};
- 핵심은 “앱 진입점에서 가장 먼저” 실행되게 하는 것입니다.
- Next.js라면 서버/클라이언트 번들 경계 때문에 파일 위치와 import 위치가 중요합니다.
2) 타입 선언을 단일 소스로 만들기
d.ts 는 “전역 Temporal은 폴리필의 Temporal과 동일”하다고 TS에 알려야 합니다.
// temporal-globals.d.ts
import type { Temporal as PolyfillTemporal } from '@js-temporal/polyfill';
declare global {
const Temporal: typeof PolyfillTemporal;
}
export {};
이렇게 하면 프로젝트 전체에서 Temporal 심볼이 폴리필 타입으로 고정되어, 런타임 주입과 타입이 정합됩니다.
3) tsconfig.json 에서 중복 선언을 피하기
여기서 중요한 포인트는 “표준 lib 쪽 Temporal 선언”과 “폴리필 선언”이 동시에 들어오지 않게 관리하는 것입니다.
- 만약 TS/DOM lib가 Temporal 글로벌을 제공하는 구성이라면, 중복 선언이 생길 수 있습니다.
- 반대로 아직 제공하지 않는다면, 위
temporal-globals.d.ts로 충분합니다.
실무에서는 한 번 결정하면 끝까지 한 가지 선언만 쓰는 게 좋습니다.
권장 구성 2: 네이티브 우선 + 폴리필 폴백(타입은 네이티브 기준)
“어차피 최신 런타임만 지원한다”거나 “폴리필 번들 비용이 부담된다”면, 네이티브를 우선하고 필요 시에만 폴리필을 로드할 수 있습니다.
다만 이 경우 타입 선언은 네이티브(표준 lib) 기준으로 맞추고, 폴리필은 런타임 호환만 담당하게 해야 합니다.
// temporal-ensure.ts
export async function ensureTemporal(): Promise<void> {
if ('Temporal' in globalThis) return;
const mod = await import('@js-temporal/polyfill');
(globalThis as any).Temporal = mod.Temporal;
}
이 방식의 함정은 다음과 같습니다.
ensureTemporal()호출 이전에Temporal.Now.instant()를 참조하면 런타임 오류- SSR/Edge에서 동적 import 제약
- 테스트 환경(jsdom/node)마다 로딩 경로가 달라질 수 있음
따라서 UI 상호작용 전에만 필요하다는 식으로 미루지 말고, 서버 엔트리/클라이언트 엔트리에서 확실히 보장하는 게 안전합니다.
타입추론 실전: 입력을 좁혀야 반환 타입이 선명해진다
Temporal의 from 류 API는 입력이 넓습니다. 예를 들어 Temporal.Instant.from(...) 는 문자열을 받지만, 문자열이 “어떤 포맷인지” TS는 모릅니다. 그래서 이후 로직에서 narrowing이 잘 안 되는 경우가 생깁니다.
1) 파서 함수에서 타입을 고정해 반환하기
export function parseInstant(iso: string): Temporal.Instant {
// 런타임에서 유효성 검증이 일어나고, 실패하면 예외
return Temporal.Instant.from(iso);
}
export function parseZonedDateTime(iso: string): Temporal.ZonedDateTime {
return Temporal.ZonedDateTime.from(iso);
}
이렇게 “경계(boundary)에서 한 번만 파싱하고 타입을 고정”하면, 내부 로직에서는 Temporal.Instant 로 추론이 유지됩니다.
2) string | Temporal.Instant 같은 유니온 입력은 초반에 정규화
type InstantInput = string | Temporal.Instant;
export function toInstant(input: InstantInput): Temporal.Instant {
if (typeof input === 'string') return Temporal.Instant.from(input);
return input;
}
정규화 함수는 단순하지만 효과가 큽니다.
- 오버로드/유니온 때문에 흐려진 타입을 초반에 단일 타입으로 수렴
- 이후 함수 시그니처가 깔끔해져서 추론이 깨질 여지가 줄어듦
런타임·d.ts 불일치가 만드는 “서로 다른 Temporal.Instant” 문제
가장 골치 아픈 케이스는 다음입니다.
- A 모듈은 폴리필 타입을 통해
Temporal.Instant를 봄 - B 모듈은 표준 lib(또는 다른 선언 파일)로
Temporal.Instant를 봄
이때 두 타입은 이름이 같아도 TS 입장에서는 다른 심볼일 수 있습니다. 그래서 이런 현상이 생깁니다.
// a.ts
export function takesInstant(x: Temporal.Instant) {
return x.epochMilliseconds;
}
// b.ts
import { takesInstant } from './a';
const x = Temporal.Instant.from('2025-01-01T00:00:00Z');
// 어떤 구성에서는 여기서 타입 오류가 날 수 있음
// (서로 다른 선언에서 온 Temporal.Instant로 인식)
takesInstant(x);
해결 체크리스트
Temporal글로벌 선언이 프로젝트에서 “딱 한 번만” 정의되는지 확인node_modules안의 여러d.ts가 경쟁하지 않는지 확인types/typeRoots를 쓴다면, Temporal 관련 선언이 중복 포함되지 않는지 확인- 모노레포라면 패키지별
tsconfigtypes설정이 서로 다른지 확인
이 문제는 TS 자체의 타입추론 문제라기보다, 선언 소스가 둘 이상일 때 생기는 정합성 문제입니다. 즉, 타입추론을 살리려면 먼저 선언을 단일화해야 합니다.
.d.ts 를 배포하는 라이브러리라면: Temporal을 어떻게 노출할까
라이브러리를 만들 때는 소비자 환경이 제각각이라 더 조심해야 합니다.
1) 공개 API에 Temporal.* 을 직접 노출하면 의존성이 전파된다
// 라이브러리 공개 API가 Temporal 타입을 직접 노출
export interface WindowRange {
start: Temporal.Instant;
end: Temporal.Instant;
}
이렇게 하면 소비자 프로젝트도 Temporal 타입 선언을 반드시 갖춰야 합니다.
- 소비자가 폴리필 기반이면 OK
- 소비자가 네이티브 기반이면 OK
- 하지만 선언이 섞이거나 없으면 바로 타입 오류
2) 대안: 직렬화 가능한 타입(ISO string)으로 경계를 만들기
export type InstantISO = string;
export interface WindowRangeDTO {
start: InstantISO;
end: InstantISO;
}
// 내부에서는 Temporal을 사용
export function toWindowRangeDTO(start: Temporal.Instant, end: Temporal.Instant): WindowRangeDTO {
return { start: start.toString(), end: end.toString() };
}
이 방식은 타입추론 관점에서도 안정적입니다.
- 공개 경계는 단순한
string - 내부 구현만 Temporal
- 런타임/타입 선언 불일치가 소비자에게 번지지 않음
물론 시간 연산을 라이브러리 수준에서 제공해야 한다면 Temporal 을 노출해야 할 수도 있습니다. 그 경우에는 README에 “필수 폴리필/필수 lib 설정”을 명시하고, peer dependency 전략을 고민해야 합니다.
테스트/빌드 파이프라인에서의 정합성 유지
Temporal은 환경 의존이 강해서, CI에서만 깨지는 패턴이 많습니다.
1) Jest/Vitest 환경에서 부트스트랩을 강제
// vitest.setup.ts (또는 jest.setup.ts)
import './temporal-bootstrap';
테스트 러너는 Node 버전/환경이 다를 수 있으니, 프로덕션과 동일하게 globalThis.Temporal 을 보장하는 게 좋습니다.
2) 번들러 트리쉐이킹/side effect 주의
부트스트랩 파일이 “사용되지 않는 import”로 판단되어 제거되면, 런타임에서 Temporal이 사라집니다.
- 엔트리에서 명시적으로 import
- 필요하면 패키지
sideEffects설정을 검토
이런 류의 문제는 디버깅이 길어지기 쉬운데, AI 툴콜/런타임 컨텍스트가 섞이면 더 복잡해집니다. 비슷한 성격의 디버깅 접근법은 Assistants API+LangChain 툴콜 오류 디버깅 가이드에서 정리한 “환경/입력/출력 경계 고정” 원칙이 그대로 통합니다.
타입추론을 더 좋게 만드는 패턴 3가지
1) “지금(now)”을 함수 인자로 주입해서 테스트 가능하게
Temporal.Now.* 는 편하지만, 테스트에서 고정하기 어렵습니다.
export function computeExpiry(now: Temporal.Instant, ttlMs: number): Temporal.Instant {
return now.add({ milliseconds: ttlMs });
}
// 사용처
const expiry = computeExpiry(Temporal.Now.instant(), 30_000);
이렇게 하면 타입추론은 그대로 유지되면서, 테스트에서는 now 를 고정할 수 있습니다.
2) satisfies 로 DTO 정합성을 강제
type EventDTO = {
id: string;
occurredAt: string; // ISO
};
const dto = {
id: 'e1',
occurredAt: Temporal.Now.instant().toString(),
} satisfies EventDTO;
satisfies는 타입을 “검사”만 하고 값의 구체 타입은 보존합니다.- Temporal을 내부에서 쓰되, 외부 경계를 문자열로 유지하기 좋습니다.
3) 타임존/캘린더는 명시적으로 고정
타임존이 문자열로 퍼지면 추론은 되더라도 런타임 의미가 흔들립니다.
export function toSeoulZdt(i: Temporal.Instant): Temporal.ZonedDateTime {
return i.toZonedDateTimeISO('Asia/Seoul');
}
이런 “정책 함수”를 두면, 팀 내에서 타임존 처리 기준이 코드로 고정되고 실수가 줄어듭니다.
Next.js에서의 실무 팁: 서버/클라 경계에서 한 번 더 확인
Next.js는 SSR, RSC, Edge 등 실행 환경이 다양합니다. Temporal 부트스트랩이 어디서 실행되는지 명확히 하세요.
- 서버 전용 유틸은 서버 엔트리에서만 부트스트랩
- 클라이언트에서 Temporal을 쓴다면 클라이언트 엔트리에서도 보장
또한 “즉시 반응 UI”를 만들며 낙관적 업데이트를 쓰는 경우, 시간 스탬프를 클라이언트에서 만들지 서버에서 만들지 정책이 중요합니다. 클라이언트 시간은 드리프트가 있을 수 있으니, 이벤트 소싱/정렬 기준이 필요하면 서버 생성 시간을 우선하는 편이 안전합니다. UI 패턴은 Next.js useOptimistic로 즉시반응 UI 만들기도 참고할 만합니다.
정리
- Temporal 도입에서 가장 큰 위험은 “타입이 되는 것처럼 보이는데 런타임이 다르다” 또는 그 반대입니다.
- 해결의 핵심은 전역
Temporal을 런타임에서 보장하고,d.ts선언 소스를 단일화하는 것입니다. - 타입추론을 살리려면 입력을 초반에 정규화하고(파서/정규화 함수), 공개 API 경계에서는 직렬화 가능한 타입(ISO 문자열 등)을 고려하세요.
- 테스트/빌드에서 부트스트랩이 제거되지 않도록 엔트리 import와 설정을 점검하세요.
Temporal은 단순히 “날짜 라이브러리 대체”가 아니라, 런타임/타입/직렬화/정책이 합쳐진 인프라 레이어에 가깝습니다. 한 번 정합을 맞춰두면, 이후 시간 관련 버그의 상당수를 구조적으로 제거할 수 있습니다.