시간대를 넘는 스케줄링: cron·서머타임·시계 오차
02:30 작업이 3월엔 0번, 10월엔 2번 실행되고, 주간 회의가 1시간 밀리고, 시계 오차 30초에 토큰 절반이 거부됐다. 서머타임 없는 한국에서 놓치기 쉬운 시간 버그를 시뮬레이션으로 재고 테스트로 고친다.
시리즈: 날짜·시간·타임존 강좌 6/6 (고급). 배치를 돌리고, 알림을 보내고, 토큰을 검증하는데 사용자나 서버가 한 시간대에 다 있지 않은 개발자를 위한 글입니다. 이 강좌를 마치면 서머타임 밤에 스케줄러가 무엇을 하는지 정확히 설명하고, 반복 일정을 밀리지 않게 저장하고, 시계 오차에 대한 leeway를 숫자로 정하고, 이 모두를 주입한 시계로 테스트할 수 있습니다.
지금의 한국(Asia/Seoul)에는 서머타임이 없습니다. 그래서 국내 서비스만 만들 때는 "새벽 2시 반 배치가 안 돌았다"는 장애를 거의 겪지 않습니다. 하지만 tz 데이터베이스를 열어 보면 한국도 1987~88년에 서머타임을 썼고, 그 시절 날짜를 계산하는 코드는 지금도 그 규칙을 따릅니다. 그리고 해외 사용자를 받거나, 해외 리전에 서버를 두거나, 해외 지사와 정기 회의를 잡는 순간 이 버그들이 한꺼번에 찾아옵니다. 원인은 같습니다. 세 가지 시계, 즉 어떤 시간대의 벽시계, 전 세계 공통 UTC, 한 기계 안의 단조 시계(monotonic clock)를 헷갈린 코드입니다. 이 강좌에서는 순진한 스케줄러를 2026년 서머타임 주말에 1분 단위로 돌리고, 주간 회의를 두 방식으로 펼치고, 시계 오차가 토큰 검증에 주는 영향을 재고, 윤초를 다섯 도구에 넣어 보고, 마지막으로 서머타임에 강한 next_run()과 그 테스트를 씁니다.
환경: Python 3.9.6(zoneinfo, 시스템 tzdata 2026d), 전환 확인용 zdump, Java 26, PHP 8.5.6, SQLite 3.51.0, BSD date, Apple Silicon Mac (M1 Pro). 모든 명령에 시간대를 명시했습니다(TZ=UTC, ZoneInfo("Asia/Seoul") 등). 시뮬레이션은 고정 날짜와 고정 시드를 쓰고, 경로는 ~/demo/로 줄였습니다.
세 가지 시계: 벽시계, UTC, 단조 시계
프로그램이 시간에 대해 묻는 질문은 세 가지입니다.
- 마드리드 벽시계는 지금 몇 시인가? 어떤 시간대의 벽시계입니다. 서머타임 밤에 건너뜁니다.
- 지금은 어느 순간인가? UTC나 유닉스 시간입니다. 서머타임으로는 건너뛰지 않지만 조정 가능합니다. NTP나 관리자가 앞뒤로 옮길 수 있습니다.
- 이 프로세스 안에서 X 이후 몇 초가 지났나? 단조 시계입니다. 절대 뒤로 가지 않지만 0점에 의미가 없고 기계끼리 비교할 수 없습니다.
어느 것이 어느 것인지는 Python이 알려 줍니다.
import time
for name in ("time", "monotonic", "perf_counter", "process_time"):
print(name, time.get_clock_info(name))
time impl=clock_gettime(CLOCK_REALTIME) monotonic=False adjustable=True resolution=1e-06
monotonic impl=mach_absolute_time() monotonic=True adjustable=False resolution=4.16667e-08
perf_counter impl=mach_absolute_time() monotonic=True adjustable=False resolution=4.16667e-08
process_time impl=clock_gettime(CLOCK_PROCESS_CPUTIME_ID) monotonic=True adjustable=False resolution=1e-06
time.time()은 adjustable=True입니다. if time.time() - start > 30으로 쓴 타임아웃은 기다리는 동안 시계가 조정되면 일찍 터지거나 영영 안 터질 수 있습니다. 단조 시계가 더 비쌀까요? 100만 번 호출을 5번 반복해 1회당 최소/중앙값을 쟀습니다.
time 62.8 ns/call (min of 5), median 63.0
time_ns 66.1 ns/call (min of 5), median 66.5
monotonic 56.1 ns/call (min of 5), median 56.2
perf_counter 56.3 ns/call (min of 5), median 56.4
perf_counter_ns 53.8 ns/call (min of 5), median 54.0
empty lambda 64.3 ns/call
모두 빈 Python 함수 호출(64.3 ns)과 비슷합니다. 비용의 대부분은 루프이고 시계 자체는 거의 공짜입니다. 연속된 두 번의 읽기에서 관찰한 가장 작은 간격은 time.time() 0.715 µs, perf_counter() 0.291 µs였습니다. 경과 시간을 벽시계로 잴 성능상의 이유는 없습니다. 규칙: 경과 시간과 타임아웃은 monotonic()/perf_counter(), 저장하거나 보내는 타임스탬프는 time.time()이나 datetime.now(timezone.utc).
서머타임 밤의 cron: 1분 단위 시뮬레이션
설치된 tz 데이터베이스에서 필요한 전환을 그대로 꺼냅니다.
$ zdump -v -c 2025,2028 America/New_York | grep 2026
Sun Mar 8 06:59:59 2026 UT = Sun Mar 8 01:59:59 2026 EST isdst=0 gmtoff=-18000
Sun Mar 8 07:00:00 2026 UT = Sun Mar 8 03:00:00 2026 EDT isdst=1 gmtoff=-14400
Sun Nov 1 05:59:59 2026 UT = Sun Nov 1 01:59:59 2026 EDT isdst=1 gmtoff=-14400
Sun Nov 1 06:00:00 2026 UT = Sun Nov 1 01:00:00 2026 EST isdst=0 gmtoff=-18000
$ zdump -v -c 1986,1990 Asia/Seoul | grep 1987
Sat May 9 16:59:59 1987 UT = Sun May 10 01:59:59 1987 KST isdst=0 gmtoff=32400
Sat May 9 17:00:00 1987 UT = Sun May 10 03:00:00 1987 KDT isdst=1 gmtoff=36000
Sat Oct 10 16:59:59 1987 UT = Sun Oct 11 02:59:59 1987 KDT isdst=1 gmtoff=36000
Sat Oct 10 17:00:00 1987 UT = Sun Oct 11 02:00:00 1987 KST isdst=0 gmtoff=32400
뉴욕에서는 3월 8일 02:00~02:59가 존재하지 않고 11월 1일 01:00~01:59가 두 번 있습니다. 한국도 1987년 5월 10일에 02:00에서 03:00으로 건너뛰고 10월 11일에 03:00에서 02:00으로 돌아갔습니다(1988년은 5월 8일과 10월 9일). 순진한 스케줄러는 3일 동안의 UTC 매 분을 현지 시각으로 바꾸고 HH:MM이 맞으면 실행합니다.
# cronsim.py (핵심)
def simulate(zone, hhmm, start_utc, days=3):
tz = ZoneInfo(zone); fires = []
t = start_utc
while t < start_utc + timedelta(days=days):
local = t.astimezone(tz)
if local.strftime("%H:%M") == hhmm:
fires.append(local)
t += timedelta(minutes=1)
return fires
America/New_York 02:30 runs per local day: 03-08=0, 03-09=1
America/New_York 01:30 runs per local day: 11-01=2, 11-02=1
2026-11-01T01:30:00-04:00 fold= 0 EDT
2026-11-01T01:30:00-05:00 fold= 1 EST
Europe/Madrid 02:30 runs per local day: 03-29=0, 03-30=1
Europe/Madrid 02:30 runs per local day: 10-25=2, 10-26=1
2026-10-25T02:30:00+02:00 fold= 0 CEST
2026-10-25T02:30:00+01:00 fold= 1 CET
Europe/London 01:30 runs per local day: 03-29=0, 03-30=1
Europe/London 01:30 runs per local day: 10-25=2, 10-26=1
America/Santiago 00:30 runs per local day: 09-06=0, 09-07=1
America/Santiago 23:30 runs per local day: 04-04=2, 04-05=1
Asia/Tokyo 02:30 runs per local day: 03-08=1, 03-09=1
같은 함수로 Asia/Seoul의 02:30을 돌리면 1987년 5월 10일은 0번(5월 11일, 12일만 실행), 10월 11일은 02:30:00+10:00과 02:30:00+09:00 두 번, 2026년 5월은 매일 1번이었습니다.
가장 고약한 것은 마드리드의 02:30입니다. 3월에는 건너뛰고 10월에는 두 번 돕니다. 칠레 산티아고는 자정에 바뀌기 때문에 "매일 00:30"조차 안전하지 않습니다. 오늘의 서울과 도쿄는 매일 정확히 1번입니다. 한국 팀이 이 버그를 국내에서는 발견하지 못하는 이유가 바로 이것입니다. 테스트 환경이 한국 시간이든 UTC든, 서머타임 없는 시간대로만 돌리면 이 표의 0과 2는 나오지 않습니다.
실제 cron 구현의 동작(여기서는 실행하지 않음, 문서 기준): macOS 기본 cron에는 cron(8) 매뉴얼에 나온 -s 플래그가 있습니다. "Enable special handling of situations when the GMT offset of the local timezone changes". 이 플래그를 주면 한 시간보다 드물게 도는 작업은 "executed exactly once, they are not skipped nor executed twice"가 되고, 기본값(-o)은 "the old behavior"입니다. 리눅스의 Vixie 계열 cron에도 작은 시계 변화에 대한 특별 처리가 있고, systemd 타이머와 Kubernetes CronJob은 시간대를 지정할 수 있습니다. 세부는 구현마다 다르니 의존하지 마세요. 서버는 UTC로 돌리고, 업무상 중요한 작업은 UTC로 예약하고, 꼭 현지 벽시계를 따라야 하는 작업은 다음 실행 시각을 직접 계산합니다(마지막 절). cron 작업의 로그 남기기는 cron용 셸 스크립트를 튼튼하게, cron으로 도는 실제 예는 PHP cron으로 FCM 예약 푸시 보내기를 참고하세요.
흔한 실수: fold를 넘는 뺄셈
실행 간격을 계산하던 첫 스크립트는 뉴욕의 두 번의 01:30 사이를 0.0시간으로 출력했습니다. 스크립트의 계산 실수가 아니라 Python의 정의입니다.
ny = ZoneInfo("America/New_York")
a = datetime(2026, 11, 1, 1, 30, tzinfo=ny) # 첫 번째 01:30 (EDT)
b = datetime(2026, 11, 1, 1, 30, fold=1, tzinfo=ny) # 두 번째 01:30 (EST)
2026-11-01T01:30:00-04:00 2026-11-01T01:30:00-05:00
b - a (same tzinfo): 0:00:00
in UTC: 1:00:00
a == b: True a < b: False
Sat 02:30 -> Mon 02:30 wall: 2 days, 0:00:00 real: 1 day, 23:00:00
두 aware datetime이 같은 tzinfo 객체를 가지면 Python은 오프셋을 무시하고 벽시계 값으로 빼고 비교합니다. 1시간 떨어진 두 순간이 같다고 판정됩니다. 3월 주말에는 "토요일 02:30부터 월요일 02:30"이 벽시계로는 2일이지만 실제로는 47시간입니다.
- 무슨 일이 생기나: 중복 실행 감지(
if run == last_run)가 두 번째 실행을 놓치고, 과금이나 요청 제한의 "경과 시간"이 1시간 틀립니다. - 해결: 빼거나 비교하기 전에 양쪽을 UTC로 바꿉니다(
.astimezone(timezone.utc)). 계산은 전부 UTC 인스턴트로 하고, 현지 시간대는 입력과 표시에만 씁니다.
반복 일정: 첫 UTC가 아니라 규칙을 저장하라
해외 지사와의 정기 회의가 "매주 월요일 09:00 뉴욕 시간"이라고 합시다. 방식 A는 첫 회차를 UTC(2월은 EST라서 14:00)로 저장하고 매주 반복합니다. 방식 B는 규칙, 즉 09:00과 America/New_York을 저장하고 날짜마다 그 시간대에서 펼칩니다.
date A: UTC-fixed -> NY | B: NY 09:00 -> UTC Seoul London
2026-03-02 09:00 | 14:00 23:00 14:00
2026-03-09 10:00 | 13:00 22:00 13:00
2026-03-16 10:00 | 13:00 22:00 13:00
2026-03-23 10:00 | 13:00 22:00 13:00
2026-03-30 10:00 | 13:00 22:00 14:00
2026-04-06 10:00 | 13:00 22:00 14:00
방식 A에서는 3월 9일부터 뉴욕 팀에게 10:00 회의가 됐습니다. 방식 B는 뉴욕의 09:00을 지키고, 대신 다른 모두의 시각이 움직입니다. 서울 쪽은 월요일 23:00에서 22:00으로 바뀝니다. 한국에는 서머타임이 없는데 한국 쪽 시각이 바뀌는 것입니다. 런던은 5주 사이에 두 번 바뀌었습니다. 14:00, 미국만 바뀐 3주간 13:00, 3월 30일부터 다시 14:00입니다. 같은 계산에서 멕시코시티는 08:00 → 07:00이었습니다. 멕시코는 2022년 10월 전환을 마지막으로 서머타임이 없기 때문입니다(zdump는 Sun Oct 30 07:00:00 2022 UT 이후를 보여 주지 않습니다).
| 저장할 것 | 쓰는 곳 | 예 |
|---|---|---|
| UTC 인스턴트 | 일어난 일, UTC로 정한 일회성 마감 | 결제 완료, 로그 줄, 토큰 exp |
| 현지 시각 + IANA 존 | 어떤 장소의 사람들이 정한 미래 일정 | 정기 회의, 매장 오픈, "매일 아침 8시 알림" |
| 날짜만 | 하루 단위의 것 | 생일, 공휴일, 마감일 |
방식 B는 tzdata 갱신에도 강합니다. 일정 날짜 전에 어떤 나라가 서머타임 규칙을 바꿔도, 갱신된 tzdata로 규칙을 펼치면 올바른 순간이 나옵니다. 미리 계산한 UTC 목록은 그렇지 않습니다. 달력 표준(iCalendar의 TZID가 붙은 RRULE)이 방식 B를 쓰는 이유입니다. 음력 생일 알림도 같은 원리입니다. 양력으로 바꾼 날짜 하나가 아니라 "음력 몇 월 며칠"이라는 규칙을 저장하고, 해마다 그해의 양력 날짜를 계산해야 합니다.
기계 사이의 시계 오차와 토큰 leeway
서버마다 자기 UTC 시계가 있고 NTP로 맞추지만, 딱 맞는 것은 없습니다. 기계 A가 iat/nbf를 "지금"으로 한 토큰을 발급하고 기계 B가 100 ms 뒤에 검증하면, B의 "지금"이 A보다 이전일 수 있고 토큰은 "아직 유효하지 않음"이 됩니다. leeway가 있는 검사:
def check(now, claims, leeway):
if now + leeway < claims["nbf"]:
return "not yet valid"
if now - leeway >= claims["exp"]:
return "expired"
return "ok"
발급 0.1초 뒤에 검증한 토큰 하나(T0 = 2026-03-10 00:00:00 UTC, 유효 기간 300초):
verifier clock +0.0 s: leeway 0 -> ok 30 -> ok 60 -> ok
verifier clock -0.5 s: leeway 0 -> not yet valid 30 -> ok 60 -> ok
verifier clock -2.0 s: leeway 0 -> not yet valid 30 -> ok 60 -> ok
verifier clock -30.0 s: leeway 0 -> not yet valid 30 -> ok 60 -> ok
verifier clock -90.0 s: leeway 0 -> not yet valid 30 -> not yet valid 60 -> not yet valid
이어서 요청 10,000건. 검증 쪽 오차는 정규분포(0, σ), 네트워크 지연 0~200 ms, random.Random(7):
sigma 0.05 s leeway 0s: 10.58% leeway 5s: 0.00% leeway 30s: 0.00% leeway 60s: 0.00%
sigma 1.0 s leeway 0s: 46.62% leeway 5s: 0.00% leeway 30s: 0.00% leeway 60s: 0.00%
sigma 30.0 s leeway 0s: 50.46% leeway 5s: 43.69% leeway 30s: 15.79% leeway 60s: 2.35%
오차 퍼짐이 50 ms만 돼도 leeway 0에서는 막 발급된 토큰의 10.58%가 거부됐습니다. 시계가 맞춰져 있으면 몇 초의 leeway로 문제가 사라지고, 맞춰져 있지 않으면 어떤 leeway로도 못 구합니다(퍼짐 30초에서 leeway 60초도 2.35%). 순서는 NTP로 시계 맞추기, 작은 leeway(분이 아니라 초) 두기, 오차를 로그로 남기기(거부할 때마다 now - iat)입니다. 그래야 다음 장애 대응 때 숫자가 손에 있습니다. leeway는 탈취된 토큰의 수명도 늘리므로 작게 유지합니다. 서명 쪽 이야기는 JWT HS256 서명의 함정에서 다룹니다.
윤초: 23:59:60을 다섯 도구에 넣으면
UTC에는 1972년 이후 27번의 윤초가 들어갔고 마지막은 2016년 말이었습니다. 한국 시간으로는 2017년 1월 1일 08시 59분 60초입니다. 유닉스 시간은 윤초가 없는 척하므로 유닉스 초 하나가 실제 2초를 덮습니다. 2016-12-31 23:59:60(UTC)을 넣은 결과:
| 도구 | 결과 |
|---|---|
Python datetime(2016, 12, 31, 23, 59, 60) |
ValueError: second must be in 0..59 |
Python calendar.timegm(...) |
1483228800, 2017-01-01 00:00:00과 같음 |
Java 26 Instant.parse("2016-12-31T23:59:60Z") |
2016-12-31T23:59:59Z (:59로 접힘) |
Java 26 LocalTime.parse("23:59:60") |
DateTimeParseException: ... Invalid value for SecondOfMinute (valid values 0 - 59): 60 |
SQLite 3.51.0 datetime('2016-12-31 23:59:60') |
NULL |
PHP 8.5.6, BSD date -j -f |
1483228800 2017-01-01 00:00:00 (올림) |
네 가지 동작이 있고 에러 방식도 제각각입니다. 큰 사업자들은 leap smear(윤초 번지기)로 이 계단 자체를 피합니다. NTP 서버가 윤초 앞뒤 몇 시간에 걸쳐 1초를 조금씩 늘려서 시계에 23:59:60이 나타나지 않게 합니다. 한 플릿 안에서는 괜찮지만 번지는 시간원과 그렇지 않은 시간원을 섞으면 그동안 최대 0.5초 어긋납니다. 윤초는 2022년의 국제적 결정에 따라 2035년까지 폐지될 예정입니다. 그때까지는 서머타임처럼 다루세요. :60을 저장하지 않고, 경과 시간은 단조 시계로 재고, 벽시계 UTC는 1초가 반복되거나 늘어날 수 있다고 받아들입니다. 이 Mac에는 right/ 존이 없어서 윤초가 들어간 zdump는 보여 줄 수 없었습니다.
시간 테스트: 주입한 시계와 서머타임에 강한 next_run()
위의 결과가 모두 쉽게 재현된 것은 시스템에 시간을 묻는 함수가 하나도 없고 모두 now를 인자로 받았기 때문입니다. 이것이 시계·난수 때문에 가끔 실패하는 테스트 잡기의 핵심 생각입니다. 스케줄러 핵심:
# nextrun.py
from datetime import datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
def _exists(local):
"""이 벽시계 시각이 실제로 존재하는지 (UTC를 왕복해 확인)"""
back = local.astimezone(timezone.utc).astimezone(local.tzinfo)
return back.replace(tzinfo=None) == local.replace(tzinfo=None)
def next_run(now_utc, at, zone):
tz = ZoneInfo(zone)
day = now_utc.astimezone(tz).date()
for _ in range(3):
local = datetime.combine(day, at, tz) # fold=0: 두 번 있는 01:30 중 첫 번째
if not _exists(local): # 빈 구간이면
local = local.astimezone(timezone.utc).astimezone(tz) # 밀린 시각에 실행
run = local.astimezone(timezone.utc)
if run > now_utc:
return run
day += timedelta(days=1)
class FixedClock:
def __init__(self, t): self.t = t
def now(self): return self.t
def advance(self, **kw): self.t += timedelta(**kw)
정책을 글로 적어 둡니다. 빈 구간의 시각은 밀린 시각에 한 번 실행한다(02:30 → 03:30 EDT). 겹치는 구간의 시각은 첫 번째에 한 번만 실행한다. 테스트는 경계를 고정하고, 1월에 -05:00이라는 고정 오프셋을 잡아 버린 버전과도 비교합니다.
def test_spring_gap_runs_once_shifted(self):
c = FixedClock(datetime(2026, 3, 8, 5, 0, tzinfo=UTC)) # 00:00 EST
r = next_run(c.now(), time(2, 30), "America/New_York")
self.assertEqual(r.astimezone(ZoneInfo("America/New_York")).isoformat(),
"2026-03-08T03:30:00-04:00")
def test_fall_overlap_runs_once(self):
c = FixedClock(datetime(2026, 11, 1, 4, 0, tzinfo=UTC)) # 00:00 EDT
first = next_run(c.now(), time(1, 30), "America/New_York")
second = next_run(first, time(1, 30), "America/New_York")
self.assertEqual(second - first, timedelta(hours=25))
def test_fixed_offset_version(self): # 1월에 잡은 오프셋: 3월에 실패
est = timezone(timedelta(hours=-5))
naive = lambda now, at: datetime.combine(now.astimezone(est).date() + timedelta(days=1), at, est)
now = datetime(2026, 3, 9, 0, 0, tzinfo=UTC)
self.assertEqual(naive(now, time(2, 30)).astimezone(UTC),
next_run(now, time(2, 30), "America/New_York"))
test_fall_overlap_runs_once (__main__.T) ... ok
test_fixed_offset_version (__main__.T) ... FAIL
test_madrid_october (__main__.T) ... ok
test_spring_gap_runs_once_shifted (__main__.T) ... ok
... (트레이스백 생략)
AssertionError: datetime.datetime(2026, 3, 9, 7, 30, tzinfo=datetime.timezone.utc) != datetime.datetime(2026, 3, 9, 6, 30, tzinfo=datetime.timezone.utc)
FAILED (failures=1)
고정 오프셋 버전은 07:30 UTC, 즉 전환 뒤의 뉴욕에서 03:30에 실행합니다. 강좌 2의 "고정 오프셋은 시간대가 아니다"라는 교훈이 3월에 고객 문의로 오는 대신 테스트에서 몇 밀리초 만에 잡혔습니다. 겹침 테스트는 두 실행 사이가 정확히 25시간으로 통과합니다. next_run이 UTC 인스턴트를 돌려주고 뺄셈이 UTC에서 일어나기 때문입니다. Asia/Seoul로만 테스트했다면 이 중 어느 것도 실패하지 않습니다. 테스트 케이스에 서머타임이 있는 시간대를 최소 하나 넣으세요.
시리즈 마무리 체크리스트
여섯 강좌를 한 페이지로:
- 인스턴트는 항상 UTC. 유닉스 초나
Z가 붙은 ISO 8601로 저장하고 시간대 변환은 보여 줄 때만(강좌 1, 5). - 시간대는 IANA 이름으로. 약어나 고정 오프셋이 아니라
Asia/Seoul,America/New_York(강좌 2). - 받는 문자열은 형식과 오프셋이 명시돼야 한다. 없으면 거부하거나 표시한다(강좌 3, 5).
- 달력 계산은 정책이다. 1월 31일의 "+1개월"이 무엇인지 적고 테스트한다. 만 나이 계산도 같다(강좌 4).
- 경과 시간은 단조 시계에서, 타임스탬프는 UTC 시계에서(강좌 6).
- 미래의 현지 일정은 규칙을 저장한다(현지 시각 + 시간대). 미리 계산한 UTC가 아니라(강좌 6).
- 작업과 서버는 UTC로. 현지 시각 작업은 테스트된 서머타임 대응
next_run으로(강좌 6). - 시계는 어긋난다. 먼저 NTP, 다음에 작은 leeway, 그리고 오차 로그(강좌 6).
- 테스트는 시계를 주입한다. 서머타임 빈 구간, 겹침, 23:59:59, 1월 1일, 2월 29일을 넣는다.
- tzdata를 갱신한다. OS, 런타임과 함께. 시간대 규칙은 법으로 바뀝니다.
솔직한 한계와 버전 메모
- 스케줄러, 회의, 시계 오차 결과는 Python 3.9.6과 tzdata 2026d로 한 시뮬레이션입니다. crontab 설치나 수정, 시스템 시계 변경, NTP 서버 접속은 하지 않았습니다. cron 동작은 macOS
cron(8)매뉴얼 인용이고 다른 구현은 설명만 했습니다. zoneinfo는 Python 3.9부터 표준 라이브러리에,fold는 3.6부터 있습니다. "같은 tzinfo면 벽시계로 비교"는 문서화된 Python 동작이며zoneinfo버그가 아닙니다.- 오차 분포(정규분포, 고정 σ)는 모델입니다. 실제로는 "설정이 잘못된 한 대만 크게 어긋나고 나머지는 가깝다"가 흔하니, 거부할 때
now - iat를 로그로 남겨 자기 환경을 재세요. - Java는 OpenJDK 26, PHP는 8.5.6, SQLite는 3.51.0 결과입니다. 윤초 처리는 라이브러리마다 표준화되어 있지 않고 버전에 따라 바뀔 수 있습니다.
연습 문제
- 어떤 알림 서비스는 사용자가
Europe/Madrid에서 "매일 08:00"을 고를 때 한 번 계산한remind_at을 UTC로 저장합니다. 1년에 두 번 불만이 들어옵니다. 스키마와 알림 발송 작업을 어떻게 바꿀까요?
정답
- 규칙을 저장합니다.
local_time = '08:00',zone = 'Europe/Madrid', 그리고 인덱스가 있는 INTEGERnext_run_utc. 작업(UTC로 매분 실행)은next_run_utc <= now인 행을 골라 보내고, 서머타임 대응next_run(now, 08:00, zone)으로next_run_utc를 다시 계산합니다. 고정 시계로 2026년 3월 28·29일, 10월 24·25일, 그리고 전환이 없는 날의 테스트를 추가합니다.
이전: 날짜·시간·타임존 강좌 5: DB에 날짜·시간 저장하기, SQLite와 타임스탬프 · 시리즈 처음: 날짜·시간·타임존 강좌 1: 유닉스 타임스탬프와 UTC · 프로그래밍 학습 로드맵으로 돌아가기
자주 묻는 질문
서머타임 전환 밤에 02:30 cron 작업은 어떻게 되나요?
cron 구현에 따라 다릅니다. "현지 시계가 02:30이면 실행"하는 순진한 스케줄러는 2026년 3월 8일 뉴욕과 3월 29일 마드리드에서 0번, 10월 25일 마드리드에서 1시간 간격으로 2번 실행됐습니다. macOS cron은 -s 플래그를 줄 때만 이것을 보정합니다. UTC로 예약하거나 서머타임을 고려한 함수로 다음 실행 시각을 계산하세요.
실행 시간 측정에는 time.time()과 time.monotonic() 중 무엇을 써야 하나요?
경과 시간에는 time.monotonic()이나 time.perf_counter()를 씁니다. macOS의 Python 3.9.6은 time.time()을 adjustable(clock_gettime(CLOCK_REALTIME))로 보고하므로 NTP나 관리자가 바꿀 수 있고, monotonic과 perf_counter는 mach_absolute_time()이라 조정되지 않습니다. 호출 비용도 거의 같았습니다(루프 포함 54~66 ns).
토큰 만료 검사의 leeway는 얼마로 해야 하나요?
실제 시계 오차를 덮을 만큼만입니다. 막 발급된 토큰 10,000개 시뮬레이션에서 오차 퍼짐이 1초면 leeway 0에서 46.62%가 거부, 5초에서 0%였습니다. 퍼짐이 30초면 leeway 30초에서도 15.79%, 60초에서도 2.35%가 거부됐습니다. 먼저 NTP로 시계를 맞추고, 그다음 작은 leeway를 둡니다.