Advanced (업데이트: 2026. 6. 2.)

Claude Code로 큐 시스템 구현하기: 비동기 처리 실전 가이드

producer, retry, DLQ, 멱등성, 모니터링까지 Claude Code 큐 설계를 정리합니다.

Claude Code로 큐 시스템 구현하기: 비동기 처리 실전 가이드

Claude Code로 웹 서비스를 만들다 보면 모든 처리를 요청 핸들러 안에 넣고 싶어집니다. 문의 폼을 받자마자 메일을 보내고, 이미지를 업로드하자마자 리사이즈하고, 결제 webhook 안에서 주문 갱신, 영수증 발송, CRM 기록까지 모두 처리하는 방식입니다. 데모에서는 단순하지만, 실제 운영에서는 외부 API 지연, 중복 요청, 네트워크 오류, 배포 재시작, provider rate limit이 겹칩니다.

작업 큐는 “지금 요청 안에서 끝내기에는 느리거나 위험하지만, 반드시 처리해야 하는 일”을 따로 저장하고 worker가 처리하게 만드는 구조입니다. Claude Code에 큐 구현을 맡길 때도 “큐 만들어줘”라고만 쓰면 부족합니다. producer(작업을 큐에 넣는 코드), consumer(작업을 꺼내 실행하는 worker), message payload(worker가 읽는 데이터), visibility timeout(worker가 작업을 잡은 동안 다른 worker에게 숨기는 시간), retry(재시도), dead-letter queue/DLQ(반복 실패 작업의 격리 장소), idempotency(같은 작업이 두 번 와도 비즈니스 결과가 한 번만 생기는 성질), backpressure(처리량이 부족할 때 입력을 늦추는 장치), monitoring(상태를 추적하는 지표)을 명시해야 합니다.

이 글의 예제는 Redis, AWS, RabbitMQ 없이 실행되는 Node.js 스크립트입니다. 운영에서는 SQS, RabbitMQ, BullMQ 같은 서비스를 선택할 수 있지만, 먼저 실패 처리 원리를 손으로 확인하는 것이 중요합니다.

큐 시스템의 구조

큐는 단순히 느린 작업을 백그라운드로 보내는 도구가 아닙니다. 요청과 작업을 분리하고, 외부 서비스 장애를 흡수하고, 실패한 작업을 보관하며, worker 처리량을 제한하고, 운영자가 “어디가 막혔는지” 볼 수 있게 합니다.

flowchart LR
  A["Producer<br/>API, cron, webhook"] --> B["Queue<br/>message payload"]
  B --> C["Consumer<br/>worker process"]
  C --> D["External service<br/>mail, image, billing"]
  C -- "retryable failure" --> B
  C -- "poison message" --> E["DLQ<br/>manual review"]
  C --> F["Metrics<br/>logs and alerts"]
용어쉬운 설명설계할 내용
Producer작업을 큐에 넣는 API나 배치payload 형식, 검증, 우선순위, 중복 방지 키
Consumer큐에서 작업을 꺼내 실행하는 worker동시 실행 수, timeout, 실패 처리
Message payloadworker가 처리에 사용하는 데이터ID, 종류, schema version, secret 제외
Visibility timeout처리 중인 작업을 잠시 숨기는 시간p95 처리 시간보다 조금 길게
Retry일시 실패를 다시 시도최대 횟수, backoff, jitter, 실패 사유
DLQ계속 실패하는 작업을 격리하는 큐담당자, 알림, 재투입 조건
Idempotency재처리해도 결과가 중복되지 않게 하는 성질고유 키, 처리 기록 테이블
Backpressureworker가 밀릴 때 producer를 늦추는 장치concurrency, rate limit, 큐 깊이 기준
Monitoring큐가 건강한지 보는 증거깊이, 가장 오래된 작업 나이, 실패율, DLQ 수

이 표를 Claude Code 프롬프트에 넣으면 구현 결과가 훨씬 현실적으로 바뀝니다. 라이브러리 이름보다 실패 계약이 더 중요합니다.

대표 유스케이스

첫 번째는 메일 발송 큐입니다. 가입 환영 메일, 비밀번호 재설정, 결제 실패 알림, 상담 회신은 요청 응답을 막지 않아야 합니다. 관련 구현은 메일 자동화SendGrid 메일 구현을 함께 보면 좋습니다. payload에는 deliveryId, templateId, userId 같은 참조값만 넣고, API key나 전체 메일 본문은 넣지 않습니다.

두 번째는 이미지와 동영상 처리 큐입니다. 썸네일 생성, WebP 변환, 바이러스 검사, 자막 생성, 미리보기 클립 생성은 시간이 걸리고 CPU를 많이 씁니다. 큐를 사용하면 API는 “접수 완료”를 빠르게 반환하고, worker가 제한된 동시 실행 수로 처리할 수 있습니다. 여기서 가장 위험한 실패는 무제한 concurrency입니다.

세 번째는 결제 재시도 큐입니다. 결제 provider나 카드 네트워크는 일시적으로 실패할 수 있습니다. retry queue는 이런 실패를 회복할 수 있지만 무한 재시도는 금물입니다. 중복 청구, provider rate limit, 고객 문의 증가로 이어질 수 있으므로 idempotency key, 유한 재시도, DLQ, 수동 확인 절차가 필요합니다.

네 번째는 리드 보강과 리포트 생성 큐입니다. 문의가 들어오면 회사 정보를 보강하고, CRM에 기록하고, 영업 리포트를 만들고, Slack에 알릴 수 있습니다. 이 흐름은 이벤트 기반 아키텍처, 로그와 모니터링, 보안 모범 사례와 함께 설계해야 운영하기 쉽습니다.

예제 1: 의존성 없는 인메모리 큐

다음 스크립트는 producer, consumer, message payload, visibility timeout, backpressure를 한 파일에서 보여줍니다. queue-basic-demo.mjs로 저장하고 node queue-basic-demo.mjs로 실행하세요. 프로세스를 재시작하면 데이터가 사라지므로 운영용은 아니지만, 큐의 상태 변화를 이해하기 좋습니다.

// queue-basic-demo.mjs
let nextJobId = 1;

class InMemoryQueue {
  constructor({ visibilityTimeoutMs = 800, maxInFlight = 2 } = {}) {
    this.visibilityTimeoutMs = visibilityTimeoutMs;
    this.maxInFlight = maxInFlight;
    this.ready = [];
    this.inFlight = new Map();
  }

  enqueue(type, payload) {
    const job = {
      id: `job-${nextJobId++}`,
      type,
      payload,
      attempts: 0,
      visibleAt: 0,
      lockedBy: null,
    };
    this.ready.push(job);
    return job.id;
  }

  receive(workerId) {
    this.requeueExpired();

    if (this.inFlight.size >= this.maxInFlight) {
      return null;
    }

    const job = this.ready.shift();
    if (!job) return null;

    job.attempts += 1;
    job.lockedBy = workerId;
    job.visibleAt = Date.now() + this.visibilityTimeoutMs;
    this.inFlight.set(job.id, job);

    return {
      id: job.id,
      type: job.type,
      payload: job.payload,
      attempts: job.attempts,
    };
  }

  ack(jobId) {
    this.inFlight.delete(jobId);
  }

  requeueExpired(now = Date.now()) {
    for (const [jobId, job] of this.inFlight.entries()) {
      if (job.visibleAt <= now) {
        this.inFlight.delete(jobId);
        job.lockedBy = null;
        this.ready.push(job);
      }
    }
  }

  stats() {
    this.requeueExpired();
    return {
      ready: this.ready.length,
      inFlight: this.inFlight.size,
    };
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

function produce(queue) {
  queue.enqueue("email.send", {
    deliveryId: "mail-1001",
    templateId: "welcome",
    userId: "user-42",
  });
  queue.enqueue("image.resize", {
    assetId: "asset-9001",
    sizes: [320, 768, 1280],
  });
  queue.enqueue("report.generate", {
    reportId: "weekly-2026-06-02",
    accountId: "acct-7",
  });
}

async function consume(queue, workerId) {
  for (let step = 0; step < 8; step += 1) {
    const job = queue.receive(workerId);

    if (!job) {
      console.log(`${workerId}: no job or backpressure`, queue.stats());
      await sleep(120);
      continue;
    }

    console.log(`${workerId}: started ${job.id}`, job.payload);
    await sleep(job.type === "image.resize" ? 300 : 90);
    queue.ack(job.id);
    console.log(`${workerId}: acked ${job.id}`, queue.stats());
  }
}

async function main() {
  const queue = new InMemoryQueue({
    visibilityTimeoutMs: 500,
    maxInFlight: 2,
  });

  produce(queue);
  await Promise.all([consume(queue, "worker-a"), consume(queue, "worker-b")]);
  console.log("final stats", queue.stats());
}

void main();

운영 환경에서는 ready 배열이 SQS, RabbitMQ, Redis 같은 durable queue로 바뀝니다. 그래도 핵심 상태는 같습니다. 작업은 대기 중이거나, 처리 중이거나, ack 되었거나, visibility timeout 만료로 다시 큐에 돌아옵니다.

예제 2: worker 멱등성 가드

대부분의 큐는 at-least-once delivery를 전제로 합니다. 같은 작업이 다시 올 수 있다는 뜻입니다. 메일, 결제, 포인트, CRM 등록은 멱등성 없이 worker를 만들면 실제 고객 피해로 이어집니다.

// idempotent-worker-demo.mjs
const idempotencyStore = new Map();
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function withIdempotency(key, work) {
  const current = idempotencyStore.get(key);

  if (current?.status === "done") {
    return { skipped: true, result: current.result };
  }

  if (current?.status === "processing") {
    return { skipped: true, reason: "already processing" };
  }

  idempotencyStore.set(key, { status: "processing" });

  try {
    const result = await work();
    idempotencyStore.set(key, { status: "done", result });
    return { skipped: false, result };
  } catch (error) {
    idempotencyStore.delete(key);
    throw error;
  }
}

async function fakeSendEmail(payload) {
  await sleep(50);
  return {
    providerMessageId: `sg_${payload.deliveryId}`,
    sentToUserId: payload.userId,
  };
}

async function handleEmailJob(job) {
  const key = job.payload.idempotencyKey;
  if (!key) throw new Error("missing idempotencyKey");

  return withIdempotency(key, () => fakeSendEmail(job.payload));
}

async function main() {
  const original = {
    id: "job-1",
    payload: {
      idempotencyKey: "email:welcome:user-42",
      deliveryId: "mail-1001",
      userId: "user-42",
    },
  };

  console.log(await handleEmailJob(original));
  console.log(await handleEmailJob({ ...original, id: "job-1-redelivery" }));
}

void main();

운영에서는 Map 대신 DB unique constraint, Redis SETNX, provider의 idempotency key를 사용합니다. Claude Code에는 “외부 side effect가 성공한 뒤에만 완료 처리”, “실패 시 처리 중 lock 해제”, “payload에 secrets를 넣지 않기”를 꼭 요구하세요.

예제 3: retry와 DLQ

retry는 일시적인 네트워크 오류에 유용합니다. 하지만 잘못된 schema, 삭제된 사용자, 권한 오류, 설정 누락은 재시도해도 성공하지 않습니다. 이런 poison message를 계속 main queue로 되돌리면 worker가 같은 실패만 반복합니다.

// retry-dlq-demo.mjs
let nextRetryJobId = 1;

class RetryQueue {
  constructor({ maxAttempts = 3 } = {}) {
    this.maxAttempts = maxAttempts;
    this.ready = [];
    this.delayed = [];
    this.dead = [];
    this.completed = [];
  }

  enqueue(payload) {
    this.ready.push({
      id: `retry-job-${nextRetryJobId++}`,
      payload,
      attempts: 0,
      runAt: Date.now(),
      lastError: null,
    });
  }

  moveReadyJobs(now = Date.now()) {
    const stillDelayed = [];
    for (const job of this.delayed) {
      if (job.runAt <= now) {
        this.ready.push(job);
      } else {
        stillDelayed.push(job);
      }
    }
    this.delayed = stillDelayed;
  }

  retryOrDeadLetter(job, error) {
    job.lastError = error.message;

    if (job.attempts >= this.maxAttempts) {
      this.dead.push(job);
      return;
    }

    const delayMs = 50 * 2 ** (job.attempts - 1);
    job.runAt = Date.now() + delayMs;
    this.delayed.push(job);
  }

  async drain(handler) {
    let idleRounds = 0;

    while (this.ready.length > 0 || this.delayed.length > 0) {
      this.moveReadyJobs();
      const job = this.ready.shift();

      if (!job) {
        idleRounds += 1;
        if (idleRounds > 100) throw new Error("drain timeout");
        await sleep(20);
        continue;
      }

      idleRounds = 0;
      job.attempts += 1;

      try {
        const result = await handler(job);
        this.completed.push({ id: job.id, result });
      } catch (error) {
        this.retryOrDeadLetter(job, error);
      }
    }

    return {
      completed: this.completed.length,
      dead: this.dead.length,
    };
  }
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function handler(job) {
  if (job.payload.kind === "poison") {
    throw new Error("invalid payload schema");
  }

  if (job.payload.kind === "flaky" && job.attempts < 2) {
    throw new Error("temporary provider timeout");
  }

  return `processed ${job.payload.kind}`;
}

async function main() {
  const queue = new RetryQueue({ maxAttempts: 3 });
  queue.enqueue({ kind: "normal" });
  queue.enqueue({ kind: "flaky" });
  queue.enqueue({ kind: "poison" });

  console.log(await queue.drain(handler));
  console.log(
    "dead letters",
    queue.dead.map((job) => ({
      id: job.id,
      attempts: job.attempts,
      lastError: job.lastError,
      payload: job.payload,
    }))
  );
}

void main();

DLQ는 “나중에 보겠지”라고 쌓아두는 곳이 아닙니다. 실패 이유, 알림, 담당자, 재투입 조건, 삭제 기준이 있어야 운영 도구가 됩니다.

운영 체크리스트

  • payload에 jobId, type, schemaVersion, 비즈니스 ID, idempotency key를 넣는다.
  • API key, OAuth token, 카드 정보, 전체 메일 본문, 긴 개인정보는 payload에 넣지 않는다.
  • producer에서 payload를 검증하고 깨진 작업을 큐에 넣지 않는다.
  • visibility timeout은 p95 처리 시간보다 조금 길게 잡고, 긴 작업은 나눈다.
  • retry 횟수, backoff, jitter, DLQ 조건을 배포 전에 정한다.
  • worker concurrency는 DB 연결 수, provider rate limit, CPU, 메모리로 결정한다.
  • queue depth, oldest job age, active 수, 실패율, DLQ 수, p95 처리 시간을 모니터링한다.
  • DLQ 확인, replay, 삭제, 고객 커뮤니케이션을 runbook에 적는다.
  • 메일, 결제, 포인트, CRM 작업은 중복 delivery를 전제로 만든다.
  • Claude Code review에서 성공 경로뿐 아니라 실패 경로와 로그를 본다.

visibility timeout은 짧아도 길어도 문제가 됩니다. 너무 짧으면 아직 처리 중인 작업이 다른 worker에게 재전달됩니다. 너무 길면 worker가 죽었을 때 작업이 오래 숨어 있습니다. 실제 p95를 측정하고 여유를 주는 것이 안전합니다.

Claude Code 프롬프트 예시

라이브러리 이름보다 실패 계약을 적어야 합니다.

이 저장소에 메일 발송 큐를 추가하세요. API는 요청을 저장한 뒤 deliveryIdtemplateId만 큐에 넣습니다. worker는 idempotency key로 중복 발송을 막고, 일시적인 provider 오류는 최대 3회 지수 backoff로 retry하며, 반복 실패는 DLQ 테이블로 이동합니다. payload에는 API key, 메일 본문, 개인정보를 넣지 마세요. queue depth, oldest job age, failure rate, DLQ count를 로그나 metrics로 볼 수 있게 하고, 중복 delivery, poison message, visibility timeout 테스트를 추가하세요.

이 정도로 요구하면 Claude Code가 단순 샘플이 아니라 review 가능한 구현을 만들 가능성이 높아집니다.

공식 문서와 선택 기준

AWS 중심 인프라라면 Amazon SQS Developer Guide를 먼저 보세요. routing, exchange, pub/sub, 자체 메시징 토폴로지가 중요하면 RabbitMQ documentation이 후보입니다. Node.js와 Redis 기반으로 delayed jobs, repeatable jobs, worker ergonomics를 빠르게 만들고 싶다면 BullMQ documentation를 확인하세요.

도구를 먼저 고르지 마세요. payload, 멱등성 저장소, retry 규칙, DLQ 소유자, metrics, 권한, 비용을 먼저 정한 뒤 그 조건에 맞는 broker를 선택하는 순서가 맞습니다.

흔한 실패

첫 번째 실패는 duplicate processing입니다. 큐는 보통 exactly-once business effect를 보장하지 않습니다. ack 실패, 네트워크 오류, worker 재시작, visibility timeout 만료로 같은 작업이 다시 올 수 있습니다.

두 번째 실패는 poison message입니다. 잘못된 payload, 삭제된 사용자, 권한 오류는 여러 번 재시도해도 해결되지 않습니다. 검증, 실패 사유 기록, DLQ, controlled replay가 필요합니다.

세 번째 실패는 infinite retry loop입니다. provider 장애 때 즉시 재시도를 반복하면 복구 중인 시스템에 더 큰 부하를 줍니다. 유한 횟수, backoff, jitter, producer backpressure를 사용하세요.

네 번째 실패는 payload에 secrets를 넣는 것입니다. 큐 데이터는 로그, DLQ, 대시보드, 지원 도구에 남습니다. payload에는 참조 ID만 넣고 worker가 권한 있는 저장소에서 필요한 데이터를 읽게 해야 합니다.

교육과 상담

큐 시스템은 코드보다 운영 설계가 어렵습니다. ClaudeCodeLab은 Claude Code 프롬프트, CLAUDE.md 규칙, payload schema, DLQ runbook, metrics, CI review 기준을 팀 프로세스로 정리할 수 있습니다. 팀 도입은 Claude Code training and consulting에서 상담하고, 개인 작업에서는 이 체크리스트를 PR 템플릿에 붙여 매번 확인하는 것부터 시작하면 됩니다.

정리

작업 큐는 느린 일을 뒤로 미루는 편의 기능이 아니라 production infrastructure입니다. 실패를 격리하고, 재시도를 통제하고, 중복 처리를 막고, 처리량을 제한하며, 나중에 조사할 증거를 남깁니다. Claude Code에 큐 구현을 맡길 때는 producer, consumer, payload, visibility timeout, retry, DLQ, idempotency, backpressure, monitoring을 첫 프롬프트부터 포함하세요.

Masa의 직접 검증 결과: 세 개의 Node.js 예제를 외부 서비스 없이 로컬에서 실행해 기본 큐 흐름, 중복 delivery 가드, poison message의 DLQ 이동을 확인했습니다. 특히 멱등성 예제는 같은 메일 작업이 다시 와도 두 번째 실행이 저장된 결과를 재사용하므로, Claude Code에 운영 구현을 요청할 때 설명 자료로 쓰기 좋았습니다.

#Claude Code #작업 큐 #비동기 처리 #BullMQ #Redis
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.