The Logs Came From the Future — Five Incidents a Clock Made
One in the morning came twice
한국어 원문으로 표시합니다.
한 줄 요약
저장은 순간(UTC)으로 하고 표시만 지역 시각으로 한다. 지역 시각으로 저장한 기록은 1년에 두 번, 겹치거나 사라진다.
왜 이게 필요했나
"우리 서비스는 한국에서만 쓰니까 그냥 로컬 시각으로 저장했습니다." 이 문장은 몇 년 동안 아무 문제도 일으키지 않는다. 한국은 지금 서머타임을 쓰지 않으므로, 한국 시각과 UTC 의 차이는 늘 9시간으로 고정되어 있다. 문제는 그 다음이다. 미국 고객이 생겨서 현지 시각으로 알림을 보내기 시작하거나, 클라우드 지역을 하나 늘리거나, 정산 기준을 현지 영업일로 바꾸는 순간 이 설계는 깨진다. 그리고 깨지는 날은 1년에 두 번뿐이라, 평소의 시험에서는 절대 잡히지 않는다.
어떻게 동작하나
지역 시각과 순간 사이의 변환 규칙은 나라가 정치적으로 정하고, 그 역사를 모아 둔 것이
IANA 시간대 데이터베이스다. Asia/Seoul,
America/New_York 같은 이름은 도시 이름이 아니라 그 지역의 시각 규칙 전체의
이름 이다. 이 데이터베이스는 정치적 결정이 있을 때마다 갱신된다. 확인한 시점의
최신판은 2026d(2026-09-11 공개)였고, 그 판의 변경 사항 중 하나가 캐나다 노스웨스트
준주가 영구 -06 으로 옮긴 것이었다. 규칙이 이렇게 계속 바뀌기 때문에, 오프셋을
+09:00 같은 숫자로 코드에 박아 두면 안 된다. 이름으로 저장하고 변환은
데이터베이스에 맡긴다.
서머타임이 끝나는 날에는 시계를 한 시간 뒤로 돌린다. 그러면 그 한 시간의 지역
시각이 두 번 온다. 시작하는 날에는 한 시간 앞으로 돌리므로 그 한 시간이
아예 없다. 파이썬은 이 두 경우를 구분하려고 fold 라는 속성을 datetime 에
두었다. PEP 495가 그 제안이고, 0 이 두 후보 중
이른 쪽, 1 이 늦은 쪽이다.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
def classify(naive):
early = naive.replace(tzinfo=ny, fold=0)
late = naive.replace(tzinfo=ny, fold=1)
if early.utcoffset() == late.utcoffset():
return "normal" # 후보가 하나뿐 — 평범한 시각
back = early.astimezone(timezone.utc).astimezone(ny).replace(tzinfo=None)
return "ambiguous" if back == naive else "nonexistent"
classify(datetime(2025, 11, 2, 1, 30)) # ambiguous — 두 번 온다
classify(datetime(2025, 3, 9, 2, 30)) # nonexistent — 오지 않는다
판정의 뒷부분이 핵심이다. 두 후보의 오프셋이 다르다는 것만으로는 「두 번 오는 시각」과 「없는 시각」을 구분하지 못한다 — 두 경우 모두 오프셋이 갈린다. 구분은 왕복 으로 한다. 지역 시각을 순간으로 바꿨다가 다시 지역 시각으로 되돌렸을 때 원래 값이 나오면 그 시각은 실재하는 것이고(두 번 오는 쪽), 다른 값이 나오면 애초에 없는 시각이다.
저장 쪽 규칙은 그래서 단순해진다. 순간은 UTC 로 저장한다. PostgreSQL 의
날짜·시각 형에서
timestamptz 를 쓰라고 하는 이유가 이것이다. 이름이 헷갈리지만 이 형은 시간대를
저장하지 않는다 — 입력을 UTC 로 바꿔 저장하고, 읽을 때 세션의 시간대로 보여 준다.
반대로 timestamp(without time zone)는 적힌 숫자를 그대로 들고 있어서, 그 숫자가
어느 지역의 것인지 아무도 모른다.
다만 미래의 약속 은 예외다. "다음 달 첫 월요일 오전 9시에 회의" 는 순간이 아니라 지역 시각으로 한 약속이다. 그 사이에 그 나라가 서머타임 규칙을 바꾸면, UTC 로 굳혀 둔 값은 틀린 시각에 울린다. 이런 것은 지역 시각과 시간대 이름을 함께 저장하고 읽을 때 변환한다.
현장에서 만나는 모습
가장 흔한 사고는 지역 시각으로 이름 붙인 배치 작업이다. 서머타임이 끝나는 새벽, "01:00 정산" 이 두 번 돈다. 두 번 다 정상 종료하고, 로그에도 오류가 없고, 총 실행 횟수도 평소와 같다. 다만 그날의 매출이 두 번 반영된다.
두 번째로 흔한 것은 없는 시각에 걸린 예약 이다. 서머타임이 시작되는 새벽 2시대는 그 지역에 존재하지 않으므로, 그 시각에 걸린 작업은 조용히 한 번 건너뛴다. 실패한 것이 아니라 아예 일어나지 않았기 때문에 실패 알림도 오지 않는다. 다음 날 아침에 누군가 "어제 리포트가 없네요" 라고 말할 때까지 아무도 모른다.
세 번째는 사람의 착각이다. 지역 시각으로만 남은 기록을 보고 "01:30 에 두 건이 있는데 중복인가요?" 라고 묻는다. 그 두 건은 실제로 한 시간 떨어진 서로 다른 순간이다. 기록에 오프셋이 없으면 이것을 판별할 방법이 원리적으로 없다.
다음 퀴즈에서 확인할 것
지역 시각으로 저장했을 때 어떤 두 가지 사고가 나는지, fold 로 두 경우를 어떻게
갈라내는지, 저장은 UTC 로 하라는 규칙에 어떤 예외가 있는지 확인한다.