지원 대시보드 동기화

CloudflareBeginner
지금 연습하기

소개

영속 Agent는 지원 큐를 기억할 수 있지만, 실제로 유용한 대시보드는 연결된 모든 화면을 최신 상태로 유지해야 합니다. 폴링은 서버에 새 사본을 계속 요청합니다. 반면 Cloudflare Agents SDK는 WebSocket을 엽니다. WebSocket은 오래 유지되는 양방향 연결로, 상태가 변경되는 즉시 같은 이름의 Agent에 연결된 모든 클라이언트로 업데이트를 전달할 수 있습니다.

이 실습에서는 의도적으로 작게 만든 비-LLM 대시보드를 구축합니다. 서로 독립적인 두 개의 기본 JavaScript 클라이언트인 DispatcherObserverSupportDashboard:planning에 연결됩니다. Dispatcher는 @callable()로 표시된 서버 메서드를 호출합니다. 이 메서드는 티켓을 검증하고 Agent 상태를 한 번 업데이트하며, SDK는 그 결과 상태를 두 클라이언트에 브로드캐스트합니다. 잘못된 제목은 서버에서 거부되며 공유 revision은 증가하지 않습니다.

이 실습에서는 애플리케이션에 필요한 다음 네 가지 요소만 소개합니다.

  1. AgentClient는 브라우저의 WebSocket 연결을 유지합니다.
  2. onStateUpdate는 서버가 상태를 브로드캐스트한 후 화면을 다시 그립니다.
  3. @callable()은 연결된 클라이언트에 특정 서버 메서드를 노출합니다.
  4. setState()는 권위 있는 다음 상태 하나를 저장하고 동기화를 시작합니다.

예제에서는 합성된 지원 텍스트와 공개 일회용 Worker를 사용하므로 프로토콜에 집중할 수 있습니다. 입력 검증은 사용자 인증이 아닙니다. 실제 운영 환경의 지원 도구에서 고객 데이터나 변경 작업을 노출하려면 먼저 신원 확인과 권한 부여 계층을 추가해야 합니다.

이 과정을 직접 시작하기 전에 Connect LabEx to Your Cloudflare Account를 완료합니다. 새 LabEx VM마다 자체 Wrangler 인증이 필요합니다. 이 실습은 이름이 지정된 Agent 식별자, 영속 상태 및 명시적 정리를 기반으로 하므로 S01을 권장하지만 React나 AI 모델 지식은 필요하지 않습니다.

VM 인증 및 대시보드 구성

이 단계에서는 새 VM을 인증하고, 사용할 Cloudflare 계정을 확인하며, 대시보드가 사용할 하나의 Agent 네임스페이스를 선언합니다.

준비된 프로젝트로 이동하고 고정된 런타임 버전을 확인합니다. 설정 과정에서 의존성과 시각적 페이지 뼈대만 제공했으며, Cloudflare 인증이나 Agent 구현은 완료하지 않았습니다.

cd /home/labex/project/support-dashboard-agent
node --version
npx wrangler --version
npm list agents vite @cloudflare/vite-plugin --depth=0

Node.js는 v22.22.0, Wrangler는 4.134.0, Agents SDK는 0.23.0, Vite는 8.3.0, Cloudflare Vite 플러그인은 1.55.0이어야 합니다.

이 VM을 인증하고 구조화된 계정 정보를 확인합니다.

npx wrangler login --device --browser=false
npx wrangler whoami --json

브라우저에서 출력된 링크를 열고 짧은 코드를 입력합니다. 전용 학습 계정을 선택했는지 확인한 다음, 인증하기 전에 권한을 검토합니다. 터미널로 돌아와 loggedIn: true인지 확인한 후, ID를 출력하지 않고 확인한 표시 이름으로 계정을 선택합니다.

WHOAMI="$(npx wrangler whoami --json)"
printf '%s\n' "$WHOAMI" | jq '{loggedIn, authType, accounts: [.accounts[] | {name}]}'
ACCOUNT_ID="$(printf '%s\n' "$WHOAMI" | jq -r '.accounts[] | select(.name == "LabEx Learning") | .id')"
test -n "$ACCOUNT_ID"
RUN="labex-c11-s02-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"

학습 계정의 이름이 다르면 사용할 계정을 확인한 후 LabEx Learning 부분만 바꿉니다. 이제 구성을 생성합니다.

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": "SupportDashboard", "class_name": "SupportDashboard" }
    ]
  },
  "migrations": [
    { "tag": "v1", "new_sqlite_classes": ["SupportDashboard"] }
  ]
}
JSON

바인딩은 Agent 클래스 네임스페이스를 선택합니다. 인스턴스 이름은 각 브라우저 클라이언트가 제공합니다. 구성 파일만으로는 클라우드 리소스가 생성되지 않습니다.

검증된 Callable 메서드 구현

이 단계에서는 공유 큐 상태와 브라우저에서 호출할 수 있는 유일한 변경 메서드를 구현합니다.

변경 규칙은 서버가 소유합니다. 브라우저가 업데이트를 요청할 수는 있지만 제목이나 priority의 유효성을 판단해서는 안 됩니다. src/server.ts를 생성합니다.

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

type Priority = "normal" | "urgent";
type Ticket = {
  id: number;
  title: string;
  priority: Priority;
};

export type DashboardState = {
  tickets: Ticket[];
  revision: number;
  lastUpdatedBy: string;
};

interface Env {
  SupportDashboard: DurableObjectNamespace<SupportDashboard>;
}

export class SupportDashboard extends Agent<Env, DashboardState> {
  initialState: DashboardState = {
    tickets: [],
    revision: 0,
    lastUpdatedBy: "system"
  };

  @callable()
  addTicket(titleInput: string, priorityInput: string): DashboardState {
    const title = typeof titleInput === "string" ? titleInput.trim() : "";
    if (title.length < 3 || title.length > 80) {
      throw new Error("title must contain 3-80 characters");
    }
    if (priorityInput !== "normal" && priorityInput !== "urgent") {
      throw new Error("priority must be normal or urgent");
    }
    const priority: Priority = priorityInput;
    const next: DashboardState = {
      tickets: [
        ...this.state.tickets,
        { id: this.state.revision + 1, title, priority }
      ].slice(-6),
      revision: this.state.revision + 1,
      lastUpdatedBy: "dispatcher"
    };
    this.setState(next);
    console.log(JSON.stringify({
      event: "support_queue_updated",
      instance: this.name,
      revision: next.revision,
      ticketCount: next.tickets.length
    }));
    return next;
  }
}

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

@callable()은 명시적인 RPC 경계입니다. 이 데코레이터가 적용된 메서드만 Agent 클라이언트 프로토콜을 통해 호출할 수 있습니다. setState()를 호출하기 전에 검증하므로 거부된 호출은 revision을 증가시키지 않습니다. 최신 합성 티켓 6개만 유지하면 데모 상태의 크기를 제한할 수 있습니다. 구조화된 로그에는 인스턴스, revision 및 개수가 포함되지만 티켓 텍스트는 포함되지 않습니다.

두 개의 기본 브라우저 클라이언트 연결

이 단계에서는 현재 데코레이터 빌드 경로를 구성하고, 서로 독립적인 두 클라이언트를 하나의 이름이 지정된 Agent에 연결합니다.

현재 SDK 데코레이터는 JavaScript 표준 데코레이터 변환을 사용합니다. 따라서 수동으로 구성한 프로젝트에는 Agents TypeScript 프리셋과 Agents Vite 플러그인이 모두 필요합니다. TypeScript의 기존 experimentalDecorators 모드는 활성화하지 않습니다.

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

src/client.ts를 생성합니다.

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

function required<T>(selector: string): T {
  const element = document.querySelector(selector);
  if (!element) throw new Error(`Missing page element: ${selector}`);
  return element as unknown as T;
}

const dispatcherView = required<HTMLDivElement>("#dispatcher");
const observerView = required<HTMLDivElement>("#observer");
const statusView = required<HTMLParagraphElement>("#status");
const errorView = required<HTMLParagraphElement>("#error");
const titleInput = required<HTMLInputElement>("#title");
const priorityInput = required<HTMLSelectElement>("#priority");
const form = required<HTMLFormElement>("#ticket-form");

function render(target: HTMLDivElement, state: DashboardState | undefined) {
  if (!state) {
    target.innerHTML = '<p class="empty">Waiting for initial state…</p>';
    return;
  }
  const tickets = state.tickets.map((ticket) =>
    `<div class="ticket ${ticket.priority}"><strong>#${ticket.id}</strong> ${ticket.title}<br><small>${ticket.priority}</small></div>`
  ).join("");
  target.innerHTML = `<span class="revision">Revision ${state.revision}</span>${tickets || '<p class="empty">No tickets yet</p>'}`;
}

const shared = {
  agent: "SupportDashboard",
  name: "planning",
  host: window.location.host
};

const dispatcher = new AgentClient<DashboardState>({
  ...shared,
  onStateUpdate: (state) => render(dispatcherView, state)
});
const observer = new AgentClient<DashboardState>({
  ...shared,
  onStateUpdate: (state) => render(observerView, state)
});

Promise.all([dispatcher.ready, observer.ready]).then(() => {
  render(dispatcherView, dispatcher.state);
  render(observerView, observer.state);
  statusView.textContent = "Both clients are connected to SupportDashboard:planning";
});

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  errorView.textContent = "";
  try {
    await dispatcher.call("addTicket", [titleInput.value, priorityInput.value]);
  } catch (cause) {
    errorView.textContent = cause instanceof Error ? cause.message : String(cause);
  }
});
TS

화면에는 하나의 페이지로 보이지만 실제로는 두 개의 WebSocket 클라이언트입니다. 두 클라이언트 모두 같은 클래스와 이름으로 라우팅되므로 동일한 상태 브로드캐스트를 받습니다. 호출을 수행하는 클라이언트는 Dispatcher뿐입니다. Observer는 동기화가 복사된 DOM 업데이트가 아니라 서버에 의해 수행된다는 점을 보여 줍니다.

타입 생성 및 양쪽 빌드

이 단계에서는 공유 상태 계약의 타입을 검사하고, 런타임을 시작하기 전에 Worker와 브라우저 애플리케이션을 빌드합니다.

정확한 바인딩 구성에서 환경 타입을 생성합니다.

npx wrangler types
grep -n "SupportDashboard" worker-configuration.d.ts | head

Worker, 브라우저 클라이언트 및 Vite 구성 전체에서 TypeScript를 실행합니다.

npm run check

컴파일러 진단이 출력되지 않으면 상태 구조, callable 서버 메서드 및 DOM 클라이언트의 정의가 서로 일치한다는 뜻입니다. 두 개의 프로덕션 대상 파일을 빌드합니다.

npm run build
find dist -maxdepth 3 -type f | sort | sed -n '1,16p'

Vite는 Worker 환경과 클라이언트 환경을 보고합니다. Cloudflare 플러그인은 Worker 번들을 생성하고 빌드된 정적 페이지를 연결합니다. Agents 플러그인은 현재 데코레이터 변환을 적용합니다. 빌드 성공은 패키징이 정상이라는 뜻이지, WebSocket 동작이나 계정 소유권 또는 원격 배포까지 증명하는 것은 아닙니다.

로컬 동기화 및 거부 동작 확인

이 단계에서는 유효한 업데이트 후 두 로컬 클라이언트가 같은 상태로 수렴하는 과정과, 잘못된 업데이트 후 상태가 변하지 않는 모습을 확인합니다.

로컬 Vite 및 Workers 런타임을 백그라운드 작업으로 시작합니다.

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

LabEx 데스크톱 내부 브라우저에서 http://localhost:5173을 엽니다. 녹색 상태 메시지에 두 클라이언트가 연결되었다고 표시될 때까지 기다립니다. 두 카드는 처음에 revision 0이고 티켓이 없습니다.

미리 입력된 제목을 그대로 두고 Add with Dispatcher를 클릭합니다. 두 카드의 revision이 1로 증가하고 같은 티켓이 표시되어야 합니다. 먼저 Dispatcher가 WebSocket을 통해 RPC 프레임을 보냅니다. Agent의 addTicket()이 인자를 검증한 다음 setState(next)가 revision 1을 저장하고 브로드캐스트합니다. 두 onStateUpdate 핸들러는 각각 자신의 카드를 다시 그립니다.

이제 제목을 x로 바꾸고 다시 제출합니다. 페이지에 title must contain 3-80 characters가 표시되고 두 카드 모두 revision 1을 유지해야 합니다. 이는 상태를 저장하기 전에 검증이 수행되었다는 증거입니다.

독립적인 로컬 검사를 실행합니다.

python3 .labex/verify.py local

검증기는 화면에 표시된 예시를 신뢰하지 않고 매번 새로운 실행 전용 이름을 사용합니다. 두 클라이언트를 열어 수렴을 확인하고, 다른 이름의 Agent는 revision 0으로 유지되는지 검사합니다. 또한 잘못된 업데이트를 보낸 후 공유 revision이 변경되지 않는지 확인합니다.

배포 및 Cloud 대시보드 확인

이 단계에서는 프로덕션 번들을 배포하고, Cloudflare에서 동일한 두 클라이언트 계약을 검증한 다음 그 동작을 대시보드의 증거와 연결합니다.

정확한 로컬 프로세스를 중지하고 프로덕션 빌드를 배포합니다.

kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npm run deploy

Wrangler는 v1 마이그레이션을 적용하고 Worker와 정적 클라이언트를 업로드한 다음 workers.dev URL을 출력합니다. 출력된 정확한 URL을 저장합니다.

WORKER_URL="https://YOUR_WORKER_URL"
for attempt in $(seq 1 30); do
  if curl --silent --fail "$WORKER_URL/" > /dev/null; then
    break
  fi
  sleep 2
done

내장 브라우저에서 이 URL을 엽니다. Cloud dashboard ticketUrgent로 추가합니다. 두 카드에 같은 revision과 빨간색 urgent 표시가 나타나야 합니다. 그런 다음 x를 제출합니다. 거부 메시지가 나타나고 두 카드의 revision은 그대로 유지되어야 합니다. 이는 새로운 클라우드 소유 Agent 인스턴스이며, 로컬 Vite 상태와는 의도적으로 분리되어 있습니다.

두 클라우드 클라이언트가 revision one에서 같은 urgent 티켓을 표시하는 모습

이 실제 테스트 실행에서는 Dispatcher가 변경 작업을 수행했고 Observer는 같은 브로드캐스트를 받았습니다. 티켓 텍스트와 revision은 일회용 과정 리소스에서 사용한 예시이므로 사용자의 값은 다를 수 있습니다.

짧은 제목이 거부되고 두 클라이언트가 revision one을 유지하는 모습

오류는 입력란 옆에 표시되지만 어느 카드의 revision도 증가하지 않습니다. 두 카드에 표시된 동일한 revision을 중요한 단서로 읽어야 합니다. 서버가 setState()를 호출하기 전에 인자를 거부했기 때문입니다.

Cloudflare Dashboard에서 Workers & Pages를 열고 정확한 labex-c11-s02-... Worker를 선택합니다. Bindings 탭에서 SupportDashboardSupportDashboard Durable Object 클래스에 연결되어 있는지 확인합니다. Durable Objects에서 해당 네임스페이스가 SQL 저장소를 사용하는지 확인합니다. 마지막으로 Observability → Logs를 열고 support_queue_updated로 필터링한 다음 이벤트 하나를 펼칩니다. 인스턴스 planning, revision 및 티켓 개수를 일치시켜 확인합니다. 티켓 제목은 의도적으로 포함되지 않습니다.

일회용 Worker, 도메인, 바인딩 및 오류 0건을 표시하는 Worker 개요

개요 화면에서는 지금까지 따로 사용한 여러 개념을 함께 보여 줍니다. workers.dev 도메인은 Worker에 연결되고, 바인딩은 Worker를 영속 상태에 연결하며, 오류 0건 카운터는 빠르게 상태를 확인할 수 있는 신호입니다. 이 스크린샷의 생성된 Worker 이름은 승인된 테스트 실행 중 하나에 해당합니다.

Worker와 SupportDashboard Durable Object의 연결을 보여 주는 Bindings 화면

바인딩 그래프에는 정확한 Worker가 SupportDashboard라는 Durable Object에 연결되어 있어야 합니다. 이는 구성에 대한 증거이며, 두 클라이언트의 실제 동작 확인을 대신하지 않습니다.

SQL 저장소를 사용하는 SupportDashboard 네임스페이스 개요

네임스페이스 페이지는 Agent 클래스 뒤에 있는 영속 저장소를 식별하고 Storage: SQL을 표시합니다. 개인 정보 보호를 위해 교육용 이미지에서는 불투명한 네임스페이스 ID를 숨겼습니다. 학습자가 이 ID를 복사할 필요는 없습니다.

필드가 제한된 구조화된 support_queue_updated 이벤트

펼친 이벤트에는 합성 인스턴스 이름, revision 및 티켓 개수가 포함되지만 티켓 제목은 포함되지 않습니다. 이는 의도적인 데이터 최소화입니다. 로그는 잠재적으로 민감한 사용자 콘텐츠를 복사하지 않고도 동작을 진단하는 데 도움이 되어야 합니다.

대시보드 데이터는 늦게 도착할 수 있으므로 최근 로그가 비어 있다는 사실만으로는 결론을 내릴 수 없습니다. 인증된 설정, 소유한 네임스페이스 및 독립적인 실시간 AgentClient 검사가 신뢰할 수 있는 근거입니다.

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

첫 번째 검사는 새 원격 이름을 생성하고 화면에 보이는 planning 예시를 신뢰하지 않은 채 동기화, 격리 및 거부 동작을 검증합니다. 두 번째 검사는 읽기 전용 대시보드 확인을 위해 정확히 소유한 리소스를 유지합니다.

대시보드 네임스페이스 및 Worker 삭제

이 단계에서는 Agent 클래스 네임스페이스를 명시적으로 삭제한 다음, VM이 아직 인증된 상태에서 남은 Worker를 삭제합니다.

큐는 Durable Object 클래스 네임스페이스에 저장되므로, 남아 있는 상태 비저장 Worker를 삭제하기 전에 해당 클래스를 명시적으로 삭제합니다. 정리용 진입점을 생성합니다.

cat > src/cleanup.ts <<'TS'
export default {
  fetch() {
    return Response.json({ status: "cleanup" }, { status: 410 });
  }
};
TS

기존 마이그레이션은 유지하고 삭제를 위한 v2를 추가합니다.

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": ["SupportDashboard"] },
    { "tag": "v2", "deleted_classes": ["SupportDashboard"] }
  ]
}
JSON
npx wrangler deploy --config wrangler.cleanup.jsonc
npx wrangler delete --config wrangler.cleanup.jsonc --force
python3 .labex/verify.py deleted

마이그레이션 기록은 추가만 가능합니다. Cloudflare에 이미 적용된 전환을 설명하려면 v1을 다시 작성해서는 안 됩니다. Dashboard에서 정확한 Worker와 해당 SupportDashboard 네임스페이스가 사라졌는지 확인합니다. 계정에 다른 리소스가 있다면 그대로 유지합니다.

일회용 Worker 삭제 후의 Workers and Pages 개요

테스트한 계정은 삭제 후 Workers & Pages 개요 화면으로 돌아갔습니다. 사용자의 학습 계정에는 관련 없는 Worker가 있을 수 있으므로 계정이 비어 있기를 기대하지 말고, 정확한 labex-c11-s02-... 이름이 사라졌는지 확인합니다.

SupportDashboard 네임스페이스 삭제 후의 Durable Objects 개요

승인된 테스트 계정 역시 삭제 후 빈 Durable Objects 개요 화면으로 돌아갔습니다. 다른 네임스페이스가 있는 계정에서는 해당 네임스페이스를 유지하고 이 실습이 소유한 네임스페이스만 사라졌는지 확인합니다.

이 VM의 인증 취소

이 단계에서는 이 일회용 VM에만 저장된 OAuth 인증을 제거하고, 로그아웃된 상태를 구조화된 형식으로 확인합니다.

클라우드 정리는 완료되었지만 이 일회용 VM에는 로컬 OAuth 권한 부여 정보가 아직 남아 있습니다. 이를 제거하고 구조화된 상태를 요청합니다.

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

JSON에 "loggedIn": false가 명시적으로 포함되어야 합니다. 네트워크 오류는 로그아웃의 증거가 아닙니다. 연결이 복구되면 상태 확인을 다시 실행합니다. 이제 공개 합성 대시보드, 해당 영속 상태 및 이 VM의 인증이 모두 제거되었습니다.

요약

React나 언어 모델을 도입하지 않고 하나의 영속 이름 지정 Agent를 실제 실시간 브라우저 애플리케이션으로 전환했습니다. 두 개의 AgentClient 연결은 SupportDashboard:planning을 선택했고, 검증된 @callable() 메서드가 변경 작업을 담당했으며, setState()가 권위 있는 revision 하나를 저장하고 SDK가 해당 상태를 두 onStateUpdate 핸들러로 브로드캐스트했습니다.

또한 현재 데코레이터 경로에 agents/tsconfigagents/vite가 모두 필요한 이유를 배웠고, WebSocket RPC와 클라이언트의 직접 상태 변경을 구분했습니다. 잘못된 입력이 아무런 영향을 주지 않는지 검증하고, Cloudflare에서 동기화와 이름 격리를 반복 확인했으며, 개인 정보가 제한된 증거를 검사했습니다. 마지막으로 클래스 네임스페이스, Worker 및 VM 인증을 명시적으로 삭제했습니다.