-
Cron은 기본적으로 UTC에서 실행되므로, '자정'에 설정한 작업이 잘못된 현지 날짜에 실행될 수 있습니다.
-
UTC 오프셋 대신 타임존 문자열로 영업일(business day)을 명시하세요.
-
서머타임(DST)은 1년에 두 번 오프셋을 변경하여 작업 시간을 조용히 한 시간씩 이동시킵니다.
-
네 가지 엣지 케이스(경계 사례): 자정, 월 경계, 봄 전진(spring-forward), 가을 후퇴(fall-back)를 테스트하세요.
월요일 데이터를 포함해야 할 보고서가 일요일 데이터로 나옵니다. 작업이 자정에 실행되었는데, 시계 자체가 어떤 '자정'인지에 대해 의견 불일치가 있었습니다. 저는 이 정확한 버그를 네 개의 다른 코드베이스에서 목격했으며, 항상 근본 원인은 같습니다.
Cron은 UTC 기준으로 생각합니다. 하지만 사용자들은 각자의 현지 시계를 사용합니다. 이 두 가지는 경계 지점에서만 드러나는 방식으로 멀어집니다.
작업을 실행하기에 자정이 가장 최악의 시간인 이유
모두를 괴롭히는 설정이 여기 있습니다. 어제 주문 내역을 요약한 일일 보고서를 원한다고 가정해 봅시다. 이를 '자정'으로 스케줄링합니다. UTC로 설정된 서버에서는 0 0 * * *가 됩니다. 검토하는 사람에게는 괜찮아 보이고 배포됩니다.
그런 다음 베를린에 있는 누군가가 이 보고서를 열어봅니다.
여름철에는 베를린이 UTC+2입니다. 자정 UTC 작업은 베를린 시간으로 02:00에 실행됩니다. 지금까지는 무해합니다. 하지만 작업 내부의 로직은 보통
자정이 악당인 이유는 작은 시간 오프셋 하나가 달력 날짜를 뒤집는 유일한 순간이기 때문입니다. 14:00에 발생하는 15분 지연은 아무도 신경 쓰지 않는 것을 변화시키지 않습니다. 하지만 23:58에 같은 지연이 발생하면 날짜가 바뀌고, 때로는 월(月)까지 바뀌며, 일 년에 두 번은 스스로 한 시간이나 바뀝니다.
저는 00:00 근처에 스케줄링된 모든 작업을 코드의 악취(code smell)로 취급하기 시작했습니다.
틀린 것은 아니지만, 다시 한번 살펴볼 가치는 있습니다. 열 번 중 아홉 번은 그 사람이 '깔끔하게' 보이도록 0 0을 작성했을 뿐, 자정이 비즈니스적으로 어떤 의미를 갖는지 알았기 때문이 아닙니다.
만약 스케줄링된 작업이 재시도할 수 있는 외부 서비스와 통신한다면, 날짜 혼란은 가중됩니다. 저는 멱등 웹훅(idempotent webhooks) 작성에서 이에 대한 안전한 패턴을 정리했고, 여기서도 같은 원칙이 적용됩니다. 조심하지 않으면 작업이 두 번 실행되어 서로 다른 달력 날짜에 도달할 수 있다고 가정해야 합니다.
우연이 아닌 의도로 비즈니스 날짜 정의하기
해결책은 코드가 아니라 결정에서 시작됩니다. 비즈니스 날짜는 어떤 시간대(timezone)를 기준으로 측정되나요? 그것을 적어두세요. 설정 파일에 문자열로 넣으세요.
Europe/Berlin이나 America/Los_Angeles와 같은 IANA 시간대 이름을 사용하세요. UTC+2 같은 고정 오프셋은 안 됩니다. 고정된 오프셋은 절반의 기간 동안 거짓말이기 때문입니다. 왜냐하면 일광 절약(daylight saving)이 시작될 때 오프셋이 바뀌기 때문이죠. 이름은 모든 규칙을 인코딩하지만, 오프셋은 단 하나의 고정된 순간만을 인코딩합니다.
그 문자열을 확보했다면, 서버에게 지금 시간이 몇 시인지 묻는 것을 멈추세요. 비즈니스 시간대에서 보고 기간(reporting window)을 계산한 다음, 데이터베이스를 조회할 때만 UTC로 변환하세요.
실제로는 이런 모습입니다.
핵심은 창(window)이 현지 시간으로 정의되고 쿼리가 UTC로 실행된다는 점입니다. 사람들은 이 두 단계를 하나로 합치려는 경향이 있고, 바로 그곳에 버그가 존재합니다.
저는 이것을 위해 작은 도우미 함수를 유지하여 수학 계산을 직접 구현하지 않습니다. 시간대 문자열과 날짜를 받아 { startUtc, endUtc }를 반환하는 것입니다. 하나의 함수, 하나의 테스트 파일로 모든 곳에서 재사용됩니다. 대안은 new Date()와 수동 시(hour) 산술 연산을 6개의 작업에 여기저기 분산시키는 것이고, 각 복사본이 미묘하게 어긋나 결국 모두 불일치하게 되는 것입니다.
크론 트리거는 여전히 UTC로 예약하세요.
그 부분은 괜찮습니다. 트리거 시간과 데이터 창(data window)은 다른 관심사입니다. 자정에서 안전한 거리를 두고 (저는 현지 시간으로 03:00 또는 04:00을 좋아하며, 이를 UTC로 변환하여) 실행하고, 창 로직이 어느 날짜에 대해 보고하는지 파악하도록 하세요. 이렇게 하면 나중에 트리거가 발생하더라도 답을 바꿀 수 없습니다. 왜냐하면 답은 작업이 실제로 발화된 시점에 의존하지 않고, 사용자가 계산한 명시적인 창(window)에 의존하기 때문입니다.
이것은 신뢰하기 전에 입력을 검증하는 것과 같은 본능입니다. 저는 Request Validation at the Edge에서 이러한 경계(boundary)를 그리는 것에 대해 더 깊이 다루었습니다.
일광 절약 시간제(Daylight Saving)가 6개월 동안 숨어있는 버그입니다
작업은 몇 달 동안 잘 실행됩니다. 그러다가 3월 말이나 10월 말의 일요일에, 무언가가 한 시간 차이가 납니다. DST(Daylight Saving Time), 즉 일광 절약 시간제를 환영합니다.
두 가지 일이 발생합니다. 봄에는 시계가 앞으로 점프하여 02:00부터 03:00까지의 시간이 존재하지 않습니다. 가을에는 시계가 뒤로 돌아가서 02:00부터 03:00까지의 시간이 두 번 발생합니다. 만약 이 창 안에 현지 시간 작업이 예약되었다면, 건너뛰거나(skips) 중복됩니다.
바로 이것이 크론 트리거를 DST가 없는 UTC로 예약하고, 창은 현지 시간으로 계산해야 하는 정확한 이유입니다. UTC는 영원히, 점프 없이 한 초씩 전진합니다. 따라서 사용자의 트리거는 안정적입니다. 반면, 현지 창은 DST 경계를 가로질러 한 시간만큼 이동하는데, 이는 올바른 것입니다. 왜냐하면 해당 두 날짜에 영업일이 실제로 한 시간 더 짧거나 길기 때문입니다.
사람들이 첫 번째 버그를 수정한 후에도 흔히 범하는 실수에 대한 내용입니다. 비즈니스 시간대별 UTC 오프셋을 예를 들어 +01:00으로 저장하고 일 년 내내 재사용하는 경우입니다. 베를린의 경우 3월 말부터 10월 말까지 실제 오프셋은 +02:00이 됩니다. 따라서 약 7개월 동안 모든 창(window) 계산이 한 시간씩 틀어지게 됩니다. 겨울에 테스트할 때는 문제가 없어 보이지만, 여름이 되면 조용히 잘못 작동하게 됩니다.
Europe/Berlin이라는 이름 자체에는 오프셋이 겨울에는 +01:00이고 여름에는 +02:00이라는 규칙이 담겨 있으며, 언제 이 값이 바뀌는지 정확한 순간을 알고 있습니다. 시간대 지원 기능이 있는 제대로 된 날짜 라이브러리라면 이를 자동으로 적용해 줄 것입니다. 숫자가 아닌 이름(name)을 입력해야 합니다.
또 다른 함정이 있습니다. 가을에 두 번 발생하는 시간이 모호합니다. "현지 시간 01:30"은 그날 밤의 서로 다른 두 UTC 순간에 매핑됩니다. 만약 창 경계가 이 지점에 놓인다면, 어떤 규칙(대부분의 라이브러리는 더 빠른 순간으로 기본 설정함)을 따를지 정하고 테스트에서 어떤 값을 얻었는지 반드시 단언해야 합니다. 우연에 맡겨서는 안 됩니다.
솔직히 말해, 저는 이런 것들을 손으로 추론하는 것을 믿지 못합니다.
저는 DST(일광 절약 시간제)가 적용되는 밤의 특정 UTC 순간에 가짜 시계를 고정하고 창 경계가 올바르게 나오는지 확인하는 테스트를 작성합니다. 다음 DST 변경이 돌아오고 제가 이 모든 것을 잊어버렸을 때, 저는 그 테스트만이 유일하게 신뢰할 수 있는 것입니다.
모든 것을 포착하는 네 가지 테스트
백 개의 케이스가 필요하지 않습니다. 고정된 순간에 각각 연결되는 네 가지 테스트만 있으면 됩니다. 그래야 테스트가 영원히 동일한 답변을 제공합니다.
첫 번째 테스트는 평범한 자정입니다. 시계를 현지 시간 00:30으로 설정하고 "어제"가 실제로 이전 달력 날짜인지 확인합니다. 이는 지연 실행되는 작업이나 부주의한 창 계산이 잘못된 날짜로 넘어가는, 하루 차이가 나는 오류(off-by-a-day error)를 포착합니다.
두 번째 테스트는 월 경계입니다. 시계를 해당 월의 1일 현지 시간 00:30으로 설정하고 "어제"가 이전 달에 속하는지 확인하며, 월별 집계가 올바른 28일, 30일 또는 31일을 포함하는지도 확인합니다. 이 경우 창 계산이 UTC로 이루어진다면 로스앤젤레스의 고객이 23:30에 길을 잃게 됩니다.
테스트 3은 서머타임(spring-forward)입니다. 시계를 일광 절약 시간대(DST) 공백 밤에 고정하고, 그 날이 실제 시간으로는 23시간이라 하더라도 해당 기간이 여전히 하루 전체의 로컬 시간을 포괄하는지 확인하세요. 시작점과 끝점 경계는 단순한 시간 계산이 아닌 정확한 순간(instant)이어야 합니다.
테스트 4는 가을로 돌아오는 시간대(fall-back)입니다. 시간이 반복되는 밤에 고정하고, 해당 기간이 25시간의 하루를 처리하며 모호한 경계에 대해 일관된 순간을 선택하는지 확인하세요.
이 테스트들을 동결된 시계(frozen clock)로 실행하세요.
실제 new Date()는 오늘 통과하지만 다음 10월에는 실패할 수 있는 테스트를 만듭니다. 현재 시간을 매개변수로 주입하거나, 가짜 시스템 시간(fake system time)을 설정할 수 있는 라이브러리를 사용하세요. 그런 다음 문자열이 아닌 정확한 UTC 순간에 대해 단언(assert)해야 합니다. 왜냐하면 문자열 형식 지정 자체가 추가적인 시간대 혼란을 야기하기 때문입니다.
저는 이 네 가지 테스트를 템플릿으로 유지하고, 스케줄링된 작업이 있는 모든 프로젝트에 복사하여 사용합니다. 엣지 케이스(edge cases)는 프로젝트 간에 변하지 않습니다. 변하는 것은 시간대 문자열과 창 함수(window function)뿐입니다. 한 번 작성해서 재사용하는 것이 핵심이며, 이는 제가 기사를 위해 구조를 재활용하여 매번 다시 만들 필요가 없게 하는 것과 같은 이치입니다. (자세한 내용은 제가 사용하는 블로그 포스트 구조를 참고하세요.)
만약 이러한 작업들을 기반으로 소셜 게시물도 예약한다면, 게시되는 시점에도 동일한 시간대 논리가 적용됩니다. Buffer와 같은 도구가 게시하는 측면을 처리하지만,
만약 제가 오늘 새로운 스케줄링된 작업을 시작한다면, 작업 로직보다 윈도우 함수(window function)와 그 테스트를 먼저 작성할 것입니다. 로직은 쉬운 부분입니다. 돈이 사라지는 곳은 날짜들입니다.
제가 이러한 종류의 도우미(helper)와 그 테스트를 한 번에 구축하는 에이전트를 연결하는 방법은 Claude Blueprint에서 제가 사용하는 설정을 안내합니다. 거기서 시작하여 위에 있는 네 가지 테스트를 추가하면, 자정 버그는 더 이상 배포할 문제가 아닙니다.
본 기사에는 제휴 링크가 포함되어 있습니다. 이 링크들을 통해 가입하시면, 여러분에게 추가 비용 없이 저에게 소액의 수수료가 발생할 수 있습니다. (광고)