cron 표현식의 함정: 필드, 범위, 그리고 Vixie vs POSIX

cron 필드와 특수 문자, 사람들을 무는 일(날짜) vs 요일 OR 함정, 타임존·DST 위험, 그리고 초 필드 이식성 함정을 정리합니다.

cron 표현식은 작업이 언제 실행되는지를 함께 기술하는 다섯 개의 공백 구분 필드입니다. 문법은 사소해 보이고 실제로도 대체로 그렇지만, 몇 가지 세부가 잘못된 시각에 실행되는 거의 모든 예약 작업의 원인입니다. 논리적 함정 하나, 구현 갈래 둘, 타임존 위험 하나입니다. 이 글은 필드와 특수 문자, 그리고 코드 리뷰를 통과해 프로덕션에서 문제를 일으키는 함정들을 짚어보겠습니다.

다섯 개의 필드

필드 위치 허용 값 비고
1 0-59
2 0-23 24시간제
일(날짜) 3 1-31
4 1-12 또는 JAN-DEC 이름은 Vixie, 대소문자 무시
요일 5 0-7 또는 SUN-SAT 07 모두 일요일

줄은 왼쪽에서 오른쪽으로 읽습니다. 30 2 1 * *는 "분 30, 시 2, 일 1, 모든 월, 모든 요일", 즉 매월 1일 02:30입니다. * 필드는 "해당 범위의 모든 값"을 뜻합니다.

요일 범위가 첫 번째 함정입니다. Vixie cron은 일요일에 07을 모두 받아들이며, 그래서 0-61-7이 둘 다 한 주 전체를 덮습니다. 1-7을 "월요일부터 일요일까지"의 뜻으로 쓰는 사람은 일요일이 두 번 잡히는 결과라 의외라고 느낍니다. 0-6을 쓰거나 이름을 쓰세요.

특수 문자

네 연산자가 필드 안 값을 구성합니다.

  • * — 필드 범위의 모든 값.
  • , — 목록. 0,15,30,45는 네 개의 개별 분입니다.
  • - — 포함 범위. 9-17은 9부터 17까지 모든 값입니다.
  • / — 스텝. 분 필드의 */15는 "15분마다"입니다(0, 15, 30, 45). 스텝은 범위 위에도 올릴 수 있습니다. 0-30/10은 0, 10, 20, 30입니다.

실제 일정 대부분을 덮는 예 몇 가지입니다.

*/15 * * * *      # 15분마다
0 9-17 * * 1-5    # 매시 정각, 오전 9시~오후 5시, 월~금
0 0 * * 0         # 매주 일요일 자정
0 2 1 * *         # 매월 1일 02:00
30 3 * * 6        # 매주 토요일 03:30

*/15는 더 자세히 볼 가치가 있습니다. 스텝이 "지금"이 아니라 필드의 전체 범위를 기준으로 계산되기 때문입니다. */15는 항상 0, 15, 30, 45에 실행됩니다. "데몬이 시작한 뒤 15분"을 뜻하지 않습니다. 분 필드의 */40 같은 스텝은 0과 40에 실행된 다음, 다음 시간이 0으로 넘어갈 때까지 20분을 기다립니다. 80분이라는 것은 없으므로 분 80에는 실행되지 않습니다. 범위를 고르게 나누지 못하는 스텝은 들쭉날쭉한 간격을 만들고, 이는 */40을 "40분마다"로 기대하는 사람의 예상을 빗나갑니다.

일(날짜)과 요일의 OR 함정

이것은 경험 많은 엔지니어를 가장 자주 잡는 함정입니다.

보통 cron은 모든 필드가 매칭되기를 요구합니다. 그런데 일(날짜) 필드(3)와 요일 필드(5)가 둘 다 제한될 때, 즉 어느 쪽도 *가 아닐 때, Vixie cron과 그로부터 파생된 리눅스 cron들은 그 두 필드 사이에서 OR 논리로 전환합니다. 작업은 날짜가 맞거나 또는 요일이 맞을 때 실행됩니다.

13일의 금요일 자정에 실행하려던 이 줄을 봅시다.

0 0 13 * 5    # "13일의 금요일"이 아님

그렇게 동작하지 않습니다. 두 날짜 필드가 모두 제한됐기 때문에, cron은 매월 13일 자정에 그리고 매주 금요일 자정에 작업을 실행합니다. 의도보다 훨씬 자주입니다. 13일의 금요일은 두 조건의 교집합이지만, cron은 합집합을 계산합니다.

이 동작은 Vixie crontab man 페이지에 문서화돼 있고 1987년부터 안정적이므로 버그가 아니라 기능입니다. 다만 충분히 직관에 반해서 crontab.guru가 이에 관한 페이지를 따로 둘 정도입니다. 실용 규칙으로, 두 날짜 필드를 모두 제한하고 있다면 멈추고 AND를 의도한 것인지 확인하세요. 그렇다면 단일 표준 cron 줄로는 표현할 수 없습니다. 작업 안에 날짜 가드([ "$(date +\%a)" = Fri ] || exit 0)를 넣거나, 로직을 cron 밖으로 빼야 합니다.

두 날짜 필드 중 하나가 *이면 OR 규칙은 적용되지 않고 모든 것이 뻔하게 동작합니다.

매크로는 Vixie 확장

Vixie cron은 다섯 필드 표현식 전체를 대체하는 별칭 매크로 묶음을 추가했습니다.

@reboot     # 데몬 시작 시 1회
@yearly     # 0 0 1 1 *   (@annually도 동일)
@monthly    # 0 0 1 * *
@weekly     # 0 0 * * 0
@daily      # 0 0 * * *   (@midnight도 동일)
@hourly     # 0 * * * *

이들은 편리하고 리눅스에서 널리 지원되지만 POSIX가 아닙니다. crontab의 POSIX 명세는 다섯 숫자 필드만 정의합니다. @daily가 든 crontab을 엄격한 POSIX cron이나 임베디드 busybox 빌드, 일부 BSD 구성에서 돌리면 거부되거나 조용히 무시될 수 있습니다. @reboot이 그중 가장 비이식적입니다. 많은 컨테이너 init 시스템에서 의미가 없고, cron 데몬이 "재부팅"되지 않는 환경에서는 실행되지 않습니다. 이식성이 중요하면 다섯 필드를 풀어 쓰세요.

타임존과 DST

cron은 표현식을 데몬의 타임존으로 평가하며, 이는 시스템이 UTC로 설정돼 있지 않은 한 보통 시스템 타임존이지 UTC가 아닙니다. 0 9 * * *로 쓴 작업은 데몬이 있는 머신이 어디든 그곳의 오전 9시 로컬 시각에 실행됩니다. 워크로드를 다른 지역의 호스트로 옮기면 일정이 조용히 어긋납니다.

일광 절약 전환이 날카로운 모서리입니다. 봄에 시간을 당기는 밤, 로컬 시각은 01:59에서 03:00으로 건너뜁니다. 02:30으로 예약된 작업은 02:30이 존재하지 않으므로 그날 실행되지 않습니다. 가을에 되돌리는 밤에는 02:30이 두 번 발생하고, cron 구현에 따라 작업이 두 번 실행되거나 중복 제거될 수 있습니다. Vixie cron은 봄에 건너뛴 작업을 실행하고 가을에 그 구간의 작업이 중복 실행되지 않게 하는 특수 로직을 갖고 있지만, 그 동작은 구현마다 다르고 금융이나 청구 작업에서 기댈 것은 못 됩니다.

방어적 자세는 cron 데몬을 UTC로 돌리고 로컬 시각 변환은 작업 안에서 하는 것입니다. UTC로 예약된 작업은 UTC에 DST가 없으므로 건너뛰거나 중복되는 시각을 결코 만나지 않습니다. 대부분의 현대 스케줄러(systemd 타이머의 OnCalendar, 쿠버네티스 CronJob)는 타임존을 명시적으로 설정하게 해 줍니다. 표준 crond는 시스템 TZ를 상속하므로 OS 수준에서 UTC로 고정하는 편이 가장 좋습니다.

초 필드 함정

표준 cron에는 초 필드가 없습니다. 예약하는 가장 작은 단위는 1분입니다. 여러 인기 비유닉스 스케줄러가 초를 위한 선두 여섯 번째 필드를 추가하기 때문에, 다른 생태계에서 온 사람을 이것이 잡습니다.

  • Quartz(자바)는 6필드 또는 7필드 형식을 씁니다. 초 분 시 일 월 요일 [연] 입니다.
  • node-cron과 여러 언어 라이브러리는 선택적 선두 초 필드를 받아들여, 5필드와 6필드 표현식을 모두 받습니다.
  • **Spring의 @Scheduled(cron=...)**는 Quartz 스타일 6필드 문법을 씁니다.

이식성 함정은, Quartz 예제에서 복사한 6필드 표현식을 유닉스 crontab에 붙여 넣으면 모든 필드가 왼쪽으로 한 칸씩 밀린다는 것입니다. 0 0 12 * * ?(Quartz의 매일 정오)를 리눅스 crontab에 붙여 넣으면 분 0, 시 0, 일 12, 월 *, 요일 *로 읽히고, 표준 cron이 이해하지도 못하는 ?가 덩그러니 남습니다. Quartz는 표준 cron에 개념조차 없는 ?L/W/# 수정자도 씁니다. 시스템 간에 표현식을 복사하기 전에 대상 런타임이 5필드를 기대하는지 6필드를 기대하는지 항상 확인하세요.

표현식 점검

cron 줄을 커밋하기 전에 세 가지를 확인하세요. 필드 수가 런타임에 맞는지(유닉스 cron은 5, Quartz/node-cron은 6), AND를 의도했는데 두 날짜 필드가 실수로 제한되진 않았는지, 그리고 다음 몇 번의 실행 시각이 예상한 곳에 떨어지는지입니다. 다음 실행 시각들을 소리 내어 읽어보면 대부분의 실수가 잡힙니다. "매일처럼 보이는" 표현식이 사실 매시 실행된다면, 일정을 펼쳐 본 순간 분명해집니다.

우리 Cron 파서는 표현식을 평이한 설명으로 펼치고 다가오는 실행 시각을 나열해, 줄이 배포되기 전에 검증하는 가장 빠른 방법입니다. 특히 OR 함정에서, 펼쳐진 일정이 합집합 동작을 즉시 눈에 보이게 해 줍니다.