크론과 태스크 스케줄링

CronTask 데이터 모델, 스케줄러 생명주기, 분산 잠금, 지터, 3가지 크론 도구.

01

개요

Claude Code의 크론 시스템은 반복 작업(코드 리뷰, 보안 스캔, 보고서 생성 등)을 스케줄링하고 실행합니다. 표준 OS crontab 대신 자체 스케줄러를 구현한 이유는 세션 컨텍스트 내에서 도구를 실행하고, 멀티세션 환경에서 분산 잠금을 제공하며, GrowthBook을 통한 실시간 튜닝이 가능하기 때문입니다.

다루는 소스 파일: cron/types.tscron/scheduler.tscron/lock.tscron/jitter.tscron/tools.tscron/repl.ts

핵심 구성 요소:

02

CronTask 데이터 모델: 4가지 유형

모든 크론 태스크는 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'   // 현재 세션에서만 유효
딥 다이브 — durable vs recurring

둘 다 반복 실행되지만 핵심 차이는 영속성입니다. recurring은 세션 메모리에만 존재하여 세션 종료 시 사라집니다. durable~/.claude/cron/ 디렉토리에 JSON 파일로 영속화되어 새 세션이 시작될 때 자동으로 로드됩니다. durable 태스크는 사용자가 명시적으로 삭제해야 합니다.

03

스케줄러 생명주기

스케줄러는 지연 활성화 패턴을 사용합니다. 첫 번째 크론 태스크가 등록될 때까지 check() 루프가 시작되지 않습니다.

flowchart TD A["세션 시작"] --> B{"크론 태스크 존재?"} B -->|"아니오"| C["스케줄러 비활성"] B -->|"예"| D["check() 루프 시작"] C --> E["CronCreate 도구 호출"] E --> D D --> F{"현재 시간 >= nextRun?"} F -->|"아니오"| G["60초 대기"] G --> D F -->|"예"| H["분산 잠금 획득 시도"] H -->|"실패"| G H -->|"성공"| I["태스크 실행"] I --> J["nextRun 업데이트"] J --> D style A fill:#c47a50,color:#1a1816 style I fill:#6e9468,color:#1a1816
// 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회 실행을 한꺼번에 실행하는 대신 다음 정규 실행 시간까지 대기합니다. 이는 예측 불가능한 리소스 폭증을 방지하지만, 중요한 태스크가 건너뛸 수 있음을 의미합니다.

04

멀티세션 분산 잠금

같은 사용자가 여러 터미널 세션에서 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)
  }
}

PID 활성 탐지

isProcessAlive(pid)process.kill(pid, 0)을 사용합니다. 시그널 0은 실제로 시그널을 보내지 않고 프로세스 존재 여부만 확인합니다. 프로세스가 없으면 에러가 발생하여 스테일 잠금을 감지합니다.

딥 다이브 — 경쟁 조건

두 세션이 동시에 잠금을 획득하려 할 때 TOCTOU(Time-of-Check-to-Time-of-Use) 경쟁 조건이 발생할 수 있습니다. 두 세션 모두 잠금 파일이 없음을 확인한 후 동시에 쓰기를 시도합니다. 이 경우 마지막으로 쓴 세션의 PID가 파일에 남아 다른 세션의 태스크 실행이 조용히 건너뛰어집니다. 이는 "최대 한 번 실행"(at-most-once) 보장은 제공하지만 "정확히 한 번"(exactly-once)은 보장하지 않습니다.

05

지터: 결정론적, 10% 상한

여러 사용자가 동일한 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에 대해 항상 같은 지터 값이 계산됩니다. 이를 통해:

06

GrowthBook 라이브 튜닝 & 기능 게이트

크론 시스템의 여러 파라미터가 GrowthBook 기능 플래그로 제어됩니다. 배포 없이 실시간으로 조정할 수 있습니다.

// 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 플랜에서만 가능합니다.

07

크론 도구: CronCreate / CronDelete / CronList

어시스턴트가 사용할 수 있는 3가지 크론 관련 도구입니다.

CronCreate

새로운 크론 태스크를 생성합니다. 태스크 이름, 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'] },
  },
}

CronDelete

태스크 ID로 크론 태스크를 삭제합니다. durable 태스크의 경우 디스크의 JSON 파일도 함께 제거합니다.

CronList

현재 세션에 등록된 모든 크론 태스크를 조회합니다. 각 태스크의 다음 실행 시간, 마지막 실행 시간, 상태를 표시합니다.

REPL 배선

크론 도구는 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)
}
08

핵심 요약

핵심 포인트

  • CronTask는 4가지 유형(one-shot/recurring/durable/session-only)으로, durable만 세션 종료 후에도 영속됩니다
  • 스케줄러는 지연 활성화 패턴으로, 첫 태스크 등록 시까지 check() 루프가 시작되지 않습니다
  • 따라잡기 방지: 밀린 실행은 무시하고 다음 미래 실행 시점만 계산합니다 - 리소스 폭증을 방지하지만 태스크 누락 가능
  • 분산 잠금은 PID 파일 + process.kill(pid, 0)으로 구현되며, at-most-once 보장만 제공합니다
  • 지터는 태스크 ID 해시 기반의 결정론적 방식으로 10% 상한 내에서 실행 시점을 분산시킵니다
  • GrowthBook으로 크론 시스템의 활성화, 태스크 수 제한, check 간격, 지터 비율을 실시간 튜닝합니다
  • CronCreate/CronDelete/CronList 세 가지 도구가 어시스턴트에게 크론 관리 능력을 부여합니다
  • REPL 초기화 시 디스크의 durable 태스크가 자동 로드되어 스케줄러에 등록됩니다
09

지식 확인

퀴즈 — 5문제

Q1. durable 태스크와 recurring 태스크의 핵심 차이는?

  • A) durable은 한 번만 실행되고 recurring은 반복 실행됨
  • B) durable은 더 높은 우선순위로 실행됨
  • C) durable은 디스크에 영속화되어 세션 종료 후에도 유지되지만, recurring은 세션 종료 시 삭제됨
  • D) durable은 분산 잠금이 필요하지 않음
둘 다 반복 실행되지만, durable~/.claude/cron/에 JSON 파일로 영속화되어 세션이 종료되어도 다음 세션에서 재개됩니다. recurring은 세션 메모리에만 존재하여 세션 종료 시 사라집니다.

Q2. 스케줄러가 "따라잡기(catch-up)"를 하지 않는 이유는?

  • A) 기술적으로 구현이 불가능하기 때문
  • B) 밀린 실행의 결과가 이미 무의미하기 때문
  • C) cron 표현식이 과거 시점을 지원하지 않기 때문
  • D) 장시간 비활성 후 밀린 실행을 한꺼번에 처리하면 예측 불가능한 리소스 폭증이 발생하기 때문
12시간 동안 닫혀있던 노트북을 열 때 밀린 12회 실행을 동시에 처리하면 CPU, 네트워크, API 호출이 폭증합니다. 따라잡기 방지는 다음 정규 실행 시점까지 대기하여 이런 상황을 방지합니다.

Q3. 분산 잠금에서 process.kill(pid, 0)의 역할은?

  • A) 다른 세션의 프로세스를 강제 종료하여 잠금을 해제
  • B) 시그널 0으로 프로세스 존재 여부만 확인하여 스테일 잠금을 감지
  • C) PID 재사용 문제를 해결하기 위한 추가 검증
  • D) 프로세스 간 통신 채널을 설정
process.kill(pid, 0)은 실제로 시그널을 보내지 않습니다. 시그널 0은 프로세스 존재 여부만 확인합니다. 프로세스가 없으면 에러가 발생하여 해당 잠금이 죽은 프로세스(스테일 잠금)의 것임을 감지하고 안전하게 인계할 수 있습니다.

Q4. 지터가 "결정론적"이라는 것은 무엇을 의미하나요?

  • A) 같은 태스크 ID에 대해 항상 같은 지터 값이 계산되어, 세션 재시작 시에도 실행 시점이 일관됨
  • B) 지터 값이 태스크 생성 시 고정되어 이후 변경 불가
  • C) 지터가 정확히 간격의 10%로 고정됨
  • D) 지터가 이전 실행 결과에 따라 결정됨
결정론적 지터는 태스크 ID의 해시에서 계산됩니다. 랜덤 값이 아니므로 같은 태스크 ID는 항상 같은 지터 오프셋을 가집니다. 세션 재시작, 다른 세션, 다른 시간에 실행해도 동일한 실행 시점이 계산되어 디버깅이 용이합니다.

Q5. 스케줄러의 "지연 활성화" 패턴이란?

  • A) 스케줄러가 시작 후 일정 시간 동안 대기하는 것
  • B) 세션 종료 시까지 스케줄러 시작을 미루는 것
  • C) 첫 번째 크론 태스크가 등록될 때까지 check() 루프를 시작하지 않는 것
  • D) GrowthBook 플래그가 활성화될 때까지 대기하는 것
지연 활성화는 불필요한 리소스 소비를 방지합니다. 크론 태스크가 없는 세션에서는 check() 루프가 전혀 실행되지 않아 60초마다 깨어나는 타이머 오버헤드가 없습니다. 첫 CronCreate 호출 시 루프가 자동 시작됩니다.
0 / 5