[에러 해결] Python datetime & zoneinfo: DST 전환 시 다른 UTC 타임스탬프에도 동등 비교 True 반환 원인과 해결 방법

파이썬 애플리케이션에서 시간대(timezone-aware) 정보를 포함한 datetime 객체를 다루는 것은 매우 중요하지만, 일광 절약 시간(Daylight Saving Time, DST)과 같은 특별한 상황에서는 예상치 못한 동작을 일으킬 수 있습니다. 특히 DST가 해제되는 시점(가을)에 같은 ‘로컬 시간’이 두 번 발생할 때, 서로 다른 UTC 시점을 가리킴에도 불구하고 파이썬이 두 datetime 객체를 ‘동일하다’고 판단하여 딕셔너리 키 충돌과 같은 문제로 이어질 수 있습니다.

이 글에서는 파이썬의 datetime 객체가 DST 전환 시 왜 이런 방식으로 작동하는지 그 원인을 깊이 있게 분석하고, 이러한 혼란을 방지하며 정확한 시간 처리를 보장하는 실용적인 해결 방법을 제시합니다. 시간대 관련 오류로 고민하고 있다면, 이 글이 명확한 해결책을 제공할 것입니다.

1. 에러 발생 상황

개발자는 zoneinfo 라이브러리를 사용하여 시간대 정보를 가진 datetime 객체로 예약된 이벤트를 관리하고 있었습니다. 특정 시점, 특히 DST가 해제되어 시계가 한 시간 되돌아가는 “fall back” 상황에서 문제가 발생했습니다. 같은 로컬 시간(예: 새벽 1시 30분)이 두 번 나타날 때, 이를 구별하기 위해 datetime 객체의 fold 속성을 각각 0과 1로 다르게 설정했습니다.

그러나 이 두 객체가 서로 다른 UTC 타임스탬프를 가지고 있음에도 불구하고, 파이썬은 이 둘을 동등하다고 판단했습니다. 이로 인해 딕셔너리 키로 사용할 때 첫 번째 이벤트가 두 번째 이벤트로 덮어쓰여지는 예상치 못한 결과를 초래했습니다.

from datetime import datetime
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")

# DST 전환 시 두 번 발생하는 새벽 1시 30분
first = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=0) # 첫 번째 1시 30분
second = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=1) # 두 번째 1시 30분 (UTC 기준 1시간 후)

print("First timestamp:", first.timestamp())
print("Second timestamp:", second.timestamp())

print("Are they equal?", first == second) # True 반환

events = {
    first: "First event",
    second: "Second event" # first를 덮어씀
}

print("Dictionary length:", len(events))
print("Dictionary contents:", events)

실제 출력 (Actual output)

First timestamp: 1730611800.0
Second timestamp: 1730615400.0

Are they equal? True

Dictionary length: 1
Dictionary contents: {datetime.datetime(...): 'Second event'}

기대 출력 (Expected output)

Are they equal? False
Dictionary length: 2

위 출력에서 볼 수 있듯이, 두 객체의 timestamp() 값은 명백히 1시간 차이(3600초)가 나지만, == 비교는 True를 반환합니다. 이는 딕셔너리가 하나의 키만 인식하게 하여 데이터 손실을 야기합니다.

2. 명확한 발생 원인

이 문제의 핵심은 파이썬 datetime 객체의 동등 비교(== 연산자) 작동 방식에 있습니다.

파이썬 3.12 이하 버전에서 datetime 객체 간의 동등 비교는 다음과 같은 속성들을 비교합니다:

  • 년, 월, 일, 시, 분, 초, 마이크로초 (로컬 시간 구성 요소)
  • tzinfo 객체 자체 (시간대 정보)

중요한 점은, datetime 객체의 fold 속성은 동등 비교 시 고려되지 않는다는 것입니다. fold 속성은 일광 절약 시간(DST) 전환 시 같은 로컬 시간이 두 번 나타날 때, 이 두 시점을 구별하기 위해 도입된 기능입니다. 예를 들어, DST가 해제되어 시계가 한 시간 뒤로 돌아갈 때, “새벽 1시 30분”이 첫 번째(fold=0)와 두 번째(fold=1)로 발생할 수 있습니다.

따라서 예시 코드의 두 datetime 객체는 다음과 같은 이유로 == True가 됩니다.

  • 로컬 시간 구성 요소(년, 월, 일, 시, 분, 초)가 동일합니다.
  • tzinfo 객체(ZoneInfo("America/New_York"))가 동일합니다.
  • fold 속성은 비교 대상에서 제외됩니다.

결과적으로, 파이썬은 이 두 객체를 ‘로컬 시간으로는 동일한’ 객체로 간주하며, 이 때문에 딕셔너리 해싱(hashing) 시에도 같은 해시 값을 반환하여 한 객체가 다른 객체를 덮어쓰게 됩니다.

3. 해결 방법 및 코드 예시

서로 다른 UTC 시점을 나타내는 datetime 객체들을 명확히 구별하려면, 다음 방법 중 하나를 선택할 수 있습니다.

해결 방법 1: 모든 datetime 객체를 UTC로 정규화 (권장)

가장 강력하고 일반적으로 권장되는 방법은 모든 datetime 객체를 UTC로 변환하여 비교하거나 딕셔너리 키로 사용하는 것입니다. UTC는 DST의 영향을 받지 않으므로 fold 문제가 발생하지 않습니다. 애플리케이션 내부적으로는 항상 UTC를 사용하는 것이 좋은 설계 관행입니다.

from datetime import datetime
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")
utc_tz = ZoneInfo("UTC")

first = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=0)
second = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=1)

# UTC로 변환
first_utc = first.astimezone(utc_tz)
second_utc = second.astimezone(utc_tz)

print("First UTC:", first_utc)
print("Second UTC:", second_utc)
print("Are they equal (UTC)?", first_utc == second_utc) # False 반환

# UTC 객체를 딕셔너리 키로 사용
events_utc = {
    first_utc: "First event in UTC",
    second_utc: "Second event in UTC"
}

print("Fixed dictionary length (UTC keys):", len(events_utc)) # 2
print("Fixed dictionary contents (UTC keys):", events_utc)

이 방법은 정확한 시간 비교와 데이터 관리에 가장 신뢰할 수 있는 접근 방식입니다.

해결 방법 2: fold 속성을 포함한 비교 가능한 표현 생성

만약 로컬 시간대를 유지한 채 비교해야 한다면, datetime 객체 자체 대신 fold 속성까지 포함한 튜플과 같은 고유한 표현을 생성하여 비교하거나 딕셔너리 키로 사용할 수 있습니다. 이는 fold 속성을 명시적으로 비교에 포함시키는 방법입니다.

from datetime import datetime
from zoneinfo import ZoneInfo

tz = ZoneInfo("America/New_York")

first = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=0)
second = datetime(2024, 11, 3, 1, 30, tzinfo=tz, fold=1)

def get_comparable_key(dt):
    """datetime 객체와 fold 속성을 포함하는 고유한 키를 생성합니다."""
    # tzinfo.key는 시간대 이름을 문자열로 반환합니다.
    return (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second,
            dt.microsecond, dt.tzinfo.key, dt.fold)

key_first = get_comparable_key(first)
key_second = get_comparable_key(second)

print("Key for first:", key_first)
print("Key for second:", key_second)
print("Are keys equal?", key_first == key_second) # False 반환

# 생성된 키를 딕셔너리에 사용
events_fixed_keys = {
    key_first: "First event with custom key",
    key_second: "Second event with custom key"
}

print("Fixed dictionary length (custom keys):", len(events_fixed_keys)) # 2
print("Fixed dictionary contents (custom keys):", events_fixed_keys)

이 방법은 datetime 객체 자체를 키로 사용하는 것보다는 번거롭지만, 로컬 시간대 정보를 유지하면서도 정확한 구별을 가능하게 합니다.

4. 향후 예방을 위한 팁

datetime 객체, 특히 시간대 정보를 다룰 때는 몇 가지 중요한 사항을 염두에 두어야 합니다.

1. fold 속성의 이해

fold 속성은 DST 전환과 같은 모호한 로컬 시간 상황에서 두 시점을 구별하기 위한 목적으로 존재합니다. 이 속성이 `datetime` 동등 비교에 항상 영향을 미 미치는 것은 아니라는 점을 인지하는 것이 중요합니다 (파이썬 3.12 이하 버전 기준).

2. 일관된 시간대 전략: “Store UTC, Display Local”

대부분의 애플리케이션에서 가장 권장되는 시간 관리 전략은 데이터를 저장하거나 내부 로직을 처리할 때는 항상 UTC 시간을 사용하고, 사용자에게 시간을 표시할 때만 사용자의 로컬 시간대로 변환하는 것입니다. 이 전략은 DST나 다른 시간대 관련 문제로 인한 오류를 최소화하는 데 큰 도움이 됩니다.

3. 명시적인 비교 방식 사용

datetime 객체의 동등 비교가 필요한 경우, 단순히 == 연산자를 사용하기보다는 다음과 같이 의도를 명확히 하는 방법을 고려하세요:

  • `dt1.astimezone(ZoneInfo(“UTC”)) == dt2.astimezone(ZoneInfo(“UTC”))`: UTC 기준으로 비교
  • `dt1.timestamp() == dt2.timestamp()`: Unix 타임스탬프(UTC 기준)를 사용하여 비교 (부동 소수점 정밀도 문제에 유의)

4. Python 3.13+ 버전의 변화 고려

매우 중요한 변화로, Python 3.13 버전부터는 datetime 객체 동등 비교 시 fold 속성도 고려하도록 변경되었습니다. 즉, Python 3.13 이상을 사용하는 환경에서는 fold 값이 다르면 == False가 반환되어 이 문제 자체가 발생하지 않습니다. 따라서 가능한 경우 최신 Python 버전으로 업데이트하는 것을 고려하거나, 기존 버전을 사용하는 경우 위 해결책들을 적용해야 합니다.

시간과 날짜는 개발에서 가장 까다로운 부분 중 하나이며, 특히 시간대와 DST는 복잡성을 더합니다. datetime 객체의 내부 동작 방식을 정확히 이해하고, UTC 기반의 일관된 시간 처리 전략을 적용하며, 필요한 경우 명시적인 비교 방법을 사용하는 것이 견고한 애플리케이션을 만드는 데 필수적입니다.

댓글 남기기