[Cloudflare 완전 정복: 입문부터 2026 AI 에이전트까지] 15/16화: Cloudflare Email·Workflows v2 에이전트 완전 정복
이 글은 「Cloudflare 완전 정복」 시리즈의 15회입니다. 14화에서 MCP Server Portals로 AI 에이전트에게 안전한 도구 접근 경로를 열어줬다면, 이번 화에서는 에이전트가 이메일이라는 새로운 소통 채널을 얻고 몇 시간, 며칠에 걸친 장기 작업을 안정적으로 수행하는 방법을 다룹니다. Cloudflare Email Service(퍼블릭 베타)와 Workflows v2 — 두 가지 인프라를 결합하면, 이메일 한 통에 반응해 AI가 분석하고, 사람의 승인을 기다렸다가, 결과를 다시 이메일로 보내는 자율 에이전트 파이프라인을 서버 없이 구축할 수 있습니다.
왜 이메일과 워크플로우인가 — 에이전트의 채널과 지구력
13화에서 살펴본 Agents Week 2026의 핵심 메시지를 한 문장으로 요약하면 이렇습니다: “에이전트는 두뇌만으로는 부족하다. 채널과 지구력이 있어야 일을 한다.”
12화의 Workers AI가 에이전트의 두뇌를, 14화의 MCP Server Portals가 에이전트의 손(도구 접근)을 담당했다면, 이번 화에서 다루는 두 가지 서비스는 각각 다음 역할을 합니다.
- Cloudflare Email Service → 에이전트의 귀와 입. 이메일을 수신해 읽고, 처리 결과를 이메일로 발신합니다.
- Workflows v2 → 에이전트의 지구력. Worker의 30초 실행 제한을 넘어, 며칠 단위의 장기 작업을 단계별로 안전하게 수행합니다.
이메일은 1971년에 탄생한 프로토콜이지만, 2026년 현재에도 비즈니스 커뮤니케이션의 절대 채널입니다. 전 세계에서 하루에 발송되는 이메일은 약 3,600억 통. Slack과 Teams가 아무리 성장해도 계약서 발송, 고객 문의, 장애 알림, 뉴스레터는 여전히 이메일로 흐릅니다. AI 에이전트가 진짜 “업무”를 하려면 이 채널을 빠뜨릴 수 없습니다.
동시에, 실제 업무 워크플로우는 밀리초 단위가 아닙니다. 고객이 문의 이메일을 보내면, AI가 분석하고, 담당자의 승인을 기다렸다가, 24시간 내에 답장을 보내야 합니다. 서류 검토 요청은 3일 뒤 마감이고, 그 사이 상태를 추적해야 합니다. 일반 Worker의 30초(유료 플랜 기준) CPU 제한으로는 이런 흐름을 도저히 담을 수 없습니다. Workflows v2의 내구 실행(durable execution)이 필요한 이유입니다.
이번 화에서는 이 두 서비스를 밑바닥부터 해부한 뒤, 실제로 이메일 한 통이 AI 에이전트를 깨우고, 분석하고, 사람의 승인을 기다렸다가, 결과를 이메일로 돌려보내는 완전한 파이프라인을 구축합니다.
Cloudflare Email Service — 에이전트에게 이메일 채널을 열다
Email Routing에서 Email Service로: 무엇이 달라졌나
Cloudflare의 이메일 기능은 단계적으로 진화해왔습니다.
- 2022년 — Email Routing (GA): Cloudflare에 등록된 도메인의 이메일을 다른 주소로 전달하는 기능.
[email protected]으로 온 메일을[email protected]으로 포워딩하는 수준이었습니다. - 2023년 — Email Workers: Email Routing의 목적지를 Worker로 설정할 수 있게 되면서, 수신 이메일을 프로그래밍 방식으로 처리할 수 있게 됐습니다. 하지만 발신은 여전히 제한적이었습니다.
- 2026년 5월 — Email Service (퍼블릭 베타): Agents Week 2026에서 발표. 수신 + 발신을 모두 Workers 코드에서 네이티브로 처리합니다. DKIM 서명, SPF/DMARC 자동 구성, 첨부파일 처리, HTML 이메일 — 트랜잭셔널 이메일 서비스에 필요한 모든 것을 Cloudflare 인프라 위에서 제공합니다.
핵심 변화는 “발신 능력”의 추가입니다. 이전까지 Cloudflare Worker는 이메일을 받을 수는 있었지만 보내려면 SendGrid, Mailgun 같은 외부 서비스에 의존해야 했습니다. Email Service는 이 의존성을 제거합니다. Worker 코드 안에서 env.SEND_EMAIL.send() 한 줄이면 이메일이 나갑니다. AI 에이전트가 분석 결과를 이메일로 회신하는 시나리오가 외부 API 키 없이 가능해진 것입니다.
이메일 수신 — email() 핸들러 심화
Worker에서 이메일을 수신하려면 email() 핸들러를 내보내면(export) 됩니다. Cloudflare Dashboard에서 Email Routing을 활성화하고, 원하는 주소(예: [email protected])의 라우팅 규칙을 해당 Worker로 설정하면 준비 완료입니다.
// src/email-handler.ts
export default {
async email(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext
) {
// 발신자, 수신자, 헤더 접근
const from = message.from; // "[email protected]"
const to = message.to; // "[email protected]"
const subject = message.headers.get("subject") || "(제목 없음)";
const messageId = message.headers.get("message-id");
// 이메일 원본(raw MIME)을 텍스트로 읽기
const rawEmail = await new Response(message.raw).text();
// 이메일 크기 확인 (최대 25MB)
console.log(`수신: ${from} → ${to}, 크기: ${message.rawSize} bytes`);
// 조건부 거부 (스팸 필터링 등)
if (isSpam(from, subject)) {
message.setReject("Spam detected");
return;
}
// 다른 주소로 전달도 가능
// await message.forward("[email protected]");
// 여기서 AI 분석, DB 저장, Workflow 트리거 등 수행
await processEmail(env, { from, to, subject, rawEmail });
}
};
ForwardableEmailMessage 객체가 제공하는 주요 속성과 메서드를 정리합니다.
message.from: 발신자 이메일 주소 (문자열)message.to: 수신자 이메일 주소 (문자열)message.headers: 이메일 헤더에 접근하는Headers객체.subject,date,message-id,content-type등을 꺼낼 수 있습니다.message.raw: 원본 이메일의ReadableStream. MIME 파싱 라이브러리(예:postal-mime)에 넘기면 본문, 첨부파일, 인라인 이미지를 구조적으로 추출할 수 있습니다.message.rawSize: 원본 이메일 크기(바이트). Cloudflare Email Workers는 최대 25MB 이메일을 처리합니다.message.setReject(reason): 이메일을 거부합니다. SMTP 레벨에서 bounce가 발생합니다.message.forward(address): 다른 이메일 주소로 전달합니다.
MIME 파싱과 첨부파일 처리
실전에서는 이메일 원본(raw MIME)을 그대로 쓰는 경우가 드뭅니다. 본문과 첨부파일을 분리해야 합니다. postal-mime 라이브러리를 사용하면 간단합니다.
import PostalMime from "postal-mime";
async function parseEmail(rawStream: ReadableStream) {
const rawBytes = await new Response(rawStream).arrayBuffer();
const parser = new PostalMime();
const parsed = await parser.parse(rawBytes);
return {
subject: parsed.subject,
from: parsed.from, // { name: "홍길동", address: "hong@..." }
to: parsed.to, // [{ name: "...", address: "..." }]
text: parsed.text, // 플레인텍스트 본문
html: parsed.html, // HTML 본문
attachments: parsed.attachments.map((att) => ({
filename: att.filename,
mimeType: att.mimeType,
content: att.content, // ArrayBuffer
size: att.content.byteLength,
})),
};
}
파싱한 첨부파일은 R2에 저장하고, 메타데이터는 D1에 기록하는 패턴이 자연스럽습니다. 10화에서 다룬 R2+D1 조합이 여기서 빛을 발합니다.
// 첨부파일 → R2 저장
for (const att of parsed.attachments) {
const key = `emails/${messageId}/${att.filename}`;
await env.ATTACHMENTS.put(key, att.content, {
httpMetadata: { contentType: att.mimeType },
});
attachmentKeys.push(key);
}
// 메타데이터 → D1 기록
await env.DB.prepare(
`INSERT INTO emails (message_id, from_addr, subject, body_text, attachment_count, received_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))`
)
.bind(messageId, from, subject, parsed.text, attachmentKeys.length)
.run();
이메일 발신 — send_email 바인딩
Email Service의 핵심 신기능입니다. wrangler.toml에 send_email 바인딩을 추가하고, Worker 코드에서 EmailMessage 객체를 만들어 보내면 됩니다.
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";
async function sendAnalysisReport(
env: Env,
to: string,
subject: string,
htmlBody: string
) {
// MIME 메시지 조립
const mime = createMimeMessage();
mime.setSender({ name: "AI 에이전트", addr: "[email protected]" });
mime.setRecipient(to);
mime.setSubject(subject);
mime.addMessage({
contentType: "text/html",
data: htmlBody,
});
// Cloudflare Email Service로 발송
const message = new EmailMessage(
"[email protected]", // From
to, // To
mime.asRaw() // Raw MIME string
);
await env.SEND_EMAIL.send(message);
}
wrangler.toml에는 다음과 같이 바인딩을 선언합니다.
# wrangler.toml — send_email 바인딩
[[send_email]]
name = "SEND_EMAIL"
# 특정 수신자로 제한하려면:
# destination_address = "[email protected]"
# 여러 수신자를 허용하려면:
# allowed_destination_addresses = ["[email protected]", "[email protected]"]
destination_address를 지정하면 해당 주소로만 발송이 가능합니다. 에이전트가 불특정 다수에게 메일을 보내는 것을 방지하는 안전장치입니다. 생략하면 도메인 내 모든 verified 주소로 발송할 수 있습니다.
발신 인증: DKIM·SPF·DMARC 자동 구성
이메일 발신에서 가장 까다로운 부분은 인증입니다. DKIM 서명이 없거나 SPF 레코드가 틀리면 Gmail, Outlook이 스팸함으로 보내버립니다. Email Service는 이 과정을 대부분 자동화합니다.
- SPF: Cloudflare DNS를 사용 중이라면 Email Routing 활성화 시 SPF 레코드가 자동 추가됩니다.
include:_spf.mx.cloudflare.net이 TXT 레코드에 들어갑니다. - DKIM: Email Service가 발송하는 모든 이메일에 자동으로 DKIM 서명을 추가합니다. 별도의 키 생성이나 DNS 레코드 관리가 필요 없습니다.
- DMARC:
_dmarc.yourdomain.comTXT 레코드를 추가하면 됩니다. 최소 설정은v=DMARC1; p=quarantine; rua=mailto:[email protected].
3화에서 다뤘던 Cloudflare의 DNS 관리 역량이 여기서 시너지를 발휘합니다. DNS + HTTPS + 이메일 인증을 한 대시보드에서 통합 관리할 수 있다는 건 운영 부담을 크게 줄여줍니다.

Workflows v2 완전 해부 — 에이전트에게 장기 기억을 선물하다
Worker의 30초 벽, Workflows로 넘다
9화에서 배운 Cloudflare Workers는 강력하지만, 근본적인 제약이 있습니다.
- 무료 플랜: CPU 시간 10ms (벽시계 기준 약 30초)
- 유료 플랜: CPU 시간 30초 (벽시계 기준 약 15분)
단순 API 응답이라면 충분하지만, AI 에이전트의 실제 업무 흐름을 생각해보면 전혀 부족합니다.
- 고객 문의 이메일 수신 → AI 1차 분류 → 담당자 승인 대기 (최대 24시간) → 최종 답변 발송
- 주간 리포트 수집 → 7일간 데이터 축적 → 매주 월요일 9시에 종합 리포트 발송
- 장애 알림 수신 → AI 진단 → 5분 대기 후 재확인 → 해소되지 않으면 에스컬레이션
이런 흐름에는 시간을 견디는 능력이 필요합니다. 정확히 Workflows가 해결하는 문제입니다.
Workflows의 실행 모델: 내구 실행(Durable Execution)
Workflows는 10화에서 다룬 Durable Objects 위에 구축된 상위 추상화입니다. 핵심 아이디어는 간단합니다: 각 단계(step)의 결과를 자동으로 영속화해서, 중간에 Worker가 종료되더라도 마지막으로 완료된 단계 이후부터 재개할 수 있습니다.
일반 Worker가 “한 번 호출되면 끝까지 달려야 하는 단거리 선수”라면, Workflow는 “체크포인트를 찍으며 며칠을 달리는 마라토너”입니다.
import {
WorkflowEntrypoint,
WorkflowStep,
WorkflowEvent,
} from "cloudflare:workers";
interface Env {
AI: Ai;
DB: D1Database;
}
interface AnalysisParams {
emailId: string;
content: string;
}
export class AnalysisWorkflow extends WorkflowEntrypoint<Env, AnalysisParams> {
async run(event: WorkflowEvent<AnalysisParams>, step: WorkflowStep) {
// Step 1: AI 분석 — 실패 시 자동 재시도
const analysis = await step.do("ai-analysis", async () => {
const result = await this.env.AI.run(
"@cf/meta/llama-3.3-70b-instruct-fp8-fast",
{
messages: [
{ role: "system", content: "이메일을 분석해 JSON으로 반환하세요." },
{ role: "user", content: event.payload.content },
],
}
);
return JSON.parse(result.response);
});
// ✅ Step 1 결과가 자동 영속화됨
// Step 2: DB 저장 — Step 1이 이미 완료됐으므로 재실행 안 함
await step.do("store-result", async () => {
await this.env.DB.prepare(
"INSERT INTO analyses (email_id, category, summary) VALUES (?, ?, ?)"
)
.bind(event.payload.emailId, analysis.category, analysis.summary)
.run();
});
// ✅ Step 2 결과 영속화
// Step 3: 1시간 대기 (비긴급 케이스)
await step.sleep("cool-down", "1 hour");
// ✅ Worker 종료됨. 1시간 후 자동 재개.
// Step 4: 후속 처리
await step.do("follow-up", async () => {
// 1시간 뒤 실행되는 로직
});
}
}
위 코드에서 주목할 점: step.sleep("cool-down", "1 hour")이 호출되면 Worker 프로세스는 즉시 종료됩니다. 메모리도 CPU도 사용하지 않습니다. 정확히 1시간 뒤에 Cloudflare가 새 Worker 인스턴스를 깨워서 Step 4부터 실행합니다. Step 1~3의 결과는 Durable Objects에 영속화되어 있으므로 아무것도 재계산하지 않습니다.
step.do — 원자적 실행 단위와 자동 재시도
step.do()는 Workflow의 가장 기본적인 빌딩 블록입니다. 각 step은 다음을 보장합니다.
- 원자성: step 내부의 코드는 전부 성공하거나 전부 실패합니다.
- 멱등성: 같은 step이 재시도되더라도 결과가 동일하게 반환됩니다(이미 완료된 step은 저장된 결과를 그대로 반환).
- 자동 재시도: step이 예외를 던지면, 설정된 재시도 정책에 따라 자동으로 다시 시도합니다.
재시도 정책은 세밀하게 제어할 수 있습니다.
const result = await step.do(
"call-external-api",
{
retries: {
limit: 5, // 최대 5회 재시도
delay: "10 seconds", // 초기 대기 시간
backoff: "exponential", // 지수 백오프: 10s → 20s → 40s → 80s → 160s
},
timeout: "2 minutes", // step 전체 타임아웃
},
async () => {
const res = await fetch("https://api.example.com/data");
if (!res.ok) throw new Error(`API 오류: ${res.status}`);
return res.json();
}
);
재시도 정책을 명시하지 않으면 기본값(3회 재시도, 지수 백오프)이 적용됩니다. 외부 API 호출처럼 일시적 실패가 흔한 작업에 특히 유용합니다.
step.sleep — 시간을 견디는 에이전트
step.sleep()은 지정한 시간만큼 Workflow를 일시 중단합니다. 위에서 설명했듯이 Worker 프로세스가 종료되므로 비용이 0입니다.
// 상대 시간 대기
await step.sleep("wait-5-min", "5 minutes");
await step.sleep("wait-1-day", "1 day");
await step.sleep("wait-1-week", "7 days");
// 절대 시간 대기 (특정 시각까지)
await step.sleepUntil("wait-until-monday",
"2026-06-22T09:00:00+09:00" // 다음 주 월요일 오전 9시 (KST)
);
최대 대기 시간은 1년입니다. 연간 구독 갱신 알림, 분기별 리포트 생성 같은 매우 긴 주기의 워크플로우도 하나의 Workflow 인스턴스로 표현할 수 있습니다.
step.waitForEvent — 외부 신호를 기다리는 에이전트
에이전트 오케스트레이션에서 가장 강력한 프리미티브입니다. step.waitForEvent()는 외부에서 특정 이벤트가 도착할 때까지 Workflow를 일시 중단합니다.
// Workflow 내부: 사람의 승인을 기다림
const approval = await step.waitForEvent("manager-approval", {
timeout: "24 hours", // 24시간 내 응답이 없으면 타임아웃
type: "approval", // 이벤트 유형 필터
});
if (approval.approved) {
await step.do("send-approved-reply", async () => {
// 승인된 답변 발송
});
} else {
await step.do("send-rejection", async () => {
// 거절 안내 발송
});
}
외부에서 이벤트를 보내는 방법은 간단합니다. 별도의 Worker(또는 같은 Worker의 fetch() 핸들러)에서 Workflow 인스턴스에 이벤트를 전송합니다.
// 승인 HTTP 엔드포인트 (같은 Worker의 fetch 핸들러)
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
if (url.pathname === "/approve" && request.method === "POST") {
const { workflowId, approved, comments } = await request.json();
// 실행 중인 Workflow 인스턴스 가져오기
const instance = await env.EMAIL_WORKFLOW.get(workflowId);
// 이벤트 전송 — waitForEvent가 이것을 받고 Workflow 재개
await instance.sendEvent("manager-approval", {
approved,
comments,
approvedBy: "[email protected]",
approvedAt: new Date().toISOString(),
});
return new Response("승인 처리됨", { status: 200 });
}
return new Response("Not found", { status: 404 });
},
};
이 패턴이 바로 Human-in-the-Loop(HITL) 워크플로우입니다. AI가 1차 판단을 하고, 사람이 최종 승인하고, AI가 후속 작업을 수행합니다. 이메일로 승인 링크를 보내고, 클릭하면 /approve 엔드포인트가 호출되는 구조를 상상해보세요 — Email Service + Workflows의 시너지가 바로 여기에 있습니다.
50,000 동시 인스턴스와 관측 가능성
Workflows v2의 스케일링 수치를 정리합니다.
- 동시 실행 인스턴스: 최대 50,000개 (Agents Week 2026에서 발표된 v2 한도)
- 인스턴스당 단계 수: 사실상 무제한 (Durable Objects 스토리지 한도까지)
- 최대 sleep 기간: 1년
- 단일 step 타임아웃: 최대 15분 (유료 플랜)
50,000개 동시 인스턴스가 의미하는 바를 구체적으로 풀어보겠습니다. 하루 수신 이메일이 1,000통이고, 각 이메일의 처리 Workflow가 평균 2시간 동안 유지된다면, 동시 활성 인스턴스는 약 83개. 50,000개 한도와 비교하면 600배 여유가 있습니다. 사실상 대부분의 중소 규모 서비스에서는 한도를 걱정할 필요가 없는 수준입니다.
관측 가능성도 크게 개선됐습니다. Wrangler CLI로 실행 중인 인스턴스의 상태를 실시간으로 확인할 수 있습니다.
# 모든 Workflow 인스턴스 목록 조회
npx wrangler workflows instances list email-agent-workflow
# 특정 인스턴스의 상세 상태 조회
npx wrangler workflows instances describe email-agent-workflow \
--instance-id "abc-123-def"
# 인스턴스 일시정지 / 재개 / 종료
npx wrangler workflows instances pause email-agent-workflow --instance-id "abc-123-def"
npx wrangler workflows instances resume email-agent-workflow --instance-id "abc-123-def"
npx wrangler workflows instances terminate email-agent-workflow --instance-id "abc-123-def"
Dashboard에서도 각 인스턴스의 step 실행 이력, 현재 상태(running / sleeping / waiting / paused / complete / errored), 각 step의 입출력 값을 시각적으로 확인할 수 있습니다.

종합 실습: 이메일 트리거 AI 에이전트 구축
이론은 충분합니다. 지금부터 이메일 한 통이 AI 에이전트를 깨우고, 분석 결과를 이메일로 회신하는 완전한 파이프라인을 처음부터 구축합니다. Synology NAS DS+925 SSH 또는 Mac Studio 터미널에서 그대로 따라할 수 있습니다.
전체 아키텍처
구축할 시스템의 요청 흐름입니다.
- 사용자가
[email protected]으로 이메일 발송 - Cloudflare Email Routing이 이메일을 Worker로 전달
- Email Handler(Worker의
email())가 MIME 파싱 → 첨부파일을 R2에 저장 → Workflow 트리거 - Workflow Step 1: Workers AI로 이메일 내용 분석 (카테고리, 긴급도, 요약)
- Workflow Step 2: 결과를 D1에 저장
- Workflow Step 3: 긴급 이메일이면 즉시, 비긴급이면 1시간 대기 후 → 분석 결과를 HTML 이메일로 회신
1단계: 프로젝트 초기화
# 프로젝트 생성
npm create cloudflare@latest email-agent -- --type worker-ts
cd email-agent
# MIME 메시지 조립 라이브러리 설치
npm install mimetext
# 이메일 파싱 라이브러리 설치
npm install postal-mime
# D1 데이터베이스 생성
npx wrangler d1 create email-agent-db
# 출력에서 database_id 복사 → wrangler.toml에 붙여넣기
# R2 버킷 생성 (첨부파일 저장용)
npx wrangler r2 bucket create email-attachments
2단계: wrangler.toml 설정
name = "email-agent"
main = "src/index.ts"
compatibility_date = "2026-06-01"
# Workers AI 바인딩
[ai]
binding = "AI"
# D1 데이터베이스 바인딩
[[d1_databases]]
binding = "DB"
database_name = "email-agent-db"
database_id = "여기에-d1-create-출력의-id를-붙여넣기"
# R2 버킷 바인딩
[[r2_buckets]]
binding = "ATTACHMENTS"
bucket_name = "email-attachments"
# 이메일 발신 바인딩 (Email Service)
[[send_email]]
name = "SEND_EMAIL"
# Workflow 바인딩
[[workflows]]
name = "email-agent-workflow"
binding = "EMAIL_WORKFLOW"
class_name = "EmailAgentWorkflow"
3단계: D1 스키마 마이그레이션
# 마이그레이션 파일 생성
mkdir -p migrations
cat > migrations/0001_init.sql << 'EOF'
CREATE TABLE IF NOT EXISTS email_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id TEXT UNIQUE,
from_addr TEXT NOT NULL,
to_addr TEXT NOT NULL,
subject TEXT,
category TEXT,
urgency TEXT CHECK(urgency IN ('high', 'medium', 'low')),
summary TEXT,
attachment_count INTEGER DEFAULT 0,
workflow_id TEXT,
status TEXT DEFAULT 'received',
received_at TEXT DEFAULT (datetime('now')),
processed_at TEXT
);
CREATE INDEX idx_email_logs_from ON email_logs(from_addr);
CREATE INDEX idx_email_logs_urgency ON email_logs(urgency);
CREATE INDEX idx_email_logs_status ON email_logs(status);
EOF
# 마이그레이션 실행
npx wrangler d1 migrations apply email-agent-db
4단계: TypeScript 전체 코드
이제 핵심 코드입니다. 하나의 파일에 Email Handler와 Workflow, HTTP 엔드포인트를 모두 담습니다.
// src/index.ts
import {
WorkflowEntrypoint,
WorkflowStep,
WorkflowEvent,
} from "cloudflare:workers";
import { EmailMessage } from "cloudflare:email";
import { createMimeMessage } from "mimetext";
import PostalMime from "postal-mime";
// ─── 타입 정의 ───
interface Env {
AI: Ai;
DB: D1Database;
ATTACHMENTS: R2Bucket;
SEND_EMAIL: SendEmail;
EMAIL_WORKFLOW: Workflow;
}
interface EmailPayload {
messageId: string;
from: string;
to: string;
subject: string;
textBody: string;
htmlBody: string | null;
attachmentKeys: string[];
}
// ─── 1. Email Handler: 수신 이메일 처리 ───
export default {
async email(
message: ForwardableEmailMessage,
env: Env,
ctx: ExecutionContext
) {
try {
// MIME 파싱
const rawBytes = await new Response(message.raw).arrayBuffer();
const parser = new PostalMime();
const parsed = await parser.parse(rawBytes);
const messageId =
message.headers.get("message-id") || crypto.randomUUID();
const from = message.from;
const to = message.to;
const subject = parsed.subject || "(제목 없음)";
// 첨부파일 → R2 저장
const attachmentKeys: string[] = [];
for (const att of parsed.attachments) {
if (!att.filename) continue;
const key = `emails/${messageId}/${att.filename}`;
await env.ATTACHMENTS.put(key, att.content, {
httpMetadata: { contentType: att.mimeType },
customMetadata: { originalName: att.filename },
});
attachmentKeys.push(key);
}
// Workflow 트리거
const payload: EmailPayload = {
messageId,
from,
to,
subject,
textBody: parsed.text || "",
htmlBody: parsed.html || null,
attachmentKeys,
};
const instance = await env.EMAIL_WORKFLOW.create({ params: payload });
// 메타데이터 → D1 기록
await env.DB.prepare(
`INSERT INTO email_logs
(message_id, from_addr, to_addr, subject,
attachment_count, workflow_id, status)
VALUES (?, ?, ?, ?, ?, ?, 'processing')`
)
.bind(
messageId,
from,
to,
subject,
attachmentKeys.length,
instance.id
)
.run();
console.log(
`이메일 수신 처리 완료: ${from} → ${to}, ` +
`Workflow: ${instance.id}`
);
} catch (err) {
console.error("이메일 처리 실패:", err);
// 에러 시에도 이메일을 거부하지 않음 (메일이 바운스되면 사용자 혼란)
}
},
// HTTP 엔드포인트 (상태 조회 + 승인 처리용)
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
// Workflow 상태 조회
if (url.pathname === "/status") {
const id = url.searchParams.get("id");
if (!id) {
return Response.json(
{ error: "Missing workflow id" },
{ status: 400 }
);
}
const instance = await env.EMAIL_WORKFLOW.get(id);
const status = await instance.status();
return Response.json(status);
}
// 최근 이메일 로그 조회
if (url.pathname === "/logs") {
const results = await env.DB.prepare(
`SELECT * FROM email_logs ORDER BY received_at DESC LIMIT 20`
).all();
return Response.json(results);
}
return Response.json({
service: "Email Agent",
endpoints: ["/status?id=...", "/logs"],
});
},
};
// ─── 2. Workflow: 장기 실행 에이전트 로직 ───
export class EmailAgentWorkflow extends WorkflowEntrypoint<
Env,
EmailPayload
> {
async run(
event: WorkflowEvent<EmailPayload>,
step: WorkflowStep
) {
const {
messageId,
from,
to,
subject,
textBody,
attachmentKeys,
} = event.payload;
// ── Step 1: AI 분석 ──
const analysis = await step.do(
"ai-analysis",
{
retries: { limit: 3, delay: "5 seconds", backoff: "exponential" },
timeout: "2 minutes",
},
async () => {
const body = textBody.slice(0, 4000); // 토큰 절약
const result = await this.env.AI.run(
"@cf/meta/llama-3.3-70b-instruct-fp8-fast",
{
messages: [
{
role: "system",
content: `당신은 이메일 분석 어시스턴트입니다.
이메일 내용을 분석하고 다음 JSON 형식으로 반환하세요:
{
"category": "inquiry|complaint|request|notification|spam",
"urgency": "high|medium|low",
"summary": "2~3문장 요약",
"suggestedReply": "제안 답변 (3~5문장)"
}
JSON만 반환하세요. 다른 텍스트는 포함하지 마세요.`,
},
{
role: "user",
content: `제목: ${subject}\n\n본문:\n${body}`,
},
],
}
);
return JSON.parse(result.response);
}
);
// ── Step 2: D1에 분석 결과 저장 ──
await step.do("store-analysis", async () => {
await this.env.DB.prepare(
`UPDATE email_logs
SET category = ?, urgency = ?, summary = ?,
status = 'analyzed', processed_at = datetime('now')
WHERE message_id = ?`
)
.bind(
analysis.category,
analysis.urgency,
analysis.summary,
messageId
)
.run();
});
// ── Step 3: 긴급도에 따른 분기 ──
if (analysis.urgency !== "high") {
// 비긴급: 1시간 대기 (배치 처리 시뮬레이션)
await step.sleep("batch-wait", "1 hour");
}
// ── Step 4: 분석 리포트 이메일 발송 ──
await step.do(
"send-report",
{
retries: { limit: 3, delay: "10 seconds", backoff: "exponential" },
timeout: "1 minute",
},
async () => {
const urgencyLabel =
analysis.urgency === "high"
? "긴급"
: analysis.urgency === "medium"
? "보통"
: "낮음";
const categoryLabel: Record<string, string> = {
inquiry: "문의",
complaint: "불만",
request: "요청",
notification: "알림",
spam: "스팸",
};
const htmlBody = `
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: #f6821f; color: white; padding: 20px;">
<h1 style="margin: 0;">AI 이메일 분석 리포트</h1>
</div>
<div style="padding: 20px; border: 1px solid #eee;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px; font-weight: bold;">원본 제목</td>
<td style="padding: 8px;">${subject}</td>
</tr>
<tr style="background: #f9f9f9;">
<td style="padding: 8px; font-weight: bold;">발신자</td>
<td style="padding: 8px;">${from}</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold;">카테고리</td>
<td style="padding: 8px;">${categoryLabel[analysis.category] || analysis.category}</td>
</tr>
<tr style="background: #f9f9f9;">
<td style="padding: 8px; font-weight: bold;">긴급도</td>
<td style="padding: 8px; color: ${analysis.urgency === "high" ? "#e74c3c" : "#333"};">
${urgencyLabel}
</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold;">첨부파일</td>
<td style="padding: 8px;">${attachmentKeys.length}개</td>
</tr>
</table>
<h2>요약</h2>
<p>${analysis.summary}</p>
<h2>제안 답변</h2>
<p style="background: #f0f7ff; padding: 15px; border-left: 4px solid #3498db;">
${analysis.suggestedReply}
</p>
</div>
<div style="padding: 10px; text-align: center; color: #999; font-size: 12px;">
Powered by Cloudflare Email Service + Workers AI + Workflows v2
</div>
</body>
</html>`;
const mime = createMimeMessage();
mime.setSender({
name: "AI 에이전트",
addr: to, // 원래 수신 주소 ([email protected])
});
mime.setRecipient(from); // 원래 발신자에게 답장
mime.setSubject(`[AI 분석] Re: ${subject}`);
mime.addMessage({ contentType: "text/html", data: htmlBody });
const emailMsg = new EmailMessage(to, from, mime.asRaw());
await this.env.SEND_EMAIL.send(emailMsg);
}
);
// ── Step 5: 최종 상태 업데이트 ──
await step.do("finalize", async () => {
await this.env.DB.prepare(
`UPDATE email_logs SET status = 'completed' WHERE message_id = ?`
)
.bind(messageId)
.run();
});
}
}
5단계: 배포와 테스트
# 배포
npx wrangler deploy
# 실시간 로그 모니터링 (별도 터미널)
npx wrangler tail email-agent
배포가 완료되면 Cloudflare Dashboard에서 이메일 라우팅을 설정합니다.
- Dashboard → 도메인 선택 → Email → Email Routing
- Routing rules 탭에서 Create address 클릭
- Custom address:
agent(→[email protected]이 됩니다) - Action: Send to a Worker →
email-agent선택 - Save
이제 [email protected]으로 이메일을 보내면, Worker가 수신 → Workflow가 AI 분석 → 결과를 이메일로 회신합니다. npx wrangler tail로 전체 흐름을 실시간으로 확인할 수 있습니다.
# Workflow 인스턴스 상태 확인
npx wrangler workflows instances list email-agent-workflow
# 출력 예시:
# ID Status Created
# 550e8400-e29b-41d4-a716-446655440000 running 2026-06-20T10:30:00Z
# 6ba7b810-9dad-11d1-80b4-00c04fd430c8 sleeping 2026-06-20T09:15:00Z
# 6ba7b811-9dad-11d1-80b4-00c04fd430c8 complete 2026-06-20T08:00:00Z
# 특정 인스턴스의 step 실행 이력 확인
npx wrangler workflows instances describe email-agent-workflow \
--instance-id 550e8400-e29b-41d4-a716-446655440000

고급 패턴: Human-in-the-Loop와 서브 워크플로우
패턴 1: 이메일 승인 링크로 Human-in-the-Loop 구현
가장 실용적인 고급 패턴입니다. AI가 1차 분석한 결과를 담당자에게 이메일로 보내되, “승인” / “거절” 링크를 포함합니다. 담당자가 링크를 클릭하면 Workflow가 재개되어 후속 작업을 수행합니다.
export class ApprovalWorkflow extends WorkflowEntrypoint<Env, EmailPayload> {
async run(event: WorkflowEvent<EmailPayload>, step: WorkflowStep) {
// Step 1: AI 분석 (위와 동일)
const analysis = await step.do("ai-analysis", async () => {
// ... AI 호출 ...
return { category: "complaint", suggestedReply: "..." };
});
// Step 2: 담당자에게 승인 요청 이메일 발송
const workflowId = event.id; // 현재 Workflow 인스턴스 ID
await step.do("send-approval-request", async () => {
const approveUrl =
`https://email-agent.yourdomain.workers.dev/approve` +
`?id=${workflowId}&action=approve`;
const rejectUrl =
`https://email-agent.yourdomain.workers.dev/approve` +
`?id=${workflowId}&action=reject`;
const html = `
<h2>AI 분석 결과 — 승인 대기</h2>
<p>카테고리: ${analysis.category}</p>
<p>제안 답변: ${analysis.suggestedReply}</p>
<p>
<a href="${approveUrl}"
style="background:#27ae60;color:white;padding:12px 24px;
text-decoration:none;border-radius:4px;">
승인
</a>
<a href="${rejectUrl}"
style="background:#e74c3c;color:white;padding:12px 24px;
text-decoration:none;border-radius:4px;">
거절
</a>
</p>`;
// 담당자에게 발송
await this.sendEmail(
"[email protected]",
`[승인 필요] ${event.payload.subject}`,
html
);
});
// Step 3: 담당자의 승인/거절을 최대 48시간 대기
let decision;
try {
decision = await step.waitForEvent("manager-decision", {
timeout: "48 hours",
});
} catch (e) {
// 타임아웃: 에스컬레이션
await step.do("escalate", async () => {
await this.sendEmail(
"[email protected]",
`[에스컬레이션] 48시간 무응답: ${event.payload.subject}`,
"<p>담당자 미응답으로 에스컬레이션됩니다.</p>"
);
});
return;
}
// Step 4: 결정에 따라 후속 처리
if (decision.action === "approve") {
await step.do("send-approved-reply", async () => {
await this.sendEmail(
event.payload.from,
`Re: ${event.payload.subject}`,
`<p>${analysis.suggestedReply}</p>`
);
});
} else {
await step.do("log-rejection", async () => {
await this.env.DB.prepare(
"UPDATE email_logs SET status = 'rejected' WHERE message_id = ?"
)
.bind(event.payload.messageId)
.run();
});
}
}
private async sendEmail(to: string, subject: string, html: string) {
const mime = createMimeMessage();
mime.setSender({ name: "AI Agent", addr: "[email protected]" });
mime.setRecipient(to);
mime.setSubject(subject);
mime.addMessage({ contentType: "text/html", data: html });
const msg = new EmailMessage("[email protected]", to, mime.asRaw());
await this.env.SEND_EMAIL.send(msg);
}
}
승인 링크 클릭을 처리하는 fetch() 핸들러도 필요합니다.
// fetch 핸들러에 추가
if (url.pathname === "/approve") {
const workflowId = url.searchParams.get("id");
const action = url.searchParams.get("action"); // "approve" | "reject"
if (!workflowId || !action) {
return new Response("잘못된 요청", { status: 400 });
}
const instance = await env.EMAIL_WORKFLOW.get(workflowId);
await instance.sendEvent("manager-decision", { action });
const message = action === "approve"
? "승인되었습니다. AI 답변이 발송됩니다."
: "거절되었습니다. 기록이 저장됩니다.";
return new Response(
`<html><body style="text-align:center;padding:50px;">` +
`<h1>${message}</h1></body></html>`,
{ headers: { "Content-Type": "text/html; charset=utf-8" } }
);
}
이 패턴의 아름다움은 비용 구조에 있습니다. 담당자가 승인 링크를 클릭하기까지 48시간이 걸려도, 그 사이 Worker는 실행되지 않습니다. Workflow가 Durable Objects에 상태를 저장하고 잠들어 있을 뿐입니다. 대기 시간에는 비용이 0입니다.
패턴 2: 서브 워크플로우로 복잡한 파이프라인 분할
하나의 Workflow가 너무 복잡해지면 서브 워크플로우로 분할할 수 있습니다. 메인 Workflow가 서브 Workflow를 트리거하고, 완료를 기다리는 구조입니다.
// 메인 워크플로우
export class MainWorkflow extends WorkflowEntrypoint<Env, MainParams> {
async run(event: WorkflowEvent<MainParams>, step: WorkflowStep) {
// 서브 워크플로우 트리거
const subInstance = await step.do("trigger-sub", async () => {
const instance = await this.env.SUB_WORKFLOW.create({
params: { data: event.payload.data },
});
return { id: instance.id };
});
// 서브 워크플로우 완료 대기 (폴링)
await step.do("wait-for-sub", {
retries: { limit: 60, delay: "30 seconds", backoff: "constant" },
}, async () => {
const sub = await this.env.SUB_WORKFLOW.get(subInstance.id);
const status = await sub.status();
if (status.status !== "complete") {
throw new Error("아직 진행 중"); // 재시도 트리거
}
return status.output;
});
}
}
또는 waitForEvent를 사용해 서브 워크플로우가 완료 시 이벤트를 보내게 만들 수도 있습니다. 상황에 따라 적절한 패턴을 선택하면 됩니다.
패턴 3: 스케줄된 다이제스트 — Cron + Workflows
매주 월요일 아침에 지난 주 수신된 이메일의 AI 분석 요약을 보내는 “주간 다이제스트” 패턴입니다. Workers의 scheduled() 핸들러(Cron Trigger)와 Workflows를 결합합니다.
// wrangler.toml에 추가
[triggers]
crons = ["0 0 * * 1"] # 매주 월요일 00:00 UTC (한국 시간 09:00)
// src/index.ts — scheduled 핸들러 추가
export default {
// ... email(), fetch() ...
async scheduled(
event: ScheduledEvent,
env: Env,
ctx: ExecutionContext
) {
// 주간 다이제스트 Workflow 트리거
await env.DIGEST_WORKFLOW.create({
params: {
periodStart: new Date(Date.now() - 7 * 86400000).toISOString(),
periodEnd: new Date().toISOString(),
},
});
},
};
// 다이제스트 워크플로우
export class DigestWorkflow extends WorkflowEntrypoint<Env, DigestParams> {
async run(event: WorkflowEvent<DigestParams>, step: WorkflowStep) {
// Step 1: 지난 주 이메일 로그 조회
const logs = await step.do("fetch-logs", async () => {
const result = await this.env.DB.prepare(
`SELECT * FROM email_logs
WHERE received_at BETWEEN ? AND ?
ORDER BY urgency DESC, received_at DESC`
)
.bind(event.payload.periodStart, event.payload.periodEnd)
.all();
return result.results;
});
if (logs.length === 0) return;
// Step 2: AI 종합 요약
const digest = await step.do("ai-digest", async () => {
const summaries = logs
.map((l: any) => `[${l.urgency}] ${l.subject}: ${l.summary}`)
.join("\n");
const result = await this.env.AI.run(
"@cf/meta/llama-3.3-70b-instruct-fp8-fast",
{
messages: [
{
role: "system",
content: "주간 이메일 요약을 HTML 리포트로 작성하세요.",
},
{ role: "user", content: summaries },
],
}
);
return result.response;
});
// Step 3: 다이제스트 이메일 발송
await step.do("send-digest", async () => {
await this.sendDigestEmail(
"[email protected]",
digest,
logs.length
);
});
}
}
홈랩 시나리오: NAS 알림을 AI가 처리하는 세계
시나리오 1: Synology NAS 장애 알림 → AI 진단 → 이메일 리포트
Synology DSM은 디스크 장애, 저장 공간 부족, 패키지 업데이트, 보안 경고 등의 이벤트를 이메일로 알릴 수 있습니다. 보통은 알림 이메일을 Gmail로 받고 끝이지만, Email Service + Workflows를 결합하면 AI가 알림을 분석하고 대응 방안까지 제시하는 자동화 파이프라인을 만들 수 있습니다.
설정 방법:
- Synology DSM → 제어판 → 알림 → 이메일 → SMTP 서버를 외부 메일(Gmail 등)로 설정한 뒤, 수신자를
[email protected]으로 지정 - Cloudflare Email Routing에서
[email protected]을 Worker로 라우팅 - Worker의 email 핸들러에서 NAS 알림 특화 Workflow를 트리거
// NAS 알림 전용 Workflow
export class NasAlertWorkflow extends WorkflowEntrypoint<Env, EmailPayload> {
async run(event: WorkflowEvent<EmailPayload>, step: WorkflowStep) {
const { subject, textBody } = event.payload;
// Step 1: AI가 NAS 알림 분석
const diagnosis = await step.do("diagnose", async () => {
const result = await this.env.AI.run(
"@cf/meta/llama-3.3-70b-instruct-fp8-fast",
{
messages: [
{
role: "system",
content: `당신은 Synology NAS 관리 전문가입니다.
NAS 알림 이메일을 분석하고 다음 JSON을 반환하세요:
{
"alertType": "disk|storage|security|update|other",
"severity": "critical|warning|info",
"diagnosis": "원인 분석 (2~3문장)",
"action": "권장 조치 (구체적인 DSM 경로 포함)",
"canAutoResolve": true/false
}`,
},
{
role: "user",
content: `제목: ${subject}\n본문: ${textBody}`,
},
],
}
);
return JSON.parse(result.response);
});
// Step 2: critical이면 즉시, 아니면 5분 대기 후 재확인
if (diagnosis.severity === "critical") {
await step.do("immediate-alert", async () => {
await this.sendEmail(
"[email protected]",
`[NAS 긴급] ${diagnosis.alertType}: ${subject}`,
this.buildAlertHtml(diagnosis)
);
});
} else {
// 5분 대기 후 같은 알림이 반복됐는지 D1에서 확인
await step.sleep("recheck-wait", "5 minutes");
const isRecurring = await step.do("check-recurring", async () => {
const count = await this.env.DB.prepare(
`SELECT COUNT(*) as cnt FROM email_logs
WHERE subject LIKE ? AND received_at > datetime('now', '-10 minutes')`
)
.bind(`%${diagnosis.alertType}%`)
.first();
return (count?.cnt as number) > 1;
});
await step.do("send-summary", async () => {
const prefix = isRecurring ? "[반복 발생]" : "[단건]";
await this.sendEmail(
"[email protected]",
`${prefix} NAS ${diagnosis.alertType} 알림`,
this.buildAlertHtml(diagnosis)
);
});
}
}
}
이 시나리오의 핵심 가치: NAS가 보내는 기계적인 알림 이메일이 AI의 진단 소견이 포함된 조치 가이드로 변환됩니다. “Drive 3 has bad sectors”라는 무미건조한 알림 대신 “디스크 3에서 배드 섹터가 감지되었습니다. DSM → 저장소 관리자에서 S.M.A.R.T. 검사를 실행하고, 경고가 반복되면 디스크 교체를 준비하세요”라는 구체적 가이드를 받을 수 있습니다.
시나리오 2: 뉴스레터 AI 큐레이션 → 주간 다이제스트
기술 뉴스레터를 10개 구독하고 있다면, 매일 쏟아지는 메일을 읽기가 버겁습니다. 모든 뉴스레터의 수신 주소를 [email protected]으로 통일하고, Email Agent가 처리하게 만들면:
- 매일 수신되는 뉴스레터를 AI가 자동 분류·요약하고 D1에 축적
- 매주 금요일 오후에 Cron Trigger가 Workflow를 깨움
- Workflow가 한 주치 요약을 AI로 종합 → HTML 다이제스트 이메일로 발송
주당 이메일 읽는 시간이 2시간에서 15분으로 줄어듭니다.
실습 명령어 모음 (Synology NAS / Mac Studio)
이번 화에서 사용한 명령어를 순서대로 정리합니다. SSH 터미널에서 복사·붙여넣기로 바로 실행할 수 있습니다.
# ── 프로젝트 셋업 ──
npm create cloudflare@latest email-agent -- --type worker-ts
cd email-agent
npm install mimetext postal-mime
# ── 인프라 리소스 생성 ──
npx wrangler d1 create email-agent-db
npx wrangler r2 bucket create email-attachments
# ── DB 마이그레이션 ──
npx wrangler d1 migrations apply email-agent-db
# ── 로컬 개발 ──
npx wrangler dev
# ── 배포 ──
npx wrangler deploy
# ── 모니터링 ──
npx wrangler tail email-agent
# ── Workflow 관리 ──
npx wrangler workflows instances list email-agent-workflow
npx wrangler workflows instances describe email-agent-workflow \
--instance-id "YOUR_INSTANCE_ID"
# ── Email Routing 상태 확인 ──
# Cloudflare Dashboard → 도메인 → Email → Email Routing 에서 확인
# ── 테스트: 이메일 발송 후 로그 조회 ──
curl https://email-agent.YOUR_SUBDOMAIN.workers.dev/logs | jq .
월 비용 명세표
이번 화에서 다룬 기능들의 Cloudflare 플랜별 비용입니다.
| 기능 | Free | Workers Paid ($5/월) | 비고 |
|---|---|---|---|
| Email Routing (수신) | 무제한 | 무제한 | 라우팅 규칙 무료 |
| Email Workers (수신 처리) | 10만 요청/일 | 1,000만 요청/월 포함 | Workers 요청 한도에 포함 |
| Email Service (발신) | 퍼블릭 베타 무료 | 퍼블릭 베타 무료 | GA 이후 요금제 미정 |
| Workflows v2 | 1,000 인스턴스/일 | 50,000 동시 인스턴스 | CPU 시간은 Workers 한도에 포함 |
| Workers AI | 일 1만 뉴런/무료 | 추가분 과금 | 모델별 뉴런 단가 상이 |
| D1 (SQLite) | 5GB 스토리지, 500만 읽기/일 | $0.75/100만 읽기 초과분 | 메타데이터 저장에 적합 |
| R2 (첨부파일) | 10GB, 100만 A작업, 1,000만 B작업 | $0.015/GB·월 초과분 | 송신료 0원 |
현실적 비용 시나리오: 하루 50통 이메일을 처리하는 개인용 AI 에이전트의 경우, 모든 구성요소가 무료 한도 내에서 동작합니다. Workers Paid($5/월)로 업그레이드하면 50,000 동시 Workflow 인스턴스와 넉넉한 AI 뉴런을 확보할 수 있어 소규모 팀까지 커버됩니다. 참고로 SendGrid 유료 플랜(월 $19.95~)이나 Mailgun($35/월~)과 비교하면 이메일 발신 비용이 사실상 0이라는 점이 Email Service의 가장 큰 강점입니다.
Email Service + Workflows v2가 에이전트 생태계에서 차지하는 위치
지금까지 시리즈에서 쌓아올린 Cloudflare 에이전트 스택을 한 눈에 정리합니다.
| 에이전트 능력 | Cloudflare 서비스 | 시리즈 회차 |
|---|---|---|
| 두뇌 (추론) | Workers AI | 12화 |
| 손 (도구 접근) | MCP Server Portals | 14화 |
| 기억 (상태 저장) | D1 + KV + Durable Objects | 10화 |
| 격리 실행 환경 | Sandboxes + Dynamic Workers | 13화 |
| 소통 채널 (이메일) | Email Service | 15화 (이번 화) |
| 지구력 (장기 실행) | Workflows v2 | 15화 (이번 화) |
이 모든 서비스가 같은 Cloudflare 계정, 같은 wrangler.toml, 같은 Workers 런타임 안에서 바인딩 한 줄로 연결됩니다. AWS라면 SES + Step Functions + Lambda + DynamoDB + S3를 각각 IAM 롤로 엮어야 하는 작업이, Cloudflare에서는 하나의 프로젝트 파일 안에서 끝납니다. 1인 개발자에게 이 생산성 차이는 결정적입니다.
주의사항과 현재 제약
Email Service가 아직 퍼블릭 베타인 만큼, 알아둘 제약이 있습니다.
- 발신 한도: 베타 기간 중 발신량에 일정 제한이 있을 수 있습니다. 대량 마케팅 이메일 용도로는 부적합하며, 트랜잭셔널(알림·응답) 용도에 최적화돼 있습니다.
- 도메인 인증: 발신에 사용할 도메인은 Cloudflare DNS에 등록되어 있어야 합니다. 외부 DNS를 사용하는 도메인에서는 추가 설정이 필요합니다.
- 수신 이메일 크기: Email Workers는 최대 25MB 이메일을 처리합니다. 대용량 첨부파일이 빈번하다면 크기 검사 로직을 넣어두세요.
- Workflows 디버깅: step 내부에서
console.log는 동작하지만,wrangler tail로 볼 수 있는 것은 실시간 실행 중인 step뿐입니다. sleep 중인 인스턴스는 로그가 없습니다(당연히 — 실행 중이 아니니까). - step 콜백의 순수성:
step.do()의 콜백은 멱등해야 합니다. 재시도 시 같은 콜백이 다시 호출되므로, 부수효과가 있는 작업(이메일 발송, 외부 API 호출)은 중복 실행에 대한 방어가 필요합니다. 이메일 발송의 경우 D1에 “발송 완료” 플래그를 기록하고, 콜백 시작 시 이를 확인하는 패턴이 안전합니다.
정리 — 에이전트는 채널과 지구력으로 완성된다
이번 화에서 우리는 두 가지 핵심 인프라를 다뤘습니다.
- Cloudflare Email Service: AI 에이전트에게 이메일 수·발신 능력을 부여. 외부 이메일 API 없이, Worker 코드 안에서 이메일을 읽고 쓸 수 있게 됐습니다.
- Workflows v2: 30초 제한을 넘어 시간·일·주 단위의 장기 워크플로우를 실행.
step.do로 원자적 실행,step.sleep으로 비용 없는 대기,step.waitForEvent로 Human-in-the-Loop 패턴을 구현했습니다.
두 서비스를 결합하면 “이메일 수신 → AI 분석 → 사람 승인 대기 → 결과 회신”이라는 실제 비즈니스 워크플로우를 서버 없이, 월 $5 이하로 운영할 수 있습니다. 12화의 Workers AI가 에이전트에게 “생각하는 능력”을 줬다면, 이번 화의 Email + Workflows는 “소통하고 기다리는 능력”을 줬습니다.
다음 최종화(16화)에서는 지금까지 16회에 걸쳐 쌓아올린 모든 Cloudflare 서비스를 하나의 풀스택 프로젝트로 통합합니다. DNS부터 CDN, Tunnel, R2, D1, Workers AI, Workflows, Email까지 — 1인 개발자가 Cloudflare만으로 서버리스 SaaS를 창업하는 완전한 청사진을 그립니다.
이미지는 Leonardo AI 로 생성되었습니다.
이미지는 Claude AI 로 생성되었습니다.
◀ 이전 14화 (다음 차수는 아직 게시되지 않았습니다)

