Skip to Content

16장. 배치와 스케줄러가 무너지는 자리 — cron·DST·중복 실행

만화로 보는 요약 — 먼저 읽어보세요

만화로 보는 요약 — 먼저 읽어보세요

DST로 두 번 오거나 건너뛰는 cron → 지역 시각을 믿지 않는 실행 키 → 몇 번 트리거돼도 결과가 하나인 멱등 배치.

면접 실전 질문: ① cron이 DST 전환일에 두 번 실행되거나 건너뛰는 이유는? ② 멱등성 키를 지역 시각 문자열로 잡으면 안 되는 이유는? ③ fixedRate가 cron보다 DST에 안전한 이유와 대가는 무엇인가?


스케줄러는 벽시계 눈금만 보고 트리거를 판단하므로 서머타임 전환일엔 같은 잡이 두 번 돌거나 한 번도 안 돌 수 있다 — 막아야 할 건 그 순간이 아니라, 잡이 몇 번 실행되든 결과가 같도록 만드는 것이다.

면접 실전 질문: ① cron 표현식이 서머타임 전환일에 두 번 실행될지, 건너뛸지, 아니면 한 번만 실행될지는 무엇에 달려 있는가? ② 배치의 멱등성 키를 지역 시각 문자열로 잡으면 안 되는 이유는? ③ @Scheduled(fixedRate = ...)가 cron 표현식보다 DST에 안전한 이유는 무엇이고, 그 대신 무엇을 포기하는가?


배경 — “매일 새벽 2시”는 사실 두 가지 다른 요구사항이다

15장은 규칙 하나를 세웠습니다. 연산과 저장은 UTC, 로컬 변환은 경계에서 딱 한 번. 그 장의 마지막 질문이 이 장으로 이어졌습니다. 배치를 “매일 새벽 2시에 실행”으로 예약했다면, 서버가 UTC로 통일돼 있어도 그 스케줄 자체는 여전히 서울의 새벽 2시라는 사용자의 감각을 겨냥하고 있다는 것이었습니다.

여기서 짚어야 할 게 하나 있습니다. “매일 새벽 2시”라는 말은 사실 두 가지 다른 요구사항을 뭉뚱그리고 있습니다.

  • 정확히 24시간마다 — 이전 실행으로부터 24시간이 지나면 다시 돈다. 순수하게 경과 시간 문제입니다.
  • 그 지역의 달력으로 하루에 한 번, 벽시계가 02:00을 가리킬 때 — 서울에 사는 사람이 “오늘 새벽 배치”라고 부를 수 있는, 그 지역 달력 하루에 정확히 한 번.

평상시엔 이 둘이 완전히 같은 스케줄을 만듭니다. 하루가 언제나 24시간이면 구분할 이유가 없으니까요. 그런데 6장이 이미 실측으로 보여준 대로, DST 전환일의 하루는 24시간이 아닙니다. 가을 전환일은 25시간, 봄 전환일은 23시간입니다. 그 하루만큼은 두 요구사항이 서로 다른 스케줄을 만듭니다. 어느 쪽을 원하는지 의식적으로 고르지 않으면, 스케줄러가 그 선택을 대신 해버립니다 — 대개는 나쁜 쪽으로요.

이 장은 그 선택지를 정면으로 다룹니다. 6장이 “왜 두 번 도는가”라는 메커니즘을 진단했다면, 이 장은 “그래서 무엇을 어떻게 짜야 하는가”라는 처방을 씁니다. 처방은 “DST를 더 잘 이해하자”가 아닙니다. 스케줄이 실제로 무엇을 뜻하는지 의식적으로 고르고, 그 잡을 몇 번 실행되든 안전하게 만드는 것입니다.

스토리 — Spring CronExpression으로 실제 전환일을 걸어본다

말로는 “DST 전환일엔 크론이 두 번 돌 수 있다”고 하기 쉽습니다. 실제로 그런지, 아니면 스케줄러가 이미 알아서 막아주는지는 라이브러리마다 다릅니다. 이 책 대부분의 독자가 실제로 쓰는 스케줄러인 Spring의 CronExpression(org.springframework.scheduling.support.CronExpression, spring-context 6.1.14, Maven Central, Temurin JDK 21 컨테이너에서 실행)으로 직접 걸어봤습니다. 이 클래스는 애플리케이션 컨텍스트 없이 순수 계산만 하므로, 스프링 앱을 띄우지 않고도 next(Temporal) 하나로 다음 실행 시각을 구할 수 있습니다.

먼저 가을 전환일입니다. 중부유럽(CET) 지역의 2024년 서머타임 종료일은 10월 27일입니다 — 6장이 설명한 그 “03:00이 02:00으로 되돌아가는” 밤이고, 여는 글의 배경과 같은 성격의 전환입니다. 매일 새벽 02:00:00에 도는 크론 "0 0 2 * * *"(Spring 6필드: 초 분 시 일 월 요일)을 그 전날 정오부터 next()로 네 번 이어 불러봤습니다.

CronExpression daily2am = CronExpression.parse("0 0 2 * * *"); ZoneId paris = ZoneId.of("Europe/Paris"); ZonedDateTime cursor = ZonedDateTime.of(2024, 10, 26, 12, 0, 0, 0, paris); for (int i = 0; i < 4; i++) { ZonedDateTime next = daily2am.next(cursor); System.out.println("next(" + i + ") = " + next + " Instant=" + next.toInstant() + " offset=" + next.getOffset()); cursor = next; }
next(0) = 2024-10-27T02:00+02:00[Europe/Paris] Instant=2024-10-27T00:00:00Z offset=+02:00 next(1) = 2024-10-27T02:00+01:00[Europe/Paris] Instant=2024-10-27T01:00:00Z offset=+01:00 next(2) = 2024-10-28T02:00+01:00[Europe/Paris] Instant=2024-10-28T01:00:00Z offset=+01:00 next(3) = 2024-10-29T02:00+01:00[Europe/Paris] Instant=2024-10-29T01:00:00Z offset=+01:00

두 번 돕니다. next(0)next(1)은 둘 다 지역 시각으로 2024-10-27T02:00을 가리키지만, Instant는 정확히 한 시간 차이(00:00Z01:00Z)입니다. 6장의 오버랩과 정확히 같은 두 순간이고, CronExpression은 그 둘을 각각 유효한 “다음 실행 시각”으로 셉니다. next(2)에서야 다음 날로 넘어갑니다. 여는 글의 그 배치가 왜 두 줄로 찍혔는지, 스케줄러 계산 층에서 그대로 재현됩니다.

이번엔 반대쪽, 봄 전환일입니다. 6장과 같은 이유로 America/New_York, 2024년 3월 10일을 씁니다(뉴욕은 그날 02:00이 통째로 존재하지 않습니다). 전날 정오부터 다시 next()를 세 번 불렀습니다.

next(0) = 2024-03-11T02:00-04:00[America/New_York] Instant=2024-03-11T06:00:00Z offset=-04:00 next(1) = 2024-03-12T02:00-04:00[America/New_York] Instant=2024-03-12T06:00:00Z offset=-04:00 next(2) = 2024-03-13T02:00-04:00[America/New_York] Instant=2024-03-13T06:00:00Z offset=-04:00

3월 10일이 통째로 사라졌습니다. 커서가 3월 9일 정오였는데도 next(0)이 바로 3월 11일입니다. 존재하지 않는 02:00을 6장의 ZonedDateTime.of()처럼 갭 길이만큼 밀어서 03:00으로 “고쳐주지” 않습니다. 그냥 그날의 실행 자체를 건너뜁니다. 예외도 던지지 않습니다 — 조용히 하루를 잃어버릴 뿐입니다.

여기서 예상을 뒤집는 결과가 하나 나왔습니다. “시·분을 못 박은 크론 말고, 매시간처럼 성긴 크론이면 DST에 안전하지 않을까?”라는 직관을 같은 가을 전환일에 "0 0 * * * *"(매시 정각)로 시험해봤습니다.

next(0) = 2024-10-27T01:00+02:00[Europe/Paris] Instant=2024-10-26T23:00:00Z offset=+02:00 next(1) = 2024-10-27T02:00+02:00[Europe/Paris] Instant=2024-10-27T00:00:00Z offset=+02:00 next(2) = 2024-10-27T02:00+01:00[Europe/Paris] Instant=2024-10-27T01:00:00Z offset=+01:00 next(3) = 2024-10-27T03:00+01:00[Europe/Paris] Instant=2024-10-27T02:00:00Z offset=+01:00 next(4) = 2024-10-27T04:00+01:00[Europe/Paris] Instant=2024-10-27T03:00:00Z offset=+01:00 next(5) = 2024-10-27T05:00+01:00[Europe/Paris] Instant=2024-10-27T04:00:00Z offset=+01:00

틀렸습니다. 01:00은 한 번만 찍히지만(가을 전환은 02:00이 아니라 03:00→02:00으로 되돌아가므로, 되돌아가는 자리는 여전히 02:00~02:59 구간입니다), 02:00next(1)next(2)에서 또 두 번 찍힙니다. 그날 하루의 실행 횟수를 세면 정확히 25번 — 6장이 Duration.between()으로 실측한 “25시간짜리 하루”가 스케줄러의 실행 횟수로 그대로 나타난 겁니다. “매시간”이라는 표현은 duration처럼 들리지만, 크론에게는 그냥 시(hour) 필드가 *인 또 다른 필드 매칭 규칙일 뿐입니다. 크론 표현식은 간격이 아무리 짧아져도 벽시계 필드를 보는 한 duration이 아닙니다. 이 장 뒤쪽에서 “fixed-rate/fixed-delay가 진짜로 다른 이유”가 바로 이 차이입니다.

이 실측치는 Spring Framework spring-context 6.1.14(Maven Central), Temurin JDK 21 조합에서 나온 값입니다. CronExpressionnext() 구현이 버전에 따라 달라질 수 있으니, 운영 중인 스프링 버전으로 직접 재현해보는 걸 권합니다 — 이 장의 요지가 바로 그겁니다.

핵심 — 스케줄러마다 답이 다르고, 방어는 스케줄이 아니라 잡에서 한다

각 스케줄러가 전환일을 실제로 어떻게 다루는가

방금 실측이 보여준 것처럼, “크론은 DST에 이렇게 동작한다”는 한 문장으로 끝나지 않습니다. 도구마다 다릅니다.

스케줄러기본 시간 기준DST 전환일 동작
Spring CronExpression / @Scheduled(cron=...)지정한 zone (미지정 시 JVM 기본 zone)실측: 가을 전환일 02:00 두 번 실행, 봄 전환일 02:00 아예 건너뜀
Vixie/ISC cron(8) (대부분의 리눅스 배포판)시스템 로컬 시각 (CRON_TZ로 잡 단위 zone 지정 가능)📄 문서 기반(미검증) — cron(8) 매뉴얼: 시계가 3시간 미만으로 바뀌는 경우, 봄에 건너뛴 시각의 잡은 전환 직후 한 번 실행되고, 가을에 되돌아간 시각의 잡은 재실행되지 않도록 명시적으로 막는다. 단, 시·분을 숫자로 못 박은 잡에만 적용되고 *@hourly처럼 와일드카드를 쓴 잡은 이 특별 취급 없이 새 시각 기준으로 그냥 돈다
Quartz CronTriggerCronScheduleBuilder.cronSchedule(...).inTimeZone(...)로 지정한 zone📄 문서 기반(미검증) — 스케줄된 순간에 실행되지 못한(“misfire”) 트리거를 어떻게 처리할지 정책(misfire instruction)을 별도로 갖는다. CronTriggerMISFIRE_INSTRUCTION_FIRE_ONCE_NOW(기본값), MISFIRE_INSTRUCTION_DO_NOTHING, MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY 중 고를 수 있다. 이름이 비슷한 MISFIRE_INSTRUCTION_FIRE_NOWSimpleTrigger 쪽 상수라 CronTrigger엔 없다
Kubernetes CronJobspec.timeZone (미지정 시 kube-controller-manager 프로세스의 로컬 시각 — 대부분의 클러스터에서 UTC)📄 문서 기반(미검증) — timeZone 필드는 alpha(1.24) → beta(1.25) → stable/GA(1.27) 순으로 승격됐다(kubernetes.io 문서·릴리스 노트 기준). Go의 IANA tz 데이터베이스(5장)를 그대로 써서 DST를 해석한다

이 표에서 가장 흥미로운 줄은 Vixie cron입니다. 실측한 Spring CronExpression은 가을 전환일에 두 번 돌았는데, 오래된 유닉스 cron 데몬은 매뉴얼에 명시적으로 “재실행하지 않는다”고 적어뒀습니다. 즉 이중 실행을 막는 로직이 스케줄러 안에 이미 들어 있는 도구도 있고, 없는 도구도 있습니다. “크론이니까 알아서 막아주겠지”는 검증되지 않은 가정입니다. 지금 쓰는 스케줄러가 어느 쪽인지, 직접 이 장의 실측처럼 확인하지 않으면 알 방법이 없습니다.

Quartz의 misfire instruction도 같은 문제의 다른 이름입니다. 스레드 풀이 바쁘거나 스케줄러가 잠시 멈춰 있어 트리거가 제때 못 돌면 “misfire”라고 부르는데, 이건 봄철 갭에서 잃어버린 실행 시각과 개념적으로 같은 상황입니다 — 예정된 순간이 지나가 버렸다는 것. 그 상황에서 한 번만 바로 돌릴지(FIRE_NOW), 아예 건너뛸지(DO_NOTHING)를 정책으로 고를 수 있게 만든 게 misfire instruction입니다. Vixie cron이 매뉴얼 한 줄로 못 박아 둔 결정을, Quartz는 설정값으로 열어둔 셈입니다.

진짜 duration 기반은 fixed-rate/fixed-delay뿐이다

앞 절의 “매시간 크론” 실험이 증명한 대로, 크론은 필드가 아무리 성겨도 벽시계 기반입니다. 반대로 진짜로 벽시계와 무관하게 도는 스케줄이 있습니다. @Scheduled(fixedRate = ...), @Scheduled(fixedDelay = ...), ScheduledExecutorService.scheduleAtFixedRate(...) 같은 API입니다. 이들은 “다음 실행이 몇 시인가”를 계산하지 않습니다. “이전 실행이 끝난(또는 시작한) 시점으로부터 얼마나 지났는가”만 봅니다 — 11장에서 다룬 “얼마나”를 묻는 질문입니다. 📄 문서 기반(미검증) — 이런 스케줄러 구현체 대부분은 내부적으로 벽시계가 아니라 단조시계 기반 지연 큐 위에서 돕니다(예: ScheduledThreadPoolExecutorDelayedWorkQueueSystem.nanoTime()을 기준으로 만기를 판단합니다). 이 장에서 그 내부 동작까지 코드로 확인하지는 않았습니다.

그 대가는 명확합니다. fixedRate로 “24시간마다”를 걸면, 이 장 맨 앞에서 나눈 두 요구사항 중 전자를 정확히 구현합니다. DST 가을 전환일이 껴 있어도 정확히 24시간 뒤에 돕니다 — 다만 그 결과로 다음 실행의 지역 시각은 원래보다 한 시간 당겨진 새벽 1시가 됩니다(25시간짜리 하루를 24시간 간격으로 재고 있으니까요). 봄 전환일이 껴 있으면 반대로 한 시간 밀립니다. “매일 새벽 2시”라는 사용자 감각을 지키고 싶다면 fixed-rate는 답이 아닙니다. 이건 결함이 아니라 정확히 이 스케줄러가 하기로 한 일입니다 — 어느 쪽을 원하는지는 이 장 맨 앞에서 이미 골랐어야 합니다.

멱등성 — 방어는 스케줄이 아니라 잡 안에서 한다

지금까지 본 표는 전부 같은 결론으로 수렴합니다. 스케줄러가 정확히 한 번만 돌게 만드는 방법은 없습니다. cron이든 Spring이든 Quartz든 k8s든, 서버가 재시작되거나 여러 인스턴스로 늘어나거나 DST 전환일이 끼면 같은 잡이 두 번 트리거될 수 있는 경로가 어딘가에 남습니다. 그래서 방어는 “스케줄을 더 영리하게 짜기”가 아니라 “잡을 몇 번 실행되든 결과가 같게 만들기”, 즉 멱등성(idempotency)입니다.

멱등성 자체는 새로운 개념이 아닙니다. 진짜 함정은 멱등성 키를 어떻게 만드는가에 있습니다. 흔한 실수는 이겁니다.

// 이렇게 만들지 않는다 String idempotencyKey = "settlement:" + LocalDateTime.now();

가을 전환일 새벽, 이 코드는 한 시간 간격으로 실제로는 다른 두 순간에 각각 호출됩니다. 그런데 LocalDateTime.now()가 지역 시각으로 02:17:03 같은 값을 두 번 다 똑같이 돌려줄 수 있습니다 — 6장이 보여준 그 오버랩 구간이니까요. 두 번의 서로 다른 실제 실행이 똑같은 문자열의 키를 만듭니다. 이게 저장소의 유니크 제약과 만나면 두 가지 방식으로 다 잘못됩니다. 키가 우연히 겹쳐 두 번째 실행이 조용히 무시되면, 원래 의도(하루 한 번)와 맞아떨어질 수도 있지만 그건 순전히 운입니다. 반대로 실행마다 나노초·UUID 같은 걸 덧붙여 키를 “고유하게” 만들어버리면, 이번엔 진짜로 막아야 할 중복 실행을 막지 못합니다 — 여는 글의 사고가 정확히 이 두 번째 실패였을 가능성이 큽니다.

핵심은 이겁니다. 멱등성 키는 실행 시점에 스케줄러 콜백 안에서 now()로 읽은 지역 시각 문자열이어선 안 됩니다. 대신 둘 중 하나로 만듭니다.

  • 실행 자체를 구분하고 싶다면 — 스케줄러가 넘겨주는 Instant(트리거의 UTC fire-time)나 실행 ID를 키로 씁니다. 6장 실측이 보여준 대로, 오버랩 구간의 두 실행은 지역 시각 문자열은 같아도 Instant는 정확히 한 시간 차이 나므로 절대 충돌하지 않습니다.
  • “그 지역 달력으로 하루에 한 번”이 진짜 요구사항이라면 — 키를 실행 시점에 다시 계산하지 말고, 스케줄을 등록하는 시점(혹은 잡이 시작되기 전, 오프셋이 아직 하나로 고정된 시점)에 미리 확정해둔 사업 날짜(예: "settlement:2024-10-27")로 씁니다. 이 값은 잡이 몇 번 트리거되든 항상 같은 문자열이 나오므로, 두 번째 실행은 저장소의 유니크 제약에서 정상적으로 걸러집니다.

어느 쪽을 고르든 공통점은 하나입니다. 키의 원천이 “실행되는 그 순간 벽시계를 다시 읽는 것”이 아니라는 것. 그 순간의 벽시계 자체가 그날 밤엔 믿을 수 없는 값이라는 게 6장의 결론이었고, 이 장은 그 결론 위에 키를 세우지 말라는 것뿐입니다.

월요일에 바로 적용할 체크리스트

  • 이 스케줄이 뜻하는 게 **“정확히 N시간마다”**인지 **“그 지역 달력의 특정 시각”**인지 먼저 결정한다 — 이 장 맨 앞의 갈림길이다.
  • 전자면 fixedRate/fixedDelay처럼 duration 기반 API를 쓴다. DST와 무관해지는 대신, 지역 시각 기준으로는 하루씩 조금씩 밀린다는 걸 받아들인다.
  • 후자면 cron이나 CronExpressionzone을 명시해서 쓰고, 전환일엔 두 번 돌거나 아예 건너뛸 수 있다는 걸 코드가 아니라 팀이 미리 알고 있는다.
  • 지금 쓰는 스케줄러(cron, Spring, Quartz, k8s CronJob 등)가 전환일에 실제로 어떻게 동작하는지 문서만 믿지 말고, 이 장의 실측처럼 직접 재현해본다. 도구마다 답이 다르다는 게 이 장에서 가장 확실하게 실측된 사실이다.
  • 잡을 멱등하게 만든다. 몇 번 실행되든 최종 결과가 같아야 한다.
  • 멱등성 키는 실행 중 다시 읽은 지역 시각 문자열이 아니라, 트리거의 UTC Instant나 미리 확정해둔 사업 날짜로 만든다.

정리

  • “매일 새벽 2시”는 정확히 24시간마다그 지역 달력 하루에 한 번이라는 서로 다른 두 요구사항을 가릴 수 있다. DST 전환일에만 둘이 갈라진다 — 어느 쪽인지 의식적으로 고른다.
  • 실측(Spring spring-context 6.1.14, CronExpression, JDK 21): 가을 전환일(Europe/Paris, 2024-10-27) 매일 02:00 크론은 지역 시각 02:00을 정확히 한 시간 차이(00:00Z, 01:00Z)로 두 번 실행한다. 봄 전환일(America/New_York, 2024-03-10) 같은 크론은 그날을 통째로 건너뛰고 다음 날로 넘어간다, 예외 없이.
  • 실측(같은 조건, 크론 "0 0 * * * *"): “매시간”은 duration이 아니다. 벽시계 hour 필드가 *일 뿐이라, 가을 전환일 그날 하루에 25번 실행돼 6장이 실측한 “25시간짜리 하루”를 스케줄 실행 횟수로 그대로 재현한다.
  • 스케줄러마다 전환일 대응이 다르다. Vixie cron은 매뉴얼에 재실행 방지를 명시했지만(📄 문서 기반(미검증)), Spring CronExpression은 실측상 막지 않는다. Quartz는 misfire instruction으로 정책을 선택하게 하고(📄 문서 기반(미검증)), Kubernetes CronJobtimeZone 필드는 1.24(alpha)→1.25(beta)→1.27(stable)로 승격됐다(📄 문서 기반(미검증)).
  • 진짜 duration 기반은 fixedRate/fixedDelay뿐이다. DST와 무관해지는 대신 지역 시각 기준 스케줄은 매년 두 번 하루씩 밀린다.
  • 가장 중요한 문장: 방어는 스케줄러가 아니라 잡에서 한다. 멱등성 키는 실행 중 다시 읽은 지역 시각 문자열이 아니라, 트리거의 UTC Instant나 미리 확정해둔 사업 날짜로 만든다. 지역 시각 문자열을 키로 쓰면, 오버랩 구간에서 서로 다른 두 실행이 같은 키를 만들어낸다.
  • 제4부를 관통한 순서는 이렇다. 13장이 컬럼 하나(DATETIME vs TIMESTAMP)를 고쳤고, 14장이 그 컬럼까지 값이 통과하는 매핑 계층 세 겹(JVM zone·JDBC·DB 세션)을 맞췄고, 15장이 그 위에 UTC 하나로 통일하는 시스템 정책을 세웠고, 이 장은 그 정책이 마지막으로 부딪히는 자리 — 스케줄러라는 벽시계 트리거를 다뤘다. 넷 다 결국 같은 문장으로 되돌아간다. 지역 시각은 순간이 아니라 눈금이다.

생각해볼 질문: 이 장까지 모든 처방은 하나를 전제로 했습니다 — 서버 한 대의 시계는 정확하다. UTC로 통일하고, 크론에 zone을 명시하고, 멱등성 키를 UTC Instant로 만드는 것 모두 “그 Instant가 실제로 맞는 시각이다”라는 믿음 위에 서 있습니다. 그런데 배치가 여러 인스턴스로 늘어나고, 그 인스턴스들이 서로 다른 서버·컨테이너·리전에 떠 있다면 — 각 서버의 시계가 서로 조금씩 다르게 흐르고 있다면 어떨까요? 그 어긋남은 누가, 어떻게 바로잡을까요?

17장 · 시계는 흐른다 — NTP와 클록 드리프트

Last updated on