검증된 지원 도구 추가

CloudflareBeginner
지금 연습하기

소개

언어 모델은 무엇을 해야 하는지 제안할 수 있지만, 도구를 사용하면 특정 서버 측 작업을 요청할 수 있습니다. 이 경계는 일반적인 채팅보다 더 신중하게 다뤄야 합니다. 모델이 생성한 인수는 신뢰할 수 없는 입력이며, 형식이 올바른 요청이라도 잘못된 지원 큐를 대상으로 하거나 최신 작업을 덮어쓸 수 있습니다.

이 실습에서는 하나의 AIChatAgent에 의도적으로 작은 도구 두 개를 추가합니다.

  1. lookupSupportCase는 이름이 지정된 Agent의 자체 SQLite 저장소에서 합성 케이스 하나를 읽습니다.
  2. setSupportPriority는 해당 합성 레코드만 변경합니다.
  3. Zod 스키마는 어느 작업이 실행되기 전에 잘못된 인수를 거부합니다.
  4. 서버 측 검사는 Agent 이름, 티켓 식별자 및 예상 리비전을 적용합니다.
  5. 제한된 Workers AI 요청은 도구를 호출할 수 있으며, 독립적인 프로브는 동일한 작업을 결정적으로 검증합니다.

쓰기 가능한 레코드는 합성 데이터이며 언제든 삭제할 수 있습니다. 실제 헬프데스크 시스템에는 연결되지 않습니다. 스키마 검증은 “입력 형식이 올바른가?”에 답하지만, 권한 및 범위 검사는 “이 Agent가 해당 레코드를 변경해도 되는가?”에 답하기 때문에 이 구분이 중요합니다. 다음 실습에서는 효과를 실행하기 전에 별도의 사람 승인 경계를 추가합니다.

제공된 React 페이지와 짧은 수명의 세션 토큰을 사용하므로 프런트엔드나 인증 보일러플레이트가 아니라 도구 설계에 집중할 수 있습니다. Workers AI 무료 할당량은 계정의 다른 활동과 공유됩니다. 계정에 남은 할당량이 없다면 유료 요금제를 활성화하지 말고 중지합니다.

이 과정을 직접 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료합니다. 새로 생성된 모든 LabEx VM은 자체 Wrangler 인증이 필요합니다. 이전 과정의 실습을 완료하는 것이 좋지만, 해당 VM과 리소스는 여기에서 재사용되지 않습니다.

VM 인증 및 도구 Worker 선언

이 단계에서는 새 VM을 인증하고 도구를 사용할 Agent가 사용하는 리소스를 선언합니다.

터미널을 열고 준비된 프로젝트로 이동합니다.

cd /home/labex/project/validated-support-tools

이 VM을 인증합니다.

npx wrangler login

표시된 링크를 열고 전용 학습 계정에 대해 안내된 Wrangler 권한을 승인한 다음 터미널로 돌아옵니다. 구조화된 결과를 확인합니다.

npx wrangler whoami --json

"loggedIn": true를 확인하고 계정 이름을 확인한 뒤, 해당 계정의 실제 ID를 복사합니다. 고유한 일회용 Worker 이름과 함께 저장합니다.

ACCOUNT_ID="paste-your-confirmed-account-id"
RUN="labex-c11-s05-$(openssl rand -hex 6)"
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 },
  "ai": { "binding": "AI", "remote": true },
  "durable_objects": {
    "bindings": [
      { "name": "SupportToolsAgent", "class_name": "SupportToolsAgent" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportToolsAgent"] }
  ]
}
JSON
python3 .labex/verify.py authorization

AI 바인딩은 API 키를 코드에 넣지 않고 모델 추론을 제공합니다. Durable Object 바인딩을 사용하면 이름이 지정된 각 SupportToolsAgent가 자체 SQLite 저장소를 갖습니다. 브라우저에서는 planning이라는 이름을 사용합니다. 다른 이름을 사용하면 별도의 인스턴스가 생성되므로 planning의 데이터를 볼 수 없습니다. 아직 배포된 것은 없습니다.

도구 계약 정의

이 단계에서는 각 도구가 허용하는 인수를 정확히 정의합니다.

도구 스키마는 런타임 계약입니다. TypeScript 타입은 컴파일할 때 도움을 주지만, 모델 출력은 런타임에 도착하므로 다시 검사해야 합니다. src/cases.ts를 만듭니다.

cat > src/cases.ts <<'TS'
import { z } from "zod";

const queue = z.string()
  .min(3)
  .max(40)
  .regex(/^[a-z0-9-]+$/, "queue must use lowercase letters, digits or hyphens");

export const lookupCaseInput = z.object({
  queue,
  ticketId: z.literal("T-SYNTH-101")
}).strict();

export const updatePriorityInput = lookupCaseInput.extend({
  priority: z.enum(["low", "medium", "high"]),
  expectedRevision: z.number().int().nonnegative()
}).strict();

export type LookupCaseInput = z.infer<typeof lookupCaseInput>;
export type UpdatePriorityInput = z.infer<typeof updatePriorityInput>;
export type SupportCase = {
  queue: string;
  ticketId: "T-SYNTH-101";
  summary: string;
  priority: "low" | "medium" | "high";
  revision: number;
};

export function parseInput<T>(schema: z.ZodType<T>, input: unknown): T {
  const result = schema.safeParse(input);
  if (!result.success) {
    const issue = result.error.issues[0];
    throw new Error(`invalid tool input: ${issue.path.join(".") || "request"} ${issue.message}`);
  }
  return result.data;
}
TS
python3 .labex/verify.py schemas

읽기 계약은 올바른 큐 이름과 하나의 합성 티켓만 허용합니다. 업데이트 계약에는 우선순위 열거형과 음수가 아닌 정수 리비전이 추가됩니다. .strict()는 예상하지 않은 필드도 거부하므로 모호성을 줄이고 호출자가 지원되지 않는 지시를 작업에 몰래 넣지 못하게 합니다.

expectedRevision낙관적 동시성 검사입니다. 호출자는 자신이 확인한 버전을 전달하고, 다른 작업이 이미 해당 버전을 변경했다면 서버는 업데이트를 거부합니다. 검증만으로 접근 권한이 부여되는 것은 아닙니다. Agent는 별도로 queue를 자체 영속 이름과 비교합니다.

범위가 적용된 서버 측 도구 구현

이 단계에서는 두 스키마를 하나의 Agent 전용 레코드에 연결하고, 동일한 구현을 모델과 결정적 검증기에서 사용하도록 공개합니다.

src/server.ts를 만듭니다.

cat > src/server.ts <<'TS'
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import { callable, routeAgentRequest } from "agents";
import { convertToModelMessages, stepCountIs, streamText, tool } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import {
  lookupCaseInput,
  parseInput,
  type LookupCaseInput,
  type SupportCase,
  type UpdatePriorityInput,
  updatePriorityInput
} from "./cases";
import { verifySessionRequest } from "./session-auth";

export class SupportToolsAgent extends AIChatAgent<Cloudflare.Env> {
  maxPersistedMessages = 12;

  private ensureCase(): void {
    this.sql`CREATE TABLE IF NOT EXISTS support_cases (
      ticket_id TEXT PRIMARY KEY,
      queue TEXT NOT NULL,
      case_summary TEXT NOT NULL,
      priority TEXT NOT NULL,
      revision INTEGER NOT NULL
    )`;
    this.sql`INSERT OR IGNORE INTO support_cases
      (ticket_id, queue, case_summary, priority, revision)
      VALUES ('T-SYNTH-101', ${this.name}, 'Synthetic customer cannot open a sample invoice', 'medium', 0)`;
  }

  private scopedCase(input: LookupCaseInput): SupportCase {
    if (input.queue !== this.name) throw new Error("queue is outside this Agent scope");
    this.ensureCase();
    const rows = this.sql<{
      queue: string;
      ticketId: "T-SYNTH-101";
      summary: string;
      priority: "low" | "medium" | "high";
      revision: number;
    }>`SELECT queue, ticket_id AS ticketId, case_summary AS summary, priority, revision
       FROM support_cases WHERE ticket_id = ${input.ticketId}`;
    const record = rows[0];
    if (!record || record.queue !== this.name) throw new Error("case not found in this Agent scope");
    return record;
  }

  @callable()
  inspectCase(input: unknown): SupportCase {
    return this.scopedCase(parseInput(lookupCaseInput, input));
  }

  @callable()
  setPriority(input: unknown): SupportCase {
    const parsed: UpdatePriorityInput = parseInput(updatePriorityInput, input);
    const current = this.scopedCase(parsed);
    if (parsed.expectedRevision !== current.revision) {
      throw new Error(`revision conflict: current revision is ${current.revision}`);
    }
    this.sql`UPDATE support_cases
      SET priority = ${parsed.priority}, revision = ${current.revision + 1}
      WHERE ticket_id = ${parsed.ticketId} AND queue = ${this.name}`;
    const changed = this.scopedCase(parsed);
    console.log(JSON.stringify({
      event: "tool_event",
      tool: "setSupportPriority",
      instance: this.name,
      revision: changed.revision
    }));
    return changed;
  }

  async onChatMessage(_onFinish: unknown, options?: OnChatMessageOptions) {
    const tools = {
      lookupSupportCase: tool({
        description: "Read synthetic ticket T-SYNTH-101 only from the current named support queue.",
        inputSchema: lookupCaseInput,
        execute: async (input) => this.inspectCase(input)
      }),
      setSupportPriority: tool({
        description: "Set low, medium or high priority on synthetic ticket T-SYNTH-101 in the current queue, using its observed revision.",
        inputSchema: updatePriorityInput,
        execute: async (input) => this.setPriority(input)
      })
    };
    const workersai = createWorkersAI({ binding: this.env.AI });
    const result = streamText({
      model: workersai("@cf/zai-org/glm-4.7-flash", {
        reasoning_effort: null,
        chat_template_kwargs: { enable_thinking: false }
      }),
      system: `You assist only the synthetic ${this.name} queue. Use tools for case facts or changes. Never invent tool results, other queues or credentials. Keep the final answer to one short sentence.`,
      messages: await convertToModelMessages(this.messages),
      tools,
      stopWhen: stepCountIs(4),
      maxOutputTokens: 96,
      temperature: 0,
      abortSignal: options?.abortSignal
    });
    return result.toUIMessageStreamResponse();
  }
}

export default {
  async fetch(request: Request, env: Cloudflare.Env): Promise<Response> {
    const authorize = (candidate: Request, route: { name: string }) =>
      verifySessionRequest(candidate, route.name, env.SESSION_SIGNING_KEY);
    return (await routeAgentRequest(request, env, {
      onBeforeConnect: authorize,
      onBeforeRequest: authorize
    })) ?? new Response("Not found", { status: 404 });
  }
};
TS
python3 .labex/verify.py server

모델은 데이터베이스에 직접 접근하지 않습니다. 모델은 타입이 지정된 인수를 제안하고, execute는 Durable Object 내부의 코드를 호출합니다. 이 코드에서 서버가 현재 Agent 이름을 다시 확인합니다. 두 @callable() 메서드는 동일한 코드 경로를 재사용하므로, 검증기는 모델의 비결정적인 선택에 의존하지 않고 잘못된 요청, 범위를 벗어난 요청 및 오래된 요청을 테스트할 수 있습니다.

데이터베이스는 이름이 지정된 각 Agent 내부에서 지연 생성됩니다. INSERT OR IGNORE는 이전 업데이트를 덮어쓰지 않고 제한된 테스트 데이터를 하나 제공합니다. 로그에는 도구 이름, Agent 인스턴스 및 리비전 같은 메타데이터만 기록하며, 케이스 본문은 기록하지 않습니다.

도구를 인식하는 채팅 페이지 연결

이 단계에서는 제공된 페이지 셸을 연결하고 도구 활동을 어시스턴트 텍스트와 별도로 표시합니다.

TypeScript 및 Vite 구성을 만듭니다.

cat > tsconfig.json <<'JSON'
{
  "extends": "agents/tsconfig",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "types": ["@cloudflare/workers-types", "vite/client", "node"]
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "vite.config.ts", "worker-configuration.d.ts"]
}
JSON

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

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

src/client.tsx를 만듭니다.

cat > src/client.tsx <<'TSX'
import { useAgentChat } from "@cloudflare/ai-chat/react";
import { useAgent } from "agents/react";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";

function ToolsChat() {
  const parameters = new URLSearchParams(window.location.search);
  const session = parameters.get("session") ?? "";
  const token = parameters.get("token") ?? "";
  if (!session || !token) {
    return <main><h1>Signed session required</h1><p className="help">Open the complete URL printed by the token command.</p></main>;
  }

  const agent = useAgent({
    agent: "SupportToolsAgent",
    name: session,
    host: window.location.host,
    query: { token }
  });
  const { messages, sendMessage, status, error } = useAgentChat({ agent });

  return (
    <main>
      <p className="eyebrow">Validated server-side tools</p>
      <h1>Synthetic Support Console</h1>
      <p className="scope">Allowed queue: <strong>{session}</strong> · allowed ticket: <strong>T-SYNTH-101</strong></p>
      <p className="status">Status: <strong>{status}</strong></p>
      <section className="messages" aria-live="polite">
        {messages.length === 0 && <p className="empty">No tool requests in this signed session yet.</p>}
        {messages.map((message) => (
          <article className={`message ${message.role}`} key={message.id}>
            <span className="role">{message.role}</span>
            {message.parts.map((part, index) => {
              if (part.type === "text") return <span key={index}>{part.text}</span>;
              if (part.type.startsWith("tool-")) {
                return <span className="tool" key={index}>{part.type.replace("tool-", "tool: ")}</span>;
              }
              return null;
            })}
          </article>
        ))}
      </section>
      <form => {
        event.preventDefault();
        const input = event.currentTarget.elements.namedItem("message") as HTMLInputElement;
        const text = input.value.trim();
        if (!text) return;
        sendMessage({ text });
        input.value = "";
      }}>
        <input name="message" defaultValue={`Look up T-SYNTH-101 in ${session}, then set its priority to high using the current revision. Briefly confirm the result.`} maxLength={220} aria-label="Tool request" />
        <button type="submit" disabled={status === "streaming" || status === "submitted"}>Send</button>
      </form>
      <p className="notice">Training fixture only: this page cannot reach a real support system.</p>
      {error && <p className="error" role="alert">{error.message}</p>}
    </main>
  );
}

createRoot(document.getElementById("root")!).render(
  <Suspense fallback={<main><p>Restoring the signed tool session…</p></main>}><ToolsChat /></Suspense>
);
TSX
python3 .labex/verify.py client

useAgent()는 단기 토큰을 사용해 정확히 하나의 이름이 지정된 Agent에 연결합니다. useAgentChat()은 영속 대화와 스트리밍 응답을 표시합니다. 도구 파트는 어시스턴트 문장에 합쳐지지 않고 활동으로 표시됩니다. 이를 통해 학습자는 “모델이 작업을 요청함”과 “모델이 텍스트를 작성함”을 구분할 수 있습니다. 브라우저에서도 서버 측 검증을 우회할 수는 없습니다.

로컬에서 빌드하고 경계 검증

이 단계에서는 애플리케이션을 컴파일하고 모델 호출 없이 실제 도구 구현을 실행합니다.

정확한 환경 타입을 생성하고, 타입 검사와 두 번들 빌드를 실행합니다.

npx wrangler types
npm run check
npm run build
python3 .labex/verify.py build

Wrangler는 실제 바인딩을 기준으로 Cloudflare.Env를 생성합니다. 따라서 직접 작성한 환경 인터페이스가 wrangler.jsonc와 달라지는 문제를 방지할 수 있습니다.

Workers AI는 원격 바인딩이므로 로컬 런타임에는 Wrangler가 이미 저장한 OAuth 액세스가 필요합니다. OAuth 값을 자식 프로세스에만 전달한 뒤 셸 변수의 복사본을 즉시 삭제합니다.

DEV_PROXY_TOKEN="$(npx wrangler auth token --json | node -e 'let data="";process.stdin.on("data",chunk=>data+=chunk).on("end",()=>process.stdout.write(JSON.parse(data).token))')"
CLOUDFLARE_API_TOKEN="$DEV_PROXY_TOKEN" CI=true npm run dev > .labex/dev.log 2>&1 < /dev/null &
echo $! > .labex/dev.pid
unset DEV_PROXY_TOKEN
for attempt in $(seq 1 40); do
  curl --silent --fail http://127.0.0.1:5173/ > /dev/null && break
  sleep 1
done
tail -n 12 .labex/dev.log
python3 .labex/verify.py local

임시 OAuth 값을 출력하거나 .dev.vars에 저장하지 않습니다. 독립 프로브는 임의의 이름이 지정된 Agent를 사용하고, 모델 도구가 사용하는 동일한 inspectCase()setPriority() 메서드를 호출합니다. 다음을 검증합니다.

  • 초기 우선순위가 리비전 0에서 medium인지 확인합니다.
  • 잘못된 요청과 다른 큐에 대한 읽기가 실패하는지 확인합니다.
  • 유효한 업데이트 하나가 리비전 1에서 high가 되는지 확인합니다.
  • 리비전 0을 다시 사용한 요청이 실패하는지 확인합니다.
  • 다른 이름의 Agent가 격리된 리비전 0 레코드를 유지하는지 확인합니다.

이 결정적 테스트는 작업이 안전한지 확인합니다. 모델 선택은 확률적이므로 배포 후 별도로 확인합니다.

배포하고 제한된 도구 요청 관찰

이 단계에서는 배포한 뒤 Cloudflare를 대상으로 경계를 다시 검증하고, 제한된 실시간 모델 요청 하나를 관찰합니다.

프로덕션 번들을 배포하고 생성된 서명 키를 시크릿으로 업로드합니다.

npm run deploy
npx wrangler secret bulk .dev.vars

시크릿 명령은 값을 구성이나 번들에 넣지 않고 전송합니다. .dev.vars를 출력하지 않습니다.

배포 결과에 표시된 정확한 origin을 저장한 뒤 planning용 10분 토큰을 생성합니다.

WORKER_URL="https://paste-the-workers-dev-origin-printed-by-deploy"
TOKEN="$(node scripts/create-session-token.mjs planning)"
printf '%s/?session=planning&token=%s\n' "${WORKER_URL%/}" "$TOKEN"

LabEx 브라우저에서 전체 URL을 엽니다. 준비된 요청을 전송합니다. 상태는 submittedstreaming을 차례로 거치며, 도구 배지는 모델이 읽기와 업데이트를 요청했음을 보여 줍니다. 마지막 문장은 우선순위가 high가 되었고 새 리비전이 생성되었음을 확인합니다.

검증된 읽기 및 업데이트 도구가 실행된 후의 서명된 planning 세션

정확한 문구는 모델이 생성하므로 달라질 수 있습니다. 큐, 티켓 및 레코드는 모두 합성 예시입니다. 성공 문장은 UI가 정상 작동한다는 증거로 유용하지만, 권한과 안전성에 대한 최종 검증 기준은 아닙니다.

두 번째 요청을 전송합니다. Set T-SYNTH-101 to low using expected revision 0. 오래된 리비전은 리비전 1을 조용히 덮어쓰면 안 됩니다. 대신 도구 활동에 충돌이 표시되어야 합니다.

서버 측 도구 경계에서 거부된 오래된 리비전

새 이름을 사용해 독립적인 클라우드 프로브를 실행합니다. 이 프로브는 추가 모델 호출을 사용하지 않습니다.

python3 .labex/verify.py deployed

프로브는 실제로 배포된 바인딩과 네임스페이스를 확인한 다음, 스키마 거부, 범위 거부, 성공적인 리비전 변경 하나, 오래된 리비전 재시도 거부 및 이름이 지정된 Agent의 격리를 원격 Worker에서 다시 검증합니다.

도구 리소스 확인 및 제거

이 단계에서는 런타임 동작을 Cloudflare의 리소스 화면과 연결한 다음, 이 실습의 리소스만 삭제합니다.

Cloudflare Dashboard에서 Workers & Pages를 열고 정확한 labex-c11-s05-... Worker를 선택한 뒤 Bindings를 확인합니다. AI Workers AI 바인딩과 SupportToolsAgent Durable Object 바인딩이 표시되어야 합니다. 그런 다음 Settings > Variables and Secrets를 열어 SESSION_SIGNING_KEY가 일반 텍스트가 아닌 암호화된 시크릿으로 저장되어 있는지 확인합니다.

AI 및 SupportToolsAgent 바인딩이 있는 배포된 Worker

Durable Objects를 열고 이 Worker가 소유한 SQL 기반 네임스페이스를 선택합니다. planning과 검증기 이름은 하나의 클래스 네임스페이스 안에 있는 서로 다른 객체 인스턴스입니다.

SQL 기반 SupportToolsAgent 네임스페이스

Worker의 로그 또는 observability 화면을 열고 tool_event를 찾습니다. 구조화된 항목에는 도구 이름, Agent 인스턴스 및 리비전이 포함되지만 합성 케이스 요약이나 채팅 텍스트는 포함되지 않습니다.

Cloudflare 로그에 기록된 제한된 업데이트 도구 이벤트

확인한 뒤 명시적인 클래스 삭제 마이그레이션을 만들고 정확한 Worker를 제거합니다.

python3 - <<'PY'
import json
from pathlib import Path
path = Path('wrangler.jsonc')
data = json.loads(path.read_text())
data.pop('durable_objects', None)
data['migrations'].append({'tag': 'v2', 'deleted_classes': ['SupportToolsAgent']})
Path('wrangler.cleanup.jsonc').write_text(json.dumps(data, indent=2) + '\n')
PY
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force

일회용 Worker가 사라졌는지 확인합니다.

제거된 일회용 validated-tools Worker

그런 다음 SupportToolsAgent 네임스페이스도 사라졌는지 확인합니다.

제거된 일회용 SupportToolsAgent 네임스페이스

이 VM이 아직 인증된 상태에서 두 리소스가 모두 없는지 검증합니다.

python3 .labex/verify.py deleted

Worker만 삭제하면 상태를 보유하는 클래스의 수명 주기가 불명확해집니다. 마이그레이션 v2는 이 실습의 네임스페이스와 합성 레코드를 명시적으로 제거한 후 Worker 삭제를 검증합니다.

이 VM의 인증 취소

이 단계에서는 클라우드 정리가 완료된 것을 확인한 뒤 임시 VM 인증을 취소합니다.

npx wrangler logout
npx wrangler whoami --json || true

구조화된 결과에 "loggedIn": false가 표시되어야 합니다. 또는 Wrangler가 인증되지 않은 상태를 나타내는 0이 아닌 종료 코드를 반환할 수 있습니다. 로그아웃은 의도적으로 마지막에 수행합니다. 삭제 검증기에는 유효한 읽기 권한이 필요하지만, 폐기할 VM에는 더 이상 권한이 필요하지 않습니다.

요약

Cloudflare AIChatAgent에 제한된 서버 측 도구 두 개를 추가했습니다. 다음 작업을 수행했습니다.

  • 읽기와 합성 업데이트를 위한 엄격한 Zod 계약을 정의했습니다.
  • 서버에서 이름이 지정된 Agent의 범위를 적용해 인증을 별도로 유지했습니다.
  • 잘못된 입력, 다른 큐에 대한 접근 및 오래된 리비전을 거부했습니다.
  • 모델 도구와 결정적인 callable 프로브가 동일한 구현을 재사용하도록 했습니다.
  • 제한된 Workers AI 도구 요청 하나와 개인정보를 제한한 로그를 확인했습니다.
  • 로그아웃하기 전에 정확한 SQLite 클래스 네임스페이스와 Worker를 삭제했습니다.

이러한 제어를 사용하면 직접 수행하는 합성 업데이트를 작고 테스트 가능하게 만들 수 있지만, 효과를 승인할 사람을 요청하지는 않습니다. 다음 실습에서는 승인 경계를 추가하고 승인, 거부 및 중복 전달을 명시적으로 처리합니다.