TypeScript와 Hono.js를 활용한 소셜 미디어용 AI 에이전트 구축
요약
Hono.js와 Cloudflare Workers를 활용하여 데이터 수집부터 콘텐츠 생성 및 게시까지 수행하는 무인 AI 에이전트 백엔드 구축 방법을 설명합니다. 스케줄링된 크론 트리거를 통해 서버리스 환경에서 효율적으로 작동하는 에이전트 아키텍처를 제안합니다.
핵심 포인트
- Hono.js와 Cloudflare Workers를 이용한 서버리스 에이전트 아키텍처 구축
- Cron Triggers를 활용한 별도 스케줄러 없는 자동화 워크플로우 구현
- Fetch-Reasoning-Generation-Publish로 이어지는 파이프라인 설계
- Claude API와 Drizzle ORM을 결합한 데이터 기반 에이전트 개발
지금 모두가 AI 에이전트(AI agents)에 대해 이야기하고 있지만, 대부분의 튜토리얼은 "루프 내에서 LLM 호출하기" 수준에서 멈춥니다. 만약 데이터 수집, 사고, 콘텐츠 작성 및 게시까지 스스로 수행하며 무인으로 작동하는 에이전트를 실제로 원한다면, 단순한 프롬프트가 아닌 실제 백엔드(backend)가 필요합니다. 이 포스트에서는 제가 정확히 그 목적으로 사용하는 아키텍처를 설명합니다. 바로 Cloudflare Workers에서 실행되는 Hono.js를 기반으로 구축하여, 새로운 데이터를 찾고, Claude를 사용하여 소셜 포스트 초안을 작성하며, 이를 게시하는 스케줄링된 에이전트입니다.
저는 "금융 뉴스를 LinkedIn/Reddit에 게시하기"를 실행 예시로 사용하겠지만, 이 패턴은 모든 "감시(watch) → 사고(think) → 행동(act) → 게시(publish)" 에이전트에 일반화될 수 있습니다.
에이전트 백엔드를 위한 Hono.js를 사용하는 이유
Hono는 Cloudflare Workers, Deno, Bun, 그리고 Node에서 실행되는 작고 빠른 웹 프레임워크입니다. 특히 에이전트 워크로드(workloads)의 경우, 세 가지 요소가 적합성을 높여줍니다:
- 네이티브 Cloudflare Cron Triggers — 스케줄에 따라 실행되는 에이전트는 별도의 작업 스케줄러나 항상 켜져 있는 서버가 필요하지 않습니다.
- 엣지 런타임(Edge runtime), 거의 제로에 가까운 콜드 스타트(cold start) — 에이전트가 깨어나서 작업을 수행하고 사라집니다. 유휴 시간(idle uptime)이 아닌 실행 시간에 대해서만 비용을 지불합니다.
- 미들웨어(Middleware) 모델 — 자체 에이전트의 관리용 라우트에 대한 인증(auth), 로깅(logging), 속도 제한(rate-limiting)을 무료로 사용할 수 있습니다.
아키텍처
Cron Trigger (Cloudflare Workers 상의 Hono) │ ├── 1. Fetch 단계 → 외부 API에서 원시 데이터 가져오기 ├── 2. Reasoning 단계 → Claude API가 게시할 가치가 있는 것을 결정 ├── 3. Generation 단계 → Claude API가 플랫폼별 맞춤형 카피 초안 작성 ├── 4. 중복 체크(Dedup check) → Postgres/Neon, 이미 게시된 항목은 건너뜀 └── 5. Publish 단계 → 소셜 API (또는 통합 게시 제공업체)
각 단계는 단순한 비동기(async) 함수입니다. 에이전트 프레임워크도, 숨겨진 상태 머신(state machine)도 없습니다. 그저 위에서 아래로 읽을 수 있고 단위 테스트(unit test)가 가능한 파이프라인일 뿐입니다.
프로젝트 설정
npm create hono@latest social-agent
cd social-agent
npm install
프롬프트가 나타나면 cloudflare-workers 템플릿을 선택하세요. 그런 다음 필요한 것들을 추가합니다:
npm install @anthropic-ai/sdk drizzle-orm @neondatabase/serverless
1단계: 크론 트리거 (The cron trigger)
wrangler.toml에서 에이전트가 깨어날 시간을 정의합니다:
[triggers]
crons = ["0 * * * 1-5"] # 매시간, 평일만
Hono 앱에서는 스케줄된 이벤트(scheduled event)를 HTTP 라우트와 분리하여 처리합니다:
import { Hono } from 'hono'
const app = new Hono()
export default {
fetch: app.fetch,
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(runAgentCycle(env))
},
}
ctx.waitUntil은 매우 중요합니다. 이는 기다릴 HTTP 응답이 없더라도, 비동기 작업이 완료될 때까지 Worker 런타임이 인스턴스를 계속 유지하도록 지시합니다.
2단계: 최신 데이터 가져오기 (Fetch fresh data)
이 단계는 단순하게 유지하세요. 무언가를 결정하는 것이 아니라 구조화된 데이터(structured data)를 반환해야 합니다.
interface RawEvent {
id: string
title: string
payload: Record<string, unknown>
}
async function fetchLatestEvents(env: Env): Promise<RawEvent[]> {
const res = await fetch('https://api.example.com/events?window=today', {
headers: { Authorization: Bearer ${env.SOURCE_API_KEY} },
})
if (!res.ok) throw new Error(Source fetch failed: ${res.status})
const data = await res.json()
return data.events
}
3단계: Claude가 게시할 가치가 있는 것을 결정하게 하기 (Let Claude decide what's worth posting)
이 부분은 사람들이 건너뛰고 후회하는 대목입니다. 모든 항목에 대해 게시물을 자동으로 초안 작성하지 마세요. 모델이 먼저 분류(triage)하도록 하세요. 이것이 비용도 더 저렴하며, 피드가 봇이 쏟아낸 쓰레기처럼 보이는 것을 방지해 줍니다.
import Anthropic from '@anthropic-ai/sdk'
async function triageEvents(events: RawEvent[], env: Env) {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system:
'당신은 소셜 미디어에 올릴 가치가 있는 이벤트를 분류합니다. 놀라움, 패턴, 눈에 띄는 숫자와 같이 진정으로 흥미로운 관점을 가진 항목만 표시하세요. 산문(prose) 없이 엄격한 JSON 형식으로 반환하세요.',
messages: [
{
role: 'user',
content: JSON.stringify(events),
},
],
})
const text = message.content.find((c) => c.type === 'text')?.text ?? '[]'
return JSON.parse(text) as { id: string; angle: string }[]
}
처음에 "엄격한 JSON, 산문 제외(strict JSON, no prose)"를 요청하면, 나중에 취약한 정규 표현식(regex)으로 텍스트를 제거해야 하는 단계를 줄일 수 있습니다.
4단계: 플랫폼별 카피 생성
LinkedIn과 Reddit은 서로 다른 규범을 가지고 있습니다. LinkedIn은 자신감 있고 분석적인 어조를 선호하는 반면, Reddit은 마케팅 문구처럼 읽히는 모든 것을 기피합니다. 한 번의 호출로 두 가지를 모두 생성하되, 하나의 초안을 모든 곳에 재사용하기보다는 차이점을 명시적으로 프롬프트(prompt)에 요청하세요.
async function draftPosts(item: RawEvent, angle: string, env: Env) {
const anthropic = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY })
const message = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 800,
system: 이 이벤트에 대한 게시물 두 가지 버전을 작성하세요. LinkedIn: 80-150 단어, 분석적인 어조, 관련 맥락에 대한 가벼운 언급 한 번, 해시태그 남발 금지. Reddit: 토론 시작을 위한 프레임으로 구성, 홍보성 언어 사용 금지, 진지한 질문으로 마무리. JSON 반환 형식: { "linkedin": "...", "reddit": "..." },
messages: [
{ role: 'user', content: Event: ${item.title}\nAngle: ${angle}\nData: ${JSON.stringify(item.payload)} },
],
})
const text = message.content.find((c) => c.type === 'text')?.text ?? '{}'
return JSON.parse(text) as { linkedin: string; reddit: string }
}
5단계: Postgres를 사용한 중복 제거
크론(cron) 작업이 겹쳐서 동일한 내용을 두 번 게시하는 것만큼 신뢰도를 빠르게 떨어뜨리는 일은 없습니다. Neon의 서버리스 드라이버(serverless driver)를 사용하세요. 이는 HTTP를 통해 작동하는데, 이는 지속적인 TCP 연결을 가질 수 없는 Workers 환경에서 매우 중요합니다.
import { neon } from '@neondatabase/serverless'
async function alreadyPosted(env: Env, eventId: string) {
const sql = neon(env.DATABASE_URL)
const rows = await sqlSELECT 1 FROM posted_events WHERE event_id = ${eventId}
return rows.length > 0
}
async function markPosted(env: Env, eventId: string, platform: string, postId: string) {
const sql = neon(env.DATABASE_URL)
await sql INSERT INTO posted_events (event_id, platform, post_id, posted_at) VALUES (${eventId}, ${platform}, ${postId}, now())
}
Step 6: 게시 (Publish)
You는 여기서 두 가지 실제 옵션을 가집니다:
- 네이티브 플랫폼 API. LinkedIn은 승인된 Company Page와 w_member_social 스코프가 필요하며, Reddit은 자체 OAuth 앱이 필요하고 엄격한 속도 제한(rate limits)을 준수합니다. 둘 다 구현 가능하지만 처음 설정하는 데 시간이 걸립니다.
- 여러 플랫폼을 하나의 API 뒤에 추상화하는 통합 게시 제공업체 (예: Ayrshare). MVP를 배포하기가 훨씬 빠르며, 네이티브 통합에 투자하기 전에 아이디어를 검증할 경우 가치가 있습니다.
async function publish(platform: 'linkedin' | 'reddit', content: string, env: Env) {
const res = await fetch('https://api.ayrshare.com/api/post', {
method: 'POST',
headers: {
Authorization: Bearer ${env.AYRSHARE_API_KEY},
'Content-Type': 'application/json',
},
body: JSON.stringify({ post: content, platforms: [platform] }),
})
if (!res.ok) throw new Error(Publish failed on ${platform}: ${res.status})
return res.json()
}
Wiring it together (함께 연결하기)
async function runAgentCycle(env: Env) {
const events = await fetchLatestEvents(env)
const flagged = await triageEvents(events, env)
for (const { id, angle } of flagged) {
if (await alreadyPosted(env, id)) continue
// ...
const source = events.find((e) => e.id === id)!
const { linkedin, reddit } = await draftPosts(source, angle, env)
...
}
}
실제로 중요한 가드레일 (Guardrail)
데이터를 가져와서(fetch) 초안을 작성하는(draft) 파이프라인을 자동화하는 것은 안전합니다. 하지만 Reddit에 게시하는 단계를 자동화하는 것은, 적어도 처음에는 그렇지 않습니다. 대부분의 활발한 서브레딧(subreddits)은 엄격한 자기 홍보 규칙을 가지고 있으며, 홍보성 뉘앙스를 띠며 예측 가능한 일정으로 게시물을 올리는 계정은 빠르게 봇(bot)으로 분류되어 플래그가 지정되거나, 때로는 완전히 섀도우밴(shadowbanned)을 당하기도 합니다. 이를 해결하기 위한 두 가지 방법이 있습니다:
- 특히 Reddit의 경우 인간 참여형 (Human-in-the-loop) 방식을 사용하세요. 초안을 큐(Queue)에 쌓아두고 (Slack, 이메일, 또는 동일한 Hono 앱 내의 간단한 관리자 경로), 게시되기 전에 수동 승인을 거치도록 합니다.
- 언어를 조언이 아닌 묘사 위주로 유지하세요. 금융 관련 내용의 경우, "수익이 예상치를 X% 상회했습니다"는 논평(commentary)이지만, "이것을 사야 합니다"는 책임지고 싶지 않은 조언(advice)의 영역으로 넘어갑니다.
LinkedIn은 일관된 게시 주기(posting cadence)에 대해 더 관대하므로, 먼저 완전히 자동화하기에 더 안전한 플랫폼입니다.
앞으로의 방향
크론 트리거 (cron trigger), 가져오기 (fetch), 분류 (triage), 생성 (generate), 중복 제거 (dedup), 게시 (publish)로 이어지는 이 동일한 골격은 금융 뉴스보다 훨씬 더 많은 분야에 적용될 수 있습니다. 가져오기(fetch) 단계를 GitHub 릴리스(releases), 제품 리뷰, 컨퍼런스 논문 모집(CFPs), 또는 본인 제품의 사용 지표(usage metrics)로 바꾸기만 하면, 동일한 신뢰성 보장을 갖춘 다른 에이전트를 만들 수 있습니다.
초기에 제대로 구현해야 할 부분은 분류 (triage) 단계입니다. 모든 것에 대해 게시하는 에이전트는 단순히 단계만 더 복잡한 소음(noise)일 뿐이지만, 진정한 관점(angle)이 있을 때만 목소리를 내는 에이전트는 사람들이 실제로 팔로우하는 에이전트가 됩니다.
비슷한 것을 구축하고 계신다면, 어떤 것을 자동화하고 계신지 댓글로 알려주세요. 정말 궁금합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기