타임스탬프와 ISO 8601 설명
시간대 재앙 없이 시스템 간 날짜와 시간을 처리하는 방법.
프로덕션의 모든 시간대 버그는 소수의 개념적 실수 중 하나로 거슬러 올라갑니다. 순간(instant)과 벽시계 시간의 혼동, 오프셋과 시간대의 혼동, 혹은 파서가 의도를 알아서 추측해 주리라는 믿음. Unix 타임스탬프와 ISO 8601이라는 형식 자체는 쉬운 부분입니다. 주어진 데이터에 어떤 표현이 필요한지 아는 것이 진짜 기술입니다.
순간 vs 벽시계 시간
순간은 전역 타임라인 위의 한 점입니다. "결제가 승인된 순간." 벽시계 시간은 로컬 시계가 표시하는 것입니다. "회의는 서울 시간 09:00." 이 둘은 서로 다른 데이터 타입입니다. 순간은 UTC로 영원히 안전하게 저장됩니다. 미래의 벽시계 이벤트는 그렇지 않습니다. 아래에서 다루며, 가장 흔하게 출시되는 시간대 버그입니다.
Unix 시간
1745939938 초 (전통적 Unix)
1745939938123 밀리초 (JavaScript Date.now())
1745939938123456 마이크로초 (많은 데이터베이스)
Unix 시간은 1970-01-01T00:00:00Z 이후의 초를 세며 윤초를 무시합니다. 하루는 정의상 정확히 86,400초이고, 윤초는 어떤 초를 두 배 길게 만들거나(Google과 AWS 시계처럼 여러 시간에 걸쳐 스미어링) 흡수됩니다. 시간대가 없고, 간결하며, 정렬이 자명합니다. 위험은 단위 혼동입니다. 초 값을 밀리초로 읽으면 1970년 1월에 떨어지고, 밀리초 값을 초로 읽으면 서기 57,000년에 떨어집니다. 그리고 로그에서 읽을 수 없다는 점. 타임스탬프 필드가 두 단위를 다 실을 수 있다면 그것은 버그 공장입니다. 한 단위로 표준화하고 필드 이름에 명시하세요(created_at_ms).
ISO 8601과 RFC 3339
2026-04-30T15:32:18Z UTC 순간
2026-04-30T15:32:18.123+09:00 밀리초와 오프셋 포함
2026-04-30 시간 없는 달력 날짜
ISO 8601은 기간(P3Y6M4D), 구간, 주 날짜(2026-W18-4), 서수 날짜까지 다루는 큰 표준입니다. API가 거의 항상 의미하는 것은 인터넷 프로파일인 RFC 3339입니다. 완전한 날짜와 시간, 하이픈과 콜론의 확장 표기, 그리고 필수 오프셋 또는 Z. 사양의 유용한 세부 두 가지: 소문자 t나 z도 합법이지만 관례상 피하고, -00:00은 역사적으로 "오프셋 불명"의 신호였습니다. 내보낼 때는 Z와 대문자 구분자로 정규화하세요.
RFC 3339 문자열의 결정적 속성: (단일 오프셋 내에서) 시간순이 곧 사전순입니다. 문자열로 정렬된 로그가 곧 시간순 로그입니다.
오프셋은 시간대가 아니다
+09:00은 오프셋, 즉 고정된 숫자입니다. Asia/Seoul은 시간대입니다. 벽시계 시간을 오프셋으로 매핑하는 IANA tz 데이터베이스의 정치적 규칙 집합으로, 모든 역사적·예정된 DST 전환을 포함합니다. 정부들이 갑작스레 규칙을 바꾸기 때문에 tz 데이터베이스는 연간 여러 번 업데이트됩니다.
미래 이벤트에 오프셋이 아니라 시간대 이름이 필요한 이유입니다. "2027-03-10T09:00+01:00"이 아니라 "2027-03-10 09:00 Europe/Berlin"을 저장하세요. 그 전에 독일이 DST를 폐지하면, 저장된 오프셋이 회의를 조용히 잘못된 시각에 고정합니다. 반면 과거의 순간은 순수 UTC가 가장 안전합니다.
코드로 대비할 가치가 있는 DST 엣지 케이스
- 건너뛴 시간: 서머타임 시작 시 로컬 02:30은 그냥 존재하지 않습니다. 스케줄러는 정책을 정의해야 합니다(앞으로 이동이 일반적).
- 반복된 시간: 서머타임 종료 시 01:30이 두 번 발생합니다. 하나를 고르려면 오프셋이나 모호성 해소 플래그가 필요합니다.
- 날짜 연산: "하루 더하기"와 "24시간 더하기"는 전환일에 다릅니다. 달력 연산은 이벤트의 시간대에서 해야 하며, 절대 원시 에포크 위에서 하면 안 됩니다.
실용적 저장 규칙
- 데이터베이스의 순간: Postgres의
timestamptz(주의: UTC로 저장하고 입출력 시 변환할 뿐, 원래 시간대를 기억하지 않습니다) 또는 에포크 밀리초 BIGINT. - API와 JSON: 명시적
Z나 오프셋이 있는 RFC 3339 문자열. - 미래의 예약 이벤트: 벽시계 시간 + IANA 시간대 이름, 실행 시점에만 UTC로 구체화.
- 생일 등 날짜 전용 값: 순수
YYYY-MM-DD, 절대 어느-시간대의-자정이 아니어야 합니다. 시간을 붙이는 순간 그리니치 서쪽 사용자의 생일이 하루 밀립니다.
JavaScript 특유의 함정
Date 파싱에는 사양이 명령한 비일관성이 있습니다. new Date('2026-04-30')은 UTC 자정으로 해석되는 반면, 시간은 있지만 오프셋이 없는 new Date('2026-04-30T09:00')은 로컬 시간으로 해석됩니다. 명시적 오프셋을 붙이면 두 문제 모두 사라집니다. 렌더링은 플랫폼에 로케일 작업을 맡기세요.
new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'Asia/Seoul'
}).format(new Date(1745939938123));
Temporal API(Temporal.Instant, Temporal.ZonedDateTime, Temporal.PlainDate)는 마침내 순간/벽시계/날짜 전용의 구분을 타입 시스템에 인코딩합니다. 런타임들이 탑재하는 대로 새 코드의 올바른 목표이며, 오늘의 실용적 대역은 date-fns나 Luxon입니다.
지속 시간 측정: 잘못된 시계와 올바른 시계
벽시계 타임스탬프는 무언가가 언제 일어났는지 기록하기 위한 것이지, 얼마나 걸렸는지 측정하기 위한 것이 아닙니다. 시스템 시계는 NTP 보정 중 뒤로 스텝하고 수동 변경 시 점프하므로, 프로덕션 트레이스에서 Date.now() 차이가 가끔 음수가 됩니다. 모든 플랫폼은 구간 측정용 단조 시계를 제공합니다. JavaScript의 performance.now(), Python의 time.monotonic(), POSIX의 CLOCK_MONOTONIC. 같은 논리가 머신 간에도 적용됩니다. 클라이언트 시계는 초에서 분 단위로 표류하므로, 지연 시간을 계산하려고 클라이언트 생성 타임스탬프와 서버 생성 타임스탬프를 비교하지 마세요. 둘 다 같은 시계에서 발급하거나, 단일 관측 지점에서 왕복 시간을 측정하세요.
sdk.is/timestamp-converter 의 타임스탬프 변환기로 에포크, ISO 문자열, 사람이 읽는 형식 사이를 변환하세요.