freezegun vs time-machine, 시간대 주의점

freezegun vs time-machine 비교, 시간대 문제 주의점

3줄 요약

  • 두 라이브러리는 고정하는 함수 목록이 다릅니다. freezegun은 time.monotonic()까지 멈추고, time-machine은 그것만 실제 시각으로 남깁니다.
  • 같은 datetime(2026, 3, 1, 12, 0)을 넣어도 datetime.now()가 12:00과 21:00으로 갈립니다. 진입·해제 비용은 923.63us 대 8.52us로 108.4배 차이입니다.
  • 옮길 계획이 있다면 python -m time_machine migrate를 돌리기 전에 conftest에 naive_mode부터 정해 두시면 됩니다.

"리드님, 이 테스트가 CI에서만 안 끝나는데요."

팀원이 보여 준 잡은 12분째 초록색 스피너를 돌리고 있었습니다. 로컬에서는 0.4초에 끝나는 테스트였습니다. 차이는 하나뿐이었는데, 그 테스트에 @freeze_time이 붙어 있었고 안쪽에서 비동기 클라이언트의 타임아웃을 검증하고 있었습니다.

시간을 고정하는 도구를 고를 때 저는 오랫동안 "둘 다 되는데 하나가 빠르다" 정도로 알고 있었습니다. 실제로 두 라이브러리를 같은 코드에 붙여 놓고 함수 단위로 재 보니, 속도는 셋째쯤 되는 문제였습니다.

1. 무엇을 테스트하느냐로 갈립니다

먼저 조건별 판단을 정리해 두겠습니다. 아래 표의 근거는 이 글의 나머지 전부입니다.

조건 권고 이유
테스트 안에서 대기·타임아웃·재시도가 도는 경우 time-machine time.monotonic()을 건드리지 않아 루프가 정상 만료됩니다
asyncio 코드를 고정 시각 아래에서 돌리는 경우 time-machine, 또는 freezegun + real_asyncio=True 기본값 freezegun은 이벤트 루프가 만료를 판정하지 못합니다
고정을 거는 테스트가 수백 개 이상인 경우 time-machine 진입 비용이 모듈 수에 비례하지 않습니다
로컬 시각 의미를 그대로 유지하며 옮기는 경우 time-machine + naive_mode = LOCAL 기본값으로 두면 naive 값의 해석이 바뀝니다
CLOCK_REALTIME을 직접 읽는 의존 라이브러리가 있는 경우 time-machine freezegun은 이 경로를 막지 않습니다
PyPy를 쓰는 경우 freezegun time-machine은 설치 단계에서 거부합니다

측정 환경은 Python 3.10.12, pytest 9.1.1, Ubuntu 22.04 aarch64 4코어, TZ=Asia/Seoul입니다. 버전은 freezegun 1.5.5와 time-machine 3.5.1이고, 두 릴리스 사이에는 13개월이 있습니다. PyPI 업로드 시각 기준으로 freezegun 1.5.5는 2025-08-09 10:39 UTC, time-machine 3.5.1은 2026-09-08 22:18 UTC(KST 09-09 07:18)입니다.

버전 간격이 이 비교의 전제입니다

time-machine은 최근 6주 동안 3.3.0부터 3.5.1까지 다섯 번 나왔습니다. 뒤에 나오는 동작 중 몇 개는 2025년 11월 이후에 바뀐 것이라, 예전에 한 번 비교해 보고 결론을 내려 두셨다면 그 결론이 지금은 맞지 않을 수 있습니다.

2. 고정되는 함수 목록이 네 칸 다릅니다

시간을 읽는 표준 라이브러리 함수 15개를 2026-03-01 12:00 UTC로 고정한 채 하나씩 호출하고, 결과가 같은 것끼리 묶어 정리했습니다. TZ=UTC로 두고 잰 결과입니다.

함수 freezegun time-machine
datetime.datetime.now() 고정 고정
datetime.datetime.utcnow() 고정 고정
datetime.date.today() 고정 고정
time.time() / time.time_ns() 고정 고정
time.gmtime() / time.localtime() 고정 고정
time.monotonic() 고정 실제 시각
time.monotonic_ns() 고정 실제 시각
time.perf_counter() 고정 실제 시각
time.clock_gettime(CLOCK_REALTIME) 실제 시각 고정
time.process_time() 실제 시각 실제 시각
os.times().elapsed 실제 시각 실제 시각

어긋나는 칸은 네 개이고, 방향이 서로 반대입니다. freezegun은 단조 시계 세 개를 멈추면서 CLOCK_REALTIME을 열어 두고, time-machine은 그 반대입니다.

단조 시계를 멈춘다는 것의 의미

time.monotonic()은 절대 뒤로 가지 않는다는 계약을 가진 시계입니다. 대기 루프와 타임아웃은 이 계약을 믿고 경과를 잽니다. freezegun 아래에서 이 값은 1772366400.0, 즉 고정한 벽시계 값 그대로가 됩니다. 단조 시계가 절대 시각이 되고, 고정을 풀면 원래 값으로 되돌아가면서 시간이 뒤로 흐릅니다.

time-machine은 2023년 9월 2.13.0에서 단조 시계 모킹을 넣었다가 2025-11-18 3.0.0에서 다시 뺐습니다. 변경 기록에 제거 사유가 세 건 적혀 있는데 asyncio 이벤트 루프 정지, pytest-durations의 측정 불가, psycopg 타임아웃이고, 근본 원인을 "단조 시계의 계약을 깨서 시간이 뒤로 갈 수 있게 만든 것"이라고 적었습니다. 넣었다가 2년 만에 되돌린 결정입니다.

CLOCK_REALTIME은 반대쪽 구멍입니다

freezegun은 time.clock_gettime(time.CLOCK_REALTIME)을 막지 않습니다. 실측에서 고정 시각이 1772366400.0인데 이 함수만 1789113787.772를 돌려줬습니다. 표준 라이브러리 안에서는 이 경로를 쓰는 코드가 드물지만, 시각을 정밀하게 읽으려는 서드파티 라이브러리가 종종 이쪽을 씁니다. 고정을 걸었는데 로그 타임스탬프 하나만 오늘 날짜로 찍힌다면 여기를 보시면 됩니다.

3. 시간이 멈추면 끝나지 않는 테스트

앞의 표는 함수 목록일 뿐이고, 실제로 무엇이 망가지는지는 돌려 봐야 보입니다. 폴링 대기를 흉내 낸 함수를 네 조건에서 실행했습니다.

# 조건이 영영 참이 되지 않는 폴링 대기. 실무의 명시적 대기와 같은 모양입니다
def wait_until_ready(timeout=1.0, interval=0.05, max_iter=200):
    start = time.monotonic()
    n = 0
    while time.monotonic() - start < timeout:
        n += 1
        if n >= max_iter:                 # 안전장치 — 없으면 무한 루프입니다
            return {"결과": "안전장치로 탈출", "반복": n}
        time.sleep(interval)
    return {"결과": "정상 타임아웃", "반복": n}
조건 결과 반복 횟수 monotonic 경과
고정 없음 정상 타임아웃 19회 1.013초
freezegun 기본값 안전장치로 탈출 200회 0.0초
freezegun tick=True 정상 타임아웃 19회 1.031초
time-machine tick=False 정상 타임아웃 19회 1.019초
time-machine 기본값 정상 타임아웃 19회 1.016초

freezegun 기본값에서만 경과가 0.0초로 고정되어 루프가 끝나지 않았습니다. 안전장치를 넣어 뒀기에 200회에서 빠져나왔을 뿐이고, 실제 코드에 그런 장치가 있는 경우는 많지 않습니다.

비동기 타임아웃은 아예 만료되지 않습니다

같은 원인이 asyncio에서는 더 분명하게 드러납니다. 이벤트 루프의 loop.time()이 단조 시계이기 때문입니다. 0.4초 타임아웃을 건 asyncio.wait_for를 네 조건에서 돌리고 8초에 강제 종료되게 했습니다.

조건 결과
고정 없음 TimeoutError 정상 발생, 0.41초
freezegun 기본값 8초 초과, 강제 종료
freezegun real_asyncio=True TimeoutError 정상 발생
time-machine tick=False TimeoutError 정상 발생, 0.41초

freezegun에는 real_asyncio=True라는 인자가 있고 이것을 켜면 해결됩니다. 다만 기본값이 아니라서, 이 인자를 아는 사람이 그 테스트를 쓸 때만 켜집니다. 처음에 말씀드린 12분짜리 잡이 이 경우였습니다.

이런 실패는 통과하는 테스트 안에 숨습니다

time.sleep()이 가상 시계를 앞으로 밀지 않는다는 점은 양쪽 같습니다. 0.3초를 재워도 고정된 시각은 그대로입니다. 그래서 "시간이 흐르는지"를 검증하지 않는 테스트는 이 차이를 영영 만나지 않다가, 누군가 대기 로직을 추가하는 날 처음 멈춥니다. 커버리지가 100%인데도 남아 있는 구멍이 있다는 점에서는 뮤테이션 테스트로 확인했던 생존 뮤턴트와 성격이 같습니다.

4. 같은 값을 넣었는데 9시간이 어긋납니다

여기가 옮길 때 가장 조용히 터지는 곳입니다. TZ=Asia/Seoul에서 같은 입력을 넣고 datetime.now()를 읽었습니다.

넘긴 값 freezegun now() time-machine now() 차이
datetime(2026, 3, 1, 12, 0) 12:00 21:00 9시간
"2026-03-01 12:00:00" 12:00 12:00 없음
datetime(..., tzinfo=timezone.utc) 12:00 12:00 없음
date(2026, 3, 1) 00:00 09:00 9시간

time-machine의 기본 해석 모드는 MIXED이고, naive datetime 객체와 date 객체는 UTC로, naive 문자열은 로컬 시각으로 읽습니다. 한 라이브러리 안에서 입력 타입에 따라 해석이 갈리는 셈이라 문서도 다른 모드를 권하고 있습니다.

# conftest.py — 옮기기 전에 이 한 줄부터 정합니다
import time_machine

# LOCAL: 모든 naive 값을 로컬 시각으로. freezegun 과 같은 해석입니다
time_machine.naive_mode = time_machine.NaiveMode.LOCAL

# ERROR: naive 값을 아예 금지. 머신의 TZ 에 따라 결과가 달라지는 것을 막습니다
# time_machine.naive_mode = time_machine.NaiveMode.ERROR

ERROR 모드는 수집 단계에서 RuntimeError: Naive datetime provided while time_machine.naive_mode is set to ERROR. Please provide a timezone-aware datetime.으로 막습니다. 입력이 문자열이거나 date 객체면 문구가 조금 달라집니다. 데코레이터가 평가되는 시점에 걸리므로 테스트를 실행하기 전에 전부 드러납니다.

freezegun 안에서도 9시간이 갈립니다

이 문제는 두 라이브러리 사이에만 있는 것이 아닙니다. TZ=Asia/Seoul에서 명시적 UTC 시각을 고정하고 같은 프로세스 안에서 읽어 봤습니다.

읽는 방법 freezegun time-machine
datetime.now() 12:00 12:00
datetime.utcnow() 12:00 12:00
time.localtime() 21:00 12:00
time.strftime("%H:%M") 21:00 12:00
datetime.fromtimestamp(time.time()) 12:00 12:00

freezegun 안에서는 datetime.now()가 12:00인데 time.localtime()은 21:00입니다. 더 신경 쓰이는 쪽은 now()와 utcnow()가 같아진다는 점입니다. 실제 서버에서는 이 두 값이 9시간 차이 나는데 테스트 안에서만 같아지므로, 둘을 뒤섞어 쓴 버그는 어떤 어서션으로도 잡히지 않습니다.

time-machine은 고정하는 동안 TZ 환경변수를 직접 바꿔서 일관성을 얻습니다. 실측하면 travel 블록 안에서 TZ가 UTC로 바뀌고 빠져나오면 Asia/Seoul로 돌아옵니다. 대가도 있어서, 그 블록 안에서 띄운 자식 프로세스가 UTC 시각을 출력했습니다. 자식 프로세스에 시간 고정이 전파되지 않는 것은 양쪽 같지만, 출력되는 시각대가 달라진다는 점은 다릅니다.

5. 옮기는 비용을 실제로 재 봤습니다

time-machine에는 마이그레이션 CLI가 들어 있습니다. pip install time-machine[cli] 후 python -m time_machine migrate <파일>로 돌립니다. 디렉터리를 넘기면 IsADirectoryError가 나므로 파일 목록을 넘겨야 합니다. 패턴 다섯 개를 담은 파일에 실행한 결과입니다.

# 변환 전
@freeze_time("2026-03-01 12:00:00")
@freeze_time("2026-03-01", tick=True, ignore=["mypkg"], real_asyncio=True)
@pytest.mark.freeze_time("2026-03-01")        # 인자 freezer
with freeze_time("2026-03-01 12:00:00") as frozen: frozen.tick(...)

# 변환 후
@time_machine.travel("2026-03-01 12:00:00", tick=False)
@time_machine.travel("2026-03-01", tick=True)
@pytest.mark.time_machine("2026-03-01", tick=False)   # 인자 time_machine
with time_machine.travel("2026-03-01 12:00:00", tick=False) as frozen: frozen.shift(...)

다섯 패턴 모두 변환되고 변환 후 테스트도 통과했습니다. tick 기본값이 서로 반대라서 tick=False를 자동으로 붙여 주는 점이 손으로 고칠 때 가장 놓치기 쉬운 부분입니다. ignore와 real_asyncio는 조용히 사라지는데, 전자는 freezegun의 모듈 패치 방식 때문에 필요했던 인자이고 후자는 time-machine이 단조 시계를 건드리지 않아 불필요해진 인자입니다.

CLI를 믿고 넘어가면 안 되는 한 줄

문제는 CLI가 고칠 수 없는 곳에 있습니다. naive datetime 객체를 상수로 두고 넘기는 흔한 패턴을 변환해 봤습니다.

CUTOFF = dt.datetime(2026, 3, 1, 12, 0, 0)   # 흔한 상수 선언

@freeze_time(CUTOFF)                          # 변환 전: 통과
@time_machine.travel(CUTOFF, tick=False)      # 변환 후: assert 21 == 12 로 실패
def test_cutoff_day():
    assert dt.datetime.now().hour == 12

변환 자체는 정확합니다. 그런데 같은 파일을 TZ=Asia/Seoul에서 돌리면 실패하고 TZ=UTC에서 돌리면 통과합니다. 머신의 시간대가 판정을 바꾸는 상태가 된 것이고, 로컬과 CI의 시간대가 다른 팀이라면 한쪽에서만 빨간불이 켜집니다. conftest에 naive_mode = LOCAL 한 줄을 넣으면 다시 통과합니다.

비용은 테스트 수가 아니라 모듈 수를 따라갑니다

진입과 해제 비용을 각각 7회 측정해 최솟값을 취했습니다.

측정 대상 freezegun time-machine
진입 + 해제 1회 923.63us 8.52us
sys.modules 874개일 때 1,605.03us 8.53us
sys.modules 1,374개일 때 2,352.06us 8.57us
고정 안에서 datetime.now() 1회 0.39us 0.66us

모듈을 349개에서 1,374개로 늘리자 freezegun은 2.55배 느려지고 time-machine은 1.01배로 그대로였습니다. freezegun이 고정을 걸 때마다 sys.modules를 훑어 시간 관련 참조를 교체하기 때문이고, time-machine은 C 계층에서 함수 포인터만 바꾸기 때문입니다. 모듈 하나당 약 1.39us가 붙는 셈입니다.

늘리는 데 쓴 가짜 모듈은 datetime·date·time 참조와 정수 속성 스무 개를 가진 것입니다. 참조를 줄이면 1.89배, 시간 함수를 아홉 개까지 늘리면 3.68배까지 벌어지므로 이 배수는 프로젝트마다 다르게 나옵니다. 조건에 관계없이 흔들리지 않은 것은 time-machine이 모듈 수에 영향받지 않는다는 쪽이었습니다.

반대로 고정된 상태에서 datetime.now()를 부르는 비용은 freezegun이 0.39us로 오히려 빠릅니다. 고정 안에서 시간을 수만 번 읽는 테스트라면 이쪽이 유리하지만, 그런 테스트는 드뭅니다.

테스트 200개짜리 스위트로 확인하면 이렇습니다. 고정을 걸지 않으면 0.05초, time-machine도 0.05초, freezegun은 0.29초였습니다. sys.modules를 1,000개 늘린 조건에서는 freezegun만 0.57초로 늘었고 나머지 둘은 0.05초 그대로였습니다. 테스트당 1.20ms에서 2.60ms이므로, 고정을 2,000개 테스트에 걸고 있다면 조건에 따라 2.4초에서 5.2초가 붙습니다. 스위트 전체가 몇 분이라면 감수할 만하고, 이미 실행 순서를 무작위로 돌리고 있는 팀이라면 같은 테스트가 여러 번 도는 만큼 곱해집니다.

6. 어느 쪽을 고르든 뚫리는 것

마지막으로 양쪽 모두 막지 못하는 곳을 적어 두겠습니다. 도구를 바꿔서 해결되는 문제가 아니라 설계를 바꿔야 하는 문제입니다.

con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, "
            "created TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
# 고정 안에서 INSERT 했는데 created 에는 오늘 날짜가 들어갑니다

datetime('now')와 CURRENT_TIMESTAMP는 SQLite의 C 코드가 시스템 시계를 직접 읽으므로 두 라이브러리 모두 영향을 주지 못합니다. 실측에서 파이썬 쪽 utcnow()는 2026-03-01T12:00:00인데 같은 시점의 CURRENT_TIMESTAMP는 2026-09-11 08:09:55였습니다. NumPy의 np.datetime64("now")와 DuckDB의 시각 함수도 같은 이유로 뚫립니다.

우리 팀 기준으로 정리하면

판단 항목 freezegun 유지 time-machine 전환
도입 난이도 없음 CLI 한 번 + conftest 한 줄, 반나절
유지보수 비용 대기·비동기 테스트마다 인자를 기억해야 함 기본값이 안전한 쪽
팀 학습곡선 낮음 tick 기본값 반전과 naive 해석 두 가지만 공유하면 됨
되돌리기 — 두 라이브러리 병용은 막혀 있어 한 번에 옮겨야 함

3.2.0부터 freezegun이 활성인 상태에서 time-machine을 시작하면 RuntimeError가 납니다. 섞어 쓰면서 천천히 옮기는 방식은 선택지에 없고, 디렉터리 단위로 나눠서 옮기시는 편이 낫습니다.

저는 대기와 비동기가 들어간 테스트부터 옮기고 나머지는 남겨 둘 생각입니다. 나머지 테스트는 초당 몇 밀리초 문제일 뿐이라 급하지 않고, 옮기는 과정에서 naive 값 해석 때문에 멀쩡하던 테스트가 뒤집히는 쪽이 더 비쌉니다.

옮기기 전에 돌려 볼 것

# 1. 지금 고정을 몇 군데 쓰고 있는지, 그중 naive datetime 객체를 넘기는 곳은 어디인지
grep -rn "freeze_time\|pytest.mark.freeze_time" tests/ | wc -l
grep -rn "freeze_time(\s*dt\.datetime(\|freeze_time(\s*datetime(" tests/

# 2. 단조 시계에 의존하는 코드가 그 고정 안에 들어가는지
grep -rn "monotonic\|perf_counter\|asyncio.wait_for\|asyncio.timeout" src/

# 3. 옮긴 뒤에는 TZ 를 바꿔 두 번 돌려 봅니다. 결과가 다르면 naive_mode 를 정하지 않은 것입니다
TZ=UTC python -m pytest tests/ -q
TZ=Asia/Seoul python -m pytest tests/ -q

3번이 핵심입니다. 시간대에 따라 결과가 갈리는 테스트는 옮기기 전에도 잠재적으로 깨져 있었던 것이고, 옮기는 김에 드러난 것뿐입니다.

댓글

이 블로그의 인기 게시물

옵시디언 3기기 동기화, 유료 결제 없이 구글 드라이브로 끝내기

최저가 주유소, 얼마나 돌아가면 손해일까?

회의시간 줄이는 법, 개수보다 배치 먼저

유류세 인하 2개월 연장, 주유소 가격 실제 인하폭 계산

국민연금 추납 조회, 미루면 얼마나 더 낼까?