JavaScript에서 시간 형식화와 변환을 예측 가능하게 처리하는 방법: 타임스탬프 단위, ISO 문자열, 타임존, 서머타임, 파싱 규칙, 신뢰 가능한 패턴을 설명합니다.

JavaScript의 시간 관련 버그는 보통 “시계가 틀렸다”처럼 드라마틱하게 보이지 않습니다. 대신 미묘한 이동으로 나타납니다: 내 노트북에서는 맞는 날짜가 동료의 환경에서는 틀리게 보이거나, API 응답은 정상이었는데 다른 타임존에서 렌더링될 때 이상하게 보이거나, 계절별 시간 변경 주변에서 “하루 차이”가 나는 보고서 등이 그렇습니다.
보통 다음 중 하나(또는 복수)를 보게 됩니다:
+02:00)이 기대와 다릅니다.큰 문제의 원인은 시간(time) 이라는 단어가 서로 다른 개념을 가리킬 수 있기 때문입니다:
JavaScript의 내장 Date는 이들 모두를 다루려 하지만, 기본적으로 순간(instant) 을 표현하면서도 로컬 표시(local display) 쪽으로 자꾸 유도하기 때문에 의도치 않은 변환이 쉽게 일어납니다.
이 가이드는 실용적인 내용에 집중합니다: 브라우저와 서버 간에 예측 가능한 변환을 얻는 방법, ISO 8601 같은 더 안전한 포맷을 선택하는 방법, 초보자들이 자주 빠지는 함정(초 vs 밀리초, UTC vs 로컬, 파싱 차이)을 식별하는 법. 목표는 이론을 늘리는 것이 아니라 “왜 시간이 밀렸지?”라는 놀라움을 줄이는 것입니다.
JavaScript 시간 버그는 겉보기에는 호환되는 것처럼 보이는 표현들을 섞을 때 자주 시작됩니다.
1) 에포크 밀리초(숫자)
1735689600000 같은 단순 숫자는 보통 “1970-01-01T00:00:00Z 이후의 밀리초”를 의미합니다. 포맷이나 타임존이 붙지 않은 순간(instant) 을 나타냅니다.
2) Date 객체(순간을 감싼 래퍼)
Date는 타임스탬프와 같은 종류의 순간을 저장합니다. 혼란스러운 부분은 Date를 출력(print) 할 때 JavaScript가 환경의 로컬 규칙으로 포맷한다는 점입니다(특별히 지정하지 않으면).
3) 형식화된 문자열(사람이 읽는 표시)
"2025-01-01", "01/01/2025 10:00", "2025-01-01T00:00:00Z" 같은 문자열은 동일한 것이 아닙니다. 일부는 명확(ISO 8601에 Z 포함)하고, 일부는 로케일에 의존하거나 타임존을 포함하지 않습니다.
같은 순간도 타임존에 따라 다르게 보일 수 있습니다:
const instant = new Date("2025-01-01T00:00:00Z");
instant.toLocaleString("en-US", { timeZone: "UTC" });
// "1/1/2025, 12:00:00 AM"
instant.toLocaleString("en-US", { timeZone: "America/Los_Angeles" });
// "12/31/2024, 4:00:00 PM" (이전 날)
앱과 API 전반에서 하나의 내부 표현(보통 에포크 밀리초 또는 UTC ISO 8601)을 선택하고 일관되게 유지하세요. Date와 형식화된 문자열은 경계(입력 파싱과 UI 표시)에서만 변환하세요.
“타임스탬프”는 보통 에포크 시간(epoch time) 을 의미합니다: 1970-01-01 00:00:00 UTC 이후의 경과 시간. 문제는 시스템마다 단위를 다르게 사용한다는 점입니다.
JavaScript의 Date는 대부분의 혼란의 원인으로 밀리초를 사용합니다. 많은 API, DB, 로그는 초를 사용합니다.
17040672001704067200000같은 순간이지만 밀리초 버전은 세 자리 숫자가 더 붙습니다.
명시적으로 곱셈/나눗셈을 사용하면 단위가 분명합니다:
// seconds -> Date
const seconds = 1704067200;
const d1 = new Date(seconds * 1000);
// milliseconds -> Date
const ms = 1704067200000;
const d2 = new Date(ms);
// Date -> seconds
const secondsOut = Math.floor(d2.getTime() / 1000);
// Date -> milliseconds
const msOut = d2.getTime();
Date()에 바로 전달함다음 코드는 얼핏 괜찮아 보이지만 ts가 초 단위일 때는 틀립니다:
const ts = 1704067200; // seconds
const d = new Date(ts); // WRONG: treated as milliseconds
이 경우 결과는 1970년대의 날짜가 됩니다. 왜냐하면 1,704,067,200 밀리초는 에포크 이후 약 19일 정도이기 때문입니다.
단위를 모를 때 간단한 보호 장치를 추가하세요:
function asDateFromUnknownEpoch(x) {
// crude heuristic: seconds are ~1e9-1e10, milliseconds are ~1e12-1e13
if (x < 1e11) return new Date(x * 1000); // assume seconds
return new Date(x); // assume milliseconds
}
const input = Number(valueFromApi);
console.log({ input, digits: String(Math.trunc(input)).length });
console.log('as ISO:', asDateFromUnknownEpoch(input).toISOString());
“digits” 카운트가 ~10이면 초일 가능성이 높고, ~13이면 밀리초일 가능성이 큽니다. 디버깅할 때 toISOString()를 출력하면 단위 실수를 즉시 알아차리기 쉽습니다.
JavaScript의 Date는 단일 순간을 저장하지만 그 순간을 표현할 때 서로 다른 타임존으로 보여줄 수 있어서 혼란을 일으킵니다.
내부적으로 Date는 본질적으로 “유닉스 에포크(1970-01-01T00:00:00Z) 이후의 밀리초”입니다. 이 숫자는 UTC상의 순간을 나타냅니다. 문제가 생기는 건 JavaScript에게 그 순간을 로컬 시간(컴퓨터/서버 설정 기반)으로 포맷하라고 요청할 때와 UTC로 포맷하라고 요청할 때의 차이입니다.
많은 Date API는 로컬과 UTC 버전 둘 다 제공합니다. 같은 순간에 대해 다른 숫자를 반환합니다:
const d = new Date('2025-01-01T00:30:00Z');
d.getHours(); // hour in *local* time zone
d.getUTCHours(); // hour in UTC
d.toString(); // local time string
d.toISOString(); // UTC (always ends with Z)
만약 머신이 뉴욕(UTC-5)에 있다면 그 UTC 시간은 로컬에서는 이전 날 “19:30”으로 보일 수 있습니다. 서버가 UTC로 설정되어 있으면 “00:30”으로 보일 것입니다. 같은 순간, 다른 표시입니다.
로그는 종종 Date#toString()을 사용하거나 Date를 암묵적으로 보간(interpolate)해서 출력합니다. 이 함수는 환경의 로컬 타임존을 사용합니다. 그래서 동일한 코드가 노트북, CI, 프로덕션에서 다른 타임스탬프를 출력할 수 있습니다.
시간은 UTC로 저장하고 전송하세요(예: 에포크 밀리초 또는 ISO 8601의 toISOString() 사용). 표시할 때만 사용자 로케일로 변환하세요:
toISOString()을 사용하거나 에포크 밀리초 전달을 선호Intl.DateTimeFormat을 사용해 사용자의 타임존에 맞게 포맷빠르게 앱을 만들 때(예: Koder.ai 같은 도구로 생성하는 경우) API 계약에 이를 초기에 반영하세요: 필드 이름을 명확히(createdAtMs, createdAtIso) 하고 서버(Go + PostgreSQL)와 클라이언트(React)에 각 필드의 의미를 일관되게 유지하세요.
브라우저, 서버, DB 사이에서 날짜/시간을 주고받아야 한다면 ISO 8601 문자열이 기본적으로 가장 안전합니다. 명시적이고 널리 지원되며(가장 중요하게) 타임존 정보를 담습니다.
서로 교환하기 좋은 두 가지 포맷:
2025-03-04T12:30:00Z2025-03-04T12:30:00+02:00Z는 무엇을 의미하는가?
Z는 졸로(Zulu) 시간, 즉 UTC를 의미합니다. 따라서 2025-03-04T12:30:00Z는 “UTC 기준 12:30”입니다.
+02:00 같은 오프셋이 왜 중요한가?
오프셋은 이벤트가 로컬 타임존 문맥에 묶여 있을 때 중요합니다(예: 약속, 예약, 상점 영업시간). 2025-03-04T12:30:00+02:00은 UTC보다 두 시간 빠른 순간을 나타내며, 2025-03-04T12:30:00Z와는 다른 순간입니다.
03/04/2025 같은 문자열은 함정입니다: 3월 4일인지 4월 3일인지 불명확합니다. 다른 사용자와 환경이 다르게 해석할 수 있습니다. 2025-03-04(ISO 날짜)나 전체 ISO 데이트타임을 선호하세요.
const iso = "2025-03-04T12:30:00Z";
const d = new Date(iso);
const back = d.toISOString();
console.log(iso); // 2025-03-04T12:30:00Z
console.log(back); // 2025-03-04T12:30:00.000Z
이런 “라운드트립” 동작은 API에 대해 일관되고 예측 가능하며 타임존을 인식하게 해 줍니다.
Date.parse()는 편리해 보입니다: 문자열을 넘기면 타임스탬프가 나옵니다. 문제는 ISO 8601이 아닌 경우 파싱이 브라우저의 휴리스틱에 의존한다는 점입니다. 이 휴리스틱은 엔진과 버전마다 달라 같은 입력이 환경에 따라 다르게 파싱되거나 전혀 파싱되지 않을 수 있습니다.
Date.parse()가 달라질 수 있는가JavaScript는 ISO 8601 스타일 문자열에 대해서만 비교적 안정적인 파싱을 표준화했습니다(그럼에도 타임존에 대한 디테일은 중요). “친근한” 형식들—예: "03/04/2025", "March 4, 2025", "2025-3-4"—은 브라우저가 다음을 기준으로 해석할 수 있습니다:
입력 문자열의 정확한 형태를 예측할 수 없다면 결과를 예측할 수 없습니다.
YYYY-MM-DD일반적인 함정은 "YYYY-MM-DD" 형태(예: "2025-01-15")입니다. 많은 개발자는 이것이 로컬 자정(local midnight) 으로 해석될 것이라 기대합니다. 실제로는 어떤 환경에서는 이 형식을 UTC 자정으로 처리하기도 합니다.
그 차이는 중요합니다: UTC 자정이 로컬 시간으로 변환되면 음의 타임존(예: 미주 지역)에서는 이전 날이 될 수 있고, 시간이 예기치 않게 이동할 수 있습니다. 이것은 “날짜가 하루 차이나는” 버그의 흔한 원인입니다.
서버/API 입력의 경우:
2025-01-15T13:45:00Z 또는 2025-01-15T13:45:00+02:00)을 선호합니다."YYYY-MM-DD")로 보관하고 의도한 타임존을 정의하지 않는 한 Date로 변환하지 마세요.사용자 입력의 경우:
03/04/2025 같은 모호한 형식은 수용하지 마세요.Date.parse()에 맡기지 말고 다음 중 하나를 선택하세요:
new Date(year, monthIndex, day) 사용).시간 데이터가 중요한 경우 “내 머신에서는 파싱된다”는 말은 충분하지 않습니다—파싱 규칙을 명시적이고 일관되게 만드세요.
사람들이 기대하는 방식으로 날짜/시간을 보여주려면 JavaScript에서 가장 좋은 도구는 Intl.DateTimeFormat입니다. 이 API는 사용자의 로케일 규칙(순서, 구분자, 월 이름)을 사용하고 month + '/' + day처럼 brittle한 수동 문자열 조립을 피하게 해 줍니다.
수동 포맷은 종종 미국식 출력에 하드코딩되거나, 앞자리 0을 잊거나, 24/12시간제를 혼동합니다. Intl.DateTimeFormat은 또한 표시할 타임존을 명시적으로 지정할 수 있어서, 데이터는 UTC로 저장되어 있지만 UI는 사용자의 로컬 시간을 반영해야 할 때 중요합니다.
“그냥 예쁘게 포맷”하려면 dateStyle와 timeStyle이 가장 간단합니다:
const d = new Date('2025-01-05T16:30:00Z');
// User’s locale + user’s local time zone
console.log(new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(d));
// Force a specific time zone (great for event times)
console.log(new Intl.DateTimeFormat('en-GB', {
dateStyle: 'full',
timeStyle: 'short',
timeZone: 'UTC'
}).format(d));
일관된 시간 표시(예: 설정에서 12시간/24시간 전환)를 원하면 hour12를 사용하세요:
console.log(new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
}).format(d));
UI에서 타임스탬프의 “유형”마다 하나의 포맷 함수를 정하고 timeZone 결정을 의도적으로 관리하세요(메시지 시간, 로그 항목, 이벤트 시작 등):
이렇게 하면 커스텀 포맷 문자열을 깨지기 쉬운 방식으로 관리하지 않고도 일관되게 로케일 친화적인 출력을 얻을 수 있습니다.
서머타임(DST)은 특정 날짜에 타임존의 UTC 오프셋을(보통 한 시간) 변경하는 것입니다. 까다로운 점은 DST가 단순히 오프셋만 바꾸는 것이 아니라 특정 로컬 시간이 존재하지 않거나 두 번 존재하게 만든다는 것입니다.
시계가 앞으로 건너뛰는(spring forward) 경우, 일부 로컬 시간이 존재하지 않습니다. 예를 들어 많은 지역에서 시계가 01:59에서 03:00으로 건너뛰므로 02:30 로컬 시간은 "존재하지 않는다" 가 됩니다.
시계가 뒤로 가는(fall back) 경우, 일부 로컬 시간이 두 번 일어납니다. 예를 들어 01:30은 전환 전과 후에 각각 한 번씩 발생할 수 있어 같은 벽시계 시간이 두 개의 다른 순간을 가리킬 수 있습니다.
DST 경계에서는 이 둘이 동일하지 않습니다:
만약 오늘 밤 DST가 시작되면 “내일 같은 로컬 시간”은 23시간 뒤일 수 있습니다. DST가 끝나면 25시간 뒤일 수 있습니다.
// Scenario: schedule “same local time tomorrow”
const d = new Date(2025, 2, 8, 9, 0); // Mar 8, 9:00 local
const plus24h = new Date(d.getTime() + 24 * 60 * 60 * 1000);
const nextDaySameLocal = new Date(d);
nextDaySameLocal.setDate(d.getDate() + 1);
// Around DST, plus24h and nextDaySameLocal can differ by 1 hour.
setHours가 놀라운 이유date.setHours(2, 30, 0, 0) 같은 작업을 서머타임이 시작되는 날에 하면 JavaScript는 해당 시간을 존재하는 유효한 시간으로 정규화할 수 있습니다(대개 03:30으로). 02:30이 존재하지 않기 때문입니다.
setDate)을 사용해 처리하세요.Z가 있는 ISO 8601을 선호해 순간을 명확히 하세요.흔한 버그 원인은 달력의 시점이 아닌 것을 표현할 때 Date를 사용하는 것입니다.
타임스탬프는 “이 일이 언제 일어났나?”를 답합니다(특정 순간, 예: 2025-12-23T10:00:00Z). 기간(duration) 은 “얼마나 오래?”(예: “3분 12초”)를 답합니다. 이 둘은 다른 개념이며 섞이면 혼란스러운 계산과 예기치 않은 타임존/DST 효과가 발생합니다.
Date가 기간에 부적합한 이유Date는 항상 에포크를 기준으로 한 시간의 한 점을 나타냅니다. “90초”를 Date로 저장하면 실제로는 “1970-01-01에 90초를 더한 시점”을 저장합니다. 포맷하면 01:01:30처럼 보이거나 한 시간씩 이동하거나 의도하지 않은 날짜를 포함할 수 있습니다.
기간에는 단순 숫자를 선호하세요:
HH:mm:ss로 변환카운트다운 타이머나 미디어 길이에는 다음 같은 간단한 포매터가 좋습니다:
function formatHMS(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const hh = String(Math.floor(s / 3600)).padStart(2, "0");
const mm = String(Math.floor((s % 3600) / 60)).padStart(2, "0");
const ss = String(s % 60).padStart(2, "0");
return `${hh}:${mm}:${ss}`;
}
formatHMS(75); // "00:01:15" (countdown timer)
formatHMS(5423); // "01:30:23" (media duration)
분 단위에서 변환할 경우 먼저 곱하세요(minutes * 60) 그리고 렌더링할 때까지 값은 숫자로 유지하세요.
시간을 비교할 때는 포맷된 텍스트가 아니라 숫자를 비교하는 것이 가장 안전합니다. Date 객체는 본질적으로 에포크 밀리초라는 숫자 래퍼이므로 비교는 결국 “숫자 vs 숫자”가 되게 하세요.
신뢰성 있게 비교하려면 getTime()(또는 같은 값을 반환하는 Date.valueOf())를 사용하세요:
const a = new Date('2025-01-10T12:00:00Z');
const b = new Date('2025-01-10T12:00:01Z');
if (a.getTime() < b.getTime()) {
// a is earlier
}
// Also works:
if (+a < +b) {
// unary + calls valueOf()
}
포맷된 문자열("1/10/2025, 12:00 PM")을 비교하는 것은 피하세요—이들은 로케일에 의존하며 올바르게 정렬되지 않습니다. 예외는 동일한 형식과 타임존(예: 모두 ...Z)인 ISO 8601 문자열로 모두 표현된 경우입니다. 그 경우는 사전식(lexicographic)으로 정렬 가능합니다.
에포크 밀리초로 정렬하면 간단합니다:
items.sort((x, y) => new Date(x.createdAt).getTime() - new Date(y.createdAt).getTime());
범위로 필터링하는 것도 동일한 아이디어입니다:
const start = new Date('2025-01-01T00:00:00Z').getTime();
const end = new Date('2025-02-01T00:00:00Z').getTime();
const inRange = items.filter(i => {
const t = new Date(i.createdAt).getTime();
return t >= start && t < end;
});
“하루의 시작”은 로컬 시간인지 UTC인지에 따라 달라집니다:
// Local start/end of day
const d = new Date(2025, 0, 10); // Jan 10 in local time
const localStart = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
const localEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
// UTC start/end of day
const utcStart = new Date(Date.UTC(2025, 0, 10, 0, 0, 0, 0));
const utcEnd = new Date(Date.UTC(2025, 0, 10, 23, 59, 59, 999));
초기에 하나의 정의를 정하고 비교와 범위 로직 전체에 일관되게 적용하세요.
시간 버그는 무엇(타입: 타임스탬프? 문자열? Date?)이 있는지와 어디서(파싱, 타임존 변환, 포맷) 이동이 발생했는지를 확인하기 전까지는 랜덤처럼 느껴집니다.
먼저 같은 값을 세 가지 방법으로 로깅하세요. 이는 단위 문제인지, 로컬 vs UTC 문제인지, 문자열 파싱 문제인지 빠르게 드러냅니다.
console.log('raw input:', input);
const d = new Date(input);
console.log('toISOString (UTC):', d.toISOString());
console.log('toString (local):', d.toString());
console.log('timezone offset (min):', d.getTimezoneOffset());
찾아야 할 것들:
toISOString()가 크게 어긋나 있으면(예: 1970년대 혹은 먼 미래) 초 vs 밀리초를 의심하세요.toISOString()는 맞아 보이는데 toString()이 이동했다면 로컬 타임존 표시 문제입니다.getTimezoneOffset()이 날짜에 따라 바뀌면 서머타임 경계를 넘고 있는 것입니다.많은 “내 머신에서는 되는데” 문제는 단순히 환경 기본값이 다른 경우입니다.
console.log(Intl.DateTimeFormat().resolvedOptions());
console.log('TZ:', process.env.TZ);
console.log(Intl.DateTimeFormat().resolvedOptions().timeZone);
서버가 UTC로 실행되고 노트북이 로컬 존이면, timeZone을 명시적으로 지정하지 않으면 포맷된 출력이 달라집니다.
DST 경계 및 엣지 타임에 대한 유닛 테스트를 만드세요:
23:30 → 00:30 교차빠르게 반복한다면 이런 테스트를 스캐폴딩의 일부로 포함하세요. 예를 들어 React + Go 앱을 생성할 때 작고 명확한 “시간 계약(time contract)” 테스트 스위트를 미리 추가하면 배포 전에 회귀를 잡을 수 있습니다.
"2025-03-02 10:00" 같은 모호한 문자열은 없음.locale과 (필요시) timeZone을 명시.JavaScript에서 신뢰할 수 있는 시간 처리는 대체로 “진실 소스(source of truth)”를 정하고 저장에서 표시까지 일관성을 유지하는 것입니다.
UTC로 저장하고 계산하세요. 사용자에게 보이는 로컬 시간은 표현(presentation) 세부사항으로 취급하세요.
시스템 간 전송은 명시적 오프셋이 있는 ISO 8601 문자열(가능하면 Z)을 사용하세요. 숫자 에포크를 써야 하면 단위를 문서화하고 일관되게 유지하세요(JS에서는 밀리초가 일반적 기본값).
사람에게 보여줄 때는 Intl.DateTimeFormat(또는 toLocaleString)을 사용하고 결정론적 출력을 원하면 timeZone을 명시적으로 전달하세요(예: 항상 UTC로 보이게 하거나 특정 비즈니스 지역을 고정).
Z가 있는 ISO 8601을 선호(예: 2025-12-23T10:15:00Z). 에포크를 쓴다면 createdAtMs처럼 필드 이름으로 단위를 명확히 하라.반복 이벤트, 복잡한 타임존 규칙, DST에 안전한 산술(“내일 같은 로컬 시간”), 또는 불일치한 입력을 많이 파싱해야 하는 경우 전용 날짜-시간 라이브러리를 고려하세요. 장점은 더 명확한 API와 엣지 케이스 버그 감소입니다.
더 깊이 들어가려면 /blog에서 시간 관련 가이드를 더 살펴보세요. 도구 선택이나 지원 옵션을 평가 중이면 /pricing을 참조하세요.