CronTask 데이터 모델, 스케줄러 생명주기, 분산 잠금, 지터, 3가지 크론 도구.
Claude Code의 크론 시스템은 반복 작업(코드 리뷰, 보안 스캔, 보고서 생성 등)을 스케줄링하고 실행합니다. 표준 OS crontab 대신 자체 스케줄러를 구현한 이유는 세션 컨텍스트 내에서 도구를 실행하고, 멀티세션 환경에서 분산 잠금을 제공하며, GrowthBook을 통한 실시간 튜닝이 가능하기 때문입니다.
cron/types.ts → cron/scheduler.ts → cron/lock.ts → cron/jitter.ts → cron/tools.ts → cron/repl.ts
핵심 구성 요소:
모든 크론 태스크는 CronTask 인터페이스를 구현하며, 4가지 유형으로 분류됩니다.
// cron/types.ts — CronTask 인터페이스
interface CronTask {
id: string
name: string
schedule: string // cron 표현식: "*/5 * * * *"
type: CronTaskType
command: string // 실행할 명령 또는 프롬프트
lastRun?: number // Unix 타임스탬프
nextRun: number // 다음 실행 예정 시간
createdBy: string // 세션 ID
jitterMs?: number // 지터 오프셋
}
type CronTaskType =
| 'one-shot' // 한 번 실행 후 삭제
| 'recurring' // 반복 실행, 세션 종료 시 삭제
| 'durable' // 반복 실행, 세션 종료 후에도 유지
| 'session-only' // 현재 세션에서만 유효
둘 다 반복 실행되지만 핵심 차이는 영속성입니다. recurring은 세션 메모리에만 존재하여 세션 종료 시 사라집니다. durable은 ~/.claude/cron/ 디렉토리에 JSON 파일로 영속화되어 새 세션이 시작될 때 자동으로 로드됩니다. durable 태스크는 사용자가 명시적으로 삭제해야 합니다.
스케줄러는 지연 활성화 패턴을 사용합니다. 첫 번째 크론 태스크가 등록될 때까지 check() 루프가 시작되지 않습니다.
// cron/scheduler.ts — 지연 활성화 check() 루프
class CronScheduler {
private running = false
private tasks: Map<string, CronTask> = new Map()
register(task: CronTask): void {
this.tasks.set(task.id, task)
if (!this.running) {
this.running = true
this.startCheckLoop() // 첫 태스크 등록 시 루프 시작
}
}
private async startCheckLoop(): Promise<void> {
while (this.running && this.tasks.size > 0) {
await this.check()
await sleep(60_000) // 60초 간격
}
this.running = false
}
private async check(): Promise<void> {
const now = Date.now()
for (const task of this.tasks.values()) {
if (now >= task.nextRun) {
await this.executeTask(task)
}
}
}
}
세션이 오랫동안 비활성이었다면 밀린 실행이 누적될 수 있습니다. 스케줄러는 따라잡기(catch-up)를 하지 않습니다 - 현재 시간 이전의 모든 실행 예정은 무시하고 다음 미래 실행 시점만 계산합니다.
따라잡기 방지는 의도적인 설계입니다. 12시간 동안 노트북을 닫았다가 열면, 밀린 12회 실행을 한꺼번에 실행하는 대신 다음 정규 실행 시간까지 대기합니다. 이는 예측 불가능한 리소스 폭증을 방지하지만, 중요한 태스크가 건너뛸 수 있음을 의미합니다.
같은 사용자가 여러 터미널 세션에서 Claude Code를 실행할 수 있습니다. durable 태스크가 모든 세션에서 동시에 실행되면 중복 작업이 발생합니다. 분산 잠금이 이를 방지합니다.
// cron/lock.ts — PID 기반 분산 잠금
class CronLock {
private lockDir = join(homedir(), '.claude', 'cron', 'locks')
async acquire(taskId: string): Promise<boolean> {
const lockFile = join(this.lockDir, `${taskId}.lock`)
try {
// 기존 잠금 확인
const existing = readFileSafe(lockFile)
if (existing) {
const pid = parseInt(existing)
if (isProcessAlive(pid)) {
return false // 다른 세션이 실행 중
}
// 죽은 프로세스의 잠금 → 스테일 잠금, 인계
}
// 잠금 획득: 현재 PID 기록
writeFileSync(lockFile, String(process.pid))
return true
} catch {
return false
}
}
release(taskId: string): void {
const lockFile = join(this.lockDir, `${taskId}.lock`)
unlinkSync(lockFile)
}
}
isProcessAlive(pid)는 process.kill(pid, 0)을 사용합니다. 시그널 0은 실제로 시그널을 보내지 않고 프로세스 존재 여부만 확인합니다. 프로세스가 없으면 에러가 발생하여 스테일 잠금을 감지합니다.
두 세션이 동시에 잠금을 획득하려 할 때 TOCTOU(Time-of-Check-to-Time-of-Use) 경쟁 조건이 발생할 수 있습니다. 두 세션 모두 잠금 파일이 없음을 확인한 후 동시에 쓰기를 시도합니다. 이 경우 마지막으로 쓴 세션의 PID가 파일에 남아 다른 세션의 태스크 실행이 조용히 건너뛰어집니다. 이는 "최대 한 번 실행"(at-most-once) 보장은 제공하지만 "정확히 한 번"(exactly-once)은 보장하지 않습니다.
여러 사용자가 동일한 cron 표현식(예: 0 * * * *, 매 정시)을 사용하면 서버에 동시 요청이 집중됩니다. 지터(jitter)가 실행 시점을 분산시킵니다.
// cron/jitter.ts — 결정론적 지터 계산
function calculateJitter(taskId: string, intervalMs: number): number {
// 태스크 ID에서 결정론적 해시 생성
const hash = simpleHash(taskId)
// 최대 지터 = 간격의 10%
const maxJitter = Math.floor(intervalMs * 0.1)
// 해시를 [0, maxJitter) 범위로 매핑
return hash % maxJitter
}
function simpleHash(str: string): number {
let hash = 0
for (const char of str) {
hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0
}
return Math.abs(hash)
}
지터는 결정론적입니다 - 같은 태스크 ID에 대해 항상 같은 지터 값이 계산됩니다. 이를 통해:
크론 시스템의 여러 파라미터가 GrowthBook 기능 플래그로 제어됩니다. 배포 없이 실시간으로 조정할 수 있습니다.
cron_enabled: 전체 크론 시스템 활성화/비활성화cron_max_tasks_per_session: 세션당 최대 태스크 수cron_check_interval_ms: check() 루프 간격 (기본 60초)cron_max_jitter_pct: 지터 상한 백분율 (기본 10%)// cron/scheduler.ts — GrowthBook 기능 플래그 확인
function isCronEnabled(): boolean {
return getFeatureFlag('cron_enabled', true)
}
function getMaxTasks(): number {
return getFeatureFlag('cron_max_tasks_per_session', 10)
}
function getCheckInterval(): number {
return getFeatureFlag('cron_check_interval_ms', 60_000)
}
기능 게이트는 구독 플랜에도 적용됩니다. durable 태스크는 Pro 이상, 높은 태스크 수 제한은 Max/Enterprise 플랜에서만 가능합니다.
어시스턴트가 사용할 수 있는 3가지 크론 관련 도구입니다.
새로운 크론 태스크를 생성합니다. 태스크 이름, cron 표현식, 실행 명령, 유형을 지정합니다.
// cron/tools.ts — CronCreate 도구
const CronCreate = {
name: 'CronCreate',
description: '크론 태스크를 생성합니다',
parameters: {
name: { type: 'string', description: '태스크 이름' },
schedule: { type: 'string', description: 'cron 표현식 (예: "*/5 * * * *")' },
command: { type: 'string', description: '실행할 명령 또는 프롬프트' },
type: { type: 'string', enum: ['one-shot', 'recurring', 'durable', 'session-only'] },
},
}
태스크 ID로 크론 태스크를 삭제합니다. durable 태스크의 경우 디스크의 JSON 파일도 함께 제거합니다.
현재 세션에 등록된 모든 크론 태스크를 조회합니다. 각 태스크의 다음 실행 시간, 마지막 실행 시간, 상태를 표시합니다.
크론 도구는 REPL 초기화 시 도구 레지스트리에 등록됩니다. 세션 시작 시 durable 태스크가 디스크에서 로드되어 스케줄러에 자동 등록됩니다.
// cron/repl.ts — REPL 배선: 누락 태스크 시작 따라잡기
async function initCronSystem(): Promise<void> {
// 1. 디스크에서 durable 태스크 로드
const durableTasks = await loadDurableTasks()
// 2. 스케줄러에 등록
for (const task of durableTasks) {
scheduler.register(task)
}
// 3. 도구 레지스트리에 크론 도구 등록
registerTool(CronCreate)
registerTool(CronDelete)
registerTool(CronList)
}
process.kill(pid, 0)으로 구현되며, at-most-once 보장만 제공합니다Q1. durable 태스크와 recurring 태스크의 핵심 차이는?
durable은 ~/.claude/cron/에 JSON 파일로 영속화되어 세션이 종료되어도 다음 세션에서 재개됩니다. recurring은 세션 메모리에만 존재하여 세션 종료 시 사라집니다.Q2. 스케줄러가 "따라잡기(catch-up)"를 하지 않는 이유는?
Q3. 분산 잠금에서 process.kill(pid, 0)의 역할은?
process.kill(pid, 0)은 실제로 시그널을 보내지 않습니다. 시그널 0은 프로세스 존재 여부만 확인합니다. 프로세스가 없으면 에러가 발생하여 해당 잠금이 죽은 프로세스(스테일 잠금)의 것임을 감지하고 안전하게 인계할 수 있습니다.Q4. 지터가 "결정론적"이라는 것은 무엇을 의미하나요?
Q5. 스케줄러의 "지연 활성화" 패턴이란?
CronCreate 호출 시 루프가 자동 시작됩니다.