Published on

ES2024+ Temporal 타입추론 - 런타임·d.ts 정합

Authors

서버·클라이언트 모두에서 날짜/시간을 다루다 보면, 결국 문제는 두 가지로 수렴합니다.

  1. 런타임에는 분명 Temporal 이 동작하는데 TypeScript가 타입을 못 잡거나
  2. 타입은 맞는 것 같은데 배포 환경에서 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.Instant
  • Temporal.ZonedDateTime
  • Temporal.PlainDate
  • Temporal.PlainDateTime
  • Temporal.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 관련 선언이 중복 포함되지 않는지 확인
  • 모노레포라면 패키지별 tsconfig types 설정이 서로 다른지 확인

이 문제는 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은 단순히 “날짜 라이브러리 대체”가 아니라, 런타임/타입/직렬화/정책이 합쳐진 인프라 레이어에 가깝습니다. 한 번 정합을 맞춰두면, 이후 시간 관련 버그의 상당수를 구조적으로 제거할 수 있습니다.