지원 후속 조치 예약

CloudflareBeginner
지금 연습하기

소개

지원팀은 고객이 수정 사항을 적용한 후, 서비스 점검 시간이 끝난 후 또는 에스컬레이션 기한 전에 티켓을 다시 확인하겠다고 약속하는 경우가 많습니다. 브라우저 타이머에 이 약속을 맡기면 탭을 닫는 순간 예약이 사라지므로 안전하지 않습니다. Agent 예약은 향후 작업을 이름이 지정된 Agent에 저장하므로, 예약 시간이 되면 플랫폼이 해당 지속 인스턴스를 깨울 수 있습니다.

이 실습에서는 언어 모델 없이 간단한 후속 조치 보드를 만듭니다.

  1. schedule()은 지연된 콜백 하나를 등록하고 지속 가능한 예약 ID를 반환합니다.
  2. listSchedules()는 현재 비동기 API를 사용해 애플리케이션에서 대기 중인 작업을 확인할 수 있게 합니다.
  3. cancelSchedule()은 서버가 소유 항목인지 확인한 후 아직 대기 중인 항목을 제거합니다.
  4. 콜백은 Agent 상태에 제한된 완료 기록을 저장하고 개인정보가 제한된 로그를 출력합니다.

짧은 작업을 예약해 완료되는 과정을 확인한 다음, 더 긴 작업을 만들고 실행 전에 취소합니다. 호출에는 합성 티켓 참조만 사용합니다. 동일한 등록 요청은 SDK 멱등성을 사용하므로, 실수로 버튼을 두 번 클릭해도 중복 작업이 생성되지 않습니다.

Agents SDK는 SQLite 기반 Durable Object 알람 위에 이 생명 주기를 구현합니다. 알람 시각과 저장소 레코드를 직접 관리하는 대신 상위 수준의 예약 API를 사용하지만, 작업은 여전히 이름이 지정된 하나의 Agent 인스턴스에 속하며 일반적인 Worker 재시작 후에도 유지됩니다.

이 과정을 직접 시작하기 전에 LabEx를 Cloudflare 계정에 연결을 완료합니다. 새 LabEx VM마다 자체 Wrangler 인증이 필요합니다. 이전 과정의 실습을 먼저 완료하는 것이 좋지만, 이 실습은 자체적으로 격리된 리소스를 만들고 삭제합니다.

VM 인증 및 Agent 구성

이 단계에서는 새 VM을 인증하고 실습에서 사용할 일회성 Worker 하나와 Durable Object 클래스 하나를 정의합니다.

cd /home/labex/project/follow-up-agent
npx wrangler login
npx wrangler whoami --json

출력된 디바이스 링크를 LabEx 브라우저에서 열고, 표시된 코드를 확인한 후 학습용 계정에 대한 액세스를 승인합니다. 비밀번호, 토큰 또는 인증 코드를 다른 사람에게 보내지 마세요. JSON 결과에서 "loggedIn": true인지 확인하고 계정 이름을 읽은 다음 ID를 복사합니다.

고유한 리소스 이름을 생성하고 wrangler.jsonc를 만듭니다.

RUN="labex-c11-s04-$(openssl rand -hex 6)"
ACCOUNT_ID="YOUR_ACCOUNT_ID"
cat > wrangler.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/server.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": { "enabled": true },
  "durable_objects": {
    "bindings": [
      { "name": "FollowUpAgent", "class_name": "FollowUpAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] }
  ]
}
JSON
python3 .labex/verify.py auth

바인딩 이름은 라우터와 클라이언트가 사용하는 이름이고, 클래스 이름은 구현에 사용하는 이름입니다. 마이그레이션 v1은 Cloudflare에 해당 클래스의 SQLite 기반 저장소를 만들도록 요청합니다. 이 단계에서 특정 이름의 인스턴스를 만들지는 않습니다. planning 같은 인스턴스는 트래픽이 처음 해당 인스턴스를 대상으로 전송될 때 생성됩니다.

지속 가능한 후속 작업 예약 구현

이 단계에서는 이름이 지정된 하나의 Agent에서 등록, 확인, 취소 및 나중에 실행될 콜백을 구현합니다.

cat > src/server.ts <<'TS'
import { Agent, callable, routeAgentRequest, type Schedule } from "agents";

type CompletedFollowUp = { ticketId: string; completedAt: string };
export type FollowUpState = { completed: CompletedFollowUp[]; revision: number };
export type PendingFollowUp = { id: string; ticketId: string; runAt: string };

export class FollowUpAgent extends Agent<Cloudflare.Env, FollowUpState> {
  initialState: FollowUpState = { completed: [], revision: 0 };

  private ticket(value: unknown): string {
    const ticketId = typeof value === "string" ? value.trim().toUpperCase() : "";
    if (!/^T-[A-Z0-9-]{3,24}$/.test(ticketId)) {
      throw new Error("ticket must look like T-DEMO-101");
    }
    return ticketId;
  }

  @callable()
  async scheduleFollowUp(ticketInput: string, delaySeconds: number): Promise<PendingFollowUp> {
    const ticketId = this.ticket(ticketInput);
    if (!Number.isInteger(delaySeconds) || delaySeconds < 3 || delaySeconds > 300) {
      throw new Error("delay must be an integer from 3 to 300 seconds");
    }
    const scheduled = await this.schedule(
      delaySeconds,
      "completeFollowUp",
      { ticketId },
      {
        idempotent: true,
        retry: { maxAttempts: 2, baseDelayMs: 100, maxDelayMs: 500 }
      }
    );
    return this.pending(scheduled);
  }

  @callable()
  async listFollowUps(): Promise<PendingFollowUp[]> {
    const schedules = await this.listSchedules({ type: "delayed" });
    return schedules
      .filter((item) => item.callback === "completeFollowUp")
      .map((item) => this.pending(item))
      .sort((left, right) => left.runAt.localeCompare(right.runAt));
  }

  @callable()
  async cancelFollowUp(scheduleId: string): Promise<boolean> {
    if (!/^[a-zA-Z0-9_-]{8,80}$/.test(scheduleId)) throw new Error("invalid schedule ID");
    const owned = await this.getScheduleById(scheduleId);
    if (!owned || owned.callback !== "completeFollowUp") return false;
    return this.cancelSchedule(scheduleId);
  }

  @callable()
  getBoard(): FollowUpState {
    return this.state;
  }

  async completeFollowUp(payload: unknown, _schedule: Schedule<unknown>): Promise<void> {
    const ticketId = this.ticket((payload as { ticketId?: unknown })?.ticketId);
    const next: FollowUpState = {
      completed: [...this.state.completed, { ticketId, completedAt: new Date().toISOString() }].slice(-5),
      revision: this.state.revision + 1
    };
    this.setState(next);
    console.log(JSON.stringify({
      event: "follow_up_completed",
      instance: this.name,
      revision: next.revision,
      completedCount: next.completed.length
    }));
  }

  private pending(schedule: Schedule<unknown>): PendingFollowUp {
    const payload = schedule.payload as { ticketId?: unknown };
    return {
      id: schedule.id,
      ticketId: this.ticket(payload.ticketId),
      runAt: new Date(schedule.time * 1000).toISOString()
    };
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    return (await routeAgentRequest(request, env)) ?? new Response("Not found", { status: 404 });
  }
};
TS
python3 .labex/verify.py server

Cloudflare.Env는 컴파일 전에 생성할 Wrangler 바인딩 선언에서 제공되므로, 소스 코드에서 환경에 대한 별도의 수동 선언을 관리하지 않아도 됩니다. schedule()은 상대 지연 시간, 콜백 이름 및 직렬화 가능한 작은 페이로드를 받습니다. { idempotent: true }는 동일한 콜백과 페이로드를 반복해서 전달할 때 새 예약을 추가하지 않고 기존 대기 중인 예약을 반환한다는 의미입니다. 재시도 정책은 짧고 제한된 백오프를 사용해 콜백을 최대 두 번 실행하도록 허용하므로, 영구 오류가 무한히 반복되지 않습니다. 콜백은 합성 완료 기록을 최대 5개만 보존하고, 구조화된 로그에는 티켓 참조를 포함하지 않습니다.

목록 및 조회 메서드에는 의도적으로 await를 사용합니다. 이전 예제에는 동기식 getSchedule() 또는 getSchedules() 호출이 나올 수 있지만, 현재 Agents SDK 코드에서는 getScheduleById()listSchedules()를 사용해야 합니다.

후속 조치 보드 연결

이 단계에서는 현재 데코레이터 변환을 구성하고 제공된 페이지를 이름이 지정된 하나의 Agent에 연결합니다.

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": { "noEmit": true },
  "include": ["src/**/*.ts", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

cat > vite.config.ts <<'TS'
import { cloudflare } from "@cloudflare/vite-plugin";
import agents from "agents/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [agents(), cloudflare()] });
TS

cat > src/client.ts <<'TS'
import { AgentClient } from "agents/client";
import type { FollowUpState, PendingFollowUp } from "./server";

document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
  <main><p class="eyebrow">Durable scheduling</p><h1>Support Follow-Up Board</h1>
  <p id="status" class="status">Connecting to FollowUpAgent:planning…</p>
  <form id="form"><input id="ticket" value="T-DEMO-101" aria-label="Ticket reference">
  <input id="delay" type="number" min="3" max="300" value="12" aria-label="Delay in seconds">
  <button>Schedule follow-up</button></form><p id="error" class="error"></p>
  <div class="columns"><section class="panel"><h2>Pending</h2><div id="pending"></div></section>
  <section class="panel"><h2>Completed</h2><div id="completed"></div></section></div>
  <p class="notice">This demonstration uses synthetic ticket references only.</p></main>`;

const client = new AgentClient<FollowUpState>({ agent: "FollowUpAgent", name: "planning", host: window.location.host });
const pendingView = document.querySelector<HTMLDivElement>("#pending")!;
const completedView = document.querySelector<HTMLDivElement>("#completed")!;
const statusView = document.querySelector<HTMLParagraphElement>("#status")!;
const errorView = document.querySelector<HTMLParagraphElement>("#error")!;

function renderCompleted(state: FollowUpState) {
  completedView.innerHTML = state.completed.map((item) =>
    `<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.completedAt).toLocaleTimeString()}</small></div>`
  ).join("") || '<p class="empty">No completed follow-ups yet</p>';
}

async function refresh() {
  const pending = await client.call<PendingFollowUp[]>("listFollowUps", []);
  pendingView.innerHTML = pending.map((item) =>
    `<div class="item"><strong>${item.ticketId}</strong><br><small>${new Date(item.runAt).toLocaleTimeString()}</small><br>` +
    `<button class="secondary" data-id="${item.id}">Cancel</button></div>`
  ).join("") || '<p class="empty">No pending follow-ups</p>';
  const state = await client.call<FollowUpState>("getBoard", []);
  renderCompleted(state);
}

await client.ready;
statusView.textContent = "Connected to FollowUpAgent:planning";
await refresh();
setInterval(() => refresh().catch(() => undefined), 2000);

document.querySelector<HTMLFormElement>("#form")!.addEventListener("submit", async (event) => {
  event.preventDefault(); errorView.textContent = "";
  try {
    const ticket = document.querySelector<HTMLInputElement>("#ticket")!.value;
    const delay = Number(document.querySelector<HTMLInputElement>("#delay")!.value);
    await client.call("scheduleFollowUp", [ticket, delay]); await refresh();
  } catch (cause) { errorView.textContent = cause instanceof Error ? cause.message : String(cause); }
});

pendingView.addEventListener("click", async (event) => {
  const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-id]");
  if (!button) return;
  await client.call("cancelFollowUp", [button.dataset.id]); await refresh();
});
TS
python3 .labex/verify.py client

페이지는 이 일반 TypeScript 예제를 쉽게 읽을 수 있도록 2초마다 Agent를 폴링합니다. 예약 자체는 브라우저 타이머가 아니므로 페이지를 닫아도 예약이 취소되지 않습니다. 검증, 소유권 확인 및 실행은 서버가 담당합니다.

타입 생성 및 애플리케이션 빌드

이 단계에서는 바인딩 타입을 생성하고 런타임을 시작하기 전에 애플리케이션의 두 부분을 모두 검사하고 빌드합니다.

정확한 바인딩에서 환경 타입을 생성하고, TypeScript 양쪽을 검사한 다음 Worker와 정적 페이지를 빌드합니다.

npx wrangler types
grep -n "FollowUpAgent" worker-configuration.d.ts | head
npm run check
npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'
python3 .labex/verify.py build

오류 없이 빌드되면 바인딩, 데코레이터 변환, 공유 타입 및 번들이 서로 일치한다는 뜻입니다. 아직 알람이 실행되거나 클라우드 계정에 배포된 리소스가 존재한다는 것까지 증명하지는 않습니다. 이러한 내용은 다음 단계에서 런타임으로 확인합니다.

로컬에서 생명 주기 확인

이 단계에서는 로컬 Cloudflare 런타임에서 지속적인 실행과 취소가 동작하는지 확인합니다.

로컬 런타임을 계속 실행되는 백그라운드 작업으로 시작합니다.

CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
for attempt in $(seq 1 40); do
  curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
  sleep 1
done
curl --silent --head http://127.0.0.1:5173/ | head

LabEx 데스크톱 브라우저에서 http://localhost:5173을 엽니다. T-DEMO-101을 12초 후 실행되도록 예약합니다. 처음에는 Pending 아래에 표시됩니다. 페이지를 닫거나 새로 고침해도 해당 작업의 소유권이 바뀌거나 작업이 취소되지 않습니다. 예약 시간이 지나면 콜백이 예약 저장소에서 해당 항목을 제거하고 Completed에 기록합니다.

다음으로 T-DEMO-CANCEL을 90초 후 실행되도록 예약하고 Cancel을 클릭합니다. 항목이 Pending에서 사라지고 Completed에는 나타나지 않습니다. 자체적으로 무작위 Agent 이름을 사용하며 멱등성 등록, 실행 및 취소를 확인하는 독립 프로브를 실행합니다.

python3 .labex/verify.py local

배포 및 예약 작업 확인

이 단계에서는 Cloudflare에서 생명 주기를 다시 실행하고, 관찰 가능한 동작을 Dashboard의 증거와 연결합니다.

정확한 로컬 프로세스를 중지하고 프로덕션 빌드를 배포한 다음 URL이 준비될 때까지 기다립니다.

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy
WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
  curl --silent --fail "$WORKER_URL/" > /dev/null && break
  sleep 2
done

내장 브라우저에서 정확한 URL을 엽니다. T-CLOUD-101을 20초 후 실행되도록 예약하고, 먼저 지속적으로 유지되는 대기 행을 확인합니다.

클라우드 후속 작업이 대기 중인 예약 목록에 표시됩니다

실행 시간과 예약 ID는 일회성으로 승인된 실행에 속하므로 표시되는 값은 다를 수 있습니다. 중요한 증거는 해당 항목이 페이지에 저장된 카운트다운이 아니라 Agent에 의해 목록으로 표시된다는 점입니다.

콜백이 실행될 때까지 기다린 다음, 동일한 합성 티켓이 Completed에 표시되는지 확인합니다.

예약된 콜백이 합성 티켓을 완료 상태로 이동했습니다

T-CLOUD-CANCEL을 90초 후 실행되도록 만들고 대기 상태를 캡처한 다음 취소합니다. Pending 패널은 다시 비어야 하며, Completed 항목은 변경되지 않은 상태로 남아 있어야 합니다.

더 긴 대기 후속 작업을 명시적으로 취소할 수 있습니다

취소된 예약은 사라지고 이전 완료 기록은 남아 있습니다

Workers & Pages를 열고 정확한 labex-c11-s04-... Worker를 선택한 다음 Bindings를 확인합니다. FollowUpAgent가 동일한 클래스 이름을 가리키는지 확인합니다.

Worker 바인딩이 요청을 FollowUpAgent에 연결합니다

Durable Objects를 열고 FollowUpAgent 네임스페이스를 확인합니다. Agent 상태와 예약에는 지속적인 레코드가 필요하므로 SQL 저장소가 사용됩니다.

FollowUpAgent Durable Object 네임스페이스가 SQL 저장소를 사용합니다

마지막으로 Observability → Logs를 열고 follow_up_completed로 필터링한 다음 이벤트 하나를 펼칩니다. 제한된 이벤트에는 Agent 인스턴스, revision 및 완료 수가 포함되지만 티켓 참조는 포함되지 않습니다.

제한된 완료 로그에 합성 티켓 참조가 포함되지 않습니다

Dashboard 화면에는 반영이 늦을 수 있으므로 독립적인 원격 프로브를 기준으로 사용합니다.

python3 .labex/verify.py deployed
python3 .labex/verify.py observed

예약 네임스페이스 및 Worker 삭제

이 단계에서는 이 실습에서 만든 클래스 네임스페이스와 Worker만 삭제합니다.

예약과 완료 상태는 Durable Object 클래스 네임스페이스에 저장됩니다. 남아 있는 상태 비저장 Worker를 삭제하기 전에 해당 클래스를 명시적으로 삭제합니다.

cat > src/cleanup.ts <<'TS'
export default { fetch() { return Response.json({ status: "cleanup" }, { status: 410 }); } };
TS
RUN="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name)')"
ACCOUNT_ID="$(node -e 'console.log(JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).account_id)')"
cat > wrangler.cleanup.jsonc <<JSON
{
  "\$schema": "./node_modules/wrangler/config-schema.json",
  "name": "$RUN",
  "account_id": "$ACCOUNT_ID",
  "main": "src/cleanup.ts",
  "compatibility_date": "2026-09-18",
  "compatibility_flags": ["nodejs_compat"],
  "workers_dev": true,
  "preview_urls": false,
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["FollowUpAgent"] },
    { "tag": "v2", "deleted_classes": ["FollowUpAgent"] }
  ]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted

계정의 관련 없는 리소스는 삭제하지 마세요. 정확히 생성된 Worker와 해당 FollowUpAgent 네임스페이스만 삭제되었는지 확인합니다.

삭제 후 일회성 예약 Worker가 사라졌습니다

삭제 마이그레이션 후 FollowUpAgent 네임스페이스가 사라졌습니다

이 VM의 인증 취소

이 단계에서는 일회성 VM에 저장된 OAuth 권한 부여를 제거하고, 구조화된 로그아웃 상태를 확인합니다.

클라우드 정리가 성공한 후 이 일회성 VM에 저장된 OAuth 인증을 제거합니다.

npx wrangler logout
npx wrangler whoami --json
python3 .labex/verify.py logout

명시적으로 "loggedIn": false인지 확인합니다. 네트워크 오류만으로는 판단할 수 없으므로 다시 시도해야 합니다. 이제 일회성 Worker, 해당 예약 네임스페이스 및 이 VM의 로컬 인증이 모두 제거되었습니다.

요약

열려 있는 브라우저나 언어 모델에 의존하지 않고, 이름이 지정된 하나의 Cloudflare Agent에 지속적인 미래 작업을 제공했습니다. 제한된 지연 콜백을 등록하고, 반복 등록에 멱등성을 적용하고, 현재 비동기 API를 통해 대기 중인 예약을 확인하고, 취소 전에 소유권을 검증하고, 작은 완료 기록만 저장했습니다.

또한 SDK 추상화를 Durable Object 알람 생명 주기에 연결하고, 로컬과 원격에서 완료 및 취소를 확인했으며, 개인정보가 제한된 증거를 검토했습니다. 마지막으로 클래스 네임스페이스, Worker 및 일회성 VM 인증을 명시적으로 삭제했습니다.