본문으로 건너뛰기
2026. 7. 18
© WONKOOK LEE

왜 날짜만 넣었는데 하루 전이 나올까요?

같은 날짜 문자열이 두 갈래로 갈라지는 그림

프론트엔드에서 시간을 다루며 매번 어렴풋이 넘겼던 것들을 세 편에 걸쳐 정리하는 시리즈입니다. 개념 → 언어 → 계약 순으로 내려갑니다.

  1. '오후 3시'는 언제인가요?
  2. 왜 날짜만 넣었는데 하루 전이 나올까요? — 이 글
  3. 이 필드는 UTC여야 할까요?

서버가 "2026-07-30"을 내려줬고 화면에는 "7월 29일"이 떴습니다. 제 자리에서는 멀쩡했는데 미국 지사 동료의 화면에서만 하루가 밀렸습니다. 코드는 딱 한 줄이었습니다 — new Date(res.date).getDate().

원인을 찾고 나면 늘 그렇듯 허무합니다. 그런데 허무한 게 문제입니다. 원인을 몰라도 +1을 붙이면 화면이 맞아 보이니까, 우리는 그 자리에서 이해를 멈추고 다음 버그를 예약해 둡니다.

Date는 대체 어떤 값이길래 같은 문자열이 실행 환경마다 다른 날짜가 되는 걸까요?


이 글에서 다루는 내용

1편에서 시각을 절대시각·벽시계시각·기간으로 나눴습니다. 이 글은 그 모델을 들고 JavaScript로 내려갑니다. Date가 실제로 무엇을 담고 있는지, 파싱과 출력과 산술이 각각 어디서 어긋나는지, 그리고 이미 브라우저에 들어 있는 Intl로 어떻게 제대로 하는지를 봅니다.

마지막에는 서버와 브라우저가 서로 다른 시계를 볼 때 생기는 문제 — SSR 하이드레이션 불일치와 사용자 기기 시계 오차 — 까지 다룹니다.





1. Date는 숫자 하나입니다

먼저 Date의 정체부터 정확히 해두겠습니다. Date 객체가 내부에 들고 있는 값은 Epoch 밀리초 정수 하나뿐입니다.

const d = new Date("2026-07-30T06:00:00Z");
d.valueOf(); // 1785391200000 — 이게 전부다

연도도 월도 타임존도 들어 있지 않습니다. 1편의 분류로 말하면 Date절대시각만 담는 타입입니다.


그런데 API의 절반은 로컬 타임존을 통해 봅니다

문제는 그다음입니다. 그 숫자를 읽는 메서드들이 두 갈래로 나뉩니다.

UTC 렌즈로컬 렌즈돌려주는 것
getUTCFullYear()getFullYear()연도
getUTCMonth()getMonth()월 (둘 다 0부터 시작)
getUTCDate()getDate()
getUTCHours()getHours()
toISOString()toString(), toLocaleString()문자열

Date 하나에 렌즈 두 개가 달려 있는 셈입니다. 그리고 로컬 렌즈의 정체는 실행 환경의 타임존입니다. 코드 어디에도 적혀 있지 않고, 브라우저는 OS 설정에서, Node.js는 TZ 환경 변수나 OS에서 가져옵니다.

const d = new Date("2026-07-30T06:00:00Z");

// 실행 환경이 Asia/Seoul일 때
d.getDate(); // 30
d.getHours(); // 15

// 실행 환경이 America/Los_Angeles일 때 — 같은 객체, 같은 숫자
d.getDate(); // 29
d.getHours(); // 23

같은 절대시각이니 둘 다 맞습니다. 다만 어느 렌즈로 보고 있는지가 코드에 드러나지 않을 뿐입니다. 이것이 이 글에 나오는 거의 모든 함정의 뿌리입니다.


왜 이렇게 됐나

1995년에 Brendan Eich가 열흘 만에 JavaScript를 만들면서 당시 Java의 java.util.Date를 거의 그대로 옮겨 왔다는 이야기는 유명합니다. 0부터 시작하는 월, 가변(mutable) 객체, 타임존 없는 설계까지 함께 넘어왔습니다.

Java는 2014년 Java 8에서 java.time을 내놓으며 이 유산을 정리했습니다. JavaScript는 웹의 하위 호환 때문에 그러지 못했고, 대신 30년 뒤에 Temporal이라는 새 네임스페이스를 추가하는 길을 택했습니다. 3편에서 다룹니다.

정보

Date가변 객체입니다. setDate, setHours 같은 메서드는 새 객체를 돌려주지 않고 자기 자신을 바꿉니다. 그래서 함수에 Date를 넘길 때는 new Date(d)로 복사본을 만드는 습관이 안전합니다. date-fns와 Temporal이 모두 불변(immutable) 설계를 택한 것도 이 때문입니다.


Recap

Date가 담는 것은 Epoch 밀리초 정수 하나이며, 절대시각만 표현할 수 있습니다. 그런데 getter와 포매터의 절반이 실행 환경의 로컬 타임존을 통해 그 숫자를 해석하고, 그 타임존은 코드 어디에도 적혀 있지 않습니다. 1995년 java.util.Date를 급히 옮겨 온 설계가 하위 호환 때문에 그대로 남았습니다.




2. 파싱 — 문자열의 형태가 시각을 정합니다

서두의 버그로 돌아가겠습니다. new Date("2026-07-30")은 몇 시일까요?


날짜만 있으면 UTC, 시각까지 있으면 로컬

// 실행 환경: Asia/Seoul (+09:00)

new Date("2026-07-30");
// → 2026-07-30T00:00:00Z = 서울 시각 2026-07-30 09:00
// 날짜만 있는 형식은 UTC 자정으로 해석된다

new Date("2026-07-30T00:00:00");
// → 2026-07-29T15:00:00Z = 서울 시각 2026-07-30 00:00
// 시각까지 있는데 오프셋이 없으면 로컬 자정으로 해석된다

new Date("2026-07-30T00:00:00Z");
// → 2026-07-30T00:00:00Z = 오프셋을 명시했으니 모호함이 없다

T00:00:00을 붙였을 뿐인데 아홉 시간이 움직였습니다. 이것은 버그가 아니라 명세대로의 동작입니다. ECMAScript는 날짜만 있는 형식(date-only)은 UTC로, 시각까지 있고 오프셋이 없는 형식(date-time)은 로컬로 해석하도록 정해 두었습니다. ES5와 ES6를 지나며 몇 번 뒤집힌 끝에 지금의 규칙으로 굳었습니다.

같은 날짜 문자열이 UTC 갈래와 로컬 갈래로 나뉘어 서로 다른 시점에 도착하는 다이어그램

그래서 서두의 버그가 설명됩니다. new Date("2026-07-30")은 UTC 자정이고, 이걸 America/Los_Angeles(-07:00) 렌즈로 getDate() 하면 아직 7월 29일 오후 5시입니다. 29가 나옵니다.


표준이 아닌 형식은 구현체 마음입니다

더 위험한 것은 표준 형식이 아닌 문자열입니다.

new Date("2026/07/30");        // V8은 로컬 자정으로 해석한다 — 명세에 없는 동작
new Date("07/30/2026"); // 미국식. 로컬 자정
new Date("30/07/2026"); // Invalid Date (V8 기준)
new Date("2026-07-30 15:00"); // 가운데가 T가 아니라 공백 — 명세 밖

명세는 표준 형식을 벗어난 문자열의 처리를 전적으로 구현체 재량으로 남겨 두었습니다. 엔진마다 다르고, 같은 엔진에서도 버전마다 바뀔 수 있습니다. 실제로 "2026-07-30 15:00" 같은 형태는 오랫동안 브라우저마다 결과가 달랐습니다.

실무 규칙 하나로 줄이면 이렇습니다. Date.parsenew Date(문자열)은 오프셋이 명시된 RFC 3339 문자열에만 씁니다. 서버 응답에 오프셋이 없다면 그것부터 계약 문제로 다뤄야 합니다. 사용자 입력이나 레거시 포맷은 직접 쪼개서 숫자로 넘기거나 라이브러리의 명시적 파서를 쓰는 편이 안전합니다.


숫자 생성자도 나름의 함정이 있습니다

new Date(2026, 6, 30);       // 7월 30일. 월은 0부터 — 6이 7월이다
new Date(2026, 6, 30, 0, 0); // 로컬 자정
Date.UTC(2026, 6, 30); // UTC 자정의 밀리초 값을 돌려준다 (Date가 아니라 number)

new Date(50, 0, 1); // 1950년 1월 1일 — 0~99는 1900을 더한다
new Date("0050-01-01"); // 서기 50년. 네 자리로 쓰면 그대로 해석된다

new Date(y, m, d)언제나 로컬 렌즈입니다. UTC로 만들고 싶으면 Date.UTC()를 거쳐야 합니다. 그리고 월이 0부터 시작한다는 것은 java.util.Date에서 물려받은 유산입니다.


실패해도 던지지 않습니다

const d = new Date("아무 말");
d.getTime(); // NaN
d instanceof Date; // true — 객체는 멀쩡히 만들어진다
isNaN(d.getTime()); // true — 유효성은 이렇게 확인한다

파싱에 실패해도 예외가 나지 않고 Invalid Date라는 정상 객체가 만들어집니다. 이 값이 계산에 섞이면 NaN이 조용히 전파되다가 한참 뒤 엉뚱한 곳에서 화면이 비어 보입니다. 외부에서 들어온 문자열을 파싱했다면 그 자리에서 유효성을 확인하는 편이 좋습니다.


Recap

new Date(문자열)은 날짜만 있으면 UTC로, 시각까지 있고 오프셋이 없으면 로컬로 해석합니다. 표준 형식을 벗어난 문자열의 처리는 구현체 재량이라 엔진마다 다릅니다. 숫자 생성자는 월이 0부터 시작하고 언제나 로컬 렌즈로 동작하며, 파싱 실패는 예외 대신 Invalid Date로 조용히 넘어갑니다.




3. 출력 — 어떤 렌즈로 보고 있는가

값을 꺼낼 때도 같은 갈림길이 있습니다. 이번엔 실무에서 자주 보는 순서로 짚겠습니다.


toISOString은 언제나 UTC입니다

const d = new Date("2026-07-30T00:00:00+09:00"); // 서울 7월 30일 자정

d.toISOString(); // "2026-07-29T15:00:00.000Z"
d.toISOString().slice(0, 10); // "2026-07-29" ← 하루 밀렸다

toISOString().slice(0, 10)으로 "오늘 날짜"를 만드는 코드는 정말 흔한데, 이건 UTC 기준 날짜입니다. 한국에서는 매일 오전 9시 이전에 어제 날짜가 나옵니다. 새벽에 배포하고 새벽에 QA하면 잘 안 걸리고, 아침 회의 시간에 누가 발견합니다.

JSON.stringify도 같은 함정을 공유합니다.

JSON.stringify({ at: new Date("2026-07-30T00:00:00+09:00") });
// '{"at":"2026-07-29T15:00:00.000Z"}'

Date.prototype.toJSON이 내부적으로 toISOString을 부르기 때문입니다. 절대시각을 보낼 때는 이게 정답입니다. 문제는 벽시계 날짜를 Date에 담아 보낼 때인데, 그건 애초에 Date에 담지 말아야 할 값입니다. 3편에서 다룹니다.


getTimezoneOffset은 부호가 반대입니다

// Asia/Seoul (+09:00)에서
new Date().getTimezoneOffset(); // -540

// America/New_York (-04:00, 여름)에서
new Date().getTimezoneOffset(); // 240

+09:00인데 -540이 나옵니다. 이름은 "타임존 오프셋"인데 실제로 계산하는 값은 UTC − 로컬 이기 때문입니다. 단위도 시간이 아니라 분입니다.

그리고 이름과 달리 이 값은 타임존이 아닙니다. 호출한 그 순간의 오프셋일 뿐이라 DST가 있는 지역에서는 계절마다 달라집니다. 사용자의 타임존을 알고 싶다면 이 값이 아니라 뒤에 나올 Intl을 써야 합니다.


비교와 동등성

const a = new Date("2026-07-30T00:00:00Z");
const b = new Date("2026-07-30T00:00:00Z");

a < b; // false — 관계 연산자는 valueOf()를 써서 숫자로 비교한다
a > b; // false
a === b; // false — 서로 다른 객체다
a.getTime() === b.getTime(); // true — 이렇게 비교한다
+a; // 1785369600000 — 단항 플러스로 숫자화

부등호는 잘 동작하는데 등호만 안 되는 것이 은근히 헷갈립니다. <>는 원시값으로 변환해 비교하지만 ===는 참조를 비교하기 때문입니다.


그 밖의 자잘한 것들

d.getDay();        // 요일 (0=일요일). getDate()와 헷갈리기 쉽다
d.getYear(); // 폐기됨. 1900을 뺀 값을 돌려준다
d.toLocaleString(); // 로케일·타임존 모두 실행 환경 기본값 — 테스트마다 결과가 다르다

toLocaleString()을 옵션 없이 부르면 로케일도 타임존도 실행 환경에 맡기게 됩니다. CI에서 통과하던 스냅샷 테스트가 동료 노트북에서 깨지는 흔한 원인입니다. 포맷이 결과물의 일부라면 로케일과 타임존을 항상 명시하는 편이 좋습니다.


Recap

toISOStringtoJSON은 언제나 UTC라서 slice(0, 10)으로 만든 "오늘"은 UTC 날짜입니다. getTimezoneOffsetUTC − 로컬을 분 단위로 돌려주므로 부호가 직관과 반대이고, 그 순간의 오프셋일 뿐 타임존이 아닙니다. ===는 참조를 비교하므로 시각 동등성은 getTime()으로 확인해야 하고, toLocaleString을 옵션 없이 쓰면 환경에 따라 결과가 달라집니다.




4. 산술 — 하루는 항상 24시간이 아닙니다

1편 3절에서 DST 전환일은 23시간이거나 25시간이라고 했습니다. 그 사실이 코드에서 어떻게 드러나는지 보겠습니다.


밀리초 덧셈과 날짜 덧셈은 다른 연산입니다

const DAY = 24 * 60 * 60 * 1000;

// (1) 절대시각을 정확히 24시간 뒤로 민다
const a = new Date(d.getTime() + DAY);

// (2) 로컬 렌즈에서 날짜 숫자를 1 올린다
const b = new Date(d);
b.setDate(b.getDate() + 1);

평소에는 둘의 결과가 같습니다. 그런데 DST 전환을 넘어가는 순간 갈라집니다. America/New_York에서 2026년 3월 7일 오후 3시에 두 연산을 걸어보면, (1)은 3월 8일 오후 4시가 되고 (2)는 3월 8일 오후 3시가 됩니다. 그날은 23시간짜리 하루라서 "24시간 뒤"와 "다음 날 같은 시각"이 한 시간 어긋납니다.

둘 중 무엇이 맞느냐는 무엇을 하려던 것이냐에 달려 있습니다.

  • 캐시 만료, 세션 타임아웃, 쿨다운처럼 물리적 경과 시간이 기준이면 (1)입니다.
  • "내일 같은 시각 알림", "다음 주 회의"처럼 달력상의 이동이 기준이면 (2)입니다.

이 둘을 구분하지 않고 상수 DAY를 여기저기 쓰는 코드는 1년에 두 번 조용히 틀립니다.


존재하지 않는 자정

// 그날의 시작을 만드는 흔한 코드
const start = new Date(d);
start.setHours(0, 0, 0, 0);

대부분의 지역에서는 잘 동작합니다. 그런데 자정에 DST 전환을 거는 지역이 있습니다. 칠레(America/Santiago)는 토요일 24시에 시계를 앞당기므로, 그날 일요일의 00:00은 아예 존재하지 않습니다. 이런 날 setHours(0, 0, 0, 0)을 부르면 Date는 예외를 던지지 않고 조용히 01:00으로 밀어줍니다.

"그날의 시작"이 실제로는 그날의 1시가 되고, 그 값으로 하루 범위를 잡으면 한 시간이 통째로 빠집니다. 이런 종류의 버그는 재현 조건이 1년에 하루뿐이라 대개 로그로만 남습니다.


월 산술의 오버플로

const d = new Date(2026, 0, 31); // 2026년 1월 31일
d.setMonth(1); // "2월로 옮겨라"
d.toDateString(); // "Tue Mar 03 2026" — 3월 3일

2026년 2월은 28일까지입니다. setMonth(1)은 2월 31일을 만들려다 넘치는 3일을 다음 달로 넘겨서 3월 3일에 착지합니다. 예외도 경고도 없습니다.

"한 달 뒤"라는 개념 자체가 애매하다는 것이 근본 문제입니다. 1월 31일의 한 달 뒤는 2월 28일일까요, 3월 3일일까요. 정답이 도메인마다 다르기 때문에 date-fnsaddMonths는 2월 28일로 잘라내고(clamp), Temporal은 overflow 옵션으로 잘라낼지 거부할지를 고르게 합니다. 어느 쪽이든 명시적으로 고르게 한다는 점이 Date와 다릅니다.


Recap

밀리초 덧셈은 절대시각의 이동이고 setDate는 달력상의 이동이라, DST 전환일에 두 결과가 한 시간 갈라집니다. 자정에 전환을 거는 지역에서는 setHours(0,0,0,0)이 존재하지 않는 시각을 조용히 밀어냅니다. 월 산술은 넘친 날짜를 다음 달로 흘려보내며, 어느 쪽도 경고를 주지 않습니다. Date로 달력 산술을 하지 않는 것이 가장 확실한 예방입니다.




5. Intl — 이미 들어 있는 정답

여기까지 읽으면 라이브러리를 찾게 되는데, 그전에 알아둘 것이 있습니다. 타임존 변환을 표준으로 할 수 있는 수단이 이미 브라우저에 들어 있습니다. Intl입니다.


유일하게 타임존을 지정할 수 있는 곳

Date의 메서드 중 임의의 타임존을 지정할 수 있는 것은 하나도 없습니다. Intl.DateTimeFormattimeZone 옵션이 유일한 표준 통로입니다.

const fmt = new Intl.DateTimeFormat("ko-KR", {
timeZone: "America/New_York", // IANA 식별자를 그대로 받는다
dateStyle: "full",
timeStyle: "short",
});

fmt.format(new Date("2026-07-30T06:00:00Z"));
// "2026년 7월 30일 목요일 오전 2:00"

내부적으로 브라우저에 내장된 ICU/CLDR 데이터를 씁니다. 즉 1편 4절에서 본 tzdb를 직접 들고 있는 그 데이터입니다.


사용자의 타임존 알아내기

Intl.DateTimeFormat().resolvedOptions().timeZone;
// "Asia/Seoul" — IANA 식별자를 돌려준다

브라우저에서 사용자의 타임존 식별자를 얻는 표준 경로는 이것뿐입니다. getTimezoneOffset()은 앞서 본 대로 숫자 하나라 규칙을 담지 못합니다.


특정 타임존의 "오늘"을 안전하게 구하기

toISOString().slice(0, 10) 문제의 정석 해법입니다. formatToParts로 부품을 받아 직접 조립합니다.

/** 주어진 시각을 특정 타임존에서 봤을 때의 달력 날짜를 YYYY-MM-DD로 돌려준다 */
function toPlainDate(date, timeZone) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);

// [{type:'month', value:'07'}, ...] 형태로 오므로 타입으로 골라 쓴다
const get = (type) => parts.find((p) => p.type === type).value;
return `${get("year")}-${get("month")}-${get("day")}`;
}

const d = new Date("2026-07-30T00:00:00+09:00");
toPlainDate(d, "Asia/Seoul"); // "2026-07-30"
toPlainDate(d, "America/Los_Angeles"); // "2026-07-29"

format()이 아니라 formatToParts()를 쓴 이유는 로케일마다 조립 순서와 구분자가 다르기 때문입니다. en-CAsv-SE 로케일이 마침 YYYY-MM-DD를 뱉는다는 점을 이용한 짧은 코드가 널리 쓰이는데, 그건 CLDR 데이터의 현재 상태에 기대는 것이라 언젠가 바뀔 수 있습니다. 부품으로 받아 직접 조립하면 로케일과 무관하게 안정적입니다.


그 밖에 쓸 만한 것들

// 상대 시간 — "3일 전", "2시간 후"
new Intl.RelativeTimeFormat("ko", { numeric: "auto" }).format(-3, "day");
// "3일 전"

// 기간 범위 — 양쪽에 겹치는 부분은 알아서 접어준다
new Intl.DateTimeFormat("ko-KR", { month: "long", day: "numeric" })
.formatRange(new Date("2026-07-05"), new Date("2026-07-28"));
// "7월 5일~28일" — "7월"이 한 번만 나온다

// 오프셋·타임존 이름 표시. timeZoneName은 dateStyle/timeStyle과 함께 쓸 수 없다
new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "longOffset",
}).format(new Date("2026-07-30T06:00:00Z"));
// "오후 03:00 GMT+09:00"

Intl.DateTimeFormat 인스턴스를 만드는 비용은 꽤 큽니다. 목록을 렌더링하며 항목마다 new를 부르지 말고, 인스턴스를 만들어 두고 재사용하는 편이 좋습니다.

정보

Node.js에서 Intl이 영어만 지원하는 것처럼 보인다면 ICU 데이터가 축소된 빌드일 가능성이 있습니다. Node 13부터는 전체 ICU가 기본으로 들어가지만, 직접 빌드했거나 아주 오래된 이미지를 쓰고 있다면 확인해 볼 가치가 있습니다.


Recap

Intl.DateTimeFormattimeZone 옵션이 임의의 타임존으로 변환할 수 있는 유일한 표준 수단이고, resolvedOptions().timeZone이 사용자의 IANA 식별자를 알려주는 유일한 경로입니다. 특정 타임존의 달력 날짜가 필요하면 formatToParts로 부품을 받아 조립하는 것이 로케일에 흔들리지 않습니다. 포매터 인스턴스는 생성 비용이 크므로 재사용해야 합니다.




6. 서버와 브라우저가 다른 시계를 볼 때

마지막으로 렌더링 환경이 둘로 나뉘면서 생기는 문제들입니다.


하이드레이션 불일치

서버는 대개 TZ=UTC로 돌고 사용자의 브라우저는 서울에 있습니다. 그러면 로컬 렌즈에 의존하는 코드가 서버와 클라이언트에서 다른 문자열을 만들어냅니다.

// 서버에서는 "2026. 7. 30. 오전 6:00", 브라우저에서는 "2026. 7. 30. 오후 3:00"
<time>{new Date(post.createdAt).toLocaleString()}</time>

React는 하이드레이션 과정에서 이 차이를 발견하고 경고를 냅니다. 눈에 보이는 값도 순간적으로 바뀝니다.

상대 시간은 더 고약합니다. "3분 전"은 렌더 시점에 따라 값이 달라지므로, 서버 렌더와 클라이언트 하이드레이션 사이에 시간이 흐른 것만으로도 불일치가 납니다. 타임존을 고정해도 해결되지 않습니다.

해결책은 세 가지 정도입니다.

방법언제 쓰나대가
서버에서도 타임존을 명시표시 타임존이 고정된 서비스사용자별 타임존을 못 쓴다
절대시각만 SSR하고 포맷은 마운트 이후사용자 타임존을 따라야 할 때첫 페인트에 잠깐 빈다
suppressHydrationWarning상대 시간처럼 어차피 달라질 값경고만 끄는 것이라 범위를 좁혀 써야 한다

가장 깔끔한 것은 서버 응답에 절대시각만 담고, 사람이 읽을 문자열은 클라이언트에서 만드는 것입니다. 서버가 미리 포맷해서 내려주는 순간 그 문자열은 서버의 타임존과 로케일에 묶입니다.

서버 프로세스의 TZUTC로 고정하는 것은 좋은 습관입니다. 로컬 렌즈에 의존하는 코드가 있다면 개발 환경에서 바로 티가 나기 때문입니다. 배포하고 나서 시차만큼 틀린 값을 발견하는 것보다 훨씬 낫습니다.


사용자의 시계는 틀릴 수 있습니다

Date.now()사용자 기기의 시계입니다. 사용자가 마음대로 바꿀 수 있고, NTP 동기화가 안 된 기기는 몇 분씩 틀어져 있기도 합니다.

그래서 마감 카운트다운이나 토큰 만료처럼 판정이 걸린 값을 클라이언트 시계로 계산하면 안 됩니다. 서버 시각을 한 번 받아 기기 시계와의 차이를 구해 두고 보정하는 패턴이 일반적입니다.

// 응답 헤더의 Date로 서버와의 시계 차이를 구해 둔다
let skew = 0;

async function syncClock() {
const res = await fetch("/api/ping", { method: "HEAD" });
const serverNow = new Date(res.headers.get("Date")).getTime();
skew = serverNow - Date.now(); // 왕복 시간만큼의 오차는 남는다
}

const serverNow = () => Date.now() + skew;

물론 근사값입니다. 최종 판정은 언제나 서버가 해야 하고, 클라이언트의 보정은 화면에 그럴듯한 숫자를 보여주기 위한 것입니다.


경과 시간에는 performance.now()

// 나쁜 예 — 도중에 시스템 시계가 바뀌면 음수가 나올 수도 있다
const t0 = Date.now();
doWork();
const elapsed = Date.now() - t0;

// 좋은 예 — 단조 증가가 보장된다
const t0 = performance.now();
doWork();
const elapsed = performance.now() - t0;

Date.now()는 벽시계라 NTP 동기화나 사용자 조작으로 뒤로 갈 수 있습니다. performance.now()는 페이지가 열린 시점부터 단조 증가하는 값이라 경과 시간 측정에 적합하고, 정밀도도 더 높습니다. 1편 2절에서 윤초 이야기를 하며 잠깐 언급했던 그 이유입니다.


Recap

서버와 브라우저의 로컬 타임존이 다르면 SSR 하이드레이션 불일치가 나므로, 절대시각만 내려보내고 포맷은 클라이언트에서 만드는 편이 안전합니다. Date.now()는 조작 가능한 기기 시계이므로 판정에 쓰지 말고 서버 시각과의 차이를 보정해 표시용으로만 씁니다. 경과 시간 측정에는 단조 증가가 보장된 performance.now()를 씁니다.




함정 요약

함정왜 생기나어떻게 피하나
new Date("2026-07-30")이 UTC 자정날짜만 있는 형식은 UTC로 해석오프셋을 명시하거나 문자열로 다룬다
toISOString().slice(0,10)이 하루 밀림UTC 기준 날짜가 나온다Intl.formatToParts로 조립한다
getTimezoneOffset()-540UTC − 로컬을 분 단위로 계산타임존은 resolvedOptions().timeZone
+ 24 * 60 * 60 * 1000이 DST에서 어긋남절대시각 이동 ≠ 달력 이동의도에 맞는 연산을 고른다
setHours(0,0,0,0)이 1시가 됨자정이 존재하지 않는 날이 있다라이브러리의 명시적 API를 쓴다
setMonth가 3월 3일에 착지넘친 날짜를 다음 달로 흘림clamp 여부를 명시하는 API를 쓴다
SSR 하이드레이션 불일치서버와 브라우저의 로컬 렌즈가 다름절대시각만 SSR하고 포맷은 클라이언트에서
카운트다운이 사용자마다 다름기기 시계는 틀릴 수 있다서버 시각으로 보정하고 판정은 서버에서

여덟 줄 중 여섯 줄이 "로컬 렌즈가 코드에 드러나지 않아서" 생깁니다. 그래서 라이브러리를 바꾸는 것보다 먼저 할 일은 이 값이 절대시각인지 벽시계시각인지를 코드에서 구분할 수 있게 만드는 것입니다.

다음 편에서는 그 구분을 API 계약과 타입으로 옮깁니다. 어떤 필드가 UTC여야 하고 어떤 필드가 날짜여야 하는지, 미래의 약속을 왜 절대시각으로 저장하면 안 되는지, 그리고 Temporal이 이 구분을 어떻게 타입으로 강제하는지를 보겠습니다.




References

명세

  1. ECMA-262 — Date Objects
  2. ECMA-262 — Date Time String Format
  3. ECMA-402 — Intl.DateTimeFormat

MDN

  1. MDN — Date
  2. MDN — Intl.DateTimeFormat
  3. MDN — Intl.DateTimeFormat.prototype.formatToParts()
  4. MDN — Performance.now()

배경

  1. Fixing JavaScript Date — Maggie Pint
  2. TC39 — Temporal proposal의 동기(Why Temporal?)


좋은 사람들과 재미있는 일을 하며 열정적이고 즐겁게 살고 싶은 개발자