Cloudflare Agents SDK로 상태 있는 AI 에이전트 만들기

Cloudflare Agents SDK로 상태 있는 AI 에이전트 만들기

AI 채팅을 하나 만들었다고 해볼까요? 사용자가 목표를 알려주고 대화를 나누는 동안에는 제법 그럴듯하게 동작합니다. 그런데 브라우저를 닫았다가 다시 열면 이전 목표를 잊습니다. “30분 뒤에 다시 알려줘”라고 해도 그때까지 프로세스를 붙잡아 둘 방법이 마땅치 않고요. 여러 기기에서 같은 대화에 접속하면 상태가 서로 어긋나기도 합니다. 🤔

대형 언어 모델(large language model, 이하 LLM)을 호출하는 것만으로는 이런 문제를 해결할 수 없습니다. 모델 밖에서 사용자별 상태를 저장하고, 실시간 연결을 관리하고, 정해진 시각에 스스로 깨어나는 실행 환경이 필요합니다.

Cloudflare Agents SDK는 이 실행 환경을 Cloudflare Workers 위에 만들어 줍니다. 이번 글에서는 Agents SDK가 일반 Worker와 무엇이 다른지 살펴보고, 할 일을 기억하는 에이전트를 직접 만들어 보겠습니다. 상태 동기화부터 원격 프로시저 호출(remote procedure call, 이하 RPC), 예약 작업, React 연결까지 한 흐름으로 이어가 볼게요.

Cloudflare Agents SDK란?

Cloudflare Agents SDK는 상태를 가진 장기 실행 객체를 만드는 프레임워크입니다. 여기서 장기 실행이라고 해서 서버 프로세스가 몇 주 동안 계속 떠 있다는 뜻은 아닙니다. 요청이 없을 때는 잠들고 WebSocket 메시지나 HTTP 요청, 예약 시각이 도착하면 다시 깨어나 저장해 둔 상태에서 일을 이어갑니다.

이 구조의 바탕에는 Cloudflare Durable Objects가 있습니다. 에이전트 인스턴스마다 Durable Object 하나와 전용 SQLite 데이터베이스가 생기기 때문에, 코드가 절전(hibernation) 상태에 들어가거나 새 버전을 배포해도 상태는 남습니다.

일반 Worker, Durable Objects, Agents SDK의 역할을 나누면 다음과 같습니다.

도구잘하는 일직접 준비할 것
Workers무상태 HTTP 요청을 빠르게 처리상태 저장소, 실시간 연결, 예약 실행
Durable Objects인스턴스별 상태와 WebSocket 연결을 일관되게 관리상태 동기화 규약과 작업 추상화
Agents SDK에이전트의 상태, RPC, 예약, 큐, AI 대화를 통합에이전트의 행동과 인스턴스 경계 설계

먼저 짚고 갈 부분이 있어요. Agents SDK 자체가 LLM은 아닙니다. Agent 클래스는 상태와 실행 생명주기(lifecycle)를 맡고 모델 추론에는 Cloudflare Workers AI나 OpenAI, Anthropic 같은 공급자(provider)를 연결합니다. 모델이 에이전트의 두뇌라면 Agents SDK는 기억, 통신, 시간 감각과 몸을 담당하는 셈이에요.

새 프로젝트 시작하기

가장 빠른 출발점은 공식 스타터입니다. 이 스타터에는 Workers AI를 사용하는 스트리밍 채팅, React 화면, 도구 호출과 예약 작업 예제가 이미 들어 있습니다.

bunx create-cloudflare@latest -- --template cloudflare/agents-starter
cd agents-starter
bun install
bun run dev

브라우저에서 http://localhost:5173을 열면 로컬 에이전트와 대화할 수 있습니다. 생성된 프로젝트에서 먼저 볼 파일은 네 개입니다.

  • src/server.ts: 에이전트 클래스와 Worker 진입점이 들어 있습니다.
  • src/client.tsx: 에이전트에 연결하는 React 화면입니다.
  • wrangler.jsonc: Durable Object 바인딩(binding)과 SQLite 마이그레이션(migration)을 선언합니다.
  • vite.config.ts: @callable() 데코레이터(decorator)를 변환하는 Agents 플러그인을 설정합니다.

이미 Workers 프로젝트가 있다면 새로 만들 필요는 없습니다. agents 패키지를 추가하고 설정을 붙이면 됩니다.

bun add agents

이제 스타터의 채팅 예제 대신 구조를 이해하기 쉬운 할 일 에이전트를 만들어 보겠습니다.

에이전트의 경계부터 정하기

코드보다 먼저 정해야 할 것은 “에이전트 하나가 무엇을 나타내는가?”입니다. 사용자 한 명당 하나를 만들 수도 있고 프로젝트나 채팅방마다 하나를 둘 수도 있습니다. 같은 클래스라도 인스턴스 이름이 다르면 상태와 저장소가 완전히 분리됩니다.

예를 들어 TaskAgent 클래스에 daleteam-blog라는 이름을 붙이면 다음 두 주소는 서로 다른 에이전트를 가리킵니다.

/agents/task-agent/dale
/agents/task-agent/team-blog

클래스 이름 TaskAgent가 주소에서는 케밥 표기법(kebab-case)인 task-agent로 바뀐다는 점에 주의하세요. 인스턴스 이름은 단순한 라우팅 키(routing key)이지 권한 검사가 아닙니다. 다른 사용자가 이름을 추측하지 못하게 만드는 것으로 인증을 대신해서는 안 됩니다.

이번 예제에서는 사용자마다 TaskAgent 하나를 사용한다고 가정하겠습니다. 에이전트는 할 일 목록을 기억합니다. 브라우저가 요청하면 일정 시간이 지난 뒤 알림 상태도 갱신하고요.

src/server.ts
import { Agent, callable, routeAgentRequest } from "agents";

export type Task = {
  id: string;
  title: string;
  done: boolean;
};

export type TaskState = {
  tasks: Task[];
  lastReminder: string | null;
};

export class TaskAgent extends Agent<Env, TaskState> {
  initialState: TaskState = {
    tasks: [],
    lastReminder: null,
  };

  @callable()
  addTask(title: string) {
    const trimmedTitle = title.trim();

    if (!trimmedTitle) {
      throw new Error("할 일을 입력해 주세요.");
    }

    const task: Task = {
      id: crypto.randomUUID(),
      title: trimmedTitle,
      done: false,
    };

    this.setState({
      ...this.state,
      tasks: [...this.state.tasks, task],
    });

    return task;
  }

  @callable()
  completeTask(taskId: string) {
    this.setState({
      ...this.state,
      tasks: this.state.tasks.map((task) =>
        task.id === taskId ? { ...task, done: true } : task,
      ),
    });
  }

  @callable()
  async remindIn(taskId: string, seconds: number) {
    const task = this.state.tasks.find(({ id }) => id === taskId);

    if (!task) {
      throw new Error("할 일을 찾을 수 없습니다.");
    }

    await this.schedule(seconds, "markReminded", {
      taskId: task.id,
      title: task.title,
    });
  }

  async markReminded(payload: { taskId: string; title: string }) {
    this.setState({
      ...this.state,
      lastReminder: `${payload.title} 작업을 확인할 시간입니다.`,
    });
  }

  validateStateChange(nextState: TaskState) {
    if (nextState.tasks.length > 100) {
      throw new Error("할 일은 최대 100개까지 저장할 수 있습니다.");
    }
  }

  onStateChanged(state: TaskState) {
    console.log(`할 일 ${state.tasks.length}개를 저장했습니다.`);
  }
}

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

Agent<Env, TaskState>의 첫 번째 타입은 Worker 바인딩이고 두 번째 타입은 에이전트 상태입니다. 새 인스턴스가 처음 만들어지면 initialState가 초기값이 됩니다. 이후에는 this.state로 현재 값을 읽고 this.setState()로 새 상태를 저장합니다.

setState()는 세 가지 일을 한 번에 처리합니다. SQLite에 상태를 영속화(persistence)한 뒤 같은 인스턴스에 연결된 모든 클라이언트에 변경 내용을 보냅니다. 서버의 onStateChanged() 훅(hook)도 호출하죠. 클라이언트에서 상태를 바꿔도 같은 흐름을 거칩니다.

여기서 setState()에는 일부 필드가 아니라 완성된 다음 상태 전체를 넘겨야 합니다. 그래서 lastReminder를 유지하면서 tasks만 바꿀 때도 ...this.state가 필요합니다. 저장 전에 규칙을 검사하고 싶다면 validateStateChange()에서 예외를 던지면 됩니다.

브라우저에서 호출할 메서드에는 @callable()을 붙였습니다. 이 데코레이터가 붙은 메서드는 WebSocket을 통한 RPC로 공개됩니다. 반면 예약 시스템이 내부에서 부르는 markReminded()에는 붙일 필요가 없습니다.

Wrangler에 에이전트 등록하기

에이전트 클래스만 작성해도 Cloudflare는 어떤 Durable Object를 만들어야 하는지 알 수 없습니다. wrangler.jsonc에 바인딩과 SQLite 마이그레이션을 선언해야 합니다.

wrangler.jsonc
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "task-agent",
  "main": "src/server.ts",
  "compatibility_date": "2026-07-25",
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [
      {
        "name": "TaskAgent",
        "class_name": "TaskAgent",
      },
    ],
  },
  "migrations": [
    {
      "tag": "v1",
      "new_sqlite_classes": ["TaskAgent"],
    },
  ],
}

nameenv.TaskAgent처럼 Worker 코드에서 접근할 바인딩 이름이고 class_name은 내보낸 클래스 이름입니다. 철자와 대소문자가 다르면 런타임에서 네임스페이스를 찾지 못합니다. Worker 진입점에서 TaskAgent 클래스를 반드시 내보내야 하는 이유도 여기에 있습니다.

이미 배포한 프로젝트에 에이전트 클래스를 추가한다면 기존 v1 마이그레이션을 고치지 마세요. 배포된 이력을 보존한 채 새 태그를 추가해야 합니다.

{
  "tag": "v2",
  "new_sqlite_classes": ["AnotherAgent"],
}

@callable()을 사용하려면 tsconfig.json도 Agents SDK 설정을 확장해야 합니다.

tsconfig.json
{
  "extends": "agents/tsconfig"
}

experimentalDecoratorstrue로 켜면 안 됩니다. Agents SDK는 TypeScript의 옛 데코레이터 변환이 아니라 TC39 표준 데코레이터를 사용합니다. 이 옵션을 켜면 빌드는 통과해도 @callable()이 런타임에서 조용히 깨질 수 있습니다. Vite 프로젝트에서는 공식 스타터처럼 agents/vite 플러그인도 추가해야 합니다.

React에서 상태와 메서드 연결하기

서버가 준비됐으니 React 화면을 붙여보겠습니다. useAgent()는 이 인스턴스와 WebSocket 연결을 열어 상태 변경과 RPC 호출을 React 코드에 이어 줍니다.

src/client.tsx
import { useState } from "react";
import { useAgent } from "agents/react";
import type { TaskAgent, TaskState } from "./server";

const initialState: TaskState = {
  tasks: [],
  lastReminder: null,
};

export function App() {
  const [state, setState] = useState(initialState);
  const [title, setTitle] = useState("");

  const agent = useAgent<TaskAgent, TaskState>({
    agent: "TaskAgent",
    name: "dale",
    onStateUpdate: (nextState) => setState(nextState),
  });

  async function addTask() {
    await agent.stub.addTask(title);
    setTitle("");
  }

  return (
    <main>
      <h1>오늘의 할 일</h1>

      <input
        value={title}
        onChange={(event) => setTitle(event.target.value)}
        placeholder="할 일을 입력하세요"
      />
      <button onClick={addTask}>추가</button>

      <ul>
        {state.tasks.map((task) => (
          <li key={task.id}>
            <button onClick={() => agent.stub.completeTask(task.id)}>
              {task.done ? "완료" : "진행 중"}
            </button>
            {task.title}
            <button onClick={() => agent.stub.remindIn(task.id, 30)}>
              30초 뒤 알림
            </button>
          </li>
        ))}
      </ul>

      {state.lastReminder && <p>{state.lastReminder}</p>}
    </main>
  );
}

서버의 onStateChanged()와 이름이 비슷해서 헷갈리기 쉬운데 React에서는 onStateUpdate 콜백을 사용합니다. agent.stub.addTask()TaskAgent의 타입을 그대로 따라가므로 메서드 이름, 인자와 반환값까지 자동 완성됩니다.

같은 name으로 연결한 브라우저 창을 두 개 열어보세요. 한쪽에서 할 일을 추가하면 다른 쪽의 onStateUpdate도 바로 실행됩니다. 별도의 상태 API를 폴링하거나 WebSocket 메시지 형식을 직접 설계하지 않아도 됩니다.

실제 서비스에서는 "dale"을 코드에 고정하지 않고 인증된 사용자 ID를 사용해야 합니다. routeAgentRequest()를 호출하기 전에 세션을 검사하고 사용자가 접근할 수 있는 인스턴스인지 확인하세요. 상태가 실시간으로 동기화된다는 사실과 그 상태를 볼 권한이 있다는 사실은 전혀 다른 문제입니다.

잠들어도 사라지지 않는 예약 작업

remindIn()에서 사용한 schedule()은 작업 정보를 SQLite에 저장합니다. 실행 시각이 되면 Durable Object 알람으로 에이전트를 깨우죠. 브라우저 연결이 끊기거나 에이전트가 절전 상태에 들어가도 예약은 사라지지 않습니다.

Agents SDK는 네 가지 예약 방식을 제공합니다.

방식예제용도
지연 실행schedule(30, "remind", payload)30초 뒤 한 번 실행
시각 지정schedule(new Date(...), "publish", payload)특정 날짜와 시각에 실행
cron 반복schedule("0 8 * * *", "digest", payload)매일 오전 8시처럼 반복
간격 반복scheduleEvery(90, "sync", payload)시작점부터 90초마다 반복

에이전트 자신의 상태를 주기적으로 정리하려면 onStart()에서 scheduleEvery()를 등록할 수 있습니다.

async onStart() {
  await this.scheduleEvery(3600, "removeCompletedTasks");
}

async removeCompletedTasks() {
  this.setState({
    ...this.state,
    tasks: this.state.tasks.filter(({ done }) => !done),
  });
}

onStart()는 에이전트가 절전 상태에서 깨어날 때마다 실행됩니다. 다행히 scheduleEvery()는 콜백 이름, 간격과 페이로드가 같으면 중복 예약을 만들지 않습니다. 그래도 인자를 바꾸면 별개의 예약이 생기므로, 운영 중 주기를 변경할 때는 기존 예약을 조회하고 취소할지 함께 판단해야 합니다.

Cloudflare Cron Triggers가 Worker 전체를 정해진 시각에 깨우는 도구라면 Agents SDK의 예약 작업은 인스턴스별로 독립적입니다. 사용자마다 다른 알림 시각이 생기거나 실행 중에 다음 작업을 예약해야 할 때 Agents SDK 쪽이 자연스럽습니다.

상태에는 무엇을 넣어야 할까?

편리하다고 모든 데이터를 this.state 하나에 넣는 것은 좋지 않습니다. 상태는 연결된 클라이언트로 자동 전송되므로, 화면에 바로 보여줄 작은 데이터에 어울립니다. 현재 작업 목록, 진행률, 선택한 설정처럼 “지금 화면이 알아야 하는 값”을 생각하면 됩니다.

메시지 이력이나 감사 로그처럼 계속 쌓이고 일부만 조회할 데이터는 에이전트의 this.sql로 SQLite 테이블에 저장하는 편이 낫습니다. API 키와 같은 비밀값은 상태에 넣지 말고 Wrangler 시크릿이나 바인딩으로 전달해야 하고요.

오래 걸리는 여러 단계의 작업도 상태만으로 억지로 관리하지 않는 편이 좋습니다. 에이전트가 자기 상태를 점검하거나 알림을 보내는 일에는 schedule()이 잘 맞습니다. 각 단계가 독립적으로 실패하고 재시도되어야 하는 배포나 데이터 처리 파이프라인이라면 Cloudflare Workflows가 더 알맞고요.

실제 AI 채팅을 만들 때는 기본 Agent 대신 대화 이력과 스트리밍을 지원하는 AIChatAgent를 사용할 수 있습니다. 모델 호출, 도구 실행과 재연결 가능한 스트림까지 한꺼번에 필요하다면 공식 스타터에서 시작하는 편이 빠릅니다. 반대로 단순한 협업 상태나 게임 방이라면 LLM 없이 Agent만 사용해도 됩니다.

자주 막히는 지점

Agents SDK를 처음 붙일 때는 코드보다 설정에서 더 자주 막힙니다.

wrangler.jsonc의 바인딩 이름, class_name과 실제로 내보낸 클래스 이름부터 확인하세요. 셋 중 하나라도 다르면 에이전트 인스턴스를 찾지 못합니다. 새 클래스를 추가했다면 새 SQLite 마이그레이션 태그도 필요합니다.

또 자주 놓치는 부분은 setState()가 부분 업데이트가 아니라는 점입니다. { lastReminder: "..." }만 넘기면 기존 tasks가 사라집니다. 항상 새 상태 전체를 만드세요. 클라이언트가 상태를 직접 수정할 수 있는 구조라면 validateStateChange()로 서버 규칙을 강제해야 합니다.

@callable()은 공개 API라는 사실도 잊기 쉽습니다. 브라우저에서 버튼을 숨기는 것만으로 민감한 작업을 보호할 수 없습니다. 메서드 안에서 권한을 다시 검사하세요. 결제나 이메일 발송 같은 외부 부수 효과(side effect)가 있다면 상태 변경이 허용되는지 확인한 뒤 실행해야 합니다. 에이전트에게 외부 서비스 권한을 어디까지 넘길지 더 넓게 고민한다면, 같은 문제를 플랫폼 차원에서 푼 Cloudflare OS의 능력 기반 접근 제어를 살펴볼 만합니다.

마치며

Cloudflare Agents SDK의 핵심은 “계속 실행되는 프로세스”가 아니라 “계속 존재하는 객체”입니다. 요청이 없으면 잠들지만 상태와 예약은 SQLite에 남습니다. 다시 깨어나면 이전 지점에서 일을 이어가죠.

이번에 만든 TaskAgent는 단순하지만 Agents SDK의 뼈대를 모두 담고 있습니다. 인스턴스 이름으로 상태를 격리했고 setState()로 저장과 실시간 상태 동기화를 해결했습니다. 브라우저에는 @callable() 메서드를 열었고 미래의 작업은 schedule()로 예약했죠. 여기에 Workers AI나 외부 모델을 연결하면 기억하고 행동하는 AI 애플리케이션으로 확장할 수 있어요.

더 자세한 API와 최신 기능은 Cloudflare Agents 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0CCBY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord