해외 사용자의 시작·종료 시각을 빼서 작업 시간을 계산했는데, 서머타임 전환일에만 한 시간이 어긋날 수 있습니다. 같은 ZoneInfo 객체를 넣은 두 datetime을 바로 빼면 현지 시계에 표시된 날짜와 시각의 차이를 계산하기 때문입니다. 두 사건 사이의 경과 시간이 필요하다면 각각 UTC로 변환한 뒤 빼는 편이 명확합니다. 이 글은 베를린의 과거 전환일을 고정한 작은 예제로 계산 차이와 입력 검증 범위를 확인합니다.

검증 기준: 2026-10-11. Linux x86_64, CPython 3.12.14, 운영체제 시간대 데이터 2026b에서 아래 파일 전체를 실행했습니다. 외부 패키지는 설치하지 않았습니다. Python 3.12 공식 문서와 대조한 로컬 재현이며, 실제 서비스 장애나 운영 측정 사례는 아닙니다.
1. 먼저 ‘몇 시간’의 의미를 정합니다
벽시계 시간은 현지 달력과 시계에 표시된 시각입니다. 오후 9시부터 다음 날 오전 9시까지의 표시상 차이는 12시간입니다. 경과 시간은 두 시점 사이의 시간 간격입니다. 서머타임(DST)으로 시계가 앞으로 가거나 뒤로 돌아가는 구간에서는 두 값이 달라집니다.
시간대 정보를 가진 datetime을 aware 객체라고 부릅니다. 다만 두 객체가 같은 tzinfo를 공유하면 뺄셈에서는 시간대 보정을 하지 않는다는 규칙이 있습니다. Python 공식 datetime 문서의 연산 규칙에 명시된 동작입니다.
따라서 업무 종료까지 걸린 시간이나 두 로그 사건의 간격을 구하는 곳에는 UTC 기준 계산을 적용합니다. ‘매일 현지 오전 9시’ 같은 반복 일정은 현지 시간대와 달력 규칙이 중요하므로, 같은 수정으로 일괄 치환하지 않습니다.
2. 같은 입력으로 정상일·봄·가을을 재현합니다
아래 코드를 elapsed_hours_demo.py로 저장합니다. zoneinfo는 Python 3.9부터 제공되지만, 이 글에서 실제 실행한 버전은 3.12.14입니다. 예제에는 시스템에서 Europe/Berlin 시간대 데이터를 찾을 수 있어야 합니다. 데이터가 없어서 ZoneInfoNotFoundError가 나오면 먼저 공식 데이터 소스 안내에 따라 실행 환경을 확인합니다.
날짜와 입력은 설명을 위해 정한 테스트 데이터입니다. wall은 직접 뺀 값, elapsed는 UTC로 바꾼 뒤 뺀 값이며 단위는 시간입니다. 마지막 검사는 시간대 없는 naive 입력을 거부하는지 확인합니다.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
berlin = ZoneInfo("Europe/Berlin")
def elapsed_hours(start, end):
if start.utcoffset() is None or end.utcoffset() is None:
raise ValueError("aware datetime required")
delta = end.astimezone(timezone.utc) - start.astimezone(timezone.utc)
return delta.total_seconds() / 3600
cases = [
("normal", (2025, 10, 18), (2025, 10, 19), 12.0),
("spring", (2025, 3, 29), (2025, 3, 30), 11.0),
("autumn", (2025, 10, 25), (2025, 10, 26), 13.0),
]
for name, start_date, end_date, expected in cases:
start = datetime(*start_date, 21, tzinfo=berlin)
end = datetime(*end_date, 9, tzinfo=berlin)
wall = (end - start).total_seconds() / 3600
elapsed = elapsed_hours(start, end)
assert wall == 12.0
assert elapsed == expected
print(f"{name}: wall={wall}, elapsed={elapsed}")
first = datetime(2025, 10, 26, 2, 30, tzinfo=berlin, fold=0)
second = first.replace(fold=1)
wall = (second - first).total_seconds() / 3600
elapsed = elapsed_hours(first, second)
assert wall == 0.0 and elapsed == 1.0
print(f"fold: wall={wall}, elapsed={elapsed}")
try:
elapsed_hours(datetime(2025, 1, 1), datetime(2025, 1, 2))
except ValueError as exc:
print(f"naive: {exc}")
else:
raise AssertionError("naive input was accepted")
실행 명령:
python elapsed_hours_demo.py
위 환경에서 실제 출력된 결과입니다. 모든 assert를 통과했고 종료 코드는 0이었습니다.
normal: wall=12.0, elapsed=12.0
spring: wall=12.0, elapsed=11.0
autumn: wall=12.0, elapsed=13.0
fold: wall=0.0, elapsed=1.0
naive: aware datetime required
3. 12시간이 11시간 또는 13시간이 되는 이유
정상일에는 두 계산이 모두 12시간입니다. 예제의 봄 전환 구간에는 현지 시계가 앞으로 이동해 UTC 기준 간격이 11시간이고, 가을 전환 구간에는 시계가 뒤로 이동해 13시간입니다. 특정 날짜를 고정해 계산했으므로 새해의 전환일을 이 숫자로 추정해서는 안 됩니다.
핵심은 berlin 객체를 양쪽 입력이 공유한다는 점입니다. 같은 이름으로 ZoneInfo를 다시 생성하는 것만으로는 보통 이 조건이 바뀌지 않습니다. 기본 생성자는 캐시를 사용합니다. 이를 우회하려고 no_cache()를 사용하는 대신, 경과 시간 함수 안에서 UTC 변환을 명시하는 편이 의도를 읽기 쉽습니다. PEP 615는 객체 동일성이 날짜 연산의 의미와 연결되는 이유를 설명합니다.
fold는 시계가 뒤로 돌아가 같은 현지 시각이 두 번 나타날 때 둘을 구분하는 값입니다. 예제의 fold=0은 앞선 02:30, fold=1은 뒤의 02:30입니다. 직접 뺀 값은 0시간이지만 UTC로 바꾼 두 시점은 1시간 떨어져 있습니다. 이 구분은 PEP 495와 ZoneInfo 문서에서 확인할 수 있습니다.
4. 수정은 계산 경계에 작게 적용합니다
예제의 elapsed_hours()는 입력의 UTC 오프셋을 확인한 뒤 두 값을 astimezone(timezone.utc)로 변환합니다. 이 메서드는 같은 시점을 유지하면서 표현 시간대를 바꿉니다. replace(tzinfo=timezone.utc)로 시간대 표시만 덮어쓰면 원래 현지 시각이 UTC였던 것처럼 해석되므로 대체할 수 없습니다.
적용 전에는 현재 계산이 경과 시간을 원하는지 먼저 확인합니다. 맞다면 정상일·봄 전환·가을 전환·반복 시각·naive 입력을 회귀 테스트에 함께 둡니다. assert는 이 재현 파일의 확인 장치이고, 함수의 입력 거부는 ValueError로 수행합니다.
기존에 계산해서 저장한 값이 있다면 코드를 바꾼 것만으로 과거 값이 수정되지는 않습니다. 원래 시작·종료 시각과 오프셋이 남아 있는 작은 표본으로 이전 값과 새 값을 비교한 뒤, 차이가 설명되는 범위만 재계산 대상으로 정합니다. 업무가 현지 달력 기준 간격을 의도했다면 그 계산은 유지합니다.
5. UTC 변환으로 해결되지 않는 입력도 있습니다
- 모호한 현지 입력: ‘2025-10-26 02:30, Europe/Berlin’만으로는 두 시점 중 어느 쪽인지 확정할 수 없습니다. 오프셋이 포함된 원본 시각이나 사용자의 선택이 필요합니다. 임의로
fold=0을 붙여 사실로 저장하지 않습니다. - 존재하지 않는 시각: 봄에 건너뛴 구간의 현지 시각은 생성자가 자동으로 거부하지 않을 수 있습니다. 이 글의 함수는 aware 여부만 검사하며, 현지 입력의 유효성을 판정하는 검증기는 아닙니다. 입력 단계에서 별도의 정책과 검사가 필요합니다.
- 시간대 데이터 차이: 배포 환경의 시간대 데이터 버전도 함께 기록합니다. 규칙은 바뀔 수 있고, 이 글의 재현 결과는 명시한 과거 날짜와 데이터 버전에 한정됩니다.
- 프로세스 실행 시간: 코드가 실행되는 동안 걸린 시간을 재려면 시스템 시계 변경의 영향을 받지 않는 단조 시계인 time.monotonic()가 더 알맞습니다. 이 글은 저장된 사건 시각 사이의 간격을 다룹니다.
datetime은 윤초를 지원하지 않습니다.
정리: 시간대가 붙어 있다는 사실만 확인하지 말고, 그 값으로 어떤 시간을 계산할지 먼저 정합니다. 두 사건 사이의 경과 시간에는 UTC 변환을 계산 경계에 두고, 현지 시각 입력의 모호함은 입력 경계에서 해결합니다.
출처 확인일: 2026-10-11. 공식 문서의 의미를 정리하고 별도의 합성 입력으로 실행 결과를 대조했습니다. 릴리스 소식을 다루는 글이 아니며, 소개한 표준 라이브러리 기능을 신규 기능으로 주장하지 않습니다.
댓글
댓글 쓰기