본문으로 건너뛰기
Toova
모든 도구

실제 프로덕션에서 사용되는 5가지 Cron 표현식 패턴

Toova

cron 표현식은 단순해 보입니다 — 다섯 개의 필드, 소수의 연산자 — 하지만 프로덕션 팀은 같은 실수를 반복합니다: 특정 달에 조용히 건너뛰는 작업, 공유 상태를 손상시키는 겹치는 실행, 같은 초에 깨어나 데이터베이스를 압도하는 수백 개의 작업. 표현식 자체가 문제인 경우는 드뭅니다; 문제는 엣지 케이스를 깊이 생각하지 않는 것입니다.

이 가이드는 엔지니어링 팀이 실제로 프로덕션에서 사용하는 다섯 가지 cron 패턴을 각 패턴의 이유와 피해야 할 함정과 함께 다룹니다. 배포 전에 표현식을 검증하고 다음 10개의 실행 시간을 보려면 Toova Cron Parser를 사용하세요.

Cron 표현식의 구조

표준 cron 표현식은 공백으로 구분된 다섯 개의 필드를 가집니다:

*/15  *  *  *  *
  |   |  |  |  |
  |   |  |  |  +---- 요일 (0-6, 0=일요일)
  |   |  |  +------- 월 (1-12)
  |   |  +---------- 일 (1-31)
  |   +------------- 시 (0-23)
  +----------------- 분: 매 15분 (0, 15, 30, 45)

각 필드는 특정 값(5), 와일드카드(*), 범위(1-5), 목록(1,3,5) 또는 단계 값(*/15)을 받습니다. 일부 구현은 초를 위한 여섯 번째 필드를 추가합니다; 다른 구현(GitHub Actions 같은)은 정확히 다섯 개를 기대합니다. 복잡한 표현식을 작성하기 전에 항상 스케줄러가 어떤 방언을 사용하는지 확인하세요.

패턴 1: 15분마다 — */15 * * * *

*/15  *  *  *  *
  |   |  |  |  |
  |   |  |  |  +---- 요일 (0-6, 0=일요일)
  |   |  |  +------- 월 (1-12)
  |   |  +---------- 일 (1-31)
  |   +------------- 시 (0-23)
  +----------------- 분: 매 15분 (0, 15, 30, 45)

큐 프로세서, 하트비트 검사 및 자주 실행되어야 하지만 연속적이지 않은 폴링 작업을 위한 기본 표현식. 매일 매시 :00, :15, :30, :45에 실행됩니다.

crontab에서:

# crontab 항목 — 15분마다 배치 프로세서 실행
*/15 * * * * /opt/app/bin/process-queue.sh >> /var/log/queue.log 2>&1

Node.js에서:

// node-cron을 사용한 Node.js
import cron from 'node-cron';

cron.schedule('*/15 * * * *', async () => {
  const batch = await db.query(
    'SELECT * FROM jobs WHERE status = $1 LIMIT 100',
    ['pending']
  );
  await processBatch(batch);
});

Thundering herd 문제. 같은 */15 표현식으로 수백 개의 워커를 실행하면 모두 :00, :15, :30, :45에 동시에 시작됩니다. 각 워커가 공유 데이터베이스에 접근하면 부드러운 부하 대신 시간당 네 번의 급증이 발생합니다. 해결책은 각 실행 전에 무작위 지터를 추가하는 것입니다:

// 분산 시스템에서 thundering herd를 피하기 위해 지터 추가
cron.schedule('*/15 * * * *', async () => {
  const jitterMs = Math.floor(Math.random() * 30_000); // 최대 30초
  await sleep(jitterMs);
  await processBatch();
});

최대 30초의 지터는 작업을 예상되는 15분 주기 내에 유지하면서 급증을 30초 창에 분산시킵니다. 더 정교한 분산을 위해서는 패턴 5(Jenkins H 문법)를 참조하세요.

작업 타이밍을 디버깅할 때 다음 예약된 실행을 Unix epoch에서 사람이 읽을 수 있는 시간으로 변환하기 위해 Timestamp Converter를 사용하세요.

패턴 2: 평일 업무 시간 — 0 9 * * 1-5

0   9   *   *   1-5
|   |   |   |   |
|   |   |   |   +-- 요일: 월요일–금요일 (1-5)
|   |   |   +------ 월: 매월
|   |   +---------- 일: 매일
|   +-------------- 시: 9
+------------------ 분: 0 (정시)

이 표현식은 월요일부터 금요일까지 정확히 오전 9시에 한 번 실행됩니다. 다음을 위한 표준 패턴입니다: 일일 다이제스트 이메일 전송, 업무 시간 보고서 실행, 업무일 동안 사람의 검토가 필요한 워크플로 트리거링, 엔지니어링 일과 시작 시 스테이징 배포.

Kubernetes CronJob에서:

# Kubernetes CronJob — 평일 오전 9시 UTC에 다이제스트 이메일
apiVersion: batch/v1
kind: CronJob
metadata:
  name: digest-email
spec:
  schedule: "0 9 * * 1-5"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: mailer
            image: myapp/mailer:latest
            command: ["/app/send-digest"]
          restartPolicy: OnFailure

타임존 함정. Kubernetes CronJob 스케줄은 클러스터의 타임존에서 실행되며 기본값은 UTC입니다. "오전 9시 UTC"는 겨울에 동부 시간 오전 5시이고 태평양 시간 오전 4시입니다. 사용자는 한밤중에 "아침 다이제스트"를 받게 됩니다. IANA 타임존 문자열을 받는 cron 라이브러리를 사용하세요:

// 타임존 처리 — "오전 9시 UTC"는 사용자가 기대하는 것이 아닌 경우가 많음
// IANA 타임존을 이해하는 라이브러리 사용
import { CronJob } from 'cron';

const job = new CronJob(
  '0 9 * * 1-5',
  () => sendDailyDigest(),
  null,
  true,
  'America/New_York' // 오전 9시 UTC가 아닌 오전 9시 동부 시간에 실행
);

Timezone Converter는 모든 IANA 타임존에서 주어진 현지 시간에 대한 UTC 등가물을 찾는 데 도움이 됩니다.

요일 번호. 표준 cron 정의는 일요일에 0 또는 7을 사용합니다. 일부 구현(Quartz, cron4j)은 0 대신 월요일에 1을 사용합니다. 배포하기 전에 스케줄러의 문서를 확인하고 Cron Parser로 테스트하세요.

패턴 3: 야간 배치 — 0 22 * * *

0   22   *   *   *
|    |   |   |   |
|    |   |   |   +-- 요일: 모든 날
|    |   |   +------ 월: 모든 달
|    |   +---------- 일: 모든 일
|    +-------------- 시: 22 (오후 10시)
+------------------- 분: 0

하루에 한 번 오후 10시에 실행됩니다. 야간 배치는 데이터 엔지니어링에서 가장 일반적인 패턴 중 하나입니다: 하루의 이벤트 집계, 검색 인덱스 재구축, 보고서 생성, 로그 보관, 일과 종료 알림 전송.

자정 대신 오후 10시인 이유? 두 가지 이유. 첫째, 오후 10시는 날짜가 바뀌기 전에 버퍼를 제공하여 늦게 도착하는 이벤트를 가져올 가능성을 줄입니다. 둘째, 자정에 무거운 작업을 실행하면 매월 1일에 월말 청구(패턴 4)와 경쟁하게 됩니다.

일일 작업을 작성하는 세 가지 가장 일반적인 방법:

# "밤 10시에 매일"을 작성하는 세 가지 방법:
0 22 * * *    # 표준 cron
@daily        # 자정 — 밤 10시와 동일하지 않음
0 22 * * *    # 자정이 아닌 시간에는 @macros보다 명시적 시간을 항상 선호

중첩 방지. 일반적으로 45분 걸리는 야간 작업이 가끔 2시간 걸릴 수 있으며 — 다음 밤의 트리거가 시작될 때 여전히 실행 중이라면 두 작업 모두 동일한 데이터를 수정하게 됩니다. 분산 잠금을 사용하세요:

// 멱등성 검사 — 작업이 겹칠 경우 이중 처리 방지
async function runNightlyBatch() {
  const lock = await redis.set(
    'nightly-batch-lock',
    process.pid,
    'EX', 3600,   // TTL: 1시간
    'NX'          // 존재하지 않을 때만 설정
  );

  if (!lock) {
    console.log('배치가 이미 실행 중이므로 건너뜀.');
    return;
  }

  try {
    await processNightlyData();
  } finally {
    await redis.del('nightly-batch-lock');
  }
}

Redis NX 플래그는 한 번에 하나의 프로세스만 잠금을 보유할 수 있도록 보장합니다. TTL은 충돌한 작업이 잠금을 영원히 보유하지 못하게 방지합니다.

패턴 4: 월별 청구 — 0 0 1 * *

0   0   1   *   *
|   |   |   |   |
|   |   |   |   +-- 요일: 모든 요일
|   |   |   +------ 월: 모든 달
|   |   +---------- 일: 1 (첫째 날)
|   +-------------- 시: 0 (자정)
+------------------ 분: 0

매월 첫째 날 자정에 실행됩니다. 다음을 위한 전형적인 패턴: 월별 청구서 생성, 사용량 할당량 재설정, 월말 조정 실행, 이전 월 데이터 보관.

월별 작업을 위한 cron 함정:

# 매월 1일 자정 UTC
0 0 1 * *

# ---- 피해야 할 함정 ----

# 이는 "매월" 안정적으로 실행되지 않음:
0 0 29-31 * *   # 일수가 적은 달은 조용히 건너뜀

# 분기별 청구 (1월, 4월, 7월, 10월):
0 0 1 1,4,7,10 *

# 월말은 까다로움 — 표준 cron에는 "마지막 날"이 없음
# @reboot + 애플리케이션 로직을 사용하거나, 매일 실행되는 cron으로
# 오늘이 그 달의 마지막 날인지 코드에서 확인.

월말 청구. 첫째 날이 아닌 매월 마지막 날에 청구해야 하는 경우 깔끔한 cron 표현식이 없습니다. 우회 방법:

// 애플리케이션 수준의 "월말" 검사
cron.schedule('0 23 28-31 * *', async () => {
  const today = new Date();
  const tomorrow = new Date(today);
  tomorrow.setDate(today.getDate() + 1);

  // 내일이 1일이면, 오늘은 그 달의 마지막 날
  if (tomorrow.getDate() === 1) {
    await runEndOfMonthBilling();
  }
});

이는 매월 28–31일에 실행된 다음 코드에서 오늘이 실제로 마지막 날인지 확인합니다. 31일보다 적은 달에는 최대 네 번 불필요하게 실행되지만 검사는 저렴하고 로직은 명확합니다.

분기 시작 청구의 경우 월 필드에서 목록 연산자를 사용하세요: 0 0 1 1,4,7,10 *(1월, 4월, 7월, 10월).

패턴 5: 해시된 스케줄(Jenkins H 문법) — H/30 * * * *

H/30 * * * *
|
+--- H는 Jenkins가 작업별로 무작위 분을 선택함을 의미.
     H/30 = 매 30분마다, H에서 시작.
     서로 다른 작업은 서로 다른 H 값을 받아 부하를 분산함.

H 토큰은 cron 문법에 대한 Jenkins 특화 확장입니다. H를 작업 이름에서 파생된 안정적인 의사 난수로 대체합니다. 분 필드의 H/30은 "작업 이름의 해시로 결정된 분에서 시작하여 30분마다 한 번"을 의미합니다.

Jenkinsfile에서:

// Jenkinsfile — 30분마다 트리거되는 파이프라인, 분산됨
pipeline {
  triggers {
    cron('H/30 * * * *')
  }
  stages {
    stage('Deploy') {
      steps {
        sh './deploy.sh'
      }
    }
  }
}

이것이 왜 중요한가. Jenkins 설치는 종종 수백 개의 파이프라인을 가지고 있습니다. 모든 파이프라인이 */30 * * * *를 사용하면 모두 :00과 :30에 트리거되어 에이전트 풀과 모든 다운스트림 시스템에 거대한 부하 급증을 일으킵니다. H/30으로는 각 작업이 다른 오프셋을 받아 — 하나는 :03/:33에, 다른 하나는 :17/:47에 — 부하를 시간에 걸쳐 고르게 분산시킵니다.

GitHub Actions는 H를 지원하지 않습니다. 여러 Actions 워크플로를 분산시키려면 서로 다른 고정 분 오프셋을 수동으로 선택하세요:

// GitHub Actions — H가 지원되지 않음; 오프셋으로 schedule 수동 사용
// 두 작업을 분산하려면: 서로 다른 고정 분 선택
name: Job A
on:
  schedule:
    - cron: '5 * * * *'   # 매시 :05에 실행

---
name: Job B
on:
  schedule:
    - cron: '35 * * * *'  # :35에 실행 — Job A 30분 후

이는 Jenkins H보다 우아하지 않지만 소수의 워크플로에 대해 동일한 효과를 달성합니다. 많은 수의 워크플로의 경우 분산 스케줄링을 기본 지원하는 외부 오케스트레이터를 고려하세요.

가로 횡단 관심사

멱등성은 협상 불가

모든 cron 작업은 두 번 실행해도 안전해야 합니다. 인프라가 실패하고, 스케줄러가 재시작하며, 분산 잠금이 가끔 시간 초과됩니다. 같은 입력으로 같은 시간에 두 번째로 실행하면 같은 결과가 나오도록 작업을 설계하세요. 삽입 대신 upsert, check-before-act 패턴, 외부 API 호출을 위한 멱등성 키를 사용하세요.

Dead Letter 알림

조용히 실패하는 cron 작업은 전혀 실행되지 않는 작업보다 더 나쁩니다. 작업이 실패하거나 예상 시간 창 내에 완료되지 않을 때 dead letter 큐나 모니터링 시스템에 알림을 보내세요. Sentry Crons, Healthchecks.io, Cronitor 같은 도구는 하트비트 검사를 추가합니다: 작업이 시작될 때와 완료될 때 URL에 핑을 보냅니다. 완료 핑이 마감 기한 내에 도착하지 않으면 알림이 발동합니다.

스케줄 로깅

표현식, 트리거된 타임스탬프(지터 때문에 약간 다를 수 있는 현재 시간이 아님), 지속 시간을 포함하여 모든 cron 실행의 시작과 끝에서 구조화된 로그 라인을 발행하세요. 이는 다운스트림 사건 — "데이터베이스가 밤 10시에 느려졌다" — 을 특정 배치 작업 실행과 상관관계 짓기 쉽게 만듭니다.

빠른 참조

Cron Parser로 모든 표현식을 파싱하고 검증하세요. Timezone Converter로 예약된 시간을 타임존 간에 변환하세요. 작업이 Unix 타임스탬프를 로깅한다면 Timestamp Converter가 즉시 읽기 가능한 날짜로 변환합니다.

cron 문법과 일반적인 예제의 포괄적인 참조를 위해 crontab.guru는 표준 빠른 참조입니다. Jenkins H 문법과 전체 명세에 대해서는 Jenkins Pipeline cron 문법 문서를 참조하세요.