AI 호출 3분, 사용자는 1초도 못 기다린다
— 큐 패턴 도입기
사용자 입장에서 3분 빈 화면. 그게 정말 답일 리가 없잖아요. Cloudflare Workers + Queues + D1 + R2로 백엔드를 다시 설계한 이야기.
문제: 3분 빈 화면
특정 이미지 생성 AI를 부르면 결과가 나오기까지 약 3분이 걸립니다. 모델의 본질적 처리 시간이라 줄일 수 없어요. 문제는 이게 사용자가 요청 직후 결과를 기대하는 화면에서 발생한다는 거예요. 3분 동안 스피너만 보고 있게 만들 순 없죠.
그 사이에서 3분 = 인지적으로 무한.
처음엔 "더 빠른 모델 쓰면 되지 않을까" 같은 접근도 시도해봤어요. 같은 결과를 8조각으로 쪼개서 병렬 호출 → 합성하는 방식으로 30초까지 줄였지만, 결과 품질이 떨어지고 비용은 오히려 8배가 됐습니다. 그래서 결국 깨달은 게 있어요.
즉, AI가 3분 걸리는 건 어쩔 수 없으니, 그 3분 동안 사용자가 다른 일을 할 수 있게 만들면 됩니다. 백그라운드에서 처리하고, 끝나면 알림으로 부른다. 미용실 예약처럼.
전체 아키텍처
처음 갈피 잡기 어려웠던 건 "그래서 어떤 서비스를 어떻게 조합해야 하지?"였어요. Cloudflare에 다 모아두기로 결정한 후엔 단순해졌습니다.
핵심: 사용자는 ①에서 즉시 응답을 받고 떠나요. ②~⑤는 백엔드에서 비동기로 진행됩니다. 사용자가 ⑥으로 돌아오는 시점은 본인이 선택. 그 사이 백엔드는 자기 일을 하고 있죠.
Stage 1: Cloudflare 리소스 만들기
D1(DB), R2(파일 저장), Queues(메시지 큐) 세 가지를 만듭니다. 어차피 wrangler CLI로 한 줄씩.
그리고 wrangler.jsonc에 세 가지 바인딩을 등록합니다. 바인딩이 있어야 Worker 코드에서 env.DB, env.BUCKET처럼 직접 접근할 수 있어요. 외부 API 토큰이나 인증 없이.
{
"d1_databases": [
{ "binding": "DB", "database_name": "<db>", "database_id": "<uuid>" }
],
"r2_buckets": [
{ "binding": "REPORTS", "bucket_name": "<bucket>" }
],
"queues": {
"producers": [{ "binding": "JOBS", "queue": "<queue>" }],
"consumers": [{ "queue": "<queue>", "max_batch_size": 1 }]
}
}
jobs 테이블 — 최소 스키마
CREATE TABLE jobs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL, -- 'report1' 등
status TEXT NOT NULL, -- 'pending' | 'processing' | 'done' | 'failed'
selfie_key TEXT,
result_key TEXT,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX idx_jobs_status ON jobs(status);
로그인/유저 테이블은 일부러 뺐어요. 처음 한 번에 다 만들지 말고, 검증 단계를 좁히는 게 더 빠릅니다.
Stage 2-A: Producer — 잡 만들고 큐에 넣기
가장 단순한 형태. 사용자가 POST를 보내면 새 jobId를 만들고, DB에 한 줄 넣고, 큐에 메시지 하나 푸시하고, jobId를 반환합니다. 한 함수에 다 들어가요.
async function handleCreateJob(env) {
const jobId = crypto.randomUUID();
const now = Date.now();
await env.DB
.prepare("INSERT INTO jobs (id, type, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)")
.bind(jobId, "report1", "pending", now, now)
.run();
await env.JOBS.send({ jobId });
return Response.json({ ok: true, jobId });
}
Stage 2-B: Consumer — 큐를 구독하고 처리하기
Cloudflare Workers의 매력적인 점 하나: 같은 Worker가 fetch와 queue 두 핸들러를 동시에 export 할 수 있어요. 같은 코드베이스, 같은 바인딩, 다른 트리거.
export default {
// HTTP 요청 처리 (Producer + 조회 엔드포인트)
async fetch(request, env, ctx) {
/* 라우팅 ... */
},
// Queue 메시지 처리 (Consumer)
async queue(batch, env) {
for (const message of batch.messages) {
const { jobId } = message.body;
try {
await processJob(jobId, env);
message.ack();
} catch (err) {
console.error(err);
message.retry();
}
}
},
};
processJob 함수가 실제 일을 합니다:
async function processJob(jobId, env) {
// 1) 진행 중 표시
await env.DB
.prepare("UPDATE jobs SET status = 'processing', updated_at = ? WHERE id = ?")
.bind(Date.now(), jobId)
.run();
// 2) (지금은 placeholder, 다음 단계에서 OpenAI 호출로 교체)
const pngBytes = generateResult(jobId);
const resultKey = `results/${jobId}.png`;
// 3) R2에 저장
await env.REPORTS.put(resultKey, pngBytes, {
httpMetadata: { contentType: "image/png" },
});
// 4) 완료 처리
await env.DB
.prepare("UPDATE jobs SET status = 'done', result_key = ?, updated_at = ? WHERE id = ?")
.bind(resultKey, Date.now(), jobId)
.run();
}
잡의 상태 흐름
큐의 message.retry()는 실패한 메시지를 다시 큐에 넣어요. 일정 횟수 초과하면 Dead Letter Queue로 빠집니다. 운영 환경에선 거기서 알람을 받아 사람이 보면 돼요.
검증: 진짜로 동작했는가?
배포 후 PowerShell로 POST 하나 보내봤어요.
그리고 Cloudflare 대시보드의 Queue 페이지:
- Status: Active ✅ (이전엔 Inactive였음 — Consumer 등록 전엔 Inactive)
- Billable Operations: 1 (메시지 1건 처리)
- Realtime Backlog: 0 (큐에 쌓인 거 없음 — 즉시 소비됨)
- Average Consumer Lag Time: 0 seconds (지연 없음)
인프라 측면에선 끝났습니다. 남은 건 placeholder PNG 자리를 진짜 AI 호출로 교체하는 일.
배운 점
1. "속도를 늘리지 마, 대기를 없애라"
3분짜리 작업을 30초로 줄이려는 시도는 비용·품질 양쪽에서 손해였어요. 진짜 답은 사용자의 인지적 시간선에서 대기 자체를 빼내는 것. 큐 패턴이 그걸 가능하게 합니다.
2. 점진적 검증의 가치
OpenAI를 처음부터 붙였다면 "큐가 안 와서 그런가, 인증이 막혔나, R2 권한이 없나, AI가 실패했나" 디버깅이 너무 컸을 거예요. placeholder PNG로 인프라만 먼저 검증하니, 어디서 깨졌는지 즉시 좁혀집니다.
3. Cloudflare 바인딩의 편안함
외부 API 토큰 발급/저장/회전 같은 게 필요 없어요. env.DB, env.JOBS, env.REPORTS 한 줄이면 끝. 한 벤더로 묶었을 때만 누릴 수 있는 단순함입니다.
4. 큐 메트릭 지연
Cloudflare Queue 대시보드는 메시지 produced 카운트가 즉시 반영되지 않아요. 1~2분 지연이 있습니다. wrangler queues info 명령으로 producer/consumer 연결 상태는 즉시 확인 가능. 두 채널을 같이 쓰면 디버깅이 쉬워져요.
5. wrangler dev는 함정이 있다
로컬에서 띄운 worker는 Cloudflare가 producer로 인식하지 못 합니다. "Number of Producers: 0"이 나오면 그래서. 실제 검증은 deploy 후에 해야 합니다.
다음 단계
- OpenAI 호출 통합: placeholder PNG → 실제 gpt-image-2 결과로 교체
- Producer 실데이터화: 셀카 R2 업로드 + prompt_data 큐 메시지에 포함
- R2 Lifecycle Rule: 결과 PNG 3일 후 자동 삭제 (스토리지 비용 통제)
- 클라이언트 폴링 전환: 동기 호출 → jobId 받고 status 폴링 → 완료 시 다운로드
- 푸시 알림: 외부 메신저 API로 "완성됐어요" 알람 → 사용자 재진입 유도
여기까지 오는 데 한 세션이면 충분했어요. Cloudflare 풀스택의 통합성이 만들어낸 결과 같습니다.
'Tech Notes' 카테고리의 다른 글
| 화면은 누가 그리나 — SSR · CSR, 그리고 그 사이의 것들 (0) | 2026.06.01 |
|---|---|
| 큐라고 다 같은 큐가 아니다 — Kafka · SQS · RabbitMQ · Cloudflare Queues 지형도 (0) | 2026.05.31 |
| 서버 CLI 도구 정리 — Wrangler, gh, Supabase CLI (0) | 2026.05.26 |
| 앱인토스 결제 프로세스 (0) | 2026.05.18 |
| 고양이 타로 앱에 서버를 안 두기로 했다 — Edge Function 도입기 (0) | 2026.05.11 |