[개발 깨알 상식_Tips] / 시간대 없는 datetime과 UTC 값 비교 오류.md

시간대 없는 datetime과 UTC 값 비교 오류

조회

2026년 7월 15일 | 개발 깨알 상식_Tips


Python에서 API 응답의 만료 시각과 로컬에서 만든 현재 시각을 비교하다가 TypeError: can't compare offset-naive and offset-aware datetimes를 만났다. 문자열 모양은 둘 다 날짜와 시간이었지만, 한쪽에는 UTC 오프셋이 있고 다른 쪽에는 시간대 정보가 없었다. 값의 포맷보다 먼저 datetime이 어느 기준의 시각을 표현하는지가 갈린 상태였다.

이 오류를 급히 없애려고 tzinfo를 지우거나 아무 값에 UTC를 붙이면 코드는 다시 돌 수 있다. 문제는 그 순간 비교 대상이 같은 시각이 아닐 수 있다는 점이다. 나는 예외를 타입 문제로만 보지 않고, 시스템 안에서 wall clock과 실제 시점을 섞었다는 신호로 보는 편이 안전했다.

시간대 없는 datetime과 UTC datetime의 직접 비교와 정규화 비교 흐름

Figure 1. 같은 두 입력도 바로 비교하면 TypeError가 나고, UTC aware 상태로 통일하면 실제 시점 기준 비교가 된다.

한쪽만 시간대를 알고 있었다

문제가 난 입력은 외부 API의 ISO 8601 문자열이었다. 2026-07-15T00:30:00Z를 파싱하면 UTC 오프셋을 가진 aware datetime이 된다. 반면 인자 없이 만든 datetime.now()는 현재 머신의 벽시계 값을 담지만 객체 자체에는 오프셋이 없는 naive datetime이다. 서버가 KST로 설정돼 있다고 해서 그 사실이 객체에 자동으로 새겨지는 것은 아니다.

from datetime import datetime

expires_at = datetime.fromisoformat(
    "2026-07-15T00:30:00Z".replace("Z", "+00:00")
)
now_local = datetime.now()

expired = expires_at <= now_local  # TypeError

naive datetime은 무조건 로컬 시각이라는 뜻도 아니다. 코드 작성자가 UTC라고 가정했을 수도 있고, 데이터베이스 드라이버가 오프셋을 떼고 돌려줬을 수도 있다. Python은 그 숨은 가정을 알 수 없기 때문에 aware 값과의 순서 비교를 막는다. 이 제약이 없었다면 서로 다른 기준의 09:30을 같은 숫자로 정렬하는 더 조용한 오류가 생겼을 것이다.

그래서 예외가 난 줄만 고치기 전에 생성 지점을 찾는다. API 파서, DB 컬럼, 메시지 큐 payload, 테스트 fixture 중 어디에서 오프셋이 사라졌는지를 확인한다. 비교 연산은 원인이 아니라 시간대 계약이 처음 드러난 위치일 때가 많았다.

replace(tzinfo=...)는 시간 변환이 아니었다

가장 위험한 임시 수정은 로컬 벽시계 값에 replace(tzinfo=timezone.utc)를 붙이는 것이다. replace는 시계 숫자를 그대로 둔 채 이름표만 바꾼다. 한국 시각 09:30을 UTC 09:30으로 다시 라벨링하면 실제 시점은 아홉 시간 밀린다. 예외는 사라지지만 만료 판정, 배치 마감, 날짜별 집계가 틀릴 수 있다.

반대로 aware 값의 tzinfoNone으로 지우는 것도 비교 기준을 해결한 것이 아니다. 두 객체를 naive로 맞추면 Python은 숫자 비교를 허용하지만, 한쪽이 UTC였고 다른 쪽이 KST였다면 의미는 여전히 다르다. 타입을 같게 만드는 것과 같은 instant로 정규화하는 것은 별개다.

이미 시간대가 있는 객체를 다른 시간대로 바꿀 때는 astimezone을 쓴다. 시간대가 없는 값에는 먼저 그 값이 실제로 어느 지역의 벽시계인지 출처 계약을 확인한 뒤 zone을 부여한다. 이 순서를 거꾸로 하면 편한 기본값이 원본 의미를 덮는다.

입구에서 UTC aware로 통일했다

지금은 서비스 내부 비교 기준을 UTC aware datetime으로 고정한다. 외부 ISO 문자열은 오프셋을 보존해 파싱하고, 현재 시각도 처음부터 datetime.now(timezone.utc)로 만든다. 두 값이 모두 aware이면 Python은 각 오프셋을 반영해 실제 시점을 비교한다.

from datetime import datetime, timezone

expires_at = datetime.fromisoformat(
    raw_expires_at.replace("Z", "+00:00")
).astimezone(timezone.utc)
now_utc = datetime.now(timezone.utc)

expired = expires_at <= now_utc

레거시 데이터가 시간대 없는 KST 벽시계라고 명확히 정해져 있다면 zoneinfo.ZoneInfo("Asia/Seoul")로 출처 시간대를 부여한 뒤 UTC로 변환한다. 여기서 “명확히”가 중요하다. 컬럼 설명이나 생성 코드 없이 값 모양만 보고 KST라고 추측하지 않는다. 오프셋이 없는 데이터에는 기술 문제가 아니라 데이터 계약의 빈칸이 남아 있기 때문이다.

from datetime import timezone
from zoneinfo import ZoneInfo

kst = ZoneInfo("Asia/Seoul")
legacy_local = datetime.fromisoformat("2026-07-15T09:30:00")
legacy_utc = legacy_local.replace(tzinfo=kst).astimezone(timezone.utc)

DST가 있는 지역의 과거 벽시계는 더 조심해야 한다. 특정 시각이 두 번 나타나거나 아예 존재하지 않을 수 있어서 단순 zone 부여만으로 모호성이 끝나지 않는다. 외부 시스템과 계약할 때 오프셋이나 IANA zone, 모호한 시각 처리 정책까지 받는 편이 낫다.

직렬화와 로그에서 오프셋을 지우지 않았다

내부 객체를 UTC aware로 맞춰도 저장 경계에서 다시 naive 문자열로 만들면 같은 문제가 돌아온다. 로그와 JSON에는 isoformat() 결과의 +00:00을 유지한다. 사람이 보기 좋게 KST로 표시할 필요가 있으면 표현 단계에서만 변환하고, 원본 UTC 값도 함께 남긴다.

특히 날짜만 잘라 집계하는 코드는 시간대 기준을 명시해야 한다. UTC 기준 7월 15일 00:30은 KST로 7월 15일 09:30이지만, UTC 전날 16:30 이후의 값도 KST에서는 다음 날로 넘어간다. 같은 instant라도 어느 zone에서 date()를 호출했는지에 따라 일별 버킷이 달라진다. 비교 오류를 고친 뒤 날짜 경계 테스트까지 보지 않으면 대시보드에서 하루치가 옆 칸으로 밀릴 수 있다.

테스트 fixture에도 오프셋을 적는다. 2026-07-15 09:30 같은 값만 두면 테스트를 읽는 사람이 로컬인지 UTC인지 다시 추측한다. 만료 직전·직후와 자정 전후 케이스를 UTC와 표시 시간대 두 기준으로 넣어 두면, 파서나 DB 어댑터를 바꾼 뒤 오프셋이 사라지는 회귀를 빨리 잡을 수 있다.

비교 전에 확인할 네 가지

  • 각 datetime의 tzinfoutcoffset()을 확인한다.
  • naive 값이 어느 지역의 벽시계인지 생성 지점과 데이터 계약에서 찾는다.
  • 비교·정렬·만료 판정은 UTC aware로 정규화한 뒤 수행한다.
  • 로그, JSON, DB 왕복 뒤에도 오프셋이 보존되는지 테스트한다.

나는 이 오류를 만나면 양쪽에서 tzinfo를 지워 통과시키지 않는다. 먼저 어느 입력이 시간대 정보를 잃었는지 찾고, 원본 의미를 확인한 뒤 같은 instant 기준으로 바꾼다. 이렇게 고치면 TypeError 한 줄뿐 아니라 만료 판정과 날짜 집계까지 같은 규칙으로 묶인다.

핵심은 모든 시각을 무조건 UTC 문자열로 보이게 만드는 것이 아니다. 저장과 비교의 기준은 UTC로 고정하되, 사용자가 입력한 지역 시간과 표시할 시간대는 별도 정보로 보존하는 것이다. 시간대는 포맷 장식이 아니라 값의 의미 일부라는 기준을 지키면, 재실행 환경이 바뀌어도 같은 시점을 같은 순서로 읽을 수 있다.

댓글

홈으로 돌아가기

검색 결과

"" 검색 결과입니다.