Skip to content
Published on

시간과 날짜의 기술적 지옥 완전 해설 — TimeZone, DST, 윤초, Temporal API, NTP 끝장 가이드 (2025)

Authors

들어가며 — "2024년 10월 27일 02:30"이 존재하지 않을 수 있다

유럽에서 서머타임이 끝나는 날, 새벽 03:00이 되면 시계가 02:00으로 되돌아간다. 즉, 같은 날 02:30이 두 번 나온다. 그날 02:30에 쿠폰을 발급한 이벤트가 있다면, 그 쿠폰은 어느 02:30의 것인가?

반대로, 3월 마지막 일요일 유럽은 02:00에서 03:00으로 건너뛴다. 02:30이라는 시각 자체가 존재하지 않는다. 사용자가 "02:30에 예약"이라고 저장하면 그 예약은 언제 실행될까?

이런 질문들은 단순한 UX 이슈가 아니다. 금융 거래의 order 정렬, 로그의 시간순 분석, 분산 시스템의 이벤트 ordering, 임금 계산 같은 실전 시스템이 이 미묘함에 깨진다. Jon Skeet(Noda Time 저자)의 유명한 말: "Time is a joke. Seriously."

이 글은 시간과 날짜를 다루는 기술적 기반을 처음부터 설명한다. UTC가 뭐고 GMT와 어떻게 다른지, Unix time은 왜 윤초를 무시하는지, IANA 타임존 데이터베이스가 왜 자주 업데이트되는지, DST 전환을 어떻게 안전하게 처리하는지, new Date()의 악명 높은 함정들, JavaScript Temporal API가 왜 10년 만의 혁신인지, 분산 시스템에서 NTP/PTP로 시계를 맞추는 방법, 그리고 Google Spanner의 TrueTime까지.

Unicode 글에서 "문자열은 어려운 문제다"라고 했다면, 시간은 그보다 더 어렵다. 문자열은 적어도 한 컴퓨터 안에서 결정론적이지만, 시간은 지구의 자전 속도 변화, 국가의 정치적 결정, OS의 clock drift까지 얽혀 있다.


1. 시간의 3가지 개념 — Instant, Local, Civil

시간을 프로그램에서 다룰 때 먼저 구분해야 할 세 가지:

1.1 Instant (순간)

"지금 이 순간"이라는 절대적 시점. 지구 어느 곳에서 관측하든 같은 값이다. Unix timestamp(1970-01-01T00:00:00Z부터 경과한 초)가 대표적 표현이다.

예: 1760000000 (2025-10-09T07:33:20Z에 해당하는 초 단위 epoch)

1.2 Civil Date/Time (민간 시각)

"2026년 4월 15일 13:30"처럼 달력과 시계에서 읽는 값. 타임존 없이는 모호하다.

1.3 Zoned Date/Time (타임존이 붙은 민간 시각)

"2026-04-15 13:30 Asia/Seoul". 이제 Instant로 변환 가능하다.

1.4 핵심 규칙

  • DB/로그/정렬: Instant로 저장 (UTC 시점)
  • UI 표시: 사용자 타임존으로 Zoned로 변환
  • 반복 일정("매월 1일 9시")은 Civil + TimeZone으로 저장

"UTC로 저장하면 된다"는 말은 반은 맞고 반은 틀리다. "2026년 12월 25일 00:00에 알림"이라는 일정을 서울 사용자가 세팅했다면, UTC로 환산해 저장하면 DST 변화/타임존 변경 시 날짜가 바뀐다. 이런 경우는 원본 civil+tz를 보존해야 한다.


2. UTC, GMT, TAI, UT1 — 시간 표준의 혼란

2.1 GMT (Greenwich Mean Time)

영국 그리니치의 평균 태양시. 19세기 선박 항해를 위해 표준화. 법적으로는 영국의 일부 공식 문서에서 여전히 쓰이지만, 기술 문서에서 "GMT"는 거의 항상 UTC의 구어적 동의어로 쓰인다.

2.2 UTC (Coordinated Universal Time)

국제 표준. 원자시(atomic time) 기반으로 매우 정밀하지만, 지구 자전과 맞추기 위해 **윤초(leap second)**를 삽입한다. UTC = TAI - (누적 윤초 수).

2.3 TAI (International Atomic Time)

전 세계 약 400개 원자시계의 평균. 윤초 없음. 2025년 기준 UTC보다 37초 앞서 있다. GPS time은 UTC가 아니라 TAI 기반이고 1980년 기준 오프셋을 가진다.

2.4 UT1

지구 자전에 실제로 맞춘 시간. UTC는 UT1과 0.9초 이상 차이 나면 안 된다는 규약으로 윤초가 추가된다.

2.5 실전 정리

  • 프로그램에서 "UTC"라 부르는 것: 대부분 UTC = epoch + 초 (윤초 무시)
  • 엄밀한 UTC: 윤초 있음
  • 보통 사용에서는 차이가 무시 가능하지만, 금융 High-Frequency Trading, 통신 위성 동기화 등에서는 중요

3. Unix Timestamp — 단순함의 대가

3.1 정의

1970-01-01T00:00:00Z부터 경과한 초(또는 밀리초/나노초).

  • POSIX 표준: 초 단위, 윤초 무시
  • 즉, 하루는 정확히 86,400초로 취급

3.2 윤초는 어디로 사라지는가

실제 지구 시간에서 윤초가 삽입되면 UTC는 23:59:60을 한 번 거친다. Unix time은 이를 어떻게 처리하나?

두 가지 방식:

  1. Slew (권장): 윤초 전후 수 시간에 걸쳐 시간을 천천히 늘려서 흡수. Google/AWS/Meta가 쓰는 방식. 어떤 순간도 "건너뛰지" 않음.
  2. Smear: NTP 서버가 윤초 당일 86,400초가 아닌 86,401초 수준으로 slew. 실제 OS의 clock도 부드럽게 진행.
  3. Jump: 23:59:59에서 00:00:00으로 다시 점프. 많은 시스템이 crash하거나 이중 이벤트 생성.

역사적 사고:

  • 2012년 6월 30일 윤초: Linux 커널 버그로 Java 애플리케이션 다수가 CPU 100% 점유. Reddit, LinkedIn, Foursquare 다운
  • 2015년 6월 30일 윤초: Twitter/Instagram 일시 장애
  • 2017년 1월 1일 윤초: Cloudflare DNS가 윤초 계산 오류로 일부 장애

미래: 2022년 국제도량형총회(CGPM)가 2035년까지 윤초 폐지를 결정. 그 이후는 수십 년 단위로 "윤분" 등으로 큰 단위 조정.

3.3 2038년 문제

Unix time을 32비트 signed integer로 저장하면 2038-01-19T03:14:07Z 이후 오버플로우. 32비트 임베디드 시스템(IoT, 구식 데이터베이스 포맷)의 시한폭탄.

대응: 대부분 현대 OS/언어는 64비트로 전환했지만, 레거시 C/C++ 바이너리, 일부 DB 스키마, 파일 포맷을 점검해야 한다. time_t를 명시적으로 size 확인.

3.4 0년 문제 vs 음수 epoch

-62135596800은 서기 1년 1월 1일 UTC. 더 이전 날짜(기원전)는 protocol에 따라 표현 가능/불가능. Java Instant.MIN은 -9999999-01-01, Instant.MAX는 9999999-12-31.


4. ISO 8601 — 문자열 표현 표준

4.1 기본 형식

2026-04-15                       날짜
2026-04-15T13:30:00              로컬 시각
2026-04-15T13:30:00Z             UTC
2026-04-15T13:30:00+09:00        서울 시각
2026-04-15T13:30:00.123456789Z   나노초 정밀도
2026-W16-3                       주 기반 (ISO 주 16의 수요일)
P1Y2M10DT2H30M                   기간 (1년 2개월 10일 2시간 30분)
2026-04-15/2026-04-20            간격

4.2 RFC 3339 — ISO 8601의 subset

IETF가 인터넷 프로토콜용으로 제한한 subset. T 소문자 허용, 타임존 오프셋 필수 등.

4.3 일상의 함정

  • 2026-04-15T13:30:00 (타임존 없음) → 로컬? UTC? 파서마다 다름 (JavaScript new Date()는 로컬, Python fromisoformat는 naive)
  • Z vs +00:00 — 의미는 같으나 문자열 비교는 다름
  • 소수점 이하 초의 정밀도가 시스템마다 다름 (ms vs μs vs ns)
  • +09 vs +0900 vs +09:00 — 파서 지원 범위 차이

교훈: 문자열로 주고받을 때 반드시 타임존 오프셋 명시. 파서는 ISO 8601 공식 라이브러리(Java Instant.parse, Python 3.11+ fromisoformat, JS Temporal.Instant.from) 사용.


5. 타임존 — 정치의 시간

5.1 타임존이 하는 일

UTC와의 오프셋을 결정한다. 하지만 오프셋은:

  • 지역마다 다름
  • 역사적으로 변함 (국가가 DST 도입/폐지, 오프셋 변경)
  • 정치적 결정으로 갑자기 바뀔 수 있음

5.2 IANA Time Zone Database (tz database, zoneinfo, Olson DB)

1986년부터 Paul Eggert가 유지하는 시간대 역사의 교과서. Asia/Seoul, America/New_York 같은 이름을 키로, 해당 지역이 역사적으로 사용한 모든 오프셋/DST 규칙/변경 이력을 담고 있다.

예: Asia/Seoul은 1954년 3월 21일에 UTC+8:30에서 UTC+9로 변경. 1961년까지 여러 번 오프셋이 바뀌었고, 1987~1988년 DST가 있었다가 폐지. 지금 한국 API가 "1955년 1월 1일 00:00 Seoul"을 UTC로 변환하려면 이 역사를 알아야 한다.

5.3 tzdata 업데이트

매년 5~10회 새 버전 나온다. 최근 변경:

  • 2024년: 이집트 DST 재도입
  • 2023년: 멕시코 대부분 DST 폐지
  • 2022년: 칠레 DST 날짜 변경
  • 2022년: 요르단, 시리아가 영구 DST로 전환

운영에서 중요:

  • 컨테이너 이미지의 tzdata 버전 체크 (alpine은 tzdata 패키지 별도 설치 필요)
  • JVM은 TZupdater로 최신 tzdata 갱신 가능
  • DB 서버 OS의 tzdata가 구식이면 앱이 잘못된 오프셋 사용
  • Node.js는 ICU를 내장하지만 일부 slim 배포판은 small ICU만 → 타임존 제한됨 (NODE_ICU_DATA 설정)

5.4 abbreviation의 모호성

CST — 어느 국가?

  • Central Standard Time (미국, UTC-6)
  • China Standard Time (UTC+8)
  • Cuba Standard Time (UTC-5)

IST — 더 심함:

  • India Standard Time (UTC+5:30)
  • Ireland Standard Time (UTC+1)
  • Israel Standard Time (UTC+2)

규칙: 약어는 표시용으로만 사용. 프로그램에서는 **IANA 이름(Asia/Seoul, America/Chicago)**을 써라.

5.5 POSIX TZ 문자열

구식 형식이지만 여전히 살아있다: EST5EDT,M3.2.0,M11.1.0. 절대 쓰지 말고 IANA 이름으로.


6. DST — 실전 지뢰

6.1 "존재하지 않는 시간"

2024년 3월 31일 유럽, 02:00 → 03:00으로 점프. 02:30은 존재하지 않는다.

// JavaScript 잘 모르는 경우
new Date('2024-03-31T02:30:00+01:00').toISOString()
// "2024-03-31T01:30:00.000Z" — 실제로는 의도한 02:30이 아님

대응:

  • 사용자 입력의 civil time이 존재하지 않으면 에러 또는 forward shift(다음 유효 시간으로)
  • Java ZonedDateTime.of(...) + ZoneRulesProviderGap 처리 가능
  • Temporal.ZonedDateTime.from({..., disambiguation: 'earlier'/'later'/'reject'/'compatible'})

6.2 "두 번 나타나는 시간"

2024년 10월 27일 유럽, 03:00 → 02:00으로 되돌아감. 02:30은 두 번 존재한다.

  • 같은 "2024-10-27 02:30 Europe/Berlin" 문자열이 두 개의 UTC 시점에 해당
  • 금융 로그는 UTC 기준으로 정렬해야 안전

6.3 달력 예약의 재앙

"매주 월요일 09:00 미팅"을 UTC로 변환해서 저장했다면:

  • 서울(항상 UTC+9)은 UTC 00:00
  • 뉴욕(DST 있음)은 UTC 14:00 ↔ UTC 13:00 (여름/겨울 다름)

원본 civil+tz로 저장하는 게 정답. DB 컬럼을 (timestamp, timezone, recurrence_rule)로 분리.

6.4 DST 폐지 추세

EU는 2019년 "2021년부터 DST 폐지" 결정했으나 각국 이견으로 연기. 미국은 2022년 "Sunshine Protection Act"로 영구 DST 법안이 상원 통과했으나 하원 계류. 터키/러시아/아이슬란드/중국 등은 이미 폐지.

개발자 관점: 폐지 결정은 수개월 전에 갑자기 확정되므로 tzdata 업데이트 체인을 항상 유지해야 한다.


7. 언어별 datetime API

7.1 JavaScript의 Date — 악명 높은 설계

1995년 Netscape가 Java java.util.Date를 빠르게 베꼈는데, 그 Java API는 이후 폐기됐다(Joda Time/java.time으로 교체). 하지만 JS는 수십 년간 호환성 때문에 고치지 못했다.

문제점:

  • 가변(mutable): date.setMonth(3)이 원본을 바꿈
  • 월이 0부터 시작: new Date(2026, 0, 15) = 2026년 1월 15일
  • 문자열 파싱 비일관: new Date("2024-03-15")는 UTC, new Date("2024/03/15")는 로컬 — 브라우저마다 다름
  • 타임존 처리 불가: "로컬" 또는 "UTC" 둘 중 하나. 임의 IANA zone 지원 없음
  • 정밀도 ms: 나노초 불가

Moment.js, date-fns, Luxon, Day.js 같은 라이브러리가 이 갭을 메웠다.

7.2 Temporal — 2025년의 구세주

Stage 3 → 2024년 말 브라우저/Node 순차 구현. 주요 타입:

  • Temporal.Instant: 나노초 정밀도 UTC 시점
  • Temporal.ZonedDateTime: Instant + IANA timezone
  • Temporal.PlainDate / PlainTime / PlainDateTime: 타임존 없는 civil
  • Temporal.Duration: 기간
  • Temporal.Calendar: 그레고리력/이슬람력/일본력/불기 등
  • Temporal.Now: Temporal.Now.instant()
const now = Temporal.Now.zonedDateTimeISO('Asia/Seoul')
const oneHourLater = now.add({ hours: 1 })
const duration = now.until(oneHourLater) // Temporal.Duration

혁신 포인트:

  • 불변(immutable): 모든 연산이 새 객체 반환
  • 명시적 타임존 처리: disambiguation: 'reject' | 'earlier' | 'later' | 'compatible'
  • 나노초 지원
  • 윤일/윤년/각종 달력 정확히 처리

7.3 Python

  • datetime: 표준, tzinfo를 직접 넣거나 zoneinfo 모듈(Python 3.9+) 사용
  • pytz (구식): astimezone() 버그와 normalize() 필요로 악명
  • arrow: 더 친숙한 API
  • pendulum: Ruby-style DSL
  • 권장: datetime + zoneinfo (stdlib)
from datetime import datetime
from zoneinfo import ZoneInfo
dt = datetime(2026, 4, 15, 13, 30, tzinfo=ZoneInfo("Asia/Seoul"))
dt.astimezone(ZoneInfo("America/New_York"))

7.4 Java

  • java.util.Date: 구식, 쓰지 말 것
  • java.util.Calendar: 구식
  • java.time (Java 8, JSR-310 / Joda 기반): 현대
    • Instant, LocalDateTime, ZonedDateTime, OffsetDateTime
    • 불변, 스레드 안전
  • Joda Time: Java 7 이전 세대. Java 8+에서는 java.time 사용.

7.5 Go

  • time.Time 내부에 wall clock + monotonic clock 둘 다 저장
  • 타임존은 time.LoadLocation("Asia/Seoul")
  • 포맷 문자열이 독특: 2006-01-02 15:04:05 -0700 (참조 날짜)
  • 파싱: time.Parse(layout, s)

7.6 Rust

  • std::time::SystemTime: OS 시계
  • std::time::Instant: monotonic (경과 시간 측정용)
  • Civil/zoned 처리는 chrono 또는 jiff (2024년 BurntSushi 발표) crate
  • jiff는 Temporal-inspired API로 주목

8. Monotonic Clock vs Wall Clock

8.1 차이

  • Wall clock: 달력 시간. NTP 동기화로 앞/뒤로 뛸 수 있음
  • Monotonic clock: "부팅 이후 경과 시간"에 가까운 것. 절대 뒤로 안 감

8.2 왜 중요한가

경과 시간 측정에는 반드시 monotonic 사용:

// ❌ NTP sync가 뒤로 뛰면 음수가 나옴
const start = Date.now()
await doWork()
const elapsed = Date.now() - start

// ✅
const start = performance.now()
await doWork()
const elapsed = performance.now() - start

타임아웃 / rate limiter / 벤치마크 모두 monotonic clock 써야 한다.

8.3 언어별 monotonic 접근

  • JS 브라우저: performance.now() (밀리초 + 소수점, 페이지 로드부터)
  • JS Node: process.hrtime.bigint() (나노초)
  • Go: time.Now()가 monotonic 포함, time.Since(start)는 항상 안전
  • Java: System.nanoTime()
  • Python: time.monotonic(), time.monotonic_ns()

9. NTP, PTP — 분산 시스템의 시계 동기화

9.1 NTP (Network Time Protocol)

  • Layered "stratum" 구조:
    • Stratum 0: 원자시계, GPS 수신기
    • Stratum 1: Stratum 0에 직접 연결된 서버 (NIST, KRISS)
    • Stratum 2: Stratum 1에서 받는 공개 서버 (time.google.com, time.cloudflare.com)
    • Stratum 3+: 일반 서버
  • UDP 123 포트
  • 정확도: LAN 내 수 ms, 인터넷 거리 10~50 ms
  • 대부분의 OS가 기본 탑재 (chronyd, systemd-timesyncd, w32time)

9.2 PTP (Precision Time Protocol, IEEE 1588)

  • LAN 내 나노초~마이크로초 정확도
  • 스위치가 timestamping hardware 지원 필요
  • 금융 High-Frequency Trading, 5G 기지국 동기화에서 필수
  • AWS는 2023년 "Time Sync Service" (PTP 기반, 마이크로초 정확도) 공개

9.3 Google TrueTime

Spanner DB가 전 세계적으로 일관된 트랜잭션을 하려고 만든 시스템. 시간을 단일 값이 아닌 [earliest, latest] 구간으로 다룬다.

  • 각 데이터센터에 GPS + 원자시계 설치
  • TT.now()가 {earliest, latest} 반환 (보통 7ms 폭)
  • 트랜잭션 commit 시 latest를 timestamp로 삼고, latest 경과까지 대기 → 글로벌 ordering 보장

비슷한 아이디어: CockroachDB의 HLC (Hybrid Logical Clock) — NTP + 논리 시계 결합.

9.4 실전 교훈

  • NTP가 없거나 drift가 심한 서버는 분산 시스템에서 배제해야 함 (신뢰할 수 없는 timestamp)
  • 시간 기반 token(JWT exp)이나 rate limiter는 5~10초 leeway를 둬야 stratification 에러에 견딤
  • Kubernetes 노드는 모두 같은 NTP 서버 사용 권장

10. 달력 시스템 — 그레고리력이 전부가 아니다

10.1 주요 달력

  • 그레고리력: 1582년 도입, 현재 국제 표준
  • 율리우스력: 그레고리력 전. 일부 정교회가 여전히 사용
  • 이슬람력(Hijri): 달 기반, 1년 약 354일. 종교적 일정 계산에 필수
  • 히브리력: 달-태양 결합, 윤달 있음
  • 일본력: 그레고리력 + 연호(令和, 2019~). API가 연호 전환에 대응해야 함
  • 태국 불기: 그레고리력 + 543년 오프셋 → 2025년 = 불기 2568년
  • 페르시아력: 이란 공식
  • 중국력: 달-태양, 24절기. 공식 달력은 그레고리이지만 전통 명절(춘절)은 음력

10.2 Unicode CLDR / ICU

다국어 달력 지원:

new Intl.DateTimeFormat('ja-JP-u-ca-japanese', { year: 'numeric', month: 'long', day: 'numeric' })
  .format(new Date('2026-04-15'))
// "令和8年4月15日"

new Intl.DateTimeFormat('ko-KR-u-ca-buddhist').format(new Date('2026-04-15'))
// "불기 2569년"

10.3 윤년 규칙 (그레고리력)

  • 4로 나눠지면 윤년
  • 100으로 나눠지면 평년
  • 400으로 나눠지면 윤년

2000년은 윤년(400), 1900년/2100년은 평년(100).

"매년 2월 29일 미팅"은 4년에 한 번만 생긴다. Outlook 등은 "이전 달 마지막 날" 또는 "다음 달 1일"로 fallback 옵션을 제공.

10.4 1752년 9월 3~13일의 공백

영국은 1752년에 율리우스력에서 그레고리력으로 전환하면서 9월 2일 다음이 9월 14일이다. 러시아는 1918년. 이 역사적 공백을 정확히 모델링하는 라이브러리는 드물다 (Java GregorianCalendar, Noda Time 등이 일부 지원).


11. 데이터베이스의 시간 처리

11.1 PostgreSQL

  • timestamp without time zone: civil time. 저장/조회 시 그대로.
  • timestamp with time zone (timestamptz): 내부는 UTC epoch, 입출력 시 session의 TIME ZONE 설정으로 변환.
  • date, time, interval 지원
  • AT TIME ZONE 'Asia/Seoul'로 변환

권장: 특별한 이유 없으면 timestamptz 사용.

11.2 MySQL

  • DATETIME: civil time, 타임존 변환 없음
  • TIMESTAMP: 저장 시 세션 tz → UTC, 조회 시 UTC → 세션 tz (자동). 2038 문제 있음 (32비트).
  • DATE, TIME 지원

주의: TIMESTAMP는 서버의 time_zone 세션 변수에 따라 결과가 달라진다. 커넥션마다 SET time_zone = '+00:00' 권장.

11.3 MongoDB

  • BSON Date는 64비트 int milliseconds (UTC epoch)
  • 타임존 없음 → 저장 전 UTC 변환 필수

11.4 DynamoDB, Redis

  • 타임스탬프 문자열(ISO 8601 with offset) 또는 epoch ms 저장
  • TTL은 Unix timestamp(초)

11.5 ClickHouse

  • DateTime, DateTime64(서브초 정밀도)
  • 각 컬럼이 타임존을 가질 수 있음 (DateTime('Asia/Seoul'))
  • 집계 쿼리에서 toTimeZone() 함수로 변환

12. 로그와 메트릭의 timestamp

12.1 원칙: UTC로 저장, 로컬로 표시

  • 서버 로그: 모두 UTC (TZ=UTC 설정)
  • 대시보드(Grafana, Kibana): 사용자 브라우저 tz로 표시
  • 분산 tracing: span의 timestamp는 UTC epoch ns

12.2 Correlation과 Clock Skew

마이크로서비스 A, B, C가 있을 때 A가 B를 호출하고 B가 C를 호출한다. 각 서버의 시계가 약간씩 다르면:

  • B의 span이 A의 span보다 먼저 시작한 것처럼 보일 수 있음
  • OpenTelemetry는 이를 완화하기 위해 span의 duration을 monotonic clock으로 측정 후 UTC로 환산

12.3 ISO 8601 또는 RFC 3339로 저장

2026-04-15T13:30:00.123456789Z

여기서 Z(= UTC)는 필수. TZ 없는 문자열은 파싱이 모호하다.

12.4 Kafka 등 이벤트 스트림

각 메시지는 producer timestamp(producer가 붙인 시각)와 log append time(broker가 저장한 시각) 두 가지를 가진다. 순서 정렬은 offset으로 하고 시각은 참고용.


13. 실전 버그 케이스 10선

13.1 "새벽 2시에 실행될 cron이 DST 날 실행 안 됨"

원인: 새벽 2:30이 존재하지 않는 시간대. cron 표현식은 로컬 시간 기준.
해결: UTC로 cron 돌리거나, OnCalendar=UTC 옵션 명시.

13.2 "JWT exp가 이상하게 빨리 만료됨"

원인: 서버와 클라이언트 시계 차이.
해결: 5~30초 leeway. NTP 동기화 강제.

13.3 "이메일의 'Received' 헤더 시간이 미래"

원인: 어느 중계 서버의 시계가 앞서 있음.
해결: 인프라 팀에 NTP 점검 요청. Spam filter는 이런 경우 의심 신호로 취급.

13.4 "생일 알림이 하루 전/후에 옴"

원인: UTC 기준 저장했는데 사용자가 다른 tz로 이동.
해결: 생일은 PlainDate로 저장 (시각 없음). 알림 발송 시 사용자 tz의 00:00에 매핑.

13.5 "1970-01-01 표시"

원인: null timestamp를 0으로 취급 + Date로 format.
해결: null 체크.

13.6 "2038년 테스트 실패"

원인: 32비트 time_t.
해결: 64비트로 컴파일 확인.

13.7 "duration이 음수"

원인: wall clock 사용. NTP가 뒤로 보정.
해결: monotonic clock (performance.now, time.monotonic).

13.8 "이벤트 로그가 뒤죽박죽"

원인: 서버 간 clock skew.
해결: OpenTelemetry의 Span Link + Logical Clock.

13.9 "사용자가 '오늘' 예약했는데 '내일'로 저장됨"

원인: 브라우저 tz ≠ 서버 tz. 서버에서 new Date(...)로 파싱하면 서버 tz 기준.
해결: 브라우저에서 Temporal.PlainDate.from(...) 또는 ISO 문자열 + tz 오프셋 명시.

13.10 "휴가 일수 계산이 DST 전환 때 0.96일"

원인: (endMs - startMs) / (24 * 3600 * 1000)에서 DST로 하루가 23 or 25시간이 됨.
해결: ChronoUnit.DAYS.between(zonedStart, zonedEnd) 같은 civil-aware 연산.


14. 체크리스트 — 시간 관련 시스템 설계

저장

  • "Instant"와 "Civil+TZ"를 구분해 저장
  • UTC 저장 우선, 반복 일정은 original civil+tz 보존
  • DB timestamptz 사용 (MySQL은 연결 tz UTC 고정)
  • ISO 8601 + timezone offset 문자열
  • 나노초 정밀도 필요한지 확인 (부족하면 부가 컬럼)

변환

  • 사용자 tz를 profile에 저장 (IANA 이름)
  • UI 표시 시에만 사용자 tz로 변환
  • 서버 로컬 tz에 의존하지 않기 (TZ=UTC 강제)

DST

  • "존재하지 않는 시각" 핸들링 (reject / next valid)
  • "두 번 나타나는 시각" 핸들링 (earlier / later)
  • Test data에 DST 전환일 포함

동기화

  • NTP 또는 chrony 구동 확인
  • Clock skew monitoring (5초 이상 alerts)
  • 경과 시간은 monotonic clock

라이브러리

  • JS: Temporal (또는 Luxon) — Date 직접 사용 지양
  • Java: java.time (Date/Calendar 지양)
  • Python: datetime + zoneinfo
  • Go: time (지원하지만 layout 포맷 주의)
  • Rust: jiff 또는 chrono

운영

  • tzdata 정기 업데이트
  • Docker 이미지의 tzdata 버전 확인 (alpine)
  • JVM의 TZupdater 적용 프로세스
  • 신규 DST 정책 변경 뉴스 모니터링

15. 역사적 사건 아카이브

15.1 2007년 미국 DST 날짜 변경

미 정부가 DST 시작을 3주 앞당김. 많은 Outlook/Blackberry가 구 규칙대로 동작해서 미팅 1시간 어긋남. MS는 긴급 패치 배포.

15.2 2011년 사모아 시간대 변경

국제날짜변경선 넘어가면서 2011년 12월 30일이 없었다. 12월 29일 다음이 12월 31일. 뉴질랜드 거래 시간대 맞춤.

15.3 2016년 터키

당일 결정으로 DST 폐지. 구 Android 기기들이 자동 변경 안 되어 1시간 늦게 출근.

15.4 2018년 북한

UTC+8:30 → UTC+9로 변경(남북 통일 제스처). tzdata 업데이트.

15.5 2023년 그리니치 표준시의 죽음?

UK 정부가 브렉시트 후 타임존 정책 논의. 아직 변화 없음.

15.6 금융 High-Frequency Trading

나스닥 등은 나노초 정확도로 주문 순서 기록. PTP + 공유 atomic clock. 1 마이크로초 차이가 수백만 달러 거래 결과를 가름.


마무리 — "시간은 여러 개다"

본 글에서 반복한 관점 하나: "시간"이라는 단일 개념은 없다. Instant, Civil time, Zoned time, Monotonic, Logical time 등 여러 층위의 시간이 있고, 각자 다른 목적에 맞는다. 프로그램의 버그 대부분은 이 층위를 혼동할 때 발생한다.

핵심 원칙 5가지:

  1. 저장은 UTC, 표시는 local — 그리고 반복 일정은 원본 civil+tz 보존
  2. 정렬·계산은 Instant로, UI는 ZonedDateTime으로
  3. 경과 시간은 Monotonic으로 (wall clock은 뛸 수 있다)
  4. 타임존은 IANA 이름으로 (약어 금지)
  5. DST 전환일을 테스트 케이스에 포함

Unicode처럼, 시간도 인간 사회의 복잡성이 소프트웨어에 스며든 영역이다. 정치적 결정으로 DST가 바뀌고, 종교 달력에 따라 휴일이 달라지고, 지구 자전이 느려져서 윤초가 삽입된다. "Just use UTC"로 해결되지 않는 현실에서, 개발자는 시간의 레이어드 모델을 머릿속에 가져야 한다.

다음 글에서는 분산 시스템의 Consensus — Raft/Paxos, CAP 정리, 그리고 eventual consistency를 파고든다. 이번 글에서 본 시간의 불일치가 분산 시스템의 핵심 난제와 어떻게 얽히는지(Lamport clock, Vector clock, Hybrid Logical Clock 등) 이어서 보자.

시간을 이해하는 건 단지 버그를 피하는 기술이 아니다. "동시에 일어났다"가 무엇을 의미하는가에 대한 철학적 질문이기도 하다. 상대성 이론이 말하듯, 분산 시스템에서 "전역 현재 시각"은 존재하지 않는다 — 우리는 그것의 근사치를 다룰 뿐이다.