자바스크립트 Date 때문에 하루씩 어긋나던 걸 Node 26 Temporal은 어떻게 푸나
D-day가 하루씩 어긋나던 커플 앱 코드를 계기로 자바스크립트 Date의 설계 문제를 짚었다. Node 26에 들어온 Temporal의 PlainDate, ZonedDateTime, Duration으로 같은 계산을 어떻게 푸는지 정리했다.
#Node #JavaScript #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로 짜는 걸 추천한다.