JavaScript Temporal API — Date 객체의 30년 묵은 문제를 끝내다

16 min read
TL;DR
  • Temporal은 JavaScript 표준에 추가된 새 날짜/시간 API로, 불변 객체·네이티브 타임존·Duration·용도별 타입 분리를 제공합니다.
  • Firefox 139와 Chrome 144에서 이미 정식 탑재됐고, 2026년 3월 TC39 회의에서 Stage 4로 승인되어 ECMAScript 2026에 포함됐습니다.
  • 실무에서는 서버 저장은 Instant(UTC), 사용자 표시/입력은 ZonedDateTime과 PlainDateTime으로 분리하는 패턴이 가장 중요합니다.
Temporal API를 상징하는 시간축과 타임존 개념 일러스트

TL;DR

JavaScript에 드디어 제대로 된 날짜/시간 API가 들어왔습니다. Temporal은 불변 객체, 네이티브 타임존, 내장 Duration, 그리고 용도별 타입 분리를 갖춘 새 표준입니다. Firefox 139(2025년 5월)와 Chrome 144(2026년 1월)에 이미 정식 탑재됐고, 2026년 3월 TC39 회의에서 Stage 4로 승인되며 ECMAScript 2026에 확정됐습니다. 폴리필도 있어서 지금 바로 실무에 써볼 수 있습니다.

왜 아직도 날짜/시간이 이렇게 힘들었나

JavaScript로 날짜/시간을 다루다 보면 결국 라이브러리 선택의 기로에 서게 됩니다. 처음엔 기본 Date로 버티다가, 타임존 버그를 한 번 맞고 나서 Moment.js를 넣고, 번들 크기가 부담돼 day.js로 갈아타고, 타입스크립트 지원이 아쉬워 Luxon을 써보고. 저도 비슷한 흐름을 여러 번 겪었습니다.

그런데 돌아보면 문제의 핵심은 라이브러리 선택이 아니라 Date 자체였습니다. 우리는 오랫동안 부실한 기본기를 라이브러리로 덮어왔던 셈이죠.

기존 Date의 문제는 잘 알려져 있습니다.

  • 객체가 가변(mutable)이라 setDate() 같은 메서드가 원본을 직접 바꾼다
  • 월이 0부터 시작한다 (0 = January)
  • 날짜, 시간, 타임존, UTC 절대 시각이 한 객체 안에 섞여 있다
  • IANA 타임존(Asia/Seoul, America/New_York)을 1급 개념으로 다루지 못한다
  • Duration 같은 기간 타입이 없다
  • DST 전환일 계산이 까다롭고 실수하기 쉽다

1995년에 급하게 들어온 API가 30년 동안 웹 전체의 표준 자리를 차지하고 있었으니, npm 날짜 라이브러리 시장이 비정상적으로 커진 것도 이상한 일은 아니었습니다.

Temporal이란

Temporal은 JavaScript의 새 날짜/시간 API입니다. MathIntl처럼 전역 네임스페이스 객체로 제공되며, 날짜/시간의 유스케이스를 용도별 타입으로 분리합니다. 그리고 모든 객체는 **불변(immutable)**입니다.

Legacy Date와 Temporal API를 대비해 보여주는 비교 일러스트

이 설계가 중요한 이유는 타입이 곧 의도가 되기 때문입니다. 예를 들어 PlainDate를 받는 함수는 “이 로직에는 타임존이 필요 없다”는 사실을 타입 차원에서 보여줍니다. 반대로 글로벌 미팅 일정처럼 타임존이 핵심인 경우엔 ZonedDateTime을 써야 한다는 게 코드에 그대로 드러납니다.

Temporal의 핵심 타입

타입용도
Temporal.PlainDate날짜만 (연/월/일) — 생일, 공휴일
Temporal.PlainTime시간만 — “매일 오전 9시”
Temporal.PlainDateTime날짜+시간 (타임존 없음)
Temporal.ZonedDateTime날짜+시간+타임존 — 글로벌 미팅, 현지 시각
Temporal.InstantUTC 절대 시각 — 서버 타임스탬프
Temporal.Duration기간/지속시간
Temporal.Now현재 시각 유틸리티

이 분리만으로도 많은 버그가 원천 차단됩니다. Date는 한 객체가 너무 많은 역할을 떠맡고 있었고, 그래서 의도와 실제 동작이 계속 엇갈렸습니다.

핵심 API 패턴

Temporal은 타입이 여러 개지만 사용 패턴은 꽤 일관적입니다.

  • from()으로 생성
  • with()로 필드 일부 변경
  • add() / subtract()로 연산
  • until() / since()로 차이 계산
  • equals() / compare()로 비교

중요한 점은, 연산이 항상 새 객체를 반환한다는 겁니다.

const today = Temporal.PlainDate.from("2026-03-12");
const nextMonth = today.add({ months: 1 });

console.log(today.toString());     // 2026-03-12
console.log(nextMonth.toString()); // 2026-04-12
console.log(today.month);          // 3
console.log(today.dayOfWeek);      // 4 (1=월요일)
console.log(today.daysInMonth);    // 31

Date에 익숙하면 이런 동작이 오히려 새롭습니다. 더 이상 원본이 슬쩍 바뀌지 않으니, 함수형 스타일이나 상태 관리 쪽에서도 훨씬 다루기 편해집니다.

두 날짜 사이 차이 계산도 자연스럽습니다.

const start = Temporal.PlainDate.from("2025-01-01");
const end = Temporal.PlainDate.from("2026-03-12");
const diff = start.until(end, { largestUnit: "year" });

console.log(`${diff.years}${diff.months}개월 ${diff.days}`);
// 1년 2개월 11일

비교는 compare()equals()를 써야 합니다.

dates.sort(Temporal.PlainDate.compare);
a.equals(b); // boolean

여기서 흥미로운 점은 valueOf()가 의도적으로 TypeError를 던진다는 겁니다. 즉, >< 같은 산술 연산자 비교를 막아서 모호한 동작을 줄였습니다. 이건 처음엔 약간 불편해 보여도, 장기적으로는 훨씬 안전한 선택이라고 봅니다.

타임존과 DST 처리: Temporal의 진짜 이유

Temporal이 정말 빛나는 구간은 타임존과 DST(서머타임) 처리입니다.

기존 Date에서는 UTC 오프셋을 직접 계산하거나, 라이브러리와 플러그인을 붙여야 했습니다. 하지만 Temporal.ZonedDateTime은 IANA 타임존 데이터베이스를 네이티브로 다룹니다.

const seoulTime = Temporal.ZonedDateTime.from(
  "2026-03-12T14:30:00[Asia/Seoul]"
);

const nyTime = seoulTime.withTimeZone("America/New_York");

console.log(seoulTime.toString());
console.log(nyTime.toString());

이때 시차 계산은 API가 알아서 합니다. “서울 오후 2시 30분은 뉴욕에서 몇 시지?” 같은 질문이 드디어 정상적인 1급 기능이 된 셈이죠.

DST 전환일은 더 인상적입니다.

const dt = Temporal.ZonedDateTime.from({
  timeZone: "America/New_York",
  year: 2026,
  month: 3,
  day: 8,
  hour: 1,
});

console.log(dt.hoursInDay); // 23
console.log(dt.add({ hours: 1 }).toString());

2026년 3월 8일의 뉴욕은 DST 전환일이라 하루가 24시간이 아니라 23시간입니다. hoursInDay 같은 속성이 이 사실을 드러내고, 시간 더하기도 올바른 현지 시각으로 조정됩니다.

이건 단순히 편한 수준이 아닙니다. 글로벌 서비스, 예약 시스템, 알림 시스템, 회의 일정 기능에서 정말 자주 터지는 종류의 버그를 언어 차원에서 줄여줍니다.

실전에서 가장 중요한 패턴

실무에서는 아래 원칙 하나만 제대로 잡아도 Temporal 도입 가치가 큽니다.

서버 저장은 항상 Instant(UTC), 사용자 입력/표시는 필요한 타입으로 변환한다.

예를 들어 DB 저장은 이렇게 갑니다.

const now = Temporal.Now.instant();
const dbValue = now.toString();
// 2026-03-12T05:30:00Z

그리고 유저 로컬 시간으로 표시할 때만 타임존을 붙입니다.

const userTimeZone = "America/New_York";
const fromDb = Temporal.Instant.from(dbValue);

const local = fromDb.toZonedDateTimeISO(userTimeZone);

console.log(
  local.toLocaleString("ko-KR", {
    year: "numeric",
    month: "long",
    day: "numeric",
    hour: "2-digit",
    minute: "2-digit",
  })
);

반대로 사용자가 “2026년 3월 15일 오후 3시” 같은 값을 입력하는 경우는 먼저 PlainDateTime으로 받고, 그 다음 사용자 타임존을 결합해 Instant로 바꾸는 식이 자연스럽습니다.

const userInput = Temporal.PlainDateTime.from({
  year: 2026,
  month: 3,
  day: 15,
  hour: 15,
});

const toServer = userInput
  .toZonedDateTime("America/New_York")
  .toInstant()
  .toString();

console.log(toServer);
// 2026-03-15T19:00:00Z

이 패턴이 중요한 이유는 “현지 시각”과 “절대 시각”을 섞지 않게 해주기 때문입니다. 지금까지 많은 서비스가 이 둘을 제대로 분리하지 못해서 타임존 버그에 시달렸습니다.

글로벌 미팅 시간 여러 타임존으로 표시하기

글로벌 서비스에서 자주 필요한 패턴도 꽤 직관적으로 표현됩니다.

const meeting = Temporal.Instant.from("2026-03-15T19:00:00Z");

const times = [
  "America/New_York",
  "Europe/London",
  "Asia/Seoul",
  "Asia/Tokyo",
].map((tz) => ({
  city: tz.split("/")[1],
  time: meeting.toZonedDateTimeISO(tz).toLocaleString("en-US", {
    hour: "2-digit",
    minute: "2-digit",
    timeZoneName: "short",
  }),
}));

console.log(times);

이런 식이면 하나의 Instant를 기준으로 도시별 현지 시간을 안전하게 계산할 수 있습니다. 특히 런던처럼 DST 전환 시점이 다른 지역이 섞이면 수동 계산이 거의 반드시 틀어지는데, Temporal은 이걸 API 레벨에서 처리합니다.

반복 일정도 DST를 깨지 않고 유지한다

"매주 월요일 오전 9시" 같은 반복 일정도 Temporal의 장점이 잘 드러나는 케이스입니다.

const firstMonday = Temporal.ZonedDateTime.from({
  timeZone: "America/New_York",
  year: 2026,
  month: 3,
  day: 2,
  hour: 9,
});

const nextMonday = firstMonday.add({ weeks: 1 });

console.log(firstMonday.toString());
console.log(nextMonday.toString());
console.log(nextMonday.hour); // 9

2026년 3월 8일에 DST가 시작되더라도, 다음 주 월요일 오전 9시는 여전히 오전 9시입니다. 이런 반복 일정을 Date로 밀리초 단위 계산해 구현하면 1시간씩 밀리는 버그가 꽤 흔했죠. Temporal은 아예 타입과 연산 모델을 다르게 가져가서 이런 문제를 줄입니다.

요약하면, 글로벌 서비스 타임존 처리의 90%는 이 흐름으로 정리됩니다.

  • 저장: Instant
  • 표시: .toZonedDateTimeISO(userTimeZone)
  • 입력 수신: PlainDateTime -> ZonedDateTime -> Instant

Date 라이브러리는 이제 끝일까

완전히 그렇다고 보긴 어렵습니다. 현실의 코드는 이미 Date와 각종 라이브러리 위에 쌓여 있고, 프레임워크나 서드파티 패키지들도 전부 한 번에 바뀌진 않을 겁니다.

그래도 분위기는 분명히 바뀔 가능성이 큽니다.

예전에는 “JS 날짜는 원래 불편하니까 day.js나 Luxon을 써야지”가 기본 전제였다면, 이제는 “표준 API로 해결 가능한가?”를 먼저 묻게 됩니다. 이 차이는 꽤 큽니다. 브라우저 네이티브 지원이 늘어날수록 번들 의존성과 학습 비용, 라이브러리 선택 피로도도 줄어들겠죠.

개인적으로는 Moment.js 이후 10년 넘게 이어진 “어떤 날짜 라이브러리를 쓸까” 논쟁이, 이제야 진짜 끝날 수 있겠다는 느낌이 듭니다.

표준화 및 브라우저 지원 현황

2026년 3월 TC39 회의에서 Temporal은 Stage 4로 공식 승인되며 ECMAScript 2026 스펙에 확정됐습니다. 이제 사실상 “미래의 API”가 아니라 “이미 도착한 API”에 가깝습니다.

  • Firefox 139+에서 정식 지원
  • Chrome 144+에서 정식 지원
  • Edge 144+도 Chromium 기반으로 지원
  • Safari는 아직 지원이 제한적이므로 확인이 필요

즉, “언젠가 들어올 예정”이 아니라 표준화와 구현이 실제로 상당 부분 끝난 상태입니다.

지원이 부족한 환경에서는 폴리필로 바로 도입할 수도 있습니다.

import "temporal-polyfill/global";

const today = Temporal.Now.plainDateISO();
console.log(today.toString());

이런 식으로 Safari를 포함한 미지원 브라우저까지 같은 API 표면을 유지할 수 있습니다. 다만 실제 번들 전략과 런타임 지원 범위는 프로젝트 환경에 맞춰 점검하는 게 좋습니다.

도입할 때의 현실적인 전략

지금 당장 모든 Date 코드를 갈아엎을 필요는 없습니다. 오히려 아래처럼 점진적으로 들어가는 게 낫습니다.

  1. 새 기능부터 Temporal 사용
  2. 타임존/DST 버그가 자주 나는 영역부터 우선 전환
  3. 서버 저장 모델은 Instant 중심으로 정리
  4. UI 입력은 PlainDate, PlainTime, PlainDateTime으로 분리
  5. 기존 Date 경계면은 어댑터 함수로 감싼다

이렇게 가면 리스크를 줄이면서도 Temporal의 장점을 빠르게 체감할 수 있습니다.

마치며

Temporal은 단순한 API 추가가 아니라 JavaScript 날짜/시간 처리의 패러다임 전환에 가깝습니다. 불변성, 타입 분리, 네이티브 타임존이라는 세 가지 설계 원칙이 Moment.js 이후 10년 넘게 라이브러리들이 각자 메우려 했던 문제를 표준 차원에서 정리해줍니다.

Date는 너무 오래 버텼고, 개발자들은 너무 오래 우회해왔습니다. Moment, day.js, Luxon, date-fns가 번성했던 건 생태계가 창의적이어서이기도 하지만, 그 전에 표준이 너무 부족했기 때문이죠.

글로벌 서비스를 만들면서 타임존 버그를 한 번이라도 잡아본 경험이 있다면, ZonedDateTime의 존재 자체가 꽤 반갑게 느껴질 겁니다. 아직 Safari 지원은 조심해서 봐야 하지만, 폴리필까지 포함하면 새 프로젝트나 버그가 잦은 영역부터 충분히 도입을 검토할 만합니다.

Temporal은 결국 JavaScript의 30년짜리 날짜/시간 기술 부채를 정리하는 첫 번째 제대로 된 답이라고 생각합니다.

Refs