서머타임 전환일에 크론 배치가 두 번 돈 이유
한국은 서머타임이 없지만, 해외 결제사 리포트를 받는 순간 1년에 두 번 배치가 건너뛰거나 두 번 돈다. 상대가 자르는 하루가 그날만 23시간이거나 25시간이다.
해외 서비스를 붙인 뒤로 특정 날에만 정산이 어긋난다. 어떤 해에는 배치가 두 번 돌아 중복 정산이 되고, 어떤 해에는 아예 안 돌아서 하루가 빈다.
로그를 보면 스케줄러는 정상이다. 크론 표현식도 그대로고, 서버도 안 죽었다.
1년에 두 번, 그것도 특정 지역에서만 일어나니 재현이 거의 안 된다. 그래서 "가끔 그러네" 하고 남겨 뒀다가 감사나 정산 대사에서 터진다.
한국은 서머타임을 안 쓰니 남의 일 같다. 그런데 해외 결제사나 광고 플랫폼의 리포트를 받는 순간 우리 문제가 된다. 상대가 자르는 하루가 그날만 23시간이거나 25시간이다.
2시 30분이 없는 날, 두 번 오는 날
서머타임이 시작되는 날에는 어떤 시각이 아예 존재하지 않는다. 새벽 2시에서 3시로 건너뛰면, 그날은 2시 30분이 없다. 그 시각에 걸어 둔 배치는 실행되지 않는다.
끝나는 날은 반대다. 같은 시각이 두 번 온다. 2시 30분이 두 번 지나가니 배치도 두 번 돈다.
로컬 시간대로 스케줄을 잡으면 이 문제는 스케줄러 종류와 상관없이 생긴다. 크론이든 애플리케이션 스케줄러든 "매일 새벽 2시 30분"은 로컬 시각이고, 로컬 시각은 1년에 두 번 연속적이지 않다.
시간대 데이터베이스가 갱신된다는 것도 놓치기 쉽다. 나라들이 서머타임 정책을 바꾸면 tzdata 가 갱신되는데, 서버마다 이 데이터 버전이 다르면 같은 시각을 다르게 계산한다. 컨테이너 이미지가 오래됐다면 옛 규칙으로 돌고 있을지도 모른다.
저장할 때도 문제가 생긴다. 시간대 없는 타임스탬프로 저장하면 겹치는 구간의 두 값이 구분되지 않는다. 나중에 정렬하면 순서가 뒤섞인다.
뿌리는 하나다. 로컬 시각은 단조 증가하지 않는다. 그런데 시각을 순서나 유일성의 근거로 쓰는 코드는 전부 단조 증가한다고 믿고 서 있다.
예약은 UTC, 저장은 시간대 포함
로컬 시간으로 예약하지 않는 게 출발점이다.
0 30 2 * * * # 로컬 2시 30분
봄: 그 시각이 없다 → 건너뜀
가을: 두 번 온다 → 중복 실행
· UTC 로 예약한다 — 연속적이라 건너뜀도 중복도 없다
TZ=UTC
0 30 17 * * *
· 저장은 항상 시간대 포함
created_at TIMESTAMP -- 시간대 없음
created_at TIMESTAMPTZ -- PostgreSQL
created_at TIMESTAMP /* UTC 로 통일 */ -- MySQL
· 표시할 때만 사용자 시간대로 바꾼다
SELECT created_at AT TIME ZONE 'Asia/Seoul' FROM t;✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.
서머타임 없는 한국 서비스가 걸리는 길
남의 일처럼 보여도, 조건이 몇 개만 겹치면 바로 걸린다.
해외 결제사나 광고 플랫폼의 리포트를 받는 경우가 대표적이다. 상대가 미국 태평양 시간 기준으로 하루를 자르면, 그 하루의 길이가 1년에 두 번 23시간과 25시간이 된다. 우리 쪽이 24시간을 가정하고 맞추면 그날 대사가 안 맞는다.
서버마다 시간대 설정이 제각각인 경우도 흔하다. 어떤 서버는 UTC, 어떤 서버는 KST 로 떠 있으면 같은 배치가 다른 시각에 돈다. 컨테이너는 보통 UTC 인데 개발자 노트북은 KST 라서, 로컬에서만 멀쩡해 보이기도 한다.
시간대 규칙은 정치적으로 바뀌기도 한다. 나라가 서머타임을 없애거나 표준시를 옮기는 일이 실제로 일어난다. 그때 tzdata 를 갱신하지 않은 서버는 옛 규칙대로 계산한다. 미래 시각을 미리 계산해 저장해 둔 데이터가 있다면 그것도 틀어진다. 그래서 미래의 약속은 "절대 시각"이 아니라 "시간대 + 지역 시각"으로 저장해야 한다.
반복 일정이 어려운 것도 같은 이유다. "매주 화요일 오전 9시(현지)"를 UTC 로 고정하면 어떻게 될까? 서머타임 전환 뒤에는 8시나 10시가 된다. 규칙을 저장해 두고 실행할 때 계산해야 한다.
두 번 돌아도 괜찮은 배치로
- 스케줄은 UTC 로 잡는다. UTC 에는 건너뜀도 중복도 없다. 사람이 읽기 불편하다는 게 유일한 흠인데, 그건 주석으로 해결된다.
- 저장은 시간대를 포함한 타입으로. PostgreSQL 은 TIMESTAMPTZ, MySQL 은 UTC 로 통일해 저장하고 표시할 때만 바꾼다.
- 가장 확실한 방어는 배치를 멱등하게 만드는 일이다. 같은 대상 기간으로 두 번 돌아도 결과가 같으면, 서머타임이든 재시도든 상관없다. 처리 기간을 키로 삼아 중복을 막는 방식이 흔하다.
- "지금부터 24시간 전"처럼 실행 시점에 계산하면 어긋난다. 배치에는 대상 기간을 명시적으로 넘기자. "2026-08-05 의 데이터"처럼 무엇을 처리하는지 인자로 받으면 다시 돌려도 안전하다.
- 서버와 컨테이너의 시간대는 UTC 로 맞추고, tzdata 는 최신으로 유지해야 한다. 베이스 이미지를 오래 안 바꿨다면 한번 열어 볼 때다.
- 미래의 반복 일정을 절대 시각으로 펼쳐 저장하면 규칙이 바뀔 때 전부 틀어진다. 규칙으로 저장하고 실행할 때 계산한다.
어디가 로컬 시간에 묶여 있나
서버, DB, 컬럼 순으로 보면 된다.
· 서버·DB 의 시간대
$ timedatectl # OS
SHOW timezone; # PostgreSQL
SELECT @@global.time_zone, @@session.time_zone; # MySQL
· tzdata 가 최신인가 — 컨테이너에서 자주 낡는다
$ ls -l /usr/share/zoneinfo/ | head
· 시간대 없는 컬럼 찾기 (PostgreSQL)
SELECT table_name, column_name, data_type
FROM information_schema.columns
WHERE data_type = 'timestamp without time zone';
· 전환일 시뮬레이션 — 배치를 두 번 돌려 본다
같은 인자로 두 번 실행해 결과가 같은지 본다
멱등하면 서머타임은 더 이상 문제가 아니다핵심 한 줄로컬 시간은 1년에 두 번 연속적이지 않다. 예약은 UTC 로 하라.