빌드노트
매일 실행하려던 Spring cron이 한 시간마다 돈다
Spring cron의 초·분·시 필드를 확인해 매시 식과 자정 식을 구별한다. 실제 cron과 시간대를 넣은 짧은 next 계산으로 다음 세 시각을 확인하고, UTC 자정과 서울 자정의 9시간 차이를 읽는다.
Spring의 @Scheduled 작업을 매일 자정에 실행하려는데 한 시간마다 실행된다면, 실제 적용된 cron이 0 0 * * * *인지 먼저 확인한다. Spring에서 세 번째 필드는 ‘시’다. 여기에 *가 있으면 매시 0분 0초가 대상이다. 매일 자정은 0 0 0 * * *이며, 어느 지역의 자정인지는 시간대까지 정해야 한다.
서울 자정이 목적이라면 기존 @Scheduled 선언을 다음처럼 맞춘다. 스케줄링이 이미 활성화된 빈의 메서드를 전제로 한다. 외부 설정을 참조한다면 소스의 자리표시자뿐 아니라 설정이 해석된 최종 cron 문자열도 확인한다.
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
다섯 필드 뒤에 별표를 붙이면 자정이 아니다
리눅스에서 흔히 쓰는 사용자 crontab의 시간·날짜 부분은 분 시 일 월 요일 다섯 필드다. 0 0 * * *는 그 시간대의 매일 자정이며, 실행할 명령은 뒤에 따로 온다. Cronie의 crontab 매뉴얼 (새 탭에서 열림)에서 이 순서를 확인할 수 있다.
Spring은 초 분 시 일 월 요일 순서로 여섯 필드를 읽는다. 자정 식을 옮기려면 초의 0을 앞에 넣어야 한다. Spring 6.2.12의 CronExpression API (새 탭에서 열림)에도 이 순서와 매시 식이 명시돼 있다.
0 0 * * *: Spring에서는 필드가 부족하다.0 0 * * * *: 뒤에*를 덧붙여 여섯 필드로 만들었지만, ‘시’가*여서 매시 정각이다.0 0 0 * * *: 초·분·시가 모두 0인 매일 자정이다.
독립 예제에서 Spring Framework 6.2.12의 CronExpression.parse()로 확인했다. 다섯 필드는 IllegalArgumentException으로 거부됐고 메시지는 Cron expression must consist of 6 fields (found 5 in "0 0 * * *")였다. 두 여섯 필드는 모두 파싱됐다. 다섯 필드를 자동 보정해 매시 실행하는 것은 아니다. 매시 실행된다면 파싱에 성공한 최종 식의 ‘시’ 필드를 살핀다. 여기서 비교한 식은 일·월·요일이 모두 *인 경우이며, 다른 파서의 모든 문법이 호환된다는 뜻은 아니다.
식이 맞아도 어느 지역의 자정인지 확인한다
zone은 cron을 해석할 시간대다. 생략하면 스케줄러의 기본 시간대를 사용하므로 서버가 있는 나라만 보고 서울이나 UTC라고 단정할 수 없다. 명시한 zone과 사용 중인 스케줄러 설정을 확인한다. 이 기본값은 Scheduled API (새 탭에서 열림)에 나와 있다. 스케줄링 가이드 (새 탭에서 열림)도 zone을 cron 해석 시간대로 설명한다.
작업을 기다리는 대신 실제 cron과 시간대로 다음 세 시각을 계산하면, 매시 식인지와 자정의 기준이 맞는지를 구별할 수 있다. 아래는 매시 식을 서울 시간대로 계산하는 입력이다. 기준 순간을 2026-10-20T00:30:00Z로 고정했다. 서울에서는 같은 날 09:30이다.
입력은 JDK 17의 JShell에서 실행했다. 독립 실행에 필요한 spring-context 6.2.12 JAR (새 탭에서 열림)과 spring-core 6.2.12 JAR (새 탭에서 열림)을 빈 폴더에 두고, Windows PowerShell에서 jshell --class-path "spring-context-6.2.12.jar;spring-core-6.2.12.jar"로 시작한다. 자기 앱을 진단할 때는 앱과 같은 Spring 버전으로 계산한다. CronExpression은 Spring 5.3부터 제공된다.
아래에서 cron과 ZoneId를 실제 설정에 맞추고, 반환된 시각을 다음 호출에 넘긴다. JShell은 각 변수의 결과를 바로 보여 준다. 자기 식에서 next()가 null을 반환하면 그다음 줄은 실행하지 않는다. API가 다음 일치 시각을 찾지 못했다는 뜻이다.
import java.time.*;
import org.springframework.scheduling.support.CronExpression;
var cron = CronExpression.parse("0 0 * * * *");
var base = Instant.parse("2026-10-20T00:30:00Z").atZone(ZoneId.of("Asia/Seoul"));
var first = cron.next(base);
var second = cron.next(first);
var third = cron.next(second);
first.toInstant();
이 입력에서 first, second, third는 서울 시각으로 20일 10:00, 11:00, 12:00이었다. 첫 결과는 2026-10-20T10:00+09:00[Asia/Seoul], first.toInstant()는 2026-10-20T01:00:00Z였다. ZoneId만 UTC로 바꿔 같은 기준에서 다시 계산하면 20일 01:00, 02:00, 03:00이다. 이번 두 지역의 매시 식은 같은 세 순간을 가리켰다. 시간대를 바꿔도 ‘시’의 *가 자정으로 바뀌지는 않았다.
이제 문자열을 0 0 0 * * *로 고치고 위 입력을 처음부터 다시 실행한다. 서울의 다음 세 결과는 21일·22일·23일 00:00이었다. 첫 결과는 2026-10-21T00:00+09:00[Asia/Seoul], 그 Instant는 2026-10-20T15:00:00Z다. 같은 자정 식에서 ZoneId만 UTC로 바꾸면 다음 세 결과는 21일·22일·23일 00:00 UTC이며, 첫 Instant는 2026-10-21T00:00:00Z였다.
두 자정은 지역 시계로 같은 00:00이어도 실제 순간은 9시간 다르다. UTC 자정을 서울 시각으로 표시하면 09:00이다. 따라서 로그에 찍힌 시간대와 일정을 해석한 시간대를 구별해야 한다. 이 계산에서는 시간대가 있는 ZonedDateTime을 넘겼다. cron 문자열 자체가 서울을 지정하는 것은 아니다.
자기 입력에서는 다음 세 날짜와 간격이 의도와 맞는지 읽는다. 기준 순간을 바꾸면 위 날짜와 달라질 수 있다. 클래스를 찾지 못하는 오류는 cron 검사에 도달하기 전이므로 두 JAR의 위치와 클래스 경로부터 확인한다. 계산은 매일 자정인데 실제 호출은 매시라면, 실행 중인 앱에 적용된 식·파서·시간대를 다시 대조한다. JShell은 /exit로 종료한다.
이번 결과는 Windows, OpenJDK 17.0.2, Spring Framework 6.2.12, JDK 시간대 데이터 2021e에서 파싱과 다음 시각 계산만 실행해 얻었다. 첫머리의 @Scheduled 설정은 공식 API에 따른 수정 예시이며, Spring 컨텍스트나 대기 스케줄러를 띄워 메서드 호출을 확인하지는 않았다. 시스템 cron과 운영 일정도 변경하지 않았다. 고정 기준과 UTC·서울 두 시간대만 비교했으므로 DST 전환 경계, 긴 작업의 지연·누락, 여러 인스턴스의 중복 실행은 이 결과로 보장하지 않는다.