Temporal을 사용한 이벤트 차이 계산 및 기간 형식 지정

이벤트 (예: 무료 체험 만료, 구독 카운트다운, SLA 기간) 사이에 경과된 시간 또는 남은 시간을 계산하고 이러한 기간을 현지화되고 인간이 읽을 수 있는 형식으로 표시하려면 지금까지는 수동 계산이나 외부 라이브러리가 필요했습니다. 기존 Date 객체는 에포크 밀리초만 추적하므로 달력 월 산술, 일광 절약 시간 (DST) 전환, 단위 분산에서 오류가 발생하기 쉽습니다.

Temporal API는 Temporal.ZonedDateTime를 Temporal.Duration 및 Intl.DurationFormat와 페어링하여 DST 안전 시간차, 구성 가능한 단위 분산, 캘린더 인식 기간 비교, 현지화된 형식을 제공합니다.

구현 방법

  1. Temporal.ZonedDateTime 엔드포인트 가져오기: 시스템 시간대의 현재 시간에 Temporal.Now.zonedDateTimeISO()를 사용하거나 대상 이벤트의 시간대가 사용자의 시스템 기본값과 다른 경우 명시적인 IANA 시간대 문자열(Temporal.Now.zonedDateTimeISO(timeZone))을 전달합니다.
  2. .since() 및 .until()를 사용하여 차이 계산:
    • now.since(start, options)를 사용하여 과거 이벤트 이후 경과된 시간을 계산합니다.
    • now.until(end, options)를 사용하여 향후 이벤트까지 남은 시간을 계산합니다.
    • largestUnit (예: 'year', 'month', 'day', 'hour') 및 smallestUnit (예: 'minute')를 전달하여 결과 Temporal.Duration의 균형을 맞추고 반올림합니다.
  3. 독립형 Temporal.Duration 객체 균형 조정 또는 비교:
    • Temporal.Duration.from({ minutes: 90 })를 사용하여 독립형 기간을 만들고 duration.round({ largestUnit: 'hour' })를 사용하여 단위를 재조정합니다(달력 월, 연도 또는 일광 절약 시간 전환에 걸쳐 조정할 때 relativeTo 제공).
    • Temporal.Duration.compare(d1, d2, { relativeTo })를 사용하여 두 기간을 비교합니다. 기간에 달력 단위 (years, months, weeks)가 포함되거나 일광 절약 시간제 경계를 넘는 경우 PlainDate 또는 ZonedDateTime를 relativeTo로 전달해야 합니다.
  4. 사람과 기계에 적합한 형식:
    • 현지화된 사용자 대상 텍스트에는 duration.toLocaleString(locale, options)(Intl.DurationFormat에 위임)를 사용합니다.
    • duration.toString()는 기계가 읽을 수 있는 ISO 8601 기간 문자열 (예: P1M2DT4H)에만 사용합니다.

예: 무료 체험 만료 차이 및 현지화된 형식

// 1. Get the current time in an explicit target IANA time zone (or omit the
// argument to use the system's current time zone).
const tz = 'America/New_York';
const now = Temporal.Now.zonedDateTimeISO(tz);

// 2. Combine separate date and time strings directly.
// Do not build an ISO string with template literals; chain Temporal methods instead.
const startDateStr = '2025-01-01';
const startTimeStr = '12:00:00';
const endDateStr = '2025-01-31';
const endTimeStr = '12:00:00';

const start = Temporal.PlainDate.from(startDateStr).toPlainDateTime(startTimeStr).toZonedDateTime(tz);
const end = Temporal.PlainDate.from(endDateStr).toPlainDateTime(endTimeStr).toZonedDateTime(tz);

// 3. Calculate differences using .since() and .until()
// ZonedDateTime differences default to largestUnit: 'hour' (days are not a fixed
// length across DST, so Temporal won't balance into days unless asked). Pass
// largestUnit: 'day' or a calendar unit ('week', 'month', 'year') when you want a
// human-scale breakdown instead of e.g. PT720H. smallestUnit rounds away
// seconds and sub-second noise so the formatted string stays readable.
const timeActive = now.since(start, { largestUnit: 'year', smallestUnit: 'minute' });
const timeRemaining = now.until(end, { largestUnit: 'year', smallestUnit: 'minute' });

// 4. Format durations for display with .toLocaleString() (Intl.DurationFormat)
// instead of manually concatenating unit numbers and suffixes. 'en-US' is used
// here for deterministic output; pass undefined to use the user's locale.
console.log(`Active: ${timeActive.toLocaleString('en-US', { style: 'narrow' })}`);
console.log(`Remaining: ${timeRemaining.toLocaleString('en-US', { style: 'long' })}`);

// Use .toString() for ISO 8601 serialization (e.g., API payloads or <time datetime="...">)
console.log(`ISO 8601: ${timeRemaining.toString()}`);

// 5. Check status via .sign, and compare durations with Temporal.Duration.compare()
const isExpired = timeRemaining.sign < 0;
// Pass relativeTo when comparing durations that may include calendar months/years or DST shifts
const isPastHalfway = Temporal.Duration.compare(timeActive, timeRemaining, { relativeTo: now }) > 0;

예: 독립형 광고 길이 균형 맞추기 (round 및 Intl.DurationFormat)

// Convert a raw unit count (e.g., 90 minutes) into a balanced breakdown (1 hour, 30 minutes)
const rawDuration = Temporal.Duration.from({ minutes: 90 });
const balanced = rawDuration.round({ largestUnit: 'hour' });

// Format via duration.toLocaleString() or directly with Intl.DurationFormat
const formatter = new Intl.DurationFormat('en-US', { style: 'long' });
console.log(formatter.format(balanced)); // "1 hour, 30 minutes"

전략적 구현 및 권장사항

  • 권장사항: 특정 시간대에 발생하는 실제 이벤트 (예: 정기 결제 갱신 또는 이벤트 카운트다운)와 관련된 계산에는 Temporal.ZonedDateTime를 사용하고, 시스템 기본값이 아닌 시간대를 기준으로 계산할 때는 명시적 timeZone를 Temporal.Now.zonedDateTimeISO(timeZone)에 전달합니다.
  • 권장사항: `${dateStr}T${timeStr}`와 같은 ISO 문자열을 보간하는 대신 Temporal.PlainDate.from(dateStr).toPlainDateTime(timeStr).toZonedDateTime(tz) 또는 Temporal.PlainDate.from(dateStr).toZonedDateTime({ timeZone: tz, plainTime: timeStr })와 같은 Temporal 메서드를 사용하여 별도의 날짜 및 시간 문자열을 결합합니다.
  • 시간을 초과하여 기간의 균형을 맞추려면 .since(), .until() 또는 .round()에 largestUnit(예: 'day', 'month', 'year')를 전달해야 합니다(DO). ZonedDateTime 차이 메서드는 기본적으로 'hour'입니다.
  • 두 기간을 비교할 때 Temporal.Duration.compare(d1, d2, { relativeTo })를 사용하고, 기간에 달력 단위 (months, years) 또는 시간대 인식 일이 포함될 때마다 relativeTo 앵커를 제공합니다.
  • ${duration.years}y ${duration.months}m...를 수동으로 보간하는 대신 duration.toLocaleString() 또는 new Intl.DurationFormat(locale, { style }) ('long', 'short', 'narrow' 또는 'digital')를 사용하여 사용자 디스플레이의 기간 형식을 지정해야 합니다.
  • duration.toString()는 기계 가독형 ISO 8601 기간 문자열 (예: PT1H30M)에만 사용하고 사용자 대상 문구로는 사용하지 마세요.
  • PlainTime를 HH:MM (예: <input type="time">)로 렌더링할 때는 문자열 .slice(0, 5) 대신 .toPlainTime().toString({ smallestUnit: 'minute' })를 사용해야 합니다.
  • 인스턴스를 직접 수정하지 마세요. Temporal 객체는 변경할 수 없습니다. .add(), .subtract(), .round(), .with()과 같은 작업은 새 인스턴스를 반환합니다.
  • 차이 자체에 Date / .getTime() 산술을 사용하지 마세요. Date에는 시간대를 인식하는 캘린더 연산이 없으며 밀리초 정밀도만 있습니다.
  • 권장사항: 계산된 Temporal.Duration (또는 Temporal.ZonedDateTime.compare())의 네이티브 .sign 속성을 사용하여 기간이 과거/만료된 시점 (sign < 0)을 나타내는지 또는 미래의 대기 중인 시작을 나타내는지 확인합니다.

대체 전략

Temporal의 대체 및 브라우저 지원

아직 네이티브 Temporal API를 지원하지 않는 브라우저의 경우 기능 감지를 사용하고 temporal-polyfill (~15KB min+gz)를 동적으로 로드합니다.

temporal-polyfill를 가져와도 Temporal 객체가 전역 범위에 자동으로 할당되지 않습니다. 코드가 globalThis.Temporal에 의존하는 경우 명시적으로 할당합니다.

// Check if Temporal is supported natively
(async () => {
  if (typeof Temporal === 'undefined') {
    // Load the polyfill conditionally
    const module = await import('https://esm.sh/temporal-polyfill');
    globalThis.Temporal = module.Temporal;
    // Extend Date.prototype if needed
    Date.prototype.toTemporalInstant = module.toTemporalInstant;
  }
  initializeApp();
})();

Intl.DurationFormat