성능 최적화 & 모니터링
이 챕터에서 다루는 것
빠르게 동작하는 코드를 작성했더라도, 운영 단계에서는 세 가지 질문이 남는다.
- 빠른가? — 같은 응답을 반복 생성하지 않고 캐시하고 있는가
- 무슨 일이 일어나고 있는가? — 요청·에러·지연을 모니터링하고 있는가
- 얼마나 드는가? — 요금이 예산 안에 있는가
Cache API — 응답 캐싱
Workers에는 두 종류의 캐시가 있다.
| 방식 | 위치 | 특징 |
|---|---|---|
Cache-Control 헤더 | CDN 자동 처리 | 응답 헤더만 설정하면 동작 |
caches.default | Workers 코드에서 제어 | 캐시 키·TTL·무효화를 직접 제어 |
Cache-Control 헤더 (간단한 방식)
app.get('/posts', async (c) => {
const { results } = await c.env.DB
.prepare('SELECT * FROM posts')
.all()
return c.json(results, 200, {
// 브라우저: 1분 캐시 / Cloudflare CDN: 1시간 캐시
'Cache-Control': 'public, max-age=60, s-maxage=3600',
// ETag으로 조건부 요청 지원
'ETag': `"${hashPosts(results)}"`,
})
})
caches.default (코드에서 직접 제어)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
const cache = caches.default
const cacheKey = new Request(request.url, request)
// 1. 캐시 확인
const cached = await cache.match(cacheKey)
if (cached) return cached
// 2. 캐시 미스 — 원본 로직 실행
const data = await fetchExpensiveData(env)
const response = Response.json(data, {
headers: { 'Cache-Control': 'public, max-age=300' },
})
// 3. 캐시 저장 (ctx.waitUntil — 응답 반환 후 백그라운드 처리)
ctx.waitUntil(cache.put(cacheKey, response.clone()))
return response
}
}
ctx.waitUntil() 은 응답을 먼저 클라이언트에게 보내고, 캐시 저장을 백그라운드에서 처리한다. 응답 지연 없이 캐시를 채울 수 있다.
캐시 무효화
// 특정 URL의 캐시 삭제
app.post('/admin/cache/purge', async (c) => {
const { url } = await c.req.json()
await caches.default.delete(new Request(url))
return c.json({ purged: url })
})
주의 —
caches.default는 Workers가 실행되는 PoP(데이터센터) 단위로 캐시된다. 서울 사용자와 도쿄 사용자는 다른 캐시를 본다. 글로벌 캐시 무효화가 필요하면 Workers KV를 캐시 레이어로 사용한다.
KV를 캐시 레이어로 활용
KV는 쓰기가 느리지만(최대 60초 전파) 읽기가 매우 빠르다. D1 쿼리 결과를 KV에 캐시하면 D1 요금과 응답 시간을 동시에 줄일 수 있다.
async function getPostsWithCache(env: Env): Promise<Post[]> {
const CACHE_KEY = 'posts:list'
const TTL = 300 // 5분
// KV 캐시 확인
const cached = await env.CACHE.get(CACHE_KEY, 'json')
if (cached) return cached as Post[]
// D1 조회
const { results } = await env.DB
.prepare('SELECT * FROM posts ORDER BY created_at DESC LIMIT 50')
.all<Post>()
// KV에 저장 (expirationTtl로 자동 만료)
await env.CACHE.put(CACHE_KEY, JSON.stringify(results), {
expirationTtl: TTL,
})
return results
}
글쓰기·수정 시 캐시를 무효화한다.
app.post('/posts', async (c) => {
const { title, content } = await c.req.json()
await c.env.DB
.prepare('INSERT INTO posts (title, content) VALUES (?, ?)')
.bind(title, content)
.run()
// 목록 캐시 무효화
await c.env.CACHE.delete('posts:list')
return c.json({ ok: true }, 201)
})
Analytics Engine — 사용자 지표 수집
Analytics Engine은 Workers에서 이벤트를 기록하면 Cloudflare가 집계·시각화해주는 서비스다. Google Analytics 없이 자체 지표를 수집할 수 있다.
# wrangler.toml
[[analytics_engine_datasets]]
binding = "ANALYTICS"
dataset = "my_events"
type Env = { ANALYTICS: AnalyticsEngineDataset }
app.get('/posts/:id', async (c) => {
const post = await c.env.DB
.prepare('SELECT * FROM posts WHERE id = ?')
.bind(c.req.param('id'))
.first()
if (!post) return c.json({ error: 'Not Found' }, 404)
// 조회 이벤트 기록 (비동기 — 응답 지연 없음)
c.env.ANALYTICS.writeDataPoint({
blobs: [
c.req.param('id'), // 게시글 ID
c.req.header('CF-IPCountry') ?? 'unknown', // 국가 코드
],
doubles: [1], // 조회수 카운트
indexes: [c.req.param('id')], // 집계 키
})
return c.json(post)
})
수집된 데이터는 Workers Analytics → Dataset에서 SQL로 조회한다.
-- 최근 24시간 게시글별 조회수
SELECT
blob1 AS post_id,
SUM(_sample_interval * double1) AS views
FROM my_events
WHERE timestamp > NOW() - INTERVAL '1' DAY
GROUP BY post_id
ORDER BY views DESC
LIMIT 10
Tail Workers — 요청 로그 중앙화
Tail Worker는 다른 Workers의 요청·응답·에러를 수신하는 특수 Worker다. 중앙 로깅 시스템을 만들 때 사용한다.
// src/tail.ts — Tail Worker
export default {
async tail(events: TraceItem[]) {
for (const event of events) {
// 에러가 발생한 요청만 외부 로깅 서비스로 전송
if (event.exceptions.length > 0) {
await fetch('https://logs.example.com/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worker: event.scriptName,
url: event.event?.request?.url,
errors: event.exceptions.map((e) => ({
name: e.name,
message: e.message,
})),
timestamp: new Date(event.eventTimestamp).toISOString(),
}),
})
}
}
}
}
# wrangler.toml (메인 Worker)
[tail_consumers]
service = "my-tail-worker"
비용 계산
Cloudflare Workers 무료 플랜과 유료 플랜의 차이:
| 항목 | 무료 | Workers Paid ($5/월) |
|---|---|---|
| 요청 | 10만 회/일 | 1000만 회/월 포함, 초과 시 $0.30/100만 |
| CPU 시간 | 요청당 10ms | 요청당 30초 (최대) |
| 메모리 | 128 MB | 128 MB |
| Cron Triggers | 없음 | 있음 |
| KV 읽기 | 10만 회/일 | 1000만 회/월 포함 |
| D1 읽기 | 500만 회/일 | 250억 회/월 포함 |
실제 예시 계산
월 100만 API 요청 / D1 조회 300만 건 / R2 저장 5 GB / R2 서빙 50 GB 기준:
Workers Paid: $5.00 (기본)
Workers 요청: $0 (1000만 포함)
D1 읽기: $0 (250억 포함)
R2 저장: $0 (10 GB 무료)
R2 이그레스: $0 (무료)
─────────────────────
합계: $5/월
동일 스펙 AWS 기준 (EC2 t3.small + RDS + S3 + CloudFront):
EC2 t3.small: ~$16/월
RDS t3.micro: ~$25/월
S3 저장: $0.12/월
CloudFront 50GB: ~$4.50/월
─────────────────────────
합계: ~$46/월
핵심 — Workers + D1 + R2 조합은 소~중규모 트래픽에서 AWS 대비 5~10배 저렴하다. 단, CPU 집약적 작업(이미지 처리, 동영상 변환 등)은 Workers CPU 한도에 걸리므로 적합하지 않다.
성능 최적화 체크리스트
□ D1 쿼리에 인덱스가 있는가? (EXPLAIN QUERY PLAN으로 확인)
□ 반복 조회되는 데이터를 KV 또는 Cache API로 캐시하고 있는가?
□ R2 응답에 Cache-Control 헤더가 있는가?
□ ctx.waitUntil()로 로깅·캐시 저장을 백그라운드 처리하고 있는가?
□ D1 batch()로 N+1 쿼리를 하나의 요청으로 묶고 있는가?
□ Workers 번들 크기가 1 MB 이하인가? (wrangler deploy 시 출력 확인)
핵심 정리
import KeyPoints from ’../../components/KeyPoints.astro’
<KeyPoints points={[
“Cache-Control 헤더(간단) vs caches.default(코드 제어) — 정적 에셋은 헤더, 동적 캐시는 caches.default”,
“ctx.waitUntil()로 캐시 저장·로깅을 백그라운드 처리 — 응답 지연 없이 사이드이펙트 실행”,
“KV 캐시 레이어 패턴 — D1 읽기 비용 절감 + 응답 속도 개선, 쓰기 시 cache.delete()로 무효화”,
“Analytics Engine으로 외부 서비스 없이 자체 지표 수집 — SQL로 집계·분석 가능”,
“Workers + D1 + R2 조합은 소중규모에서 AWS 대비 510배 저렴 — 이그레스 무료가 핵심”
]} />
import Callout from ’../../components/Callout.astro’