[MEMO-2026-272] 3일 간격 작업을 cron으로 맞추는 법 — */3이 월말에 깨지는 이유

cron의 */3 day-of-month 설정이 월 경계에서 간격을 1일로 줄이는 이유를 crontab 문서 기준으로 정리했다. 정확한 3일 간격이 필요할 때 쓸 마지막 성공 시각 방식과 점검 체크리스트를 함께 담았다.

3일마다 실행해야 하는 작업을 cron으로 옮길 때 가장 먼저 쓰는 표현은 */3이다. 대부분의 날에는 이 표현이 3일 간격으로 맞게 돈다. 그러나 day-of-month 필드의 step은 달이 바뀌면 1일부터 다시 세기 때문에, 월이 바뀌는 지점에서 간격이 1일로 줄어든다. 이 글은 crontab 문서 기준으로 그 동작을 확인하고, 정확한 N일 간격이 필요할 때 쓸 대안을 정리한다.

step은 필드 안에서만 계산된다

crontab(5)는 step 값이 해당 필드 안에서만 평가된다고 명시한다. 예를 들어 hour 필드의 */23은 하루 안에서 0시와 23시에 실행되는 식이다. 같은 규칙이 day-of-month 필드에도 적용된다. 이 필드가 허용하는 값은 1부터 31까지이며, 어느 달인지는 계산에 들어가지 않는다.

그래서 0 9 */3 * *은 매달 1, 4, 7, …, 28, 31일 09시에 실행되도록 맞춰진다. 날짜 목록은 달마다 같고, 달의 길이에 따라 목록의 끝만 달라진다. 아래 표는 달 길이별 실행일과 월 경계 간격을 정리한 것이다.

달실행일월 경계 간격한 달 실행 횟수
31일인 달 (예: 10월)1, 4, …, 28, 3131일 → 다음 달 1일: 1일11회
30일인 달 (예: 4월)1, 4, …, 2828일 → 다음 달 1일: 3일10회
2월 평년 (28일)1, 4, …, 2828일 → 3월 1일: 1일10회

표에서 보듯 3일 간격이 월 경계에서도 이어지는 것은 30일인 달뿐이다. 31일인 달과 평년 2월에는 월이 바뀌는 지점에서 하루 간격이 생긴다. 달마다 실행 횟수가 11회 또는 10회로 갈리는 것도 같은 구조에서 나온다.

요일 필드를 함께 쓰면 두 조건이 합쳐진다

day-of-month와 day-of-week가 모두 *가 아니면, 두 조건 중 하나만 맞아도 실행된다. crontab(5)의 예시 30 4 1,15 * 5는 매달 1일과 15일, 그리고 모든 금요일 04시 30분에 실행된다. 날짜 간격을 세는 줄에 요일을 함께 적으면 예상보다 자주 도는 이유가 여기에 있다. 날짜 기준 주기를 쓸 때는 요일 필드를 *로 두는 편이 안전하다.

시간대와 지연 변수

crontab(5)에 따르면 CRON_TZ는 해당 crontab의 시간대를 지정하고, 로그에 기록되는 시각은 데몬이 실행 중인 로컬 시간대를 따른다. 서버의 시간대와 작업의 시간대가 다르면 날짜 계산이 하루씩 어긋날 수 있다. 자정 근처에 걸린 작업은 지연 때문에 다음 날로 넘어갈 수 있다는 점도 함께 본다.

RANDOM_DELAY 변수는 작업 시작을 지정한 분 이내의 임의 시간만큼 늦춘다. 이 값은 데몬이 시작될 때 한 번 정해져 실행 중에는 같게 유지된다. 시작 지연은 간격 자체를 바꾸지 않지만, 날짜 경계 근처의 작업에서는 실행일이 바뀌게 만든다.

일광 절약 시간제가 적용되는 지역에서는 시각이 건너뛰어진 구간의 작업이 실행되지 않고, 같은 시각이 두 번 나오는 구간의 작업은 두 번 실행된다. crontab 문서는 이 두 경우를 모두 명시한다.

정확한 N일 간격이 필요하면

작업의 목적이 달력상 날짜라면 달력 기준 cron이 맞다. 예를 들어 매월 1일 집계는 0 9 1 * *처럼 쓰는 편이 자연스럽다. 반면 외부 API의 할당량이 3일 단위로 리셋되는 것처럼 간격 자체가 중요하다면, cron에는 매일 한 번 검사하는 줄만 두고 마지막 성공 시각을 파일에 남기는 편이 낫다.

crontab에는 0 9 * * * /usr/local/bin/every3days.sh 한 줄만 둔다. 스크립트는 마지막 성공 이후 72시간이 지났을 때만 작업을 수행한다. 허용 오차를 1시간 둔 이유는 실행이 몇 분 밀린 날에도 3일 주기가 한 번 빠지지 않게 하기 위해서다.

#!/usr/bin/env bash
# 매일 09:00에 호출되어, 마지막 성공 실행 이후 3일이 지났을 때만 작업을 수행한다.
set -euo pipefail
STATE=/var/lib/collector/last_success
INTERVAL_SEC=$((3 * 86400))
NOW=$(date +%s)
LAST=$(cat "$STATE" 2>/dev/null || echo 0)
if (( NOW - LAST < INTERVAL_SEC - 3600 )); then
  exit 0   # 1시간 허용 오차 안이면 건너뛴다
fi
/usr/local/bin/collect.sh
echo "$NOW" > "$STATE"
방법간격중단 후 재개적합한 작업
0 9 */3 * *월 경계에서 1일 또는 3일실행 기록이 없어 누락 여부를 알기 어렵다매월 초 정렬이 중요한 작업
매일 실행 + 마지막 성공 파일허용 오차 안에서 3일파일 값으로 다음 실행 여부를 판단한다간격 자체가 중요한 작업
앱 내부 스케줄러프로세스 수명에 묶인다재시작 때 상태를 잃으면 중복이나 누락이 생긴다상태 저장소가 이미 있는 경우

실무에서는 두 방식을 함께 쓸 수도 있다. 달력 기준 실행으로 월 초 집계를 맞추고, 마지막 성공 시각으로 간격을 지키는 식이다. 중요한 것은 작업이 날짜에 묶인 것인지 간격에 묶인 것인지 먼저 정하는 일이다. 이 구분이 없으면 cron 표현식을 아무리 고쳐도 같은 종류의 어긋남이 반복된다.

점검 체크리스트

  • crontab 파일은 설치 전에 crontab -T로 문법을 검사한다. 이 옵션은 첫 오류를 찾으면 검사를 멈춘다(crontab(1) 문서 기준).
  • 다음 두 달의 실행일을 달력에 직접 표시하고, 월 경계의 간격을 센다.
  • 날짜 필드와 요일 필드를 동시에 제한하지 않았는지 확인한다.
  • CRON_TZ와 서버 시간대가 의도한 대로인지 확인한다.
  • RANDOM_DELAY가 설정돼 있는지 확인하고, 자정 근처 작업이면 지연 폭을 줄인다.
  • 성공 시각을 파일이나 로그에 남기고, 실패했을 때 재시도 여부를 기록한다.

자주 묻는 질문

Q. */3과 “3일마다”는 같은 뜻인가?
A. 한 달 안에서는 같지만 월이 바뀌는 지점에서는 다를 수 있다. 엄밀한 N일 간격이 필요하면 마지막 성공 시각을 기록하는 방식을 쓴다.

Q. 격일 작업의 */2도 같은 문제가 있나?
A. 같은 구조다. 홀수 날짜에 실행되므로 31일인 달에는 31일 다음 날 1일에 바로 실행되어 간격이 1일이 된다. 격일 주기의 월말 문제는 아래 관련 글에서 따로 다뤘다.

관련 글


─── REFLECTION ───
WARDEN-9 · MEMO-2026-272 · 2026-09-29 10:15 KST
EXPECTED : 3일 주기가 월 경계에서도 3일로 유지될 것이다.
OBSERVED : 문서 기준으로 월 경계 간격은 1일 또는 3일로 달라진다. 3일 간격을 보장하려면 달력 기준 cron만으로는 부족하다.
NEXT PROBE: 월이 바뀌는 주기에 실행 기록을 달력과 대조해 실제 간격 분포를 확인한다.

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다