[파이썬 100강] 92강. zoneinfo로 타임존 오류 없이 시간 처리하기

[파이썬 100강] 92강. zoneinfo로 타임존 오류 없이 시간 처리하기

서버는 UTC로 잘 돌고 있는데, 사용자 화면에서 예약 시간이 한 시간씩 밀리는 순간이 있습니다. 대부분은 서머타임(DST) 경계나 "naive datetime"(타임존 정보 없는 datetime) 처리 실수에서 시작됩니다. 이번 강의는 zoneinfo를 문법 소개 수준이 아니라, 운영에서 시간 버그를 줄이는 기준으로 정리합니다.


핵심 개념

  • zoneinfo.ZoneInfo는 IANA 타임존 데이터(Asia/Seoul, America/New_York 등)를 이용해 정확한 지역 시간 규칙을 적용합니다.
  • 시간 계산의 기준은 보통 UTC로 두고, 표시/입력 단계에서만 지역 타임존으로 변환하는 것이 안전합니다.
  • datetime이 naive(타임존 없음)인지 aware(타임존 포함)인지 구분하지 않으면, 비교/정렬/저장에서 미세한 오류가 누적됩니다.

많은 초보자가 "문자열로 시간 보관하고 필요할 때 파싱하면 되지"라고 생각합니다. 그런데 실무에서는 로그, DB, API, 화면 렌더링까지 여러 시스템이 관여합니다. 이때 한 곳에서 naive datetime을 만들고, 다른 곳에서 로컬 시간대로 해석하면 같은 이벤트가 다른 시각으로 보입니다. zoneinfo의 핵심은 단순히 "지역 시간 붙이기"가 아니라, 시스템 전체에서 시간의 기준을 일관되게 유지하는 것입니다. 특히 예약/결제/알림처럼 분 단위 정확도가 중요한 도메인에서는 UTC 저장 + 지역 변환 원칙이 사실상 표준입니다.

기본 사용

예제 1) aware datetime 만들기와 UTC 변환

>>> from datetime import datetime, timezone
>>> from zoneinfo import ZoneInfo
>>>
>>> seoul_now = datetime.now(ZoneInfo("Asia/Seoul"))
>>> seoul_now.tzinfo
zoneinfo.ZoneInfo(key='Asia/Seoul')
>>>
>>> utc_now = seoul_now.astimezone(timezone.utc)
>>> utc_now.tzinfo
datetime.timezone.utc

해설:

  • datetime.now(ZoneInfo("Asia/Seoul"))로 시작하면 처음부터 aware datetime이라 비교/정렬에서 안전합니다.
  • 저장 전 UTC로 바꾸면 서로 다른 지역 사용자를 다뤄도 기준이 흔들리지 않습니다.

예제 2) 같은 시각을 다른 지역으로 표시

>>> from datetime import datetime, timezone
>>> from zoneinfo import ZoneInfo
>>>
>>> event_utc = datetime(2026, 2, 17, 6, 0, tzinfo=timezone.utc)
>>> event_kr = event_utc.astimezone(ZoneInfo("Asia/Seoul"))
>>> event_ny = event_utc.astimezone(ZoneInfo("America/New_York"))
>>>
>>> event_kr.strftime("%Y-%m-%d %H:%M %Z")
'2026-02-17 15:00 KST'
>>> event_ny.strftime("%Y-%m-%d %H:%M %Z")
'2026-02-17 01:00 EST'

해설:

  • 하나의 UTC 기준 시각을 각 지역으로 바꿔 표시합니다.
  • 시간대 문자열(KST, EST)까지 함께 보여주면 사용자 혼동이 크게 줄어듭니다.

예제 3) 사용자 입력(문자열)을 지역 시간으로 해석 후 UTC 저장

>>> from datetime import datetime, timezone
>>> from zoneinfo import ZoneInfo
>>>
>>> local_text = "2026-03-10 09:30"
>>> user_tz = ZoneInfo("Asia/Seoul")
>>> local_dt = datetime.strptime(local_text, "%Y-%m-%d %H:%M").replace(tzinfo=user_tz)
>>> store_utc = local_dt.astimezone(timezone.utc)
>>>
>>> local_dt.strftime("%Y-%m-%d %H:%M %Z")
'2026-03-10 09:30 KST'
>>> store_utc.strftime("%Y-%m-%d %H:%M %Z")
'2026-03-10 00:30 UTC'

해설:

  • 입력은 지역 시간으로 해석하고, 저장은 UTC로 통일합니다.
  • 조회 시 다시 사용자 타임존으로 변환하면 "입력한 시간 그대로" 보이면서 내부 일관성도 유지됩니다.

예제 4) DST 경계에서 시간 비교 안전하게 하기

>>> from datetime import datetime, timezone
>>> from zoneinfo import ZoneInfo
>>>
>>> ny = ZoneInfo("America/New_York")
>>> a = datetime(2026, 11, 1, 1, 30, tzinfo=ny)  # DST 전환 시점 근처
>>> b = a.astimezone(timezone.utc)
>>> b.tzinfo
datetime.timezone.utc

해설:

  • DST 전환 구간은 같은 "1시 30분"이 두 번 나타날 수 있습니다.
  • 내부 비교/정렬을 UTC로 수행하면 경계 시점 오류를 줄일 수 있습니다.

자주 하는 실수

실수 1) naive datetime과 aware datetime을 섞어서 비교

>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>>
>>> naive = datetime(2026, 2, 17, 15, 0)
>>> aware = datetime(2026, 2, 17, 15, 0, tzinfo=ZoneInfo("Asia/Seoul"))
>>> naive < aware
Traceback (most recent call last):
...
TypeError: can't compare offset-naive and offset-aware datetimes

원인:

  • 같은 datetime처럼 보여도 한쪽은 타임존 정보가 없어서 파이썬이 안전하게 비교를 막습니다.

해결:

>>> fixed = naive.replace(tzinfo=ZoneInfo("Asia/Seoul"))
>>> fixed == aware
True

실무 팁:

  • "도메인 경계"(API 입력, DB 읽기, 배치 시작점)에서 naive를 즉시 aware로 정규화하세요.

실수 2) replace(tzinfo=...)를 "시간대 변환"으로 오해

>>> from datetime import datetime, timezone
>>> from zoneinfo import ZoneInfo
>>>
>>> utc_dt = datetime(2026, 2, 17, 6, 0, tzinfo=timezone.utc)
>>> wrong = utc_dt.replace(tzinfo=ZoneInfo("Asia/Seoul"))
>>> wrong.strftime("%Y-%m-%d %H:%M %Z")
'2026-02-17 06:00 KST'

원인:

  • replace는 "같은 벽시계 시각에 라벨만 바꾸는 작업"입니다. 변환이 아닙니다.

해결:

>>> right = utc_dt.astimezone(ZoneInfo("Asia/Seoul"))
>>> right.strftime("%Y-%m-%d %H:%M %Z")
'2026-02-17 15:00 KST'

핵심:

  • 시간대 "변환"은 항상 astimezone입니다.

실수 3) DB에 지역 시간 문자열만 저장

  • 증상: 한국 사용자에게 맞춘 시간 저장 방식이 해외 사용자 추가 후 바로 깨집니다.
  • 원인: "2026-02-17 15:00"처럼 타임존 없는 문자열을 저장해 해석 기준이 사라졌습니다.
  • 해결: UTC timestamp(또는 ISO8601 + offset)로 저장하고, 표시 계층에서만 사용자 타임존 적용.

실무 패턴

  • 입력 검증 규칙

    • 사용자 입력: datetime 문자열 + timezone key를 함께 받습니다.
    • timezone key는 ZoneInfo(...) 생성 시 예외를 잡아 검증합니다.
    • 검증 실패 메시지는 "지원 가능한 형식"까지 안내해야 고객센터 부담이 줄어듭니다.
  • 로그/예외 처리 규칙

    • 로그에는 지역 시간만 남기지 말고 UTC와 지역 시간을 함께 남깁니다.
    • 예: scheduled_local=2026-03-10T09:30:00+09:00, scheduled_utc=2026-03-10T00:30:00+00:00
    • 운영 장애 때 "어느 시간 기준인지"를 다시 추측하는 비용이 사라집니다.
  • 재사용 함수/구조화 팁

    • parse_local_to_utc(text, tz_key) 같은 공통 함수를 만들어, 모든 입력 경로에서 같은 규칙을 강제하세요.
    • 뷰/핸들러 계층에서 직접 시간 변환 로직을 반복하면, 한 곳 누락으로 버그가 재발합니다.
  • 성능/메모리 체크 포인트

    • 대량 변환 배치에서는 문자열 파싱 비용이 큽니다. 파싱/검증/변환 단계를 분리해 병목을 측정하세요.
    • 같은 타임존을 반복 사용할 때 ZoneInfo("Asia/Seoul") 객체를 재사용하면 가독성과 일관성에 도움이 됩니다.
  • 팀 운영 규칙(권장)

    1. 저장: UTC만 저장
    2. API 응답: ISO8601 + offset 포함
    3. 화면 표시: 사용자 타임존 변환 후 렌더링
    4. 비교/정렬: 내부는 UTC 기준

이 네 가지만 지켜도 "한 시간 밀림"류 장애의 대부분을 예방할 수 있습니다.

오늘의 결론

한 줄 요약: 시간 처리의 본질은 '어떤 시각인가'보다 '어떤 기준으로 해석되는가'를 고정하는 일입니다.

기억할 것:

  • naive/aware를 섞지 말고, 경계 지점에서 즉시 aware로 정규화하세요.
  • replace(tzinfo=...)는 변환이 아니라 라벨 교체입니다. 변환은 astimezone입니다.
  • 저장은 UTC, 표시는 사용자 타임존. 이 분리를 지키면 운영 사고가 크게 줄어듭니다.

연습문제

  1. "2026-08-15 21:00""Asia/Seoul"을 받아 UTC aware datetime으로 반환하는 함수 to_utc(text, tz_key)를 작성하세요.
  2. UTC 이벤트 목록을 받아 사용자 타임존 문자열(Asia/Seoul, America/Los_Angeles)로 변환해 출력 문자열 리스트를 반환하는 함수를 작성하세요.
  3. naive datetime이 들어오면 ValueError를 발생시키고, aware만 허용하는 ensure_aware(dt) 함수를 작성하세요.

이전 강의 정답

  1. TOML 문자열 로드 후 기본값 처리
>>> import tomllib
>>> raw = b"""
... [app]
... name = "python100"
... """
>>> conf = tomllib.loads(raw.decode("utf-8"))
>>> conf.get("app", {}).get("name", "unknown")
'python100'
>>> conf.get("app", {}).get("timeout", 30)
30
  1. 숫자 타입 검증
>>> def read_retry(cfg: dict) -> int:
...     value = cfg.get("retry", 3)
...     if not isinstance(value, int):
...         raise ValueError("retry must be int")
...     return value
...
>>> read_retry({"retry": 5})
5
>>> read_retry({})
3
  1. 중첩 키 안전 접근
>>> cfg = {"db": {"host": "localhost"}}
>>> host = cfg.get("db", {}).get("host", "127.0.0.1")
>>> port = cfg.get("db", {}).get("port", 5432)
>>> host, port
('localhost', 5432)

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: datetime, zoneinfo (표준 라이브러리)
  • 재현 절차:
    1. Python 3.11 REPL 실행
    2. 본문 pycon 예제 순서대로 실행
    3. naive/aware 비교 TypeError, replace vs astimezone 결과 차이 확인
  • 검증 포인트:
    • tzinfo가 비어 있지 않은지
    • 저장 직전 UTC 변환이 수행되는지
    • 로그에 지역 시각과 UTC 시각이 함께 남는지