Tech Notes

AI 호출 3분, 사용자는 1초도 못 기다린다 — 큐 패턴 도입기

miracle-tech 2026. 5. 31. 13:25
728x90
반응형
AI 호출 3분, 사용자는 1초도 못 기다린다 — 큐 패턴 도입기
아키텍처 · 1인 개발자 메모

AI 호출 3분, 사용자는 1초도 못 기다린다
— 큐 패턴 도입기

사용자 입장에서 3분 빈 화면. 그게 정말 답일 리가 없잖아요. Cloudflare Workers + Queues + D1 + R2로 백엔드를 다시 설계한 이야기.

2026-05-26 · 약 9분 소요

문제: 3분 빈 화면

특정 이미지 생성 AI를 부르면 결과가 나오기까지 약 3분이 걸립니다. 모델의 본질적 처리 시간이라 줄일 수 없어요. 문제는 이게 사용자가 요청 직후 결과를 기대하는 화면에서 발생한다는 거예요. 3분 동안 스피너만 보고 있게 만들 순 없죠.

3분이라는 시간감 비교: 유튜브 광고 스킵 가능 시간 5초. 메신저 메시지 전송 0.5초. 모바일 송금 1~2초.
그 사이에서 3분 = 인지적으로 무한.

처음엔 "더 빠른 모델 쓰면 되지 않을까" 같은 접근도 시도해봤어요. 같은 결과를 8조각으로 쪼개서 병렬 호출 → 합성하는 방식으로 30초까지 줄였지만, 결과 품질이 떨어지고 비용은 오히려 8배가 됐습니다. 그래서 결국 깨달은 게 있어요.

"시간을 줄이는 게 아니라, 대기를 없애야 한다."

즉, AI가 3분 걸리는 건 어쩔 수 없으니, 그 3분 동안 사용자가 다른 일을 할 수 있게 만들면 됩니다. 백그라운드에서 처리하고, 끝나면 알림으로 부른다. 미용실 예약처럼.

동기식 vs 큐 패턴
동기식 (예전) 사용자 요청 3분 동안 빈 화면 — 사용자 이탈 위험 결과 서버 AI 호출 (3분), 응답까지 연결 유지 큐 패턴 (지금) 사용자 요청 앱 닫아도 OK · 푸시 알림으로 호출됨 재진입 서버 Consumer Worker가 큐에서 꺼내서 AI 호출 + R2 저장 + DB 업데이트 완료 시간 ────────────────────────────────────────────────────→

전체 아키텍처

처음 갈피 잡기 어려웠던 건 "그래서 어떤 서비스를 어떻게 조합해야 하지?"였어요. Cloudflare에 다 모아두기로 결정한 후엔 단순해졌습니다.

잡 생성 → 처리 → 다운로드 풀 파이프라인
① ~ ② PRODUCER 경로 (즉시) 사용자 (모바일 클라이언트) Cloudflare Worker POST /api/jobs (Producer 역할) Queue 메시지 보관 ① POST ② send() ①' INSERT (status=pending) ③ ~ ⑤ CONSUMER 처리 (백그라운드) D1 (SQLite) jobs 테이블 status / result_key Consumer queue() 핸들러 ③ 자동 전달 R2 (Storage) 결과 PNG results/{jobId}.png ④ put() ⑤ UPDATE (status=done) ⑥ ~ ⑦ 사용자 재진입 & 다운로드 사용자 (나중에 재진입) ⑥ GET /api/jobs/{id} → status 'done' 확인 ⑦ GET /api/jobs/{id}/result → PNG 다운로드

핵심: 사용자는 ①에서 즉시 응답을 받고 떠나요. ②~⑤는 백엔드에서 비동기로 진행됩니다. 사용자가 ⑥으로 돌아오는 시점은 본인이 선택. 그 사이 백엔드는 자기 일을 하고 있죠.


Stage 1: Cloudflare 리소스 만들기

D1(DB), R2(파일 저장), Queues(메시지 큐) 세 가지를 만듭니다. 어차피 wrangler CLI로 한 줄씩.

$ npx wrangler d1 create <db-name>
✅ Successfully created DB. database_id = "xxxxx-xxxx-..."
$ npx wrangler r2 bucket create <bucket-name>
✅ Created bucket
$ npx wrangler queues create <queue-name>
✅ Created queue

그리고 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 });
}
"이게 끝?" 네. 큐의 우아함이 여기서 드러나요. 무거운 일은 모두 Consumer에 떠넘기고, Producer는 본인 일만 합니다. 응답까지 ~200ms.

Stage 2-B: Consumer — 큐를 구독하고 처리하기

Cloudflare Workers의 매력적인 점 하나: 같은 Worker가 fetchqueue 두 핸들러를 동시에 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();
}
점진적 검증의 힘: AI 호출은 일부러 뺐어요. 큐 → R2 → DB까지의 인프라가 잘 동작하는지를 먼저 검증한 후, 그 자리에 AI 호출만 끼워넣을 거예요. 한 번에 다 만들면 어디서 깨졌는지 추적이 너무 오래 걸립니다.

잡의 상태 흐름

jobs.status 상태 전이
pending 생성 직후 processing Consumer 진행 중 done result_key 있음 failed error 기록 Consumer 시작 성공 예외 발생 retry() — 자동 백오프

큐의 message.retry()는 실패한 메시지를 다시 큐에 넣어요. 일정 횟수 초과하면 Dead Letter Queue로 빠집니다. 운영 환경에선 거기서 알람을 받아 사람이 보면 돼요.


검증: 진짜로 동작했는가?

배포 후 PowerShell로 POST 하나 보내봤어요.

PS> Invoke-RestMethod -Method Post -Uri https://.../api/jobs/test
ok jobId
-- -----
True 3138c4b4-1255-4f94-a2b6-4af09e9b66ad
PS> npx wrangler queues info <queue>
Number of Producers: 1
Number of Consumers: 1
PS> Invoke-RestMethod -Uri https://.../api/jobs/{id}
status: done
result_key: results/3138c4b4-....png

그리고 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 풀스택의 통합성이 만들어낸 결과 같습니다.

728x90