Cloudflare에도 인프라 서비스가 있습니다
서론
저에게 Cloudflare는 오랫동안 “도메인 사고 DNS 설정하는 곳”이었습니다. 기껏해야 CDN을 붙이거나 DDoS를 막아주는 서비스 정도로만 알았습니다.
돌들의 숲을 만들기 시작했을 때도 처음엔 그랬습니다. 백엔드는 당연히 AWS에 EC2나 Lambda를 쓰거나, Railway 같은 PaaS를 쓸 거라고 막연히 생각했습니다. 그런데 Claude에게 “1인 개발로 운영 부담 없이 백엔드를 만들려면?”이라고 물었더니 Cloudflare Workers를 추천했습니다.
처음엔 반신반의했습니다. 서버를 대체하는 게 CDN 회사 제품이라니. 그런데 직접 써보면서 생각이 바뀌었습니다. Cloudflare는 이미 데이터베이스, 캐시, 상태 관리, 스케줄러까지 갖춘 인프라 플랫폼이었습니다. 단지 제가 몰랐을 뿐이었습니다.
이 글은 돌들의 숲 백엔드를 만들면서 직접 써본 Cloudflare 인프라 서비스들을 정리한 이야기입니다. 각 서비스를 왜 선택했는지, 실제로 어떻게 썼는지, 어디서 막혔는지를 함께 다룹니다.
전체 구조 한눈에 보기
6개 서비스가 wrangler.toml 하나로 연결됩니다.
클라이언트 (React 웹 / React Native 앱)
│
│ HTTP (GraphQL) / WebSocket (Subscription)
▼
┌────────────────────────────────────┐
│ Cloudflare Workers │
│ Hono 라우터 + GraphQL Yoga 서버 │
└──────┬──────────┬──────────┬───────┘
│ │ │
▼ ▼ ▼
┌───────┐ ┌───────┐ ┌─────────────────┐
│ D1 │ │ KV │ │ Durable Objects │
│ SQLite│ │세션·캐시│ │ · RateLimiter │
│주 데이터│ │ │ │ · PubSubBroker │
└───────┘ └───────┘ └─────────────────┘
백그라운드
┌─────────────────┐ ┌───────────────────────┐
│ Cron Triggers │───▶│ Workers (생명주기 Cron) │
│ (매일 새벽 3시) │ └───────────────────────┘
└─────────────────┘
이벤트 수집
Workers ──writeDataPoint()──▶ Analytics EngineWorkers가 중심입니다. 클라이언트 요청이 오면 Workers가 받아서 D1, KV, Durable Objects 중 필요한 곳을 호출합니다. 스케줄된 작업은 Cron Triggers가 Workers를 깨우고, 이벤트는 Analytics Engine에 기록됩니다. 이 모든 연결이 env.DB, env.KV_SESSIONS, env.RATE_LIMITER 같은 바인딩으로 이루어집니다.
본론
Cloudflare Workers — 서버 없는 백엔드의 시작
어떤 서비스인가
Cloudflare Workers는 Cloudflare의 엣지 네트워크 위에서 JavaScript(또는 TypeScript)를 실행하는 서버리스 런타임입니다.
일반적인 서버리스 함수는 특정 리전에 배포됩니다. 한국 사용자를 위해 서울 리전을 선택하면, 해외 사용자는 그만큼 멀리서 접속해야 합니다. Workers는 다릅니다. Cloudflare가 전 세계 330개 이상의 데이터센터에 코드를 배포해서, 요청이 들어오면 사용자와 가장 가까운 엣지에서 실행됩니다. 서울에서 요청하면 서울 근처 엣지에서, 뉴욕에서 요청하면 뉴욕 근처 엣지에서 실행됩니다. 리전을 선택하거나 관리할 필요가 없습니다.
런타임도 독자적입니다. Workers는 V8 엔진 기반의 런타임을 씁니다. fs, net 같은 Node.js 내장 모듈을 그냥 쓸 수 없고, 일부 API는 Workers 전용 방식으로 대체됩니다.
실제 사용 맥락
돌들의 숲 백엔드는 Hono + GraphQL Yoga 조합으로 GraphQL API 서버를 Workers 위에서 실행합니다. wrangler.toml 하나로 로컬 개발 환경, 개발 서버, 프로덕션을 모두 관리합니다.
// src/index.ts
import { Hono } from "hono";
import { createYoga } from "graphql-yoga";
const app = new Hono<{ Bindings: Env }>();
// GraphQL 엔드포인트
app.use("/graphql", async (c) => {
const yoga = createGraphQLServer(c.env);
return yoga.handle(c.req.raw, c);
});
// Workers fetch 핸들러
export default {
fetch: app.fetch,
scheduled: handleCron,
};Workers는 fetch 핸들러를 export하는 방식으로 동작합니다. HTTP 요청이 들어오면 이 함수가 실행됩니다. Express나 Fastify처럼 서버를 띄우는 게 아니라, 요청마다 함수가 호출되는 구조입니다.
배포는 단순합니다.
# 프로덕션 배포
wrangler deploy --env prod
# 로컬 개발
wrangler dev막혔던 부분 — Node.js 호환성 플래그
Workers 런타임은 Node.js가 아닙니다. 처음에 nodejs_compat 플래그 없이 개발을 시작했다가 일부 패키지가 런타임 에러를 냈습니다. Prisma처럼 내부적으로 Node.js 내장 모듈에 의존하는 패키지들이 Workers 환경에서 초기화에 실패하는 경우였습니다. wrangler.toml에 한 줄 추가하면 해결됩니다.
compatibility_flags = ["nodejs_compat"]이 플래그를 켜면 Node.js의 crypto, buffer 같은 내장 모듈을 Workers에서 쓸 수 있게 됩니다. 빠뜨리면 프로덕션 배포 후에야 에러가 나기도 하기 때문에, Workers로 전환할 때 처음부터 켜두는 편이 낫습니다.
AWS와 비교
| Cloudflare Workers | AWS Lambda | |
|---|---|---|
| 실행 위치 | 전 세계 엣지 (330+ 데이터센터) | 특정 리전 |
| 콜드 스타트 | 거의 없음 (수 밀리초) | 수백 ms ~ 수 초 |
| 런타임 | V8 (Web Standard API) | Node.js, Python 등 다양 |
| 무료 tier | 10만 req/일 | 100만 req/월 |
| 최대 실행 시간 | Free: CPU 10ms/req, Paid: 30초 (wall-clock은 별도) | 최대 15분 |
| 배포 도구 | Wrangler CLI | SAM, CDK, Serverless Framework 등 |
비교표의 CPU 시간은 실제 연산에 쓰인 시간 기준입니다. 대기, I/O, 외부 요청 대기 시간은 포함되지 않아서 일반적인 API 요청에서는 제한에 거의 걸리지 않습니다.
Lambda는 더 다양한 런타임을 지원하고 실행 시간 제한이 관대합니다. 긴 배치 처리나 Python 라이브러리가 필요한 ML 작업이라면 Lambda가 맞습니다.
Workers는 API 서버처럼 짧고 빠른 요청-응답에 특화되어 있습니다. 콜드 스타트가 없다는 점이 체감상 가장 크게 느껴지는 차이였습니다. Lambda는 트래픽이 뜸할 때 첫 요청에서 수백 밀리초의 지연이 생기는데, Workers에선 이런 걱정을 할 필요가 없었습니다.
한 가지 짚고 넘어갈 부분이 있습니다. AWS에도 Route 53 지리적 라우팅이나 Global Accelerator처럼 가까운 엔드포인트로 요청을 보내는 기능이 있습니다. 하지만 이 서비스들은 트래픽 경로 최적화입니다. 코드는 여전히 ap-northeast-2 같은 특정 리전에서만 실행됩니다. 서울 사용자가 Global Accelerator를 통해 접속해도, Lambda는 사전에 지정한 리전에서 동작합니다. 네트워크 홉은 줄지만 컴퓨팅 위치는 바뀌지 않습니다.
Workers와 실행 위치 면에서 실제로 비교되는 AWS 서비스는 Lambda@Edge나 CloudFront Functions입니다. 이 서비스들은 CloudFront PoP에서 코드를 실제로 실행합니다. 다만 제약이 있습니다. Lambda@Edge는 CloudFront distribution이 반드시 필요하고, Viewer Request 경로에서는 실행 시간이 5초로 제한됩니다. Workers처럼 D1, KV, Durable Objects와 바인딩으로 직접 연결하는 통합도 없습니다. 1인 개발 관점에서는 이 차이가 꽤 크게 느껴집니다.
D1 — 엣지에서 쓰는 SQLite
어떤 서비스인가
D1은 Cloudflare의 서버리스 SQL 데이터베이스입니다. 내부적으로 SQLite를 씁니다. Workers에서 직접 바인딩으로 접근할 수 있고, 별도의 연결 문자열이나 커넥션 풀 설정이 필요 없습니다.
처음 “SQLite 기반 DB를 프로덕션에?”라는 의문이 들었습니다. SQLite는 로컬 파일 DB 아닌가. 그런데 D1은 SQLite 파일을 Cloudflare 인프라에서 관리하고, Workers에서 HTTP가 아닌 바인딩으로 직접 접근합니다. 읽기는 가까운 엣지의 복제본에서 처리되어 매우 빠릅니다. 다만 쓰기는 Primary 리전까지 경유하기 때문에 지리적 거리에 따라 지연이 발생할 수 있습니다.
실제 사용 맥락
돌들의 숲에서는 Prisma와 @prisma/adapter-d1을 함께 써서 D1에 접근했습니다. Prisma가 D1용 어댑터를 공식 지원하기 때문에 ORM 레이어를 그대로 유지하면서 D1을 쓸 수 있었습니다.
// src/lib/prisma.ts
import { PrismaD1 } from "@prisma/adapter-d1";
import { PrismaClient } from "@/generated/prisma";
export function getPrismaClient(db: D1Database): PrismaClient {
const adapter = new PrismaD1(db);
return new PrismaClient({ adapter });
}wrangler.toml에서 D1 데이터베이스를 바인딩으로 등록하면, Workers 핸들러에서 env.DB로 바로 접근할 수 있습니다.
# wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "xxxxxxxx-xxxx-xxxx"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"마이그레이션도 Wrangler CLI로 처리합니다.
# 마이그레이션 적용
wrangler d1 migrations apply forest-of-stones-db-prod --env prod
# SQL 직접 실행
wrangler d1 execute forest-of-stones-db-prod --command "SELECT COUNT(*) FROM Stone"막혔던 부분 ① — SQLite 방언 차이
Prisma는 기본적으로 PostgreSQL 기능을 많이 씁니다. D1은 SQLite 기반이라 일부 기능이 다르게 동작합니다. uuid() 같은 함수가 SQLite에서 지원되지 않아서 UUID 생성 로직을 애플리케이션 레벨에서 처리해야 했습니다. 처음엔 왜 마이그레이션이 안 되는지 한참 헤맸는데, D1의 SQLite 방언 차이를 이해하고 나서 해결됐습니다.
막혔던 부분 ② — WASM 메모리 누수
Prisma는 내부적으로 WASM을 씁니다. Workers는 요청 사이에 인스턴스가 재사용되기 때문에, PrismaClient를 요청마다 새로 만들고 $disconnect()를 호출하지 않으면 WASM 메모리가 누적됩니다. 처음엔 이 문제를 몰랐다가 메모리가 계속 올라가는 것을 발견하고 나서야 원인을 파악했습니다.
해결책은 사용 후 반드시 $disconnect()를 보장하는 헬퍼를 만드는 것입니다.
// 요청 완료 후 $disconnect() 보장
export async function withPrisma<T>(
fn: (prisma: PrismaClient) => Promise<T>,
db: D1Database,
): Promise<T> {
const prisma = getPrismaClient(db);
try {
return await fn(prisma);
} finally {
await prisma.$disconnect();
}
}성능이 중요한 상황이라면 PrismaClient 인스턴스를 전역에 하나만 생성해 재사용하는 싱글톤 패턴도 고려할 수 있습니다. 매 요청마다 $disconnect()를 호출하면 WASM 엔진을 재초기화하는 오버헤드가 발생하기 때문입니다. 다만 Workers는 V8 Isolate가 언제 교체될지 런타임이 결정하기 때문에 싱글톤이 항상 보장되지는 않으며, 현재 패턴이 메모리 안전성 측면에서는 더 예측 가능한 선택입니다.
AWS와 비교
| Cloudflare D1 | AWS RDS | AWS DynamoDB | |
|---|---|---|---|
| 엔진 | SQLite | MySQL, PostgreSQL 등 | NoSQL (문서형) |
| 접근 방식 | Workers 바인딩 (네트워크 없음) | TCP 연결 (VPC 필요) | HTTP API |
| 무료 tier | 500MB, 5백만 쿼리/월 | 없음 (RDS) | 25GB, 25RCU/WCU |
| 운영 부담 | 거의 없음 | 인스턴스 관리 필요 | 거의 없음 |
| 복잡한 쿼리 | SQL 지원 | SQL 지원 | 제한적 |
AWS에서 RDS를 쓰면 VPC 설정, 보안 그룹, 인스턴스 타입 선택, 백업 정책까지 신경 써야 합니다. 1인 개발에서 이 오버헤드는 생각보다 큽니다. D1은 그 과정이 없습니다. 데이터베이스 생성하고 wrangler.toml에 ID 적어넣으면 끝입니다.
SQL을 그대로 쓸 수 있다는 것도 장점이었습니다. DynamoDB는 NoSQL 특성상 쿼리 패턴을 미리 설계해야 하고, 복잡한 조인이 어렵습니다. D1은 SQLite이기 때문에 익숙한 SQL 문법을 그대로 씁니다.
KV — 세션과 캐시를 위한 글로벌 키-값 저장소
어떤 서비스인가
Cloudflare KV는 전 세계 엣지에 분산된 키-값 저장소입니다. 이름처럼 단순합니다. 키를 넣고, 값을 꺼내고, 삭제합니다. TTL(만료 시간)을 설정할 수 있고, 값은 문자열, JSON, ArrayBuffer 등을 저장할 수 있습니다.
Redis와 역할이 비슷합니다. 다만 중요한 차이가 있습니다. KV는 Eventually Consistent입니다. 값을 쓰고 나서 전 세계 엣지에 복제되는 데 최대 60초 정도 걸릴 수 있습니다. 방금 쓴 값을 다른 엣지에서 바로 읽으면 아직 반영이 안 됐을 수 있다는 뜻입니다.
이 특성이 처음엔 불안하게 느껴졌는데, 실제로 쓰고 보니 캐시와 세션 용도에서는 문제가 없었습니다. 세션 토큰은 로그인한 직후에 바로 다른 엣지에서 검증하는 경우가 거의 없고, 캐시는 약간의 지연이 허용됩니다.
실제 사용 맥락
돌들의 숲에서는 KV를 두 가지 용도로 씁니다.
KV_SESSIONS: JWT Refresh Token을 저장합니다. 사용자가 로그인하면 토큰을 KV에 저장하고, 로그아웃하면 삭제합니다. 멀티 디바이스 세션 관리도 KV로 구현했습니다. 한 사용자가 최대 3개 기기까지 로그인할 수 있는데, user:{userId}:devices 키에 기기 목록을 JSON으로 저장합니다.
KV_CACHE: API 응답 캐시를 저장합니다. 자주 바뀌지 않는 데이터(공지사항, 설정값 등)는 D1 대신 KV에서 먼저 읽고, 없으면 D1에서 가져와서 KV에 저장합니다.
// src/services/KVService.ts
export class KVService {
static async set(
kv: KVNamespace,
key: string,
value: string,
ttlSeconds?: number,
): Promise<void> {
await kv.put(key, value, ttlSeconds ? { expirationTtl: ttlSeconds } : undefined);
}
static async get(kv: KVNamespace, key: string): Promise<string | null> {
return await kv.get(key, { type: "text" });
}
static async setJSON<T>(kv: KVNamespace, key: string, value: T, ttlSeconds?: number): Promise<void> {
await kv.put(key, JSON.stringify(value), ttlSeconds ? { expirationTtl: ttlSeconds } : undefined);
}
static async getJSON<T>(kv: KVNamespace, key: string): Promise<T | null> {
const value = await kv.get(key, { type: "json" });
return value as T | null;
}
}
// 사용 예시 — Refresh Token 저장 (7일 TTL)
await KVService.set(env.KV_SESSIONS, `refresh:${userId}:${deviceId}`, token, 60 * 60 * 24 * 7);AWS와 비교
| Cloudflare KV | AWS ElastiCache (Redis) | AWS DynamoDB | |
|---|---|---|---|
| 일관성 | Eventually Consistent | Strong Consistent | 선택 가능 |
| 지연 시간 | 읽기 빠름 (엣지 캐시) | 매우 빠름 (메모리) | 낮음 (SSD) |
| 운영 부담 | 없음 | 클러스터 관리 필요 | 없음 |
| 무료 tier | 1GB, 1천만 읽기/월 | 없음 | 25GB, 25RCU/WCU |
| TTL 지원 | 네이티브 지원 | 네이티브 지원 | 네이티브 지원 |
Redis(ElastiCache)는 초당 수십만 ops를 처리할 수 있고 다양한 데이터 구조(List, Set, Sorted Set 등)를 지원합니다. 세밀한 캐시 전략이 필요하거나 트래픽이 많은 서비스라면 Redis가 맞습니다.
KV는 설정이 없습니다. 바인딩 추가하고 바로 씁니다. Eventually Consistent라는 제약이 있지만, 캐시나 세션처럼 정확한 일관성이 필요 없는 곳에서는 충분합니다. 1인 개발에서 Redis 클러스터를 관리하는 것보다 훨씬 현실적인 선택이었습니다.
Durable Objects — 상태가 필요할 때
어떤 서비스인가
Workers의 치명적인 약점이 하나 있습니다. 무상태(stateless) 라는 점입니다. 요청이 끝나면 메모리에 있던 모든 것이 사라집니다. 전역 변수에 값을 저장해봤자 다음 요청에서 그 값이 있다는 보장이 없습니다.
카운터, 뮤텍스, WebSocket 연결 목록처럼 상태를 유지해야 하는 기능은 Workers만으로 구현할 수 없습니다. KV로 상태를 저장할 수 있지만, KV는 Eventually Consistent라서 정확한 카운팅이 안 됩니다.
Durable Objects(DO)가 이 문제를 해결합니다. DO는 전 세계에서 단 하나의 인스턴스만 존재합니다. 같은 ID로 DO를 요청하면, 어느 엣지에서 요청하든 항상 같은 인스턴스에 도달합니다. 이 인스턴스는 메모리와 영구 스토리지를 가지고 있고, 요청들은 직렬화되어 처리됩니다. 레이스 컨디션이 없습니다.
영구 스토리지의 기본 API는 this.storage.get() / this.storage.put() 형태의 KV 인터페이스입니다. 내부적으로 SQLite를 사용하며, Cloudflare는 DO 내에서 SQL 쿼리를 직접 실행하는 기능도 제공하고 있습니다. 이 글의 코드 예시는 KV 방식을 사용합니다.
실제 사용 맥락
KV로 먼저 시도했다가 막힌 이유
처음 Rate Limiting을 구현할 때 KV로 카운터를 만들었습니다. get으로 현재 카운트를 읽고, +1 해서 put으로 다시 쓰는 구조였습니다. 동시 요청이 없을 때는 잘 동작했습니다.
문제는 동시 요청이 들어올 때였습니다. 두 요청이 거의 동시에 get을 하면 둘 다 같은 카운트 값을 읽습니다. 각자 +1을 해서 put을 하면, 두 번 요청했는데 카운트는 한 번만 올라갑니다. Eventually Consistent 특성이 레이스 컨디션으로 이어지는 전형적인 패턴입니다.
정확한 카운팅은 직렬화가 필요하고, 그게 DO를 도입하게 된 이유였습니다.
돌들의 숲에서는 DO를 두 가지 목적으로 씁니다.
RateLimiter: API 요청 수를 제한합니다. 사용자 ID별로 DO 인스턴스를 만들고(idFromName(userId)), 요청이 들어올 때마다 카운트를 확인합니다. DO는 요청을 직렬화해서 처리하기 때문에 레이스 컨디션이 생기지 않습니다. 윈도우 크기나 최대 요청 수 같은 제한값은 URL 파라미터로 받지 않고 클래스 상수로 고정합니다. 외부에서 파라미터를 조작해 제한을 우회하는 것을 막기 위해서입니다.
// src/durable-objects/RateLimiter.ts
export class RateLimiter implements DurableObject {
// 제한값은 클래스 상수로 고정 — URL 파라미터로 받으면 외부에서 조작 가능
private readonly WINDOW_MS = 60_000;
private readonly MAX_REQUESTS = 60;
constructor(private state: DurableObjectState) {}
async fetch(_request: Request): Promise<Response> {
const now = Date.now();
const stored = await this.state.storage.get<{ count: number; resetAt: number }>("state");
const current = (!stored || now >= stored.resetAt)
? { count: 1, resetAt: now + this.WINDOW_MS }
: { count: stored.count + 1, resetAt: stored.resetAt };
await this.state.storage.put("state", current);
const allowed = current.count <= this.MAX_REQUESTS;
return new Response(
JSON.stringify({ allowed, remaining: Math.max(0, this.MAX_REQUESTS - current.count) }),
{ status: allowed ? 200 : 429 },
);
}
}PubSubBroker: GraphQL Subscription용 WebSocket 브로커입니다. 실시간 알림 기능을 구현할 때 씁니다. 토픽별로 DO 인스턴스를 만들고(예: notification:userId), 클라이언트가 WebSocket으로 구독하면 DO가 연결을 관리합니다. 이벤트가 발생하면 Worker가 DO에 HTTP POST를 보내고, DO가 연결된 모든 WebSocket에 브로드캐스트합니다.
DO는 Workers를 통해서만 접근할 수 있고, 외부 인터넷에 직접 노출되지는 않습니다. 하지만 방어 심층(defense in depth) 원칙에 따라 DO 레벨에서도 내부 시크릿 헤더로 호출 출처를 검증합니다. INTERNAL_SECRET은 wrangler.toml의 [vars]가 아닌 wrangler secret으로 등록해 코드에 노출되지 않게 관리합니다.
// src/durable-objects/PubSubBroker.ts
export class PubSubBroker implements DurableObject {
constructor(private state: DurableObjectState, private env: Env) {}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/subscribe") {
// 앞단 Worker에서 JWT 검증 후 호출 — DO는 내부 시크릿으로 재확인
if (request.headers.get("X-Internal-Secret") !== this.env.INTERNAL_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const pair = new WebSocketPair();
this.state.acceptWebSocket(pair[1]); // Hibernatable WebSocket
return new Response(null, { status: 101, webSocket: pair[0] });
}
if (url.pathname === "/publish") {
// Workers 내부에서만 호출 — 외부 요청 차단
if (request.headers.get("X-Internal-Secret") !== this.env.INTERNAL_SECRET) {
return new Response("Unauthorized", { status: 401 });
}
const payload = await request.text();
for (const ws of this.state.getWebSockets()) {
ws.send(payload);
}
return new Response("ok");
}
return new Response("Not Found", { status: 404 });
}
}Hibernatable WebSockets API를 써서 연결이 유휴 상태일 때 DO가 메모리를 해제하도록 했습니다. 연결만 열려 있고 메시지가 없는 시간에 비용이 발생하지 않습니다.
AWS와 비교
| Cloudflare Durable Objects | AWS ElastiCache | AWS DynamoDB (Strong) | |
|---|---|---|---|
| 일관성 | Strong Consistent | Strong Consistent | Strong Consistent |
| 상태 유지 | 메모리 + 영구 스토리지 | 메모리 (영구 저장 옵션) | 영구 저장 |
| 글로벌 단일 인스턴스 | 지원 (idFromName) | 불가 (리전별) | 불가 |
| WebSocket 관리 | 네이티브 (Hibernatable) | 별도 구현 필요 | 해당 없음 |
| 운영 부담 | 없음 | 클러스터 관리 필요 | 없음 |
AWS에서 같은 기능을 구현하려면 ElastiCache Redis + 분산 락 라이브러리를 조합하거나, SQS로 직렬화를 구현해야 합니다. WebSocket 브로커는 API Gateway WebSocket + Lambda + DynamoDB 조합이 일반적인데, 아키텍처가 꽤 복잡해집니다.
DO는 개념이 처음엔 낯설지만, 익숙해지면 “상태가 필요하면 DO를 쓴다”는 규칙 하나로 정리됩니다. Redis나 DynamoDB를 Workers에 연결하는 것보다 단순한 방식으로 정확한 일관성을 얻을 수 있었습니다.
Analytics Engine — 데이터베이스 없이 만드는 분석 파이프라인
어떤 서비스인가
Analytics Engine은 시계열 이벤트 데이터를 수집하고 SQL로 조회하는 서비스입니다. Workers에서 analytics.writeDataPoint()를 호출하면 데이터가 저장되고, Cloudflare API를 통해 SQL로 집계 쿼리를 실행할 수 있습니다.
처음엔 “왜 D1을 쓰지 않나?”라는 생각이 들었습니다. D1도 결국 SQL이니까요. 차이는 목적에 있습니다. D1은 트랜잭션과 정규화된 데이터에 적합합니다. Analytics Engine은 쓰기는 빠르고 많이, 읽기는 집계로라는 시계열 패턴에 최적화되어 있습니다. 이벤트가 발생할 때마다 D1에 INSERT를 날리면 부하가 생기지만, Analytics Engine은 fire-and-forget 방식으로 데이터를 넣습니다.
실제 사용 맥락
돌들의 숲에서는 서비스 지표를 Analytics Engine에 기록합니다. 회원가입, 로그인, 원석 생성, 조약돌 생성 같은 핵심 이벤트들입니다.
// src/services/AnalyticsService.ts
// userId는 직접 저장하지 않고 단방향 해시로 익명화
async function hashId(id: string): Promise<string> {
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(id));
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
}
function writePoint(
analytics: AnalyticsEngineDataset | undefined,
index: string,
blobs: string[], // 문자열 차원 데이터 (해시된 userId, tagName 등)
doubles: number[], // 수치 데이터 (count, id 등)
): void {
if (!analytics) return;
analytics.writeDataPoint({ indexes: [index], blobs, doubles });
}
export const AnalyticsService = {
async trackSignUp(env: Env, userId: string): Promise<void> {
writePoint(env.ANALYTICS, "user_signup", [await hashId(userId)], [1]);
},
async trackLogin(env: Env, userId: string, deviceId: string): Promise<void> {
writePoint(env.ANALYTICS, "user_login", [await hashId(userId), await hashId(deviceId)], [1]);
},
async trackStoneCreated(env: Env, userId: string, tagName: string, trustLevel: string): Promise<void> {
writePoint(env.ANALYTICS, "stone_created", [await hashId(userId), tagName, trustLevel], [1]);
},
};writeDataPoint()는 await을 쓰지 않습니다. Workers 런타임이 백그라운드에서 처리하기 때문에 비즈니스 로직에 영향을 주지 않습니다. 저장이 실패해도 그냥 넘어갑니다. 분석 데이터 수집이 실패한다고 사용자 요청까지 실패시킬 필요는 없기 때문입니다.
userId와 deviceId는 crypto.subtle.digest로 SHA-256 해시 처리한 뒤 저장합니다. Analytics Engine 데이터에 접근할 수 있는 사람이 원본 식별자를 알 수 없게 하기 위해서입니다. 고유 사용자를 세는 집계 목적에는 해시만으로 충분하고, 원본 ID가 필요한 경우는 D1에서 직접 조회하면 됩니다.
저장된 데이터는 Cloudflare Analytics Engine SQL API로 조회합니다. 내부적으로 ClickHouse 기반이라 toStartOfDay 같은 함수를 씁니다.
-- DAU 조회 (최근 30일)
SELECT
toStartOfDay(timestamp) AS date,
COUNT(DISTINCT blob1) AS dau
FROM forest_of_stones_analytics_prod
WHERE index1 = 'user_login'
AND timestamp > NOW() - INTERVAL '30' DAY
GROUP BY date
ORDER BY date DESC관리자 대시보드에서 이 SQL을 실행해서 지표를 확인합니다.
AWS와 비교
| Cloudflare Analytics Engine | AWS CloudWatch | AWS Kinesis + Athena | |
|---|---|---|---|
| 사용 방식 | Workers 바인딩, 한 줄 호출 | SDK, PutMetricData API | Producer → Stream → S3 → Athena |
| 쿼리 방식 | SQL (ClickHouse 방언) | CloudWatch Insights (자체 문법) | Athena SQL |
| 설정 복잡도 | 낮음 | 낮음~중간 | 높음 |
| 무료 tier | 1천만 이벤트/월 | 기본 지표 무료, 커스텀은 유료 | 저장/쿼리 비용 발생 |
| 실시간성 | 수 분 내 | 수 분 내 | 수십 분 ~ 수 시간 |
AWS에서 커스텀 이벤트 로깅을 구축하려면 CloudWatch Custom Metrics 또는 Kinesis Data Firehose를 써야 합니다. CloudWatch Custom Metrics는 지표당 비용이 발생하고, Kinesis + Athena 파이프라인은 구성이 복잡합니다.
Analytics Engine은 Workers에서 한 줄로 데이터를 넣고, SQL로 바로 조회합니다. 별도의 파이프라인을 구성할 필요가 없어서 1인 개발에서 빠르게 지표 시스템을 구축하는 데 적합했습니다.
Cron Triggers — 스케줄 작업도 Workers에서
어떤 서비스인가
Cron Triggers는 정해진 스케줄에 따라 Workers를 실행하는 기능입니다. cron 문법으로 스케줄을 정의하고, Cloudflare 스케줄러가 시간이 되면 Workers의 scheduled 핸들러를 호출합니다.
실행 보장에 대해 알아두어야 할 점이 있습니다. Cron Triggers는 best-effort 방식입니다. 스케줄된 시간에 Workers가 실행되지만, 실행 중 오류로 종료되어도 자동으로 재시도되지 않습니다. 중요한 배치 작업이라면 실행 결과를 D1이나 Analytics Engine에 직접 기록해서 누락 여부를 모니터링해야 합니다. 돌들의 숲에서도 생명주기 Cron이 실행된 뒤 처리된 건수를 Analytics Engine에 기록하는 이유가 이것입니다.
반드시 실행되어야 하는 작업이라면 Cloudflare Queues와 연동해 재시도 로직을 붙이는 것이 권장됩니다. Cron이 작업을 Queue에 넣으면, 처리에 실패한 메시지는 설정한 횟수만큼 자동으로 재시도됩니다.
실제 사용 맥락
돌들의 숲에서는 콘텐츠 생명주기 관리에 씁니다. 원석(게시글)은 생성 후 30일이 지나면 삭제 예정 상태로 전환되고, 7일 후 완전히 삭제됩니다. 이 작업을 매일 새벽 3시(KST)에 실행합니다.
# wrangler.toml
[env.prod.triggers]
crons = ["0 18 * * *"] # UTC 18:00 = KST 03:00// src/index.ts
export default {
fetch: app.fetch,
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(runLifecycleUpdate(getPrismaClient(env.DB), env));
},
};여기서 ctx.waitUntil()이 중요합니다. Workers는 scheduled 함수가 return을 반환하는 순간 실행을 중단할 수 있습니다. ctx.waitUntil()에 Promise를 전달하면 그 Promise가 완료될 때까지 Workers가 살아있도록 보장합니다. 이걸 빠뜨리면 D1 쿼리가 절반만 실행된 채로 종료되는 상황이 생깁니다.
// src/jobs/lifecycleCron.ts
export async function runLifecycleUpdate(prisma: PrismaClient, env: Env) {
// 1. scheduledDeletionAt 도달한 원석 → SCHEDULED_DELETION 상태로 전환
const scheduled = await prisma.stone.updateMany({
where: {
scheduledDeletionAt: { lte: new Date() },
status: { in: ["ACTIVE", "COMPLETED", "ARCHIVED"] },
},
data: { status: "SCHEDULED_DELETION" },
});
// 2. SCHEDULED_DELETION + 7일 경과 → 완전 삭제
const toDelete = await prisma.stone.findMany({
where: {
scheduledDeletionAt: { lt: sevenDaysAgo() },
status: "SCHEDULED_DELETION",
},
});
// ... 삭제 처리
// 실행 결과를 Analytics에 기록 (모니터링용)
AnalyticsService.trackLifecycle(env, "scheduled", scheduled.count);
}로컬에서 Cron을 테스트할 때는 wrangler dev를 실행한 상태에서 별도 터미널에서 curl로 트리거할 수 있습니다.
curl "http://localhost:8787/__scheduled?cron=0+18+*+*+*"AWS와 비교
| Cloudflare Cron Triggers | AWS EventBridge Scheduler | AWS Lambda + CloudWatch Events | |
|---|---|---|---|
| 설정 방식 | wrangler.toml에 cron 문법 | 콘솔 또는 IaC (CDK, SAM) | 콘솔 또는 IaC |
| 실행 대상 | Workers | Lambda, ECS, Step Functions 등 | Lambda |
| 실패 시 재시도 | 없음 (best-effort) | 설정 가능 | 설정 가능 |
| 무료 tier | Workers 무료 tier에 포함 | 140만 호출/월 무료 | Lambda 무료 tier에 포함 |
| 로컬 테스트 | curl로 직접 트리거 | 로컬 시뮬레이션 어려움 | SAM Local로 가능 |
AWS EventBridge는 실패 시 재시도 정책을 설정할 수 있고, Lambda 외에도 여러 대상에 이벤트를 보낼 수 있습니다. 복잡한 이벤트 오케스트레이션이나 실패 재시도가 중요하다면 EventBridge가 적합합니다.
Cron Triggers는 Workers와 같은 환경에서 실행되기 때문에 D1, KV, DO 바인딩을 그대로 씁니다. 별도의 연결 설정 없이 스케줄 작업에서도 동일한 서비스 레이어를 재사용할 수 있다는 점이 편리했습니다.
결론
Cloudflare의 서비스들을 직접 사용해보기 전까지는 그저 도메인 등록 사이트로 생각했었습니다. 하지만, 지금은 인프라의 대부분을 Cloudflare 위에서 운영하고 있습니다.
이 글에서 소개한 6개 서비스 — Workers, D1, KV, Durable Objects, Analytics Engine, Cron Triggers — 는 각각 따로 쓰는 도구가 아닙니다. wrangler.toml 하나에 바인딩을 선언하면 모두 같은 런타임 안에서 연결됩니다. 서로 다른 서비스를 SDK로 연결하고 인증을 맞추는 과정 없이, Workers 안에서 env.DB, env.KV_SESSIONS, env.RATE_LIMITER를 바로 씁니다. 이 통합성이 1인 개발에서 특히 크게 느껴지는 부분이었습니다.
사용해본 서비스들을 1인 개발자 관점에서 정리하면 이렇습니다.
Cloudflare가 유리한 경우:
- 설정 없이 바로 시작하고 싶을 때. VPC, 보안 그룹, IAM 역할 없이
wrangler.toml하나로 모든 서비스를 연결합니다. - 콜드 스타트가 허용되지 않는 API 서버. Workers는 항상 따뜻합니다.
- 트래픽이 전 세계에 분산되어 있을 때. 엣지 배포가 기본입니다.
- 무료로 시작해서 천천히 스케일하고 싶을 때. Workers(10만 req/일), D1(5백만 쿼리/월), KV(1천만 읽기/월) 모두 넉넉한 무료 tier가 있습니다.
AWS가 유리한 경우:
- Node.js 생태계의 모든 패키지를 제약 없이 써야 할 때. Workers 런타임에서 지원되지 않는 패키지가 있습니다.
- 실행 시간이 긴 배치 처리. Workers의 CPU 시간 제한은 유료 플랜도 30초입니다.
- ML 추론처럼 특수 런타임(Python, GPU)이 필요한 작업.
- 이미 AWS 생태계에 깊이 연결된 레거시 시스템과 통합할 때.
결국 트레이드오프는 생산성 대 유연성입니다. Cloudflare는 Worker 하나에 DB, 캐시, 상태, 스케줄러가 바인딩 하나씩으로 붙습니다. 빠르고 단순합니다. AWS는 각 서비스를 직접 조합해야 해서 복잡하지만, 그만큼 더 정밀하게 제어할 수 있습니다.
1인 개발에서 인프라에 쓸 수 있는 시간은 제한되어 있습니다. 저는 서버 관리가 아닌 서비스를 만드는 데 시간을 쓰고 싶었고, Cloudflare는 그 선택에 잘 맞았습니다.
이 글의 배경이 된 프로젝트와 개발 과정이 궁금하다면, 이전 글 프론트엔드 개발자가 클로드로 풀스택 개발을 해봤습니다.에서 더 자세히 다루고 있습니다.