Temporal을 사용하여 위치에 구애받지 않는 데이터 및 부분 날짜 캡처

웹 기능: Temporal

생년월일, 일일 알람, 신용카드 만료일, 월별 결제 주기와 같이 시청자의 위치와 관계없이 동일하게 유지되어야 하는 연대순 데이터를 기록하는 것은 기존 Date 객체에서 오류가 발생하기 쉬웠습니다. Date는 항상 특정 시점을 나타내며 뷰어의 현지 시간대를 사용하여 표시되므로 "1990-01-01"와 같은 날짜를 저장하면 UTC 서쪽의 사용자에게 "1989-12-31"가 표시될 수 있으며, 부분 날짜 (예: 월만 표시되는 카드 만료일)를 모델링하면 임의의 자리표시자 날짜가 강제로 적용되어 오버플로 버그가 발생합니다.

Temporal API는 시간대 오프셋을 전달하지 않고 달력 날짜와 벽시계 시간을 달력이나 시계에서 읽은 그대로 나타내는 위치에 구애받지 않는 '일반' 유형 계열을 제공합니다.

유형 모델링 대상 일반적인 사용 사례
Temporal.PlainDate 전체 캘린더 날짜 (year, month, day) 생년월일, 이벤트 날짜, 정기 결제 청구일
Temporal.PlainTime 실제 시간 (hour, minute, second, …) 일일 알람 (사용자가 있는 곳의 오전 9시), 영업시간
Temporal.PlainDateTime 캘린더 날짜 + 시간대가 없는 벽시계 시간 영역을 연결하기 전에 PlainDate 및 PlainTime를 결합하는 유동적인 현지 일정
Temporal.PlainMonthDay 연도 없이 월과 일 매년 반복되는 생일, 기념일, 고정된 공휴일
Temporal.PlainYearMonth 연도 및 월(일 제외) 신용카드 만료일 (2027-12), 월별 결제 기간

구현 방법

  1. 데이터와 일치하는 가장 좁은 Temporal.Plain* 유형을 선택합니다.
    • 전체 달력 날짜에는 Temporal.PlainDate.from()를 사용하고 벽시계 시간에는 Temporal.PlainTime.from()를 사용합니다.
    • 시간대에 바인딩하지 않고 달력 날짜와 벽시계 시간을 결합할 때는 Temporal.PlainDateTime.from() (또는 plainDate.toPlainDateTime(plainTime))를 사용합니다.
    • 연도가 없는 연간 날짜에는 Temporal.PlainMonthDay.from()를 사용하고 날짜 연산을 수행하기 전에 .toPlainDate({ year })를 사용하여 PlainDate로 변환합니다.
    • 임의의 일수가 계산에 포함되지 않도록 연도 및 월 값에 Temporal.PlainYearMonth.from()를 사용합니다.
  2. Plain 유형을 구성할 때 calendar 매개변수에 유의하세요. ISO 문자열을 파싱하거나 PlainDate.from({ year, month, day })로 구성할 때 ISO 8601 ('iso8601')이 기본 캘린더입니다. 속성 모음에서 PlainMonthDay 또는 PlainYearMonth를 구성하거나 ISO가 아닌 캘린더를 사용할 때는 calendar를 명시적으로 전달합니다 (예: { month: 10, day: 31, calendar: 'iso8601' }).
  3. 반복되는 캘린더 간격에 overflow 옵션과 함께 .add() 사용: plainDate.add({ months: 1 })를 호출하면 기본적으로 { overflow: 'constrain' }로 설정되어 1월 31일과 같은 월말 날짜가 2월 28일/29일로 고정됩니다. 잘못된 타겟 날짜가 고정하는 대신 RangeError을 발생시켜야 하는 경우 { overflow: 'reject' }을 전달합니다.

예: 전체 날짜, 벽시계 시간, PlainDateTime

// 1. Parse a calendar date and a wall-clock time from input strings
const birthdate = Temporal.PlainDate.from('1990-01-01');
const reminderTime = Temporal.PlainTime.from('09:00');

// Combine into a location-agnostic PlainDateTime without string interpolation
const localAppointment = birthdate.toPlainDateTime(reminderTime);

// 2. Display without time zone conversion; outputs the same date in any time zone
console.log(birthdate.toLocaleString('en-GB'));
console.log(localAppointment.toString()); // "1990-01-01T09:00:00"

// 3. Read calendar fields directly from the Plain type. A PlainDate already *is*
// a calendar date, so there is no need for the formatToParts() workaround that
// legacy Date code uses to recover year/month/day from an instant.
const { year, month, day } = birthdate;

예: 부분 날짜 (PlainMonthDay 및 PlainYearMonth)

// Annual recurring event (no year component)
// Include explicit calendar when constructing from an object
const annualHoliday = Temporal.PlainMonthDay.from({
  month: 10,
  day: 31,
  calendar: 'iso8601',
});

const today = Temporal.Now.plainDateISO();
const isToday = annualHoliday.equals(today.toPlainMonthDay());

// Convert PlainMonthDay to a PlainDate with a specific year before doing arithmetic,
// because month lengths (such as February) depend on whether the year is a leap year.
const occurrenceThisYear = annualHoliday.toPlainDate({ year: today.year });

// Monthly expiration (no day component, preventing day-of-month overflow bugs)
const cardExpiry = Temporal.PlainYearMonth.from({
  year: 2027,
  month: 12,
  calendar: 'iso8601',
});
const currentMonth = today.toPlainYearMonth();
const isExpired = Temporal.PlainYearMonth.compare(cardExpiry, currentMonth) < 0;

예: 반복되는 월별 간격 (overflow: 'constrain' 대 'reject')

const billingStart = Temporal.PlainDate.from('2024-01-31');

// Default overflow: 'constrain' clamps Jan 31 + 1 month to the last valid day of February
const nextBilling = billingStart.add({ months: 1 }, { overflow: 'constrain' });
console.log(nextBilling.toString()); // "2024-02-29" (2024 is a leap year)

// Use overflow: 'reject' to throw a RangeError when the target day does not exist
try {
  billingStart.add({ months: 1 }, { overflow: 'reject' });
} catch (err) {
  // RangeError: day 31 does not exist in February
}

예시: 기존 Date와 상호 운용

기존 Date는 캘린더 날짜가 아닌 타임스탬프 (순간)입니다. "1990-01-01"를 Date로 저장하면 특정 시점 (일반적으로 자정 UTC)을 선택해야 하며, UTC 서쪽 시간대에서 다시 읽으면 "31/12/1989"가 됩니다. 라이브러리나 API에 여전히 Date가 필요한 경우 ISO 문자열을 직접 빌드하는 대신 Temporal.Instant를 통해 명시적으로 변환합니다.

// PlainDate -> Date: attach a time zone (time of day defaults to the start of
// that day) to get an Instant, then hand the epoch milliseconds to Date.
// 'UTC' is a deliberate choice and must be documented; any zone would do,
// but the stored instant depends on it.
const instant = birthdate.toZonedDateTime('UTC').toInstant();
const legacyDate = new Date(instant.epochMilliseconds);

// Date -> PlainDate: go back through Instant (legacyDate.toTemporalInstant()
// is equivalent), then project into an explicit time zone. The result depends
// on the zone, which is exactly the drift risk.
const viewedDate = Temporal.Instant.fromEpochMilliseconds(legacyDate.getTime())
  .toZonedDateTimeISO('America/New_York')
  .toPlainDate();

// Compare Plain types with .equals() (or Temporal.PlainDate.compare), not strings.
console.log(viewedDate.equals(birthdate)); // false: the calendar date drifted to 1989-12-31

전략적 구현 및 권장사항

  • 권장사항: 전체 달력 날짜에는 Temporal.PlainDate, 벽시계 시간에는 Temporal.PlainTime, 시간대가 없는 현지 날짜-시간 쌍에는 Temporal.PlainDateTime, 반복되는 연간 날짜에는 Temporal.PlainMonthDay, 연-월 만료 또는 결제 기간에는 Temporal.PlainYearMonth를 사용합니다.
  • Temporal.PlainMonthDay에서 직접 산술 연산을 수행하지 마세요. 월 길이는 연도에 따라 다르므로 먼저 .toPlainDate({ year })를 통해 Temporal.PlainDate로 변환하세요.
  • 반복되는 캘린더 간격에는 Temporal.PlainDate에서 .add()를 사용합니다. overflow: 'constrain' (기본값)를 사용하여 월말 날짜를 고정하거나 overflow: 'reject'를 사용하여 존재하지 않는 날짜에 RangeError를 발생시킵니다.
  • 객체에서 PlainMonthDay 또는 ISO가 아닌 일반 유형을 구성할 때는 calendar (예: calendar: 'iso8601')를 지정해야 합니다.
  • 물리적 시간의 특정 순간 (시점)을 나타내야 하는 경우 Plain 유형을 사용하지 마세요. 로그, 이벤트 타임스탬프 또는 교차 시간대 예약에는 Temporal.Instant 또는 Temporal.ZonedDateTime를 사용합니다.
  • 수동으로 빌드된 ISO 문자열 (예: `${date}T00:00:00Z`)에서 Date 객체를 구성하지 마세요. Temporal.Instant 또는 Temporal.ZonedDateTime의 epochMilliseconds에서 Date를 생성하고 Temporal.Instant.fromEpochMilliseconds(date.getTime()) 또는 date.toTemporalInstant()로 다시 읽어 시간대 가정이 명시적으로 이루어지도록 합니다.
  • Intl.DateTimeFormat.prototype.formatToParts()를 사용하여 연도, 월 또는 일 값을 추출하거나 비교하지 마세요. Plain 유형에서 직접 .year, .month, .day를 읽고 .equals()와 비교합니다.
  • 권장사항: 시간대는 IANA 이름 (예: America/New_York)으로 식별하고 고정된 UTC 오프셋으로는 식별하지 않습니다. 오프셋은 일광 절약 시간 및 법률에 따라 변경되므로 "UTC-5"와 같은 라벨은 연중 일부 기간에 잘못될 수 있습니다.
  • Temporal 객체는 변경할 수 없다는 점을 기억하세요. .add(), .subtract(), .with()와 같은 메서드는 원래 객체를 제자리에서 변경하는 대신 항상 새 참조가 있는 새 인스턴스를 반환하므로 반응형 UI 상태에 직접 저장해도 안전합니다.

대체 전략

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();
})();