LabHub
배우기 러닝패스 코스

로그가 미래에서 왔다 — 시계가 만든 다섯 사건 · 새벽 한 시가 두 번 왔다 · 이론

새벽 한 시가 두 번 왔다

LabHub 에서 이어서 보기

한 줄 요약

저장은 순간(UTC)으로 하고 표시만 지역 시각으로 한다. 지역 시각으로 저장한 기록은
1년에 두 번, 겹치거나 사라진다.

왜 이게 필요했나

"우리 서비스는 한국에서만 쓰니까 그냥 로컬 시각으로 저장했습니다." 이 문장은 몇 년
동안 아무 문제도 일으키지 않는다. 한국은 지금 서머타임을 쓰지 않으므로, 한국 시각과
UTC 의 차이는 늘 9시간으로 고정되어 있다. 문제는 그 다음이다. 미국 고객이 생겨서
현지 시각으로 알림을 보내기 시작하거나, 클라우드 지역을 하나 늘리거나, 정산 기준을
현지 영업일로 바꾸는 순간 이 설계는 깨진다. 그리고 깨지는 날은 1년에 두 번뿐이라,
평소의 시험에서는 절대 잡히지 않는다.

어떻게 동작하나

지역 시각과 순간 사이의 변환 규칙은 나라가 정치적으로 정하고, 그 역사를 모아 둔 것이
[IANA 시간대 데이터베이스](https://www.iana.org/time-zones)다. Asia/Seoul,
America/New_York 같은 이름은 도시 이름이 아니라 **그 지역의 시각 규칙 전체의
이름** 이다. 이 데이터베이스는 정치적 결정이 있을 때마다 갱신된다. 확인한 시점의
최신판은 2026d(2026-09-11 공개)였고, 그 판의 변경 사항 중 하나가 캐나다 노스웨스트
준주가 영구 -06 으로 옮긴 것이었다. 규칙이 이렇게 계속 바뀌기 때문에, 오프셋을
+09:00 같은 숫자로 코드에 박아 두면 안 된다. **이름으로 저장하고 변환은
데이터베이스에 맡긴다.**

서머타임이 끝나는 날에는 시계를 한 시간 뒤로 돌린다. 그러면 그 한 시간의 지역
시각이 두 번 온다. 시작하는 날에는 한 시간 앞으로 돌리므로 그 한 시간이
아예 없다. 파이썬은 이 두 경우를 구분하려고 fold 라는 속성을 datetime 에
두었다. [PEP 495](https://peps.python.org/pep-0495/)가 그 제안이고, 0 이 두 후보 중
이른 쪽, 1 이 늦은 쪽이다.

from datetime import datetime, timezonefrom zoneinfo import ZoneInfony = 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 의
[날짜·시각 형](https://www.postgresql.org/docs/current/datatype-datetime.html)에서
timestamptz 를 쓰라고 하는 이유가 이것이다. 이름이 헷갈리지만 이 형은 시간대를
저장하지 않는다 — 입력을 UTC 로 바꿔 저장하고, 읽을 때 세션의 시간대로 보여 준다.
반대로 timestamp(without time zone)는 적힌 숫자를 그대로 들고 있어서, 그 숫자가
어느 지역의 것인지 아무도 모른다.

다만 미래의 약속 은 예외다. "다음 달 첫 월요일 오전 9시에 회의" 는 순간이 아니라
지역 시각으로 한 약속이다. 그 사이에 그 나라가 서머타임 규칙을 바꾸면, UTC 로
굳혀 둔 값은 틀린 시각에 울린다. 이런 것은 지역 시각과 시간대 이름을 함께 저장하고
읽을 때 변환한다.

현장에서 만나는 모습

가장 흔한 사고는 지역 시각으로 이름 붙인 배치 작업이다. 서머타임이 끝나는 새벽,
"01:00 정산" 이 두 번 돈다. 두 번 다 정상 종료하고, 로그에도 오류가 없고, 총
실행 횟수도 평소와 같다. 다만 그날의 매출이 두 번 반영된다.

두 번째로 흔한 것은 없는 시각에 걸린 예약 이다. 서머타임이 시작되는 새벽 2시대는
그 지역에 존재하지 않으므로, 그 시각에 걸린 작업은 조용히 한 번 건너뛴다. 실패한
것이 아니라 아예 일어나지 않았기 때문에 실패 알림도 오지 않는다. 다음 날 아침에
누군가 "어제 리포트가 없네요" 라고 말할 때까지 아무도 모른다.

세 번째는 사람의 착각이다. 지역 시각으로만 남은 기록을 보고 "01:30 에 두 건이
있는데 중복인가요?" 라고 묻는다. 그 두 건은 실제로 한 시간 떨어진 서로 다른
순간이다. 기록에 오프셋이 없으면 이것을 판별할 방법이 원리적으로 없다.

다음 퀴즈에서 확인할 것

지역 시각으로 저장했을 때 어떤 두 가지 사고가 나는지, fold 로 두 경우를 어떻게
갈라내는지, 저장은 UTC 로 하라는 규칙에 어떤 예외가 있는지 확인한다.