Cron 표현식 파서

Cron 스케줄 표현식 분석 및 설명

minute (0-59) | hour (0-23) | day (1-31) | month (1-12) | weekday (0-6)

자주 쓰는 예제

DOM/DOW OR 함정: '0 0 13 * 5'가 13일의 금요일이 아닌 이유

cron에서 가장 많이 오해되는 규칙은 POSIX crontab(5)에 명시되어 있습니다. day-of-month 필드와 day-of-week 필드가 둘 다 제한되어 있으면(즉, 둘 다 *가 아니면) 작업은 둘 중 하나만 일치해도 실행됩니다. 따라서 0 0 13 * 5는 매월 13일 자정과 매주 금요일 자정에 모두 실행됩니다. 1년에 한두 번 오는 13일의 금요일을 기대했다면, 실제로는 연간 약 60회 실행되는 것입니다. 두 트리거 조건이 각각은 그럴듯해 보이기 때문에 이 버그는 코드 리뷰를 통과하기 일쑤이고, "월간" 리포트가 지난주에 네 번 도착한 이유를 누군가 물을 때에야 드러납니다. AND 의미를 얻는 확실한 방법은 두 필드 중 하나만 표현식에서 제한하고, 나머지 하나는 명령 안에서 검사하는 것입니다. day-of-month를 13으로 고정하고 셸이 요일을 확인하게 하세요. date +%u는 ISO 요일(1 = 월요일, 7 = 일요일)을 출력하므로 5와 비교하면 금요일만 선택됩니다. 이제 작업은 최대 연 12회 실행되고, 금요일이 아닌 13일에는 가드가 조용히 종료합니다. 그 명령줄에는 두 번째 함정이 숨어 있습니다. crontab 항목에서 이스케이프되지 않은 %는 특수 문자입니다. 첫 번째 %에서 명령이 끝나고 그 뒤의 내용은 명령의 표준 입력으로 전달되며, 이후의 %는 개행 문자가 됩니다. date +%Y-%m-%d는 대화형 셸에서는 완벽히 동작하지만 cron에서는 작업을 소리 없이 잘라먹습니다. 모든 %를 \%로 이스케이프하거나, 날짜 포매팅을 스크립트 안으로 옮기고 cron은 그 스크립트만 호출하게 하세요. "cron이 돌긴 했는데 아무 일도 안 했다"는 티켓의 상당수가 이 문자 하나로 귀결됩니다.
# OR semantics: fires every Friday AND every 13th (~60x/year)
0 0 13 * 5 /opt/report.sh

# AND semantics: pin DOM, test DOW in the command (max 12x/year)
0 0 13 * * [ "$(date +\%u)" = 5 ] && /opt/report.sh

# Unescaped % silently truncates the command line
0 1 * * * echo "backup-$(date +\%Y\%m\%d)" >> /var/log/runs.log

서머타임(DST): cron 작업이 죽는 곳

서머타임으로 시계가 앞으로 점프하는 타임존에서 02:30에 작업을 걸어두면, 벽시계가 02:00에서 곧장 03:00으로 건너뛰면서 작업이 실행되어야 할 시각 자체가 그날 밤 존재하지 않게 됩니다. 이후 동작은 구현마다 다릅니다. vixie-cron과 그 후손인 cronie는 점프를 감지해 건너뛴 작업을 전환 직후에 실행하지만, 컨테이너에 들어가는 미니멀한 cron 클론 다수는 그냥 건너뜁니다. 시계가 뒤로 돌아가는 가을은 더 나쁩니다. 01:00~02:00 사이의 한 시간이 두 번 반복되고, 순진한 스케줄러는 두 번 모두 작업을 실행합니다. 그 작업이 청구서를 발행하거나 카드를 결제한다면 1년에 한 번 고객에게 이중 청구를 하게 되는 셈입니다. 방어책은 두 가지입니다. 로컬 시간 스케줄을 안전 구간(03:00 이후, 02:00 이전)에 두어 전환이 닿지 않게 하거나, 스케줄러를 전환이 아예 없는 UTC로 돌리는 것입니다. 호스팅형 스케줄러는 자체 의미론을 더합니다. GitHub Actions의 schedule: cron은 UTC로만 해석되며, 부하가 높을 때는 실행이 몇 분씩 지연되거나 아예 누락될 수 있다고 문서에 명시되어 있습니다. 정각처럼 인기 있는 분(minute)이 가장 심하게 겪으므로 17분, 43분 같은 어중간한 분을 고르세요. Kubernetes CronJob은 1.27에서 spec.timeZone 필드가 stable이 되었습니다. 여기에 startingDeadlineSeconds(시작이 얼마나 늦어도 허용되는지 — 데드라인을 넘기면 그 실행은 누락으로 집계)와 concurrencyPolicy(이전 작업이 아직 실행 중일 때 다음 작업과 공존할지 Allow(기본), 막을지 Forbid, 죽이고 대체할지 Replace)를 함께 설정하세요.
# Kubernetes CronJob with explicit time zone (stable in 1.27)
apiVersion: batch/v1
kind: CronJob
spec:
  schedule: "30 4 * * *"
  timeZone: "Asia/Seoul"
  startingDeadlineSeconds: 300
  concurrencyPolicy: Forbid

*/N은 'N마다'가 아니다: 스텝 값의 실제 동작

스텝 값 */N은 "이 필드 범위의 값들 중 범위 시작점부터 N 간격으로 떨어진 값들"로 확장됩니다 — "경과 시간 N 단위마다"가 아닙니다. 분 필드에서는 두 해석이 우연히 일치합니다. */15는 0,15,30,45가 되고 한 시간은 정확히 60분이므로 간격이 균일합니다. day-of-month에서 환상이 깨집니다. 1-31/10은 1, 11, 21, 31일로 확장되는데, 다음 달은 다시 1일부터 시작하므로 "31일 다음 1일"이 연달아 이틀 실행됩니다. 즉 진짜 "10일마다"는 cron으로 표현할 수 없습니다. 날짜 카운터는 매월 경계에서 리셋되고, cron은 마지막 실행이 언제였는지 기억하지 못합니다. 같은 이유로 0 0 */2 * *("격일")는 1,3,...,29,31일에 실행된 뒤 다음 달 1일에 또 실행됩니다 — 31일이 있는 달마다 이틀 연속 실행이 발생하며, 이는 1년에 일곱 번입니다. 월 경계를 넘는 고정 간격이 정말 필요하다면 작업을 매일 실행하고 스크립트 안에서 걸러내세요. epoch 일수를 N으로 나눈 나머지를 비교하거나, 마지막 실행 타임스탬프를 파일에 저장해 조기 종료하면 됩니다. 또는 systemd 타이머의 monotonic 스케줄링(OnUnitActiveSec=10d)을 쓰면 이전 활성화 시점부터 세기 때문에 달력을 완전히 무시합니다.
*/15 * * * *      # minutes 0,15,30,45 — uniform, as expected
0 0 1-31/10 * *   # days 1,11,21,31 — NOT "every 10 days"
0 0 */2 * *       # 1,3,...,29,31 then the 1st: twice in a row

# real "every 10 days": run daily, gate on the epoch day
0 3 * * * [ $(( $(date +\%s) / 86400 \% 10 )) -eq 0 ] && /opt/task.sh

하나의 문법, 여러 방언: Quartz, EventBridge, systemd 타이머

5필드 POSIX 형식(분, 시, 일, 월, 요일)은 기준선일 뿐입니다. 무수한 Java 서비스에 내장된 스케줄러 Quartz(Spring의 @Scheduled 포함)는 초가 맨 앞에 오고 연도가 마지막에 붙을 수 있는 6~7필드를 쓰며, DOM과 DOW가 둘 다 구체적인 값인 표현식을 거부합니다. 둘 중 하나는 반드시 "특정 값 없음"을 뜻하는 ?여야 합니다. 그 대가로 진짜 유용한 연산자를 얻습니다. L은 월의 마지막 날(6L이면 마지막 금요일), W는 특정 날짜에 가장 가까운 평일(15W), #은 n번째 요일로 MON#2는 둘째 월요일 — 순정 cron으로는 아예 표현할 수 없는 것들입니다. AWS EventBridge는 연도 필드(1970-2199)로 끝나는 자체 6필드 방언을 쓰며, Quartz처럼 DOM과 DOW에 동시에 *를 두는 것을 거부합니다. 그래서 crontab 라인을 EventBridge 규칙에 그대로 복사하면 한쪽 필드를 ?로 바꿀 때까지 검증에 실패하는 일이 잦습니다. 전통 cron은 @reboot, @daily, @hourly 같은 별칭도 받아들입니다 — 편리하지만 비표준이고 지원이 고르지 않으니 이식성이 필요한 곳에서는 피하세요. 초 단위 해상도나 밀린 작업 보충(catch-up)이 필요하다면 systemd 타이머가 현대적인 답입니다. OnCalendar는 초를 받아들이고(Mon..Fri 09:00:30), Persistent=true는 마지막 트리거 시각을 기록해 두었다가 다음 부팅 때 놓친 실행을 보충합니다 — cron에 없기로 악명 높은 동작이며, 스케줄 시각에 잠들어 있는 노트북을 위해 anacron이 존재하는 이유이기도 합니다.
0 9 * * 1-5            # POSIX: weekdays at 09:00
0 0 9 ? * MON-FRI      # Quartz: seconds first, ? required in DOM
0 0 9 ? * MON#2        # Quartz: second Monday of the month
0 9 ? * MON-FRI *      # EventBridge: year field appended

# systemd timer unit equivalent
OnCalendar=Mon..Fri 09:00:00
Persistent=true        # run missed jobs after downtime

운영 위생: 중복 실행, 사라지는 출력, 조용히 멈추는 작업

cron에는 보충 실행이 없습니다. 작업이 실행됐어야 할 시각에 호스트가 꺼져 있거나 데몬이 멈춰 있었다면 그 실행은 사라집니다 — 큐도 재시도도 없습니다. 한 번 놓치는 것이 문제가 되는 작업(로그 로테이션, 인증서 갱신, 정산)이라면 Persistent=true를 쓰는 systemd 타이머로 옮기거나, 작업 자체 로직이 공백을 견디게 만들어야 합니다. 다음 놀라움은 출력입니다. cron은 작업이 stdout이나 stderr에 쓴 모든 내용을 로컬 MTA를 통해 crontab 소유자에게 메일로 보냅니다. MTA가 설정되지 않은 요즘 서버에서는 그 출력이 그냥 증발합니다. 실제 알림이 필요하면 MAILTO를 설정하고, 아니면 >> /var/log/job.log 2>&1로 명시적으로 리다이렉트하세요 — 그리고 그 파일을 로테이션하세요. cron은 몇 년이고 기꺼이 이어 붙일 테니까요. 느린 작업이 다음 호출과 겹치는 것은 고전적인 데이터 손상 원인입니다 — 한 디렉토리에 rsync 두 개, 한 데이터베이스에 마이그레이션 두 개. 명령을 flock -n /path/lock으로 감싸면 두 번째 인스턴스가 쌓이는 대신 즉시 종료합니다. thundering herd도 조심하세요. 전 세계 crontab의 어마어마한 비율이 0 0 * * *라고 적혀 있어서, 공유 데이터베이스와 API는 자정 정각에 두들겨 맞습니다. cronie는 RANDOM_DELAY를 지원하고, systemd에는 RandomizedDelaySec이 있으며, 호스트네임 해시로 분을 뽑아 서버들이 알아서 흩어지게 할 수도 있습니다. 마지막으로 가장 위험한 실패 모드는 조용히 멈춘 작업입니다 — 잘못된 편집, 비활성화된 계정, 가득 찬 디스크. cron은 알려주지 않습니다. 모니터링을 뒤집어 dead-man's-switch를 쓰세요. 작업이 성공할 때마다 URL에 핑을 보내고(healthchecks.io 패턴), 무언가 에러를 보고할 때가 아니라 핑이 끊길 때 알림이 울리게 하는 것입니다.
[email protected]
RANDOM_DELAY=15    # cronie: spread the start by 0-15 minutes

# no overlap, logged output, dead-man's-switch ping
17 3 * * * flock -n /run/backup.lock /opt/backup.sh \
  >> /var/log/backup.log 2>&1 \
  && curl -fsS https://hc-ping.com/UUID > /dev/null
최종 수정:

도구 소개

Cron 표현식 파서는 Unix cron, GitHub Actions, Kubernetes CronJob 등에서 사용하는 5개 필드 구문을 사람이 읽을 수 있는 문장으로 풀어주고, 앞으로 실행될 시각도 미리 보여줍니다. 5개 필드는 분 / 시 / 일 / 월 / 요일이며, 각각 와일드카드(*), 범위(1-5), 리스트(1,3,5), 간격(*/15)을 지원합니다.

사용 방법

  1. `0 9 * * 1-5` 같은 cron 표현식을 입력합니다.
  2. 아래 표시되는 자연어 설명으로 의도와 일치하는지 확인합니다.
  3. 다음 실행 시각 목록으로 월 경계, 윤년 등 엣지 케이스를 검증합니다.
  4. 예제 버튼을 눌러 자주 쓰는 표현식을 빠르게 불러옵니다.
  5. 검증된 표현식을 crontab, GitHub Actions, Kubernetes 매니페스트에 그대로 붙여넣습니다.

주요 사용 사례

  • 매일 새벽 3시 데이터베이스 백업 예약 (`0 3 * * *`)
  • 매주 월요일 오전 9시에 CI 워크플로우 실행 (`0 9 * * 1`)
  • 15분마다 정리 작업 트리거 (`*/15 * * * *`)
  • 금요일 오후 5시에 주간 요약 이메일 발송 (`0 17 * * 5`)
  • 매달 1일 자정에 로그 롤테이션을 수행하는 Kubernetes CronJob 정의
  • 배포 전에 범위/스텝을 사용한 복잡한 표현식의 동작을 미리 검증

자주 묻는 질문

Q. 표현식이 예상하지 않은 시각에 실행되는 이유는?

A. cron은 일(day-of-month)과 요일(day-of-week)이 둘 다 지정되면 AND가 아닌 OR로 해석합니다. `* * 15 * 1`은 매월 15일 OR 매주 월요일에 실행됩니다.

Q. cron은 어떤 타임존을 사용하나요?

A. 호스트/컨테이너 설정 시간대를 사용합니다. GitHub Actions는 UTC, Kubernetes CronJob은 별도 spec.timeZone을 지정하지 않으면 컨트롤러 매니저의 타임존을 따릅니다.

Q. 6필드 cron 표현식도 지원되나요?

A. Quartz, AWS EventBridge 등은 초 또는 연도 필드가 추가된 6필드 변형을 사용합니다. 본 파서는 표준 POSIX 5필드 형식을 따릅니다.

Q. 특정 시각에 한 번만 실행하려면?

A. cron 자체에는 one-shot 모드가 없습니다. systemd timer, at, 이벤트 기반 트리거를 사용하세요.