Cloudflare Workers에서 긴 작업 트리거링

발행: (2025년 12월 10일 오후 10:14 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

문제: 내 작업이 HTTP에 비해 너무 길었다

관리자 UI를 담당하는 Worker가 있었다. 그 기능 중 하나는 무거운 백그라운드 프로세스를 시작하는 버튼이었다—스크래핑, 데이터 처리, 배치 작업 등과 같은 것들.

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      await runHeavyJob(); // 😬
      return new Response('Job complete!');
    }
  }
}

개발 환경에서는 잘 동작했다. 하지만 프로덕션에서는 타임아웃에 걸렸다. HTTP 요청은 엄격한 제한을 갖는다:

플랜CPU 시간실제 시간
Free10 ms30 s
Workers Paid50 ms30 s
Business+30 s30 s

내 작업은 30 초 이상의 실제 시간이 필요했고, CPU 시간도 빨리 소진되었다. 유료 플랜에서도 제한에 계속 걸렸다.

ctx.waitUntil()을 사용해 보았다:

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      ctx.waitUntil(runHeavyJob()); // Still doesn't work! 😭
      return new Response('Job started!');
    }
  }
}

waitUntil()은 타임아웃을 연장하지 않는다; 응답을 보낸 뒤 정리 작업을 할 수 있게 해줄 뿐이다. 격리 환경은 동일한 시간 제한에 따라 종료된다.

scheduled()를 바로 사용할 수 없었던 이유

기존에 있던 크론 작업을 재사용하려고 생각했다:

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      // Can I just... call scheduled() somehow? 🤔
      await this.scheduled(); // Nope!
      return new Response('Done!');
    }
  },

  async scheduled(event, env, ctx) {
    await runHeavyJob(); // This works great!
  }
}

scheduled()는 코드에서 직접 호출할 수 없다—오직 Cloudflare의 크론 시스템만이 트리거할 수 있다. 시도해 본 우회 방법은 다음과 같다:

  • 크론을 트리거하기 위해 Cloudflare API 호출 (외부 인증 필요, 즉시성 부족)
  • 외부 서비스에 웹훅 설정 (Workers의 목적에 반함)
  • KV에 플래그를 저장하고 매분 폴링 (동작은 하지만 해킹 느낌)

깨달음: 큐는 바로 이런 용도다

Cloudflare Queues는 세 번째 유형의 호출 핸들러를 제공한다:

export default {
  async fetch(request, env, ctx) { /* ... */ },
  async scheduled(event, env, ctx) { /* ... */ },
  async queue(batch, env, ctx) { /* ... */ } // 👈 This one!
}

핸들러 유형별 실행 제한

핸들러CPU 시간주 사용 용도
fetch()10‑50 ms (대부분 플랜)빠른 API, UI
scheduled()30 s주기적 작업
queue()제한 없음무거운 처리 작업

큐 핸들러는 CPU 시간 제한이 없으며—분 단위로 측정되는 실제 시간 제한만 존재한다.

실제 해결 방법

Worker 1: 관리자 UI (생산자)

export default {
  async fetch(request, env, ctx) {
    if (request.url.endsWith('/admin/run-job')) {
      // Enqueue a message
      await env.MY_QUEUE.send({
        type: 'heavy-job',
        triggeredBy: 'admin',
        timestamp: Date.now()
      });
      return new Response('Job queued!');
    }
  }
}

Worker 2: 작업 실행기 (소비자)

export default {
  async queue(batch, env, ctx) {
    for (const message of batch.messages) {
      const { type, triggeredBy } = message.body;

      if (type === 'heavy-job') {
        await runHeavyJob(); // Runs with unlimited CPU time! 🎉
        message.ack();
      }
    }
  }
}

왜 이렇게 하면 되는가:

  • UI Worker는 빠르게 동작한다 (단지 큐에 넣고 반환).
  • 작업 Worker는 무제한 CPU 시간으로 실행된다.
  • 큐는 자동으로 재시도를 처리한다.
  • Workers를 독립적으로 스케일링할 수 있다.
  • 실행이 거의 즉시 이루어진다 (폴링 지연 없음).

중요: 핸들러는 자원을 경쟁하지 않는다

각 핸들러 호출은 자체 격리 실행 컨텍스트에서 동작하므로, 실행 중인 큐 작업이 HTTP 요청을 느리게 만들지 않는다. 공유되는 것은 코드 번들(큰 번들은 콜드 스타트가 느려짐)과 배포(한 핸들러의 버그가 전체 Worker에 영향을 줌)뿐이다.

원한다면 세 가지 핸들러를 하나의 Worker에 합칠 수 있다:

export default {
  async fetch(request, env, ctx) {
    await env.MY_QUEUE.send({ type: 'job' });
    return new Response('Queued!');
  },

  async scheduled(event, env, ctx) {
    await env.MY_QUEUE.send({ type: 'cron-job' });
  },

  async queue(batch, env, ctx) {
    await runHeavyJob(); // This won't slow down fetch()
  }
}

나는 UI 번들을 작게 유지하고, 독립적인 배포와 깔끔한 관심사 분리를 위해 별도로 두는 것을 선호한다.

고려했던 다른 옵션

크론 폴링

KV에 플래그를 저장하고 scheduled()로 매분 확인한다:

export default {
  async fetch(request, env, ctx) {
    await env.KV.put('pending-job', 'true');
    return new Response('Job will run soon');
  },

  async scheduled(event, env, ctx) {
    const pending = await env.KV.get('pending-job');
    if (pending) {
      await runHeavyJob();
      await env.KV.delete('pending-job');
    }
  }
}

동작은 하지만 즉시성이 없으며 크론 간격(최소 1 분)에 제한된다.

Durable Object 알람

Durable Objects는 거의 즉시 실행되는 알람을 설정할 수 있다:

export class JobRunner {
  async fetch(request) {
    await this.storage.setAlarm(Date.now() + 100); // 100 ms
    return new Response('Alarm set');
  }

  async alarm() {
    await runHeavyJob(); // Runs in the DO context
  }
}

우아하지만 간단한 백그라운드 작업에 비해 설정이 무거울 수 있다.

내 권장 사항

온‑디맨드 장시간 작업에는 Queues를 사용하라. 이 시나리오를 위해 설계된 기능이다:

  • 무제한 CPU 시간
  • 내장된 재시도 로직
  • 간단한 API
  • 자동 스케일링
  • 거의 즉시 실행

최소 설정

# wrangler.toml
[[queues.producers]]
queue = "my-jobs"
binding = "MY_QUEUE"

[[queues.consumers]]
queue = "my-jobs"
max_batch_size = 10
max_batch_timeout = 30

마무리

  • 플랫폼과 싸우지 마라. fetch()가 설계되지 않은 일을 하게 하면 시간 낭비다.
  • 제한을 읽어라. CPU와 실제 시간 차이를 이해하면 디버깅 시간을 크게 줄일 수 있다.
  • Queues는 과소평가되지 않는다. 분산 시스템 전용이 아니라 모놀리식 백그라운드 작업에도 완벽하다.
Back to Blog

관련 글

더 보기 »