해외 로그의 두 번 나타나는 현지 시각을 UTC로 구별하기
대표 사진은 AI로 만든 연출 이미지이며, 실제 로그 시각이나 UTC 변환 결과를 보여 주지 않습니다.
해외 로그에 같은 날짜의 01:30이 두 번 기록되어도 두 사건이 같은 순간이라고 단정할 수 없습니다. 시계를 뒤로 돌리는 날에는 같은 현지 시각이 다시 나타날 수 있기 때문입니다. 시간대 이름과 첫 번째·두 번째 시각을 구별할 근거가 있어야 UTC 순서를 정할 수 있습니다.
이번에는 뉴욕의 가상 로그 두 건을 변환합니다. 2026-11-01T01:30:00에 fold=0을 지정한 결과는 UTC 05:30, fold=1은 UTC 06:30입니다. 시간대나 어느 쪽인지 모르면 값을 추측하지 않고 보류합니다.
1 현지 시각만으로 사건 순서를 정하지 않습니다
Python의 ZoneInfo는 IANA 시간대 규칙을 사용합니다. 되돌림으로 현지 시각이 겹치는 구간에서 fold=0은 앞선 쪽, fold=1은 뒤쪽을 선택합니다. Python zoneinfo 공식 문서의 설명과 예제를 참고할 수 있습니다.
실제 로그에 fold라는 열이 없을 수도 있습니다. 그때는 원본 UTC, 명시된 UTC 오프셋, 시스템이 기록한 시간대 규칙처럼 사건을 구별할 근거를 확인해야 합니다. 이 실습의 0과 1은 예제에서 명시한 값입니다. 단지 파일 위쪽에 있다는 이유로 0, 아래쪽이라는 이유로 1을 붙이지 않습니다.
| 이벤트 | 현지 시각 | 시간대 | fold | 기대 UTC |
|---|---|---|---|---|
| F0 | 2026-11-01T01:30:00 | America/New_York | 0 | 2026-11-01T05:30:00+00:00 |
| F1 | 2026-11-01T01:30:00 | America/New_York | 1 | 2026-11-01T06:30:00+00:00 |
이 두 건의 UTC 차이는 3600초입니다. 모든 시간대의 되돌림 폭이 한 시간이라는 뜻은 아닙니다. 날짜와 지역에 맞는 시간대 데이터가 필요합니다.


2 반복되는 시각과 존재하지 않는 시각을 나눕니다
시계를 앞으로 돌리는 날에는 건너뛴 현지 시각도 생깁니다. 예제의 뉴욕 2026-03-08T02:30:00은 존재하지 않는 현지 시각으로 보류합니다. 임의로 03:30으로 고치지 않습니다.
datetime에 시간대나 fold를 붙이는 것만으로 유효성이 자동 검사되지는 않습니다. PEP 495의 유효하지 않은 시각 설명을 확인하세요. 아래 코드는 두 fold 후보를 UTC로 옮겼다가 같은 지역으로 되돌려 원래 현지 시각과 fold가 일치하는 후보만 남깁니다.
- 후보 0개: 존재하지 않는 현지 시각으로 보류
- 후보 1개: 일반 시각으로 처리.
fold=null또는 유효한 0만 허용 - 후보 2개: 반복 구간. 명시된 0이나 1이 없으면 보류
일반 시각에 무조건 fold=1을 붙이는 입력도 거부합니다. 이 실습은 초 단위의 YYYY-MM-DDTHH:MM:SS만 받습니다. 오프셋이 이미 붙은 문자열, 소수초, 날짜만 있는 값은 이 입력 규칙에 맞지 않습니다.
3 입력 파일과 새 보고서 이름을 확인합니다
실습 ZIP P30_Practice_Timezone_Folds.zip을 빈 연습 폴더에 풀고 그 폴더에서 터미널을 엽니다. 입력은 실제 서버 기록이 아닌 가상 JSON입니다. fixtures/normal.json에는 다음 두 건이 있습니다.
[
{"event_id": "F0", "local_time": "2026-11-01T01:30:00", "zone": "America/New_York", "fold": 0},
{"event_id": "F1", "local_time": "2026-11-01T01:30:00", "zone": "America/New_York", "fold": 1}
]
각 항목은 event_id, local_time, zone, fold 네 필드를 갖습니다. event_id는 비어 있지 않은 문자열입니다. 보고서의 source_row는 JSON 배열의 1부터 시작하는 항목 번호이며 원본 줄 번호는 아닙니다. 동일한 이벤트 ID가 반복되어도 항목 번호로 추적하며, 이 코드는 이벤트 중복 여부를 판정하지 않습니다.
이 예제의 실행 확인 환경은 Linux의 CPython 3.12.14입니다. 시스템 IANA 데이터 2026b와 tzdata 2026.3 패키지의 IANA 데이터 2026c를 각각 사용해 제공된 입력 결과를 대조했습니다. 다른 Python 버전이나 운영체제의 실행 결과까지 확인한 것은 아닙니다.
zoneinfo는 시스템 데이터부터 찾고 없으면 tzdata 패키지를 찾습니다. Windows처럼 IANA 데이터가 없는 환경을 고려할 때는 tzdata 의존성이 필요할 수 있습니다. 데이터 소스 공식 안내를 확인하고 자신의 데이터 버전도 기록하세요. 패키지가 필요한 환경을 위한 고정 버전은 ZIP의 requirements.txt에 있습니다.
4 ZIP과 같은 전체 변환 코드
resolve_times.py의 전체 코드입니다. 입력 파일은 읽기만 하며 출력은 새 파일로만 만듭니다. 기존 보고서가 있으면 덮어쓰지 않습니다.
import argparse
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import sys
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
FIELDS = {"event_id", "local_time", "zone", "fold"}
LOCAL = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}")
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate_json_key")
result[key] = value
return result
def resolve_one(raw):
if not isinstance(raw, dict) or set(raw) != FIELDS:
raise ValueError("invalid_record_schema")
if not isinstance(raw["event_id"], str) or not raw["event_id"].strip():
raise ValueError("invalid_record_schema")
text, key, choice = raw["local_time"], raw["zone"], raw["fold"]
if not isinstance(key, str) or not key.strip():
raise ValueError("missing_zone")
if choice is not None and (type(choice) is not int or choice not in (0, 1)):
raise ValueError("invalid_fold")
if not isinstance(text, str) or not LOCAL.fullmatch(text):
raise ValueError("invalid_local_time")
try:
wall = datetime.fromisoformat(text)
except ValueError:
raise ValueError("invalid_local_time") from None
try:
zone = ZoneInfo(key)
except (ZoneInfoNotFoundError, ValueError):
raise ValueError("unknown_zone_or_missing_data") from None
candidates = {}
try:
for fold in (0, 1):
aware = wall.replace(tzinfo=zone, fold=fold)
utc = aware.astimezone(timezone.utc)
back = utc.astimezone(zone)
if back.replace(tzinfo=None) == wall and back.fold == fold:
candidates[fold] = (aware, utc)
except OverflowError:
raise ValueError("unsupported_time_range") from None
if not candidates:
raise ValueError("nonexistent_local_time")
if len(candidates) == 2 and choice is None:
raise ValueError("ambiguous_missing_fold")
selected = 0 if choice is None else choice
if selected not in candidates:
raise ValueError("fold_not_valid_for_time")
aware, utc = candidates[selected]
return {"event_id": raw["event_id"], "local_time": text, "zone": key,
"fold": selected, "local_with_offset": aware.isoformat(), "utc": utc.isoformat()}
def resolve_records(records):
if not isinstance(records, list):
raise ValueError("input_must_be_json_array")
converted, held = [], []
for number, raw in enumerate(records, 1):
try:
converted.append({"source_row": number, **resolve_one(raw)})
except ValueError as error:
held.append({"source_row": number, "input": raw, "reason": str(error)})
return {"input_rows": len(records), "converted_count": len(converted),
"held_count": len(held), "converted": converted, "held": held}
def main():
parser = argparse.ArgumentParser(description="Resolve declared local times without guessing folds.")
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
args = parser.parse_args()
try:
with args.input.open("r", encoding="utf-8") as stream:
records = json.load(stream, object_pairs_hook=unique_object)
report = resolve_records(records)
# Validate UTF-8 serialization before creating the new output file.
payload = (json.dumps(report, ensure_ascii=False, allow_nan=False, indent=2) + "\n").encode("utf-8")
with args.output.open("xb") as stream:
stream.write(payload)
except (OSError, UnicodeError, ValueError, RecursionError) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 2
print(f"input={report['input_rows']} converted={report['converted_count']} held={report['held_count']}")
return 1 if report["held_count"] else 0
if __name__ == "__main__":
raise SystemExit(main())
replace(tzinfo=...)는 현지 시각에 해석 규칙을 붙이는 단계이고, astimezone(timezone.utc)가 UTC 변환 단계입니다. 비교나 경과 시간 계산에 쓸 값은 변환된 UTC로 통일합니다. 같은 tzinfo를 가진 현지 datetime끼리 비교·뺄셈을 하면 fold나 오프셋을 기대한 방식으로 반영하지 않을 수 있습니다. datetime 비교·뺄셈 규칙을 참고하세요.
5 정상 결과와 보류 결과를 직접 대조합니다
정상 입력부터 실행합니다. 아래 명령은 Linux에서 확인한 python3 명령입니다.
python3 resolve_times.py fixtures/normal.json report-normal.json
콘솔 기대값은 다음과 같고 종료 코드는 0입니다.
input=2 converted=2 held=0
새 report-normal.json의 converted 배열에서 F0·F1의 UTC와 현지 오프셋을 위 표와 대조합니다. F0은 -04:00, F1은 -05:00입니다. 원본 local_time은 둘 다 01:30으로 남습니다.
이어서 정상 두 건과 보류 세 건이 섞인 입력을 처리합니다.
python3 resolve_times.py fixtures/mixed.json report-mixed.json
input=5 converted=2 held=3
이번 종료 코드는 1입니다. 보고서를 만들었지만 확인할 항목이 남았다는 뜻입니다. held에는 원본 항목과 이유가 남습니다.
| 항목 | 이유 | 확인하고 다시 시도할 것 |
|---|---|---|
| H1, 뉴욕 01:30, fold 미정 | ambiguous_missing_fold | 원본 UTC·오프셋 등으로 첫 번째인지 두 번째인지 확인 |
| H2, 뉴욕 3월 8일 02:30 | nonexistent_local_time | 로그 생성 시스템의 시간 표기·변환 규칙 확인 |
| H3, 시간대 빈칸 | missing_zone | 기록 시스템의 지역 시간대 확인 |
변환 2건과 보류 3건의 합이 입력 5건이어야 합니다. source_row 1~5도 빠짐없이 한 번씩 남아야 합니다. 보류 건을 정상 결과에 억지로 끼워 넣지 않습니다.

6 오류와 재실행을 구분합니다
같은 출력 이름으로 다시 실행하면 종료 코드 2와 ERROR: 메시지가 나오며 기존 파일은 그대로 남습니다. 재검토한 입력은 report-normal-2.json처럼 새 이름으로 처리하세요.
unknown_zone_or_missing_data: 시간대 키의 오타와 데이터 미설치를 구분해 확인합니다. 한국어 문서라고Asia/Seoul을 자동 지정하지 않습니다invalid_local_time: 날짜가 실제 존재하는지, 초 단위 입력 형식이 맞는지 확인합니다. 이미 오프셋이 있는 값은 이 예제에 다시 넣지 않습니다invalid_fold: 0·1·null 이외의 값을 고칩니다. 문자열"0"이나 참·거짓도 받지 않습니다fold_not_valid_for_time: 일반 시각에 잘못 붙인 1 등을 확인합니다invalid_record_schema: 네 필드의 누락·추가와 이벤트 ID를 확인합니다- JSON 문법·중복 키·지나치게 깊은 중첩·UTF-8로 표현할 수 없는 문자열·읽기 오류: 종료 코드 2. 변환 보고서를 만들기 전에 중단합니다
출력 저장 중 디스크 오류가 나면 새 보고서가 일부만 남을 수 있습니다. ERROR:가 나온 파일은 완료 결과로 쓰지 말고 저장 공간을 확인한 뒤 새 이름으로 실행하세요. 이 코드는 서버 시계, 로그 수집기의 정렬 순서나 시스템 시간 설정을 바꾸지 않습니다.
ZIP에는 정상·보류·잘못된 값·잘못된 필드·빈 목록·JSON 문법 오류 입력과 기대 계약이 들어 있습니다. 다음 시험은 임시 폴더에 결과를 만들고 원본 해시와 재실행 거부를 함께 확인합니다.
python3 verify_practice.py
python3 run_fixture_checks.py
단위 시험 18개와 입력 파일 9개를 시스템·패키지 데이터 두 방식으로 실행한 18개 결과를 확인했습니다. 같은 시간 문자열을 정렬하기 전에 변환 건수, 보류 이유, UTC 순서가 모두 맞는지 확인하는 것이 이 실습의 완료 조건입니다.