Cloudflare Workers에서 긴 작업 트리거링
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 시간 | 실제 시간 |
|---|---|---|
| Free | 10 ms | 30 s |
| Workers Paid | 50 ms | 30 s |
| Business+ | 30 s | 30 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는 과소평가되지 않는다. 분산 시스템 전용이 아니라 모놀리식 백그라운드 작업에도 완벽하다.