자바스크립트 Date 때문에 하루씩 어긋나던 걸 Node 26 Temporal은 어떻게 푸나

커플 앱에서 사귄 날부터 며칠째인지(D-day)를 계산하는데, 어느 날 보니 하루가 어긋나 있었다. 또 Date였다. 자바스크립트의 Date는 날짜를 다룰 때마다 한 끗씩 사람을 배신한다. Node 26에 Temporal API가 기본으로 들어왔다길래, 이참에 제대로 봤다.

이 글에서는 Date가 왜 그렇게 불편했는지, Temporal이 그걸 어떻게 고쳤는지 정리해본다. 그리고 정작 내 서버에는 왜 아직 못 올렸는지도 같이 적어둔다.

하루가 어긋난 이유부터

증상은 단순했다. 100일 기념일 당일에 앱을 열었는데 99일이라고 떴다. 만난 날을 DB에는 DATE 타입으로 넣어두고, 화면에서는 오늘에서 그 날을 빼서 하루치 밀리초로 나누고 있었다.

// 처음 짰던 모양
const meet = new Date(coupleInfo.meetDay);   // 서버에서 온 값. 날짜만 있는 줄 알았다
const days = Math.floor((Date.now() - meet.getTime()) / 86400000);

문제는 두 개가 겹쳐 있었다. 하나는 세는 기준이다. 만난 날을 1일차로 세야 하는데 0일차로 세고 있었다. 다른 하나는 시각이다. DATE 컬럼은 날짜만 있는 것 같지만, 서버가 JSON으로 내려주는 순간 시각이 붙은 문자열이 되고, 그걸 다시 new Date로 만들면 시간대에 따라 자정이 아닌 시각이 잡힌다. 그 상태에서 지금 시각과 빼면 자정 기준이 서로 달라서 소수점이 생기고, floor를 하면 하루가 빠진다.

지금은 양쪽을 다 자정으로 맞춘 뒤 빼고, 1을 더하는 걸로 막아뒀다. 실제 코드다.

// 100일 기념일 당일에 "99일"이 뜨던 불일치를 맞춘다.
const calculateDaysTogether = () => {
  if (!coupleInfo?.meetDay) return null
  const meetDate = new Date(coupleInfo.meetDay)
  meetDate.setHours(0, 0, 0, 0)                       // 만난 날을 로컬 자정으로
  const todayMs = new Date().setHours(0, 0, 0, 0)     // 오늘도 로컬 자정으로
  return Math.floor((todayMs - meetDate.getTime()) / 86400000) + 1   // 만난 날 = 1일차
}

고치긴 했는데, setHours를 두 번 부르고 +1을 하는 이유를 주석으로 적어둬야 나중의 내가 이해할 수 있는 코드다. 매번 이런 걸 알고 있어야 한다는 게 Date의 문제다.

Date는 설계 자체가 낡았다

Date가 불편한 건 내가 방법을 몰라서가 아니라, 설계가 틀려서였다. 대표적인 함정만 꼽아도 네 개다.

첫째, 월이 0부터 시작한다. 1월이 0이고 12월이 11이다. new Date(2026, 5, 14)는 6월 14일이다. 이거 하나로 평생 실수한다.

둘째, mutable(변경 가능)하다. date.setMonth()를 하면 원본 객체가 바뀐다. 어디선가 날짜를 슬쩍 바꿔놓으면, 그걸 참조하던 다른 곳이 같이 틀어진다. 추적하기 정말 힘든 버그다.

셋째, 타임존이 없다. Date는 사실상 "로컬 시간"이랑 "UTC" 둘만 안다. 날짜만 다루고 싶어도 시각이 늘 따라붙고, 위의 D-day 문제도 결국 여기서 나왔다. 다른 나라 시간대를 제대로 다루려면 라이브러리를 따로 끼워야 했다.

넷째, 파싱이 제멋대로다. 문자열을 Date로 바꾸는 동작이 환경마다 들쭉날쭉이라, "되는 줄 알았던" 코드가 다른 데서 깨진다.

Temporal이 고친 것

Temporal은 이 문제들을 설계 단계에서 다시 짰다. 위의 네 가지에 하나씩 대응된다.

1월은 1이다. 사람이 생각하는 그대로다. 그리고 immutable(불변)이라 한 번 만든 객체는 안 바뀐다. 날짜를 더하면 새 객체가 나온다. 원본이 슬쩍 바뀌는 사고가 구조적으로 안 난다.

타임존은 처음부터 제대로 다룬다. 시간대를 나중에 끼워맞추다 어긋나는 일이 없다. 그리고 용도별로 타입이 나뉜다. 시간대 없는 순수 날짜는 PlainDate, 특정 순간의 타임스탬프는 Instant, 시간대까지 붙은 건 ZonedDateTime, 기간은 Duration이다. 뭘 다루는지가 타입에 드러나서 헷갈리지 않는다.

// 순수 날짜. 시간도 시간대도 없다
const d = Temporal.PlainDate.from('2026-06-14');
d.month;                       // 6 (0이 아니라 6이다)
d.add({ months: 1 });          // 새 객체가 나온다. d는 그대로다

// 시간대가 붙은 날짜시간
const seoul = Temporal.ZonedDateTime.from('2026-06-14T09:00[Asia/Seoul]');
seoul.withTimeZone('America/New_York').toString();
// -> '2026-06-13T20:00:00-04:00[America/New_York]'

// 기간
const dur = Temporal.Duration.from({ hours: 26 });
dur.total({ unit: 'days' });   // 1.0833...

D-day 계산이 이렇게 깔끔해졌다

예전엔 두 날짜의 차이를 구하려고 밀리초로 빼서 86400000으로 나누다가 타임존이나 서머타임에서 하루가 어긋났다. Temporal은 Duration으로 이걸 정확하게 준다.

// 옛날: 밀리초 빼기. 타임존이 끼면 하루씩 틀어진다
const days = Math.floor((today - start) / (1000 * 60 * 60 * 24));

// Temporal: 날짜끼리 빼면 정확한 기간(Duration)이 나온다
const start = Temporal.PlainDate.from('2024-03-01');
const today = Temporal.Now.plainDateISO();       // 오늘 날짜만. 시간은 없다
const dday = start.until(today).days;            // 며칠째인지

// 반대로 "다음 기념일까지 며칠"도 같은 방법이다
const next = start.add({ years: 3 });
today.until(next).days;

PlainDate끼리 계산하니까 "시간"이 안 끼어든다. 서머타임이든 타임존이든 영향을 안 받는다. D-day처럼 날짜만 중요한 계산엔 시간 정보가 빠진 PlainDate가 딱이다. 위에서 setHours로 억지로 자정을 맞추던 일 자체가 필요 없어진다.

Node 26부터는 기본 탑재

그동안 Temporal은 폴리필(따로 설치하는 임시 구현)로만 써야 했는데, Node 26부터 V8 엔진에 기본으로 들어왔다. 폴리필을 빼도 그냥 Temporal이 있다. 브라우저도 Chrome, Firefox가 따라붙고 있어서, 이제 진짜 쓸 때가 됐다.

폴리필로 쓸 때는 이렇게 시작해야 했다. 이 한 줄이 없어진다는 게 기본 탑재의 의미다.

// Node 26 전에는 이렇게
import { Temporal } from '@js-temporal/polyfill';

// Node 26부터는 그냥 전역에 있다
Temporal.Now.plainDateISO();

아직 서버에는 못 올렸다

Temporal을 쓰려면 런타임이 받쳐줘야 하는데, 지금 이 서버는 이렇다. 이 글을 다시 손보는 2026년 9월에도 같은 값이다.

$ node -v
v18.19.1

한 대에 여러 서비스가 PM2로 같이 돌고 있어서 Node를 올린다는 건 그 전부를 한꺼번에 재시작한다는 뜻이다. 하나가 새 버전에서 안 뜨면 나머지도 같이 멈춘다. 실제로 이 블로그는 sharp 버전 하나 잘못 깔았다가 통째로 내려간 적이 있어서, 런타임 자체를 바꾸는 건 더 조심스럽다.

그래서 지금은 날짜가 중요한 곳에만 방어 코드를 넣어두고 쓰는 중이다. 위의 D-day 계산이 딱 그런 방어 코드다. 올릴 때는 트래픽이 적은 시간에 한 번에 하는 게 낫고, 그 전에 서비스마다 새 버전에서 뜨는지 하나씩 확인해볼 생각이다. 새로 만드는 프로젝트라면 Node 26에서 시작해서 처음부터 Temporal로 짜는 걸 추천한다.