본문 바로가기

프로그래밍

JavaScript Temporal API 실전: 타임존·캘린더 안전 날짜 연산 패턴 정리와 초보자 실무 가이드

반응형

왜 Temporal API가 지금 필요한가

 

자바스크립트의 Date로는 타임존, 서머타임(DST), 음력·ISO 달력 차이를 안전하게 다루기 어렵습니다. 실무에서는 같은 시각이 지역마다 다른 시간으로 보이거나, 월말 연산이 엇나가는 문제가 자주 발생합니다.

 

Temporal API는 “시각(Instant)”, “타임존 있는 날짜시간(ZonedDateTime)”, “달력 기준 날짜(PlainDate)”처럼 역할이 분리된 타입으로 혼동을 줄입니다. 초보자도 의도를 드러낸 타입을 선택하면, 변환 실수 없이 읽고 수정하기 쉬운 코드를 만들 수 있습니다.

 

이 글은 타임존·캘린더에 안전한 날짜 연산 패턴을 실무 예시로 정리합니다. 복잡한 내부 이론보다, “예약 마감 시각 계산”, “급여 월말 산정”, “사용자 현지 시각 표시” 같은 상황별 접근법을 간단한 코드로 안내합니다.

 

> 안티패턴: 문자열로 시간 계산(예: ‘+0900’ 더하기)이나 Date를 타임존 변환 없이 재활용하는 방식은 DST 전환일에 오류를 만들 수 있습니다.

 

타입을 나눠 시간 오류 줄이기

 

시간은 ‘시각(Instant)’, ‘지역(ZonedDateTime)’, ‘날짜 규칙(PlainDate)’이 섞이면 헷갈립니다. Temporal은 이 셋을 분리해 안전하게 다룹니다.

 

Instant는 UTC 기준의 한 순간입니다. 결제 승인처럼 전 세계에서 같은 값이 필요한 저장에 씁니다.

 

ZonedDateTime은 타임존이 붙은 지역 시각입니다. 서울 9시 회의처럼 사용자에게 보이는 시간에 맞습니다.

 

PlainDate는 연·월·일만 있는 달력 날짜입니다. 급여 월말, 갱신 주기처럼 시간대 영향을 빼고 계산할 때 적합합니다.

 

> 안티패턴: ZonedDateTime에 바로 +1 month를 하면 DST 전환에 시각이 어긋날 수 있습니다. 월말 같은 연산은 PlainDate로 계산하세요.

 

- 저장: Instant로 기록해 비교·정렬 단순화
- 표시: 사용자 타임존의 ZonedDateTime으로 변환
- 계산: 월/분기/주차는 PlainDate/PlainMonthDay로 처리

 

아래 예시는 예약 마감을 세 축으로 나눕니다.

 

// 1) 서버 저장: 전역 일관성
const placedAt = Temporal.Now.instant(); // 주문 생성 UTC 시각

// 2) 마감 날짜 계산: 시간대 영향 제거
const placedDate = placedAt.toZonedDateTimeISO('Asia/Seoul').toPlainDate();
const dueDate = placedDate.add({ days: 3 }); // 3일 후 (달력 기준)

// 3) 사용자 표시: 지역 시각으로 렌더링
const dueZdt = dueDate.toZonedDateTime({ timeZone: 'Asia/Seoul', plainTime: '23:59' });
const label = dueZdt.toLocaleString('ko-KR');

 

정리: 저장은 Instant, 계산은 PlainDate, 표시는 ZonedDateTime으로 나누면 DST·타임존 경계 오류를 줄일 수 있습니다.

 

Temporal 날짜·시간 빠른 점검표

 

날짜·시간은 저장·계산·표시를 나누면 오류가 줄어듭니다. 역할을 분리하면 타임존과 월말 같은 까다로운 케이스를 놓치지 않습니다.

 

- 저장: 서버에서 Instant.now()로 UTC 시각 저장
- 계산: 달력 규칙은 PlainDate/PlainYearMonth로 연산(달·일 계산 전용 타입)
- 표시: 사용자 타임존으로 ZonedDateTime 변환 후 렌더(지역 시간)
- 변환: Instant ↔ ZonedDateTime ↔ PlainDate는 목적별 단방향 사용
- 검증: DST 경계, 월말(28·29·30·31), 윤년 케이스를 샘플 테스트

 

예약 마감, 급여 산정처럼 규칙이 반복되면 함수를 만들어 재사용하세요. 아래는 “사용자 지역 기준 월말 23:59 마감 → 서버는 UTC로 저장” 예시입니다.

 

// 목적: 사용자 지역 '이번 달 말 23:59'를 만들고 서버 저장용 UTC Instant로 변환
import { Temporal } from 'temporal-polyfill';

function monthEndDeadline(zone: string) {
  const nowZdt = Temporal.Now.zonedDateTimeISO(zone);
  const ym = Temporal.PlainYearMonth.from(nowZdt);
  const lastDate = ym.atEndOfMonth(); // PlainDate
  const localDeadline = lastDate.toPlainDateTime({ hour: 23, minute: 59 });
  const zdt = localDeadline.toZonedDateTime(zone); // ZonedDateTime
  return zdt.toInstant(); // 서버 저장: Instant
}

// 사용 예
const deadlineInstant = monthEndDeadline('Asia/Seoul');
// 표시용 변환
const viewTime = deadlineInstant.toZonedDateTimeISO('America/Los_Angeles');

 

핵심은 달력 계산은 Plain 계열에서 끝내고, 마지막에만 타임존/UTC로 바꾸는 것입니다. 테스트는 최소 두 타임존과 DST 전환 주간을 포함하세요.

 

> 팁: 폼이 ‘날짜만’ 입력이면 PlainDate로 받고, 제출 직전에 사용자 타임존을 붙여 ZonedDateTime으로 만든 뒤 Instant로 저장하세요.

 

흔한 실수와 안전한 선택 기준

 

타임존과 달력 규칙을 섞어 계산하면 작은 차이가 큰 오류로 번집니다. 실무에서는 ‘무엇을 저장·계산·표시하는가’를 먼저 구분하는 것이 안전합니다.

 

- 문자열 파싱에 의존(Date.parse, 타임존 오프셋 수동 더하기)
- ZonedDateTime에서 바로 월/일 연산(+1 month, +24h 등)
- 사용자 입력 로컬시간을 서버에서 그대로 저장
- ISO 달력 가정으로 월말·윤년 규칙을 하드코딩

 

> 안티패턴: “+24시간 = 내일 같은 시각”은 DST 전환(예: 23시간/25시간)에서 깨집니다. 날짜 이동은 PlainDate로, 시각 고정은 Instant로 처리하세요.

 

선택 기준은 간단합니다. 저장은 전 세계에서 동일해야 하므로 Instant, 화면 표시는 사용자 지역이므로 ZonedDateTime, 월말·주차 같은 규칙 계산은 PlainDate/PlainYearMonth가 맞습니다.

 

- 전사 로그·결제 타임스탬프: Instant 고정
- 알림/캘린더 표시: 사용자 timeZone의 ZonedDateTime
- 구독 갱신일·급여 산정: PlainDate로 계산 후 마지막에 timeZone을 적용

 

시간대 설정은 가장 마지막 단계에서 한 번만 수행하세요. A(Instant) -> B(ZonedDateTime) -> C(포맷) 순서로 흐름을 고정하면, 중간 변환 누락이나 재해석 오류를 줄일 수 있습니다.

 

지금 바로 쓰는 날짜·시간 기본 규칙

 

핵심은 역할을 분리하는 것입니다. 저장은 Instant(절대 시각), 계산은 PlainDate/PlainYearMonth(달력 단위), 표시는 ZonedDateTime(사용자 타임존)으로 나눕니다.

 

팀 규칙을 한 줄로 정하세요: “저장은 UTC Instant, 계산은 PlainDate, 표시는 사용자 타임존”. 코드 리뷰 체크리스트에 넣어 혼선을 막습니다.

 

- 저장: 일정/결제는 Instant.now()로 기록
- 계산: 월말·주차는 PlainDate/PlainYearMonth로 처리
- 표시: 화면은 사용자 타임존의 ZonedDateTime으로 변환
- 테스트: DST 전환일, 2월 말, 윤년 포함

 

아래 유틸은 사용자 타임존 기준 ‘이번 달 말 23:59’를 만들고, 서버 저장용 Instant로 변환합니다. 공용 모듈로 두면 재사용이 쉽습니다.

 

// 목적: 사용자 타임존의 이번 달 말 23:59 → 서버 저장용 Instant
import { Temporal } from '@js-temporal/polyfill';

export function endOfThisMonthInstant(userTimeZone: string) {
  const today = Temporal.Now.plainDateISO(userTimeZone);
  const monthEnd = today.with({ day: 1 }).add({ months: 1 }).subtract({ days: 1 });
  const localEnd = monthEnd.toPlainDateTime({ hour: 23, minute: 59 });
  return localEnd.toZonedDateTime(userTimeZone).toInstant();
}

 

달력 계산은 PlainDate로, 최종 저장은 Instant로 고정하세요. UI는 Instant → ZonedDateTime(userTimeZone) 순으로 단방향 변환합니다.

 

> 팁: “+24시간” 덧셈은 서머타임(DST) 경계에서 어긋납니다. 날짜 이동은 PlainDate, 같은 순간 보장은 Instant로 처리해 분리를 유지하세요.

반응형