
정확한 시각에서 읽기 좋은 시각으로
UTC로 고정해 보여주던 이벤트 시각을 사용자의 시간대로 바꾼 경험에서 시작해, 시간 값의 종류 구분과 Temporal로 세우는 저장·표시 기준까지 정리했습니다.
글로벌 사용자를 상대하는 서비스에서 이벤트 페이지를 맡은 적이 있습니다. 이벤트 시작 시각은 DB에 UTC로 저장했고, 화면에도 그 값을 그대로 10월 24일 00:00 (UTC)처럼 적었습니다. 기준이 명확한 표기라 운영에 문제는 없었어요.
다만 그 표기를 읽는 쪽을 생각하면 아쉬움이 남았습니다. 미국 서부 사용자에게 10월 24일 자정 UTC는 10월 23일 오후 5시입니다. 화면의 날짜와 자기 달력의 날짜가 하루 다르니, UTC 표기를 자기 시간대로 환산하는 일이 사용자 몫으로 남아 있던 구조였고요. 정확한 값이었지만 각자의 나라에서 바로 읽히는 값은 아니어서, 사용자의 시간대에 맞춘 시각으로 보여주고 싶어졌습니다.
개선은 간단해 보였습니다. 화면마다 toLocaleString을 붙이면 되는 일 같았거든요. 그런데 이 접근은 화면이 늘 때마다 같은 변환을 다시 적게 만들고, 한 곳이라도 빠지면 그 화면만 다른 기준으로 남습니다. 서버가 만든 문자열을 클라이언트가 new Date()로 다시 읽는 자리마다 해석이 갈리는 문제도 함께 따라옵니다.
정리하고 보니 필요한 것은 변환 함수가 아니었습니다. 값이 절대적인 순간인지, 달력 날짜인지, 시간대가 붙은 벽시계 시각(wall-clock time)인지 종류를 구분하는 일과, 누구의 시간대로 보여줄지를 정하는 일이 먼저였습니다. 이 글에서는 그 구분을 타입으로 강제하는 Temporal 위에 저장과 표시의 기준을 다시 세운 과정을, 재구성한 이벤트 페이지 예제로 따라갑니다.
예제의 범위
예제는 특정 서비스의 코드가 아니라 같은 구조를 다시 만든 것입니다. Temporal은 2026년 3월 TC39 Stage 4에 도달한 표준 API로 ECMA-262 연례판 수록 예상 시점은 2027년이며, 환경별 지원과 폴리필은 마지막 절에서 다룹니다.
환산은 누구의 몫인가
저장을 UTC로 통일하는 원칙 자체는 유효합니다. 결정할 것은 그 값을 화면에 올리기 전에 누가 환산하느냐입니다. 같은 순간이라도 그 시간대의 시계가 가리키는 값, 곧 벽시계 시각은 시간대마다 다르고 자정 근처에서는 날짜까지 갈라집니다. UTC를 그대로 적은 화면에서는 이 환산을 사용자가 직접 합니다.
이 환산을 Date로 구현하려 하면 다른 문제가 나타납니다. Date는 시간대 이름을 보존하지 않고 하나의 순간만 저장하며, 문자열을 읽을 때는 형태에 따라 UTC와 명시된 오프셋, 실행 환경의 로컬 시간대를 서로 다르게 적용합니다.
// 실행 환경이 Asia/Seoul일 때
new Date('2026-10-24').getDate(); // 24 — 날짜만 있으면 UTC 자정으로 파싱
new Date('2026-10-24T00:00').getDate(); // 24 — 시각이 붙으면 로컬 자정으로 파싱
// 같은 두 줄이 America/Los_Angeles에서는 23과 24를 돌려준다날짜만 적힌 문자열은 UTC로, 시각이 붙은 문자열은 로컬로 읽는 이 규칙은 ECMAScript 명세에 있는 동작입니다. 그래서 하루가 사라지는 버그는 코드가 아니라 코드가 실행된 시간대에 따라 나타났다 사라집니다. 여기에 월만 0부터 세는 생성자, 원본을 바꾸는 setMonth() 같은 성질이 더해져서, 화면마다 변환을 붙이는 접근은 환산의 부담을 사용자에게서 코드 곳곳으로 옮길 뿐 줄이지 못했습니다.
순간과 날짜와 벽시계는 서로 다른 값이다
화면마다 붙이는 접근을 멈추고 서비스가 다루는 시간 값을 전부 늘어놓아 봤습니다. 코드에서는 모두 같은 Date와 ISO 문자열이었지만 뜻은 세 갈래였고, Temporal은 그 갈래마다 다른 타입을 둡니다.
| 값이 뜻하는 것 | 예 | Temporal 타입 | 저장 형식 |
|---|---|---|---|
| 정확히 같은 순간 | 구매 시각, 글로벌 생중계 시작 | Temporal.Instant | 2026-10-24T00:00:00Z |
| 시각 없는 달력 날짜 | 생일, 정산 기준일 | Temporal.PlainDate | 2026-10-24 |
| 시간대가 붙은 벽시계 시각 | 이벤트 시작, 예약 | Temporal.ZonedDateTime | 2026-10-24T09:00:00+09:00[Asia/Seoul] |
구분의 핵심은 각 타입이 담지 않는 정보입니다. Temporal.Instant에는 시간대와 달력이 없어 요일이나 벽시계 시각을 꺼낼 수 없고, Temporal.PlainDate에는 시각이 없어 순간으로 읽힐 여지가 없습니다. 물론 문자열 경계에서 잘못 분류하면 잘못된 타입이 그대로 만들어지므로 만능은 아닙니다. 이 구분이 하는 일은 오분류의 자동 차단이 아니라, 타입 사이를 건널 때마다 필요한 변환을 코드에 드러내는 것입니다.
타입에 따라 산술의 뜻도 달라집니다. 시간대가 붙은 값에서는 하루를 더하는 것과 24시간을 더하는 것이 서머타임(DST, daylight saving time) 경계에서 서로 다른 답이 됩니다.
const zdt = Temporal.ZonedDateTime.from('2026-03-08T00:00-08:00[America/Los_Angeles]');
zdt.add({ days: 1 }); // 2026-03-09T00:00:00-07:00 — 다음 날의 같은 벽시계 시각
zdt.add({ hours: 24 }); // 2026-03-09T01:00:00-07:00 — 경과 시간 24시간이날 로스앤젤레스는 서머타임이 시작되어 하루가 23시간입니다. days: 1은 다음 날의 같은 벽시계 시각을, hours: 24는 경과 시간 24시간을 지킵니다. 둘 다 맞는 답이고 유지하려는 것이 다를 뿐인데, Temporal에서는 그 선택이 단위 이름으로 코드에 남습니다. 밀리초 덧셈으로 하루를 표현하던 Date에서는 이 선택 자체가 드러나지 않습니다.
절대 순간과 벽시계 약속은 다르게 저장한다
표시를 고치려던 일이 저장까지 내려간 이유가 여기 있습니다. 값의 종류가 정해지면 저장 형식이 따라옵니다. 정확히 같은 순간을 뜻하는 값은 Temporal.Instant로 충분합니다. 구매가 결제된 순간처럼 이미 일어난 기록이 대표적이고, 전 세계가 동시에 맞이하는 글로벌 생중계 시작처럼 미래여도 순간 자체가 확정된 값이면 같습니다. 순간은 어느 시간대에서 보든 같고 시간대 규칙이 나중에 바뀌어도 변하지 않으므로, 저장은 UTC라는 원칙이 그대로 맞는 영역입니다.
지역의 벽시계로 약속한 값은 사정이 다릅니다. 10월 24일 오전 9시처럼 특정 시간대의 벽시계 시각으로 공지한 이벤트가 그렇습니다. 시간대 규칙은 실제로 바뀝니다. 칠레는 2015년 서머타임을 상시 적용하기로 해 표준 오프셋이 바뀌었다가 2016년 계절제로 되돌아갔고, 브라질은 2019년 서머타임을 폐지했습니다. 벽시계 약속을 UTC로 환산해 저장해 두면 그 사이 규칙이 바뀌었을 때 복원되는 벽시계 시각이 공지와 어긋나므로, 공지를 지키려면 벽시계 시각과 시간대 이름을 함께 저장하는 수밖에 없습니다.
이때 시간대는 +09:00 같은 오프셋이 아니라 IANA 시간대 데이터베이스의 이름(Asia/Seoul)으로 적습니다. 오프셋은 규칙이 바뀌면 함께 달라지는 결과값이라서 미래 시각의 기준이 되지 못합니다. Temporal.ZonedDateTime의 문자열 표현이 정확히 이 조합입니다.
const opensAt = Temporal.ZonedDateTime.from('2026-10-24T09:00:00+09:00[Asia/Seoul]');
opensAt.toString(); // '2026-10-24T09:00:00+09:00[Asia/Seoul]'
// 대괄호의 시간대 이름이 없으면 파싱이 거부된다
Temporal.ZonedDateTime.from('2026-10-24T00:00:00Z'); // RangeError이 표기는 RFC 9557로 표준화된 교환 형식이라 API 응답과 영속화 형식으로 쓸 수 있습니다. 오프셋은 시간대 이름과 함께 남아 대조값 역할을 하는데, 저장 후 규칙이 바뀌어 둘이 충돌하면 from()은 기본값 'reject'대로 파싱을 거부합니다. 처리 방침을 정했다면 offset 옵션으로 고릅니다 — 'ignore'는 벽시계 시각을, 'use'는 원래의 순간을 지킵니다. 규칙이 바뀐 뒤의 선택이 암묵적 동작이 아니라 파라미터로 드러나 있습니다.
남은 갈래는 시각이 없는 값입니다. 생일이나 정산 기준일이 가리키는 것은 시간축 위의 한 점이 아니라 달력의 하루 전체라서 몇 시냐는 질문이 성립하지 않고, 어느 시간대에서 읽어도 같은 날짜여야 합니다. 이 조건에 맞으면 Temporal.PlainDate입니다. 반대로 10월 24일 자정 마감은 어느 시간대의 자정인지가 뜻을 정하니, 날짜처럼 보여도 벽시계 시각의 영역입니다.
날짜만 필요한 값은 자정의 순간이 아니라 달력 날짜
생일이나 정산 기준일을 2026-10-24T00:00:00Z처럼 자정의 순간으로 저장하면 시간대 변환이 끼어드는 순간 하루가 밀립니다. 날짜만 뜻하는 값은 2026-10-24 그대로 저장하고 그대로 표시합니다.
누구의 시간대로 보여줄 것인가
저장 형식이 정해져도 표시 시간대는 별개의 결정으로 남습니다. 그리고 이 결정은 기술보다 제품의 문제입니다. 항공권의 출발 시각은 사용자가 어디에 있든 출발지 공항의 시각이고, 한 나라 안에서 함께 쓰는 운영 도구는 업무 시간대로 고정하는 쪽이 혼선을 줄이며, 전 세계 사용자가 같은 순간에 참여하는 이벤트라면 각자의 시간대로 환산해 보여주는 쪽이 자연스럽습니다. 셋의 공통점은 표시 시간대를 실행 환경의 기본값에 맡기지 않고 명시한다는 것입니다.
이벤트 페이지는 세 번째 경우였습니다. 표시에 쓸 시간대는 계정 설정을 우선했습니다. 브라우저에서 읽는 시간대는 기기 설정을 그대로 반영해서, 여행 중에 기기 시간대가 현지로 바뀌면 화면의 시각도 함께 바뀝니다. 그래서 사용자가 계정에 시간대를 골라 두면 그 값으로 보여주고, 고르지 않았으면 브라우저에서 읽은 값을 폴백으로 썼어요.
// 계정 설정의 시간대가 있으면 우선하고, 없으면 기기의 시간대로
const userTimeZone = profile.timeZone ?? Temporal.Now.timeZoneId(); // 예: 'America/Los_Angeles'
const opensAt = Temporal.ZonedDateTime.from('2026-10-24T09:00:00+09:00[Asia/Seoul]');
const local = opensAt.withTimeZone(userTimeZone);
local.toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short' });
// 'October 23, 2026 at 5:00 PM'변환의 마지막 자리는 화면 직전입니다. .toPlainDateTime()처럼 시간대를 떼어낸 값에는 이후 산술에서 서머타임 보정이 적용되지 않으므로, 계산은 시간대가 붙은 타입으로 끝내고 떼어내는 것은 표시 직전으로 미룹니다.
서버가 HTML을 만들어 보내는 화면에서는 변환 위치가 더 중요합니다. 계정 설정을 조회하면 서버도 표시 시간대를 얻을 수 있지만, 우리 화면은 여러 사용자가 공유하는 HTML 캐시를 쓰고 있어서 서버 렌더링에 로컬 시각을 새기면 캐시가 시간대 수만큼 갈라집니다. 폴백인 기기 시간대는 클라이언트에서만 읽을 수 있습니다. 그래서 경계를 이렇게 그었습니다.
이벤트 시각에서 시작한 변환은 다른 시각으로도 이어졌습니다. 구매한 시각이나 아이템을 받은 시각 같은 지나간 순간의 기록도 같은 방식으로 사용자의 시간대에 맞춰 보여주게 됐어요. 이쪽은 Instant로 저장된 값이라 표시 직전의 변환만 얹으면 충분했고, 공지 글처럼 사용자마다 바꿀 수 없는 자리에는 기준 시간대의 벽시계 시각과 시간대 이름을 함께 적는 것으로 정리했습니다.
시간대를 바꿔 가며 같은 테스트를 돌린다
이 기준이 계속 지켜지는지는 리뷰만으로 보기 어렵습니다. 시간 코드의 버그는 작성자의 시간대에서 재현되지 않는 경우가 많습니다. 서울에서 개발하고 서울에서 확인하면, UTC와 아홉 시간 어긋나는 버그는 CI나 해외 사용자의 화면에서야 나타납니다.
그래서 같은 테스트를 시간대만 바꿔 두 번 돌립니다.
{
"scripts": {
"test": "vitest run",
"test:timezones": "TZ=UTC vitest run && TZ=America/New_York vitest run"
}
}이 두 번의 실행은 실행 환경의 시간대에 기대는 코드를 찾는 보조 장치입니다. 테스트가 자정을 낀 날짜 계산이나 서머타임 경계 같은 경로를 실제로 지날 때, 오프셋 없는 파싱과 암묵적 로컬 포매팅처럼 숨어 있던 의존이 한쪽 실행의 실패로 드러납니다.
현재 시각을 읽는 자리를 좁혀 두면 나머지가 쉬워집니다. Temporal.Now를 곳곳에서 직접 부르는 대신 현재 시각을 인자로 받거나 모듈 한곳에 모아 두면, 테스트가 고정된 시각을 넘겨 서머타임 전환일 같은 특정 순간을 그대로 재현합니다. 먼저 넣을 경계 사례는 날짜가 갈리는 자정 근처와 서머타임 전환일입니다.
지금 도입한다면
Temporal은 2026년 3월 TC39 Stage 4에 도달했습니다. ECMA-262 연례판 수록 예상 시점은 2027년이고 런타임 구현은 그보다 먼저 진행되고 있어서, 2026년 8월 기준 지원은 이렇습니다.
| 환경 | 지원 | 시점 |
|---|---|---|
| Firefox | 139부터 기본 지원 | 2025년 5월 |
| Chrome · Edge | 144부터 기본 지원 | 2026년 1월 |
| Safari | 미지원 — Technology Preview 249에 진입 | 2026년 7월 |
| Node.js | 26부터 기본 지원 | 2026년 5월 |
| Deno | 2.7부터 기본 지원 | 2026년 2월 |
Safari가 남아 있는 동안 브라우저 번들에는 폴리필이 필요합니다. 제안 챔피언 일부가 시작한 @js-temporal/polyfill은 gzip 기준 약 45kB에 전역을 건드리지 않는 명시적 import 방식이고, 경량 구현인 temporal-polyfill은 약 20kB에 전역 설치용 /global 진입점까지 제공해서 프론트엔드 기본값으로는 후자가 무난합니다. Node.js 26 이상만 상대하는 서버 코드는 폴리필 없이 전역 Temporal을 그대로 씁니다. 타입은 런타임과 별개라서, TypeScript 버전과 폴리필 구성에 따라 Temporal 타입 설정이 따로 필요할 수 있습니다.
기존 코드와의 상호 변환도 표준에 함께 들어왔습니다. Date 값은 toTemporalInstant()로 Temporal.Instant가 되고, 반대 방향은 epochMilliseconds를 Date 생성자에 넘기면 됩니다. 그래서 전면 교체 없이 시간대가 얽힌 화면부터 옮길 수 있습니다.
적용 전 체크리스트
- 값의 종류 구분 — 절대 순간인지, 달력 날짜인지, 시간대가 붙은 벽시계 시각인지
- 정확한 순간은
Instant, 지역 벽시계 약속은 벽시계 시각과 IANA 시간대 이름, 날짜만인 값은PlainDate - 표시 시간대 결정을 한곳에 — 사용자 시간대(계정 설정 우선, 기기 폴백)인지, 기준 시간대 고정인지
- 산술은 시간대가 붙은 타입으로, 시간대를 떼는 변환은 표시 직전에만
- 같은 테스트를
TZ만 바꿔 두 번 이상 실행 — 서머타임 전환일과 자정 경계 포함 - Safari 포함 시 폴리필 동봉, Node.js 26 이상 서버는 네이티브 사용
마치며
돌아보면 UTC 고정 표시가 틀렸던 적은 없습니다. 바꾸고 싶었던 것은 정확성이 아니라 환산의 부담이 어디에 있느냐였고, 그러자면 값의 종류를 가르는 일과 표시 시간대를 정하는 일이 먼저였어요. 이 둘을 정하고 나니 Temporal은 새로 배우는 API라기보다 이미 내린 결정을 타입 이름으로 적어 두는 도구에 가까웠습니다. 이벤트 시각을 읽기 좋게 만들고 싶어서 시작한 일이, 구매 시각과 아이템을 받은 시각까지 사용자의 시간대로 보여주는 기준이 되어 끝났습니다.
참고 자료
- Temporal documentation — TC39 — 타입 구분과 산술, 시간대 처리의 기준 문서
- Temporal — MDN — 각 타입의 API 레퍼런스와 브라우저별 구현 상태
- Time Zones and Resolving Ambiguity — TC39 Temporal docs — 서머타임 경계의 모호성과 offset·disambiguation 옵션의 근거
- RFC 9557: Date and Time on the Internet — IETF — 시간대 이름을 담는 확장 타임스탬프 표기의 규격
- How to save datetimes for future events — Creative Deletion — 미래 시각을 UTC로 환산해 저장할 때 생기는 문제의 원전
- Temporal — web-features explorer — 브라우저별 지원 버전과 시점
- temporal-polyfill — GitHub — 경량 폴리필의 크기와 도입 방법