Edge Computing 실전 가이드 | Cloudflare Workers· Vercel Edge
이 글의 핵심
엣지 함수는 사용자와 가까운 곳에서 실행돼 지연을 줄이지만, 일반 서버처럼 fs 같은 Node 전용 모듈을 쓸 수 없고 상태를 유지할 수 없다는 조건이 붙습니다. 이 글은 플랫폼별 기본 예제와 캐싱·조건부 요청·스트리밍 최적화를 거쳐 API 게이트웨이, 이미지 최적화, 개인화, 서버사이드 A/B 테스트 사례로 이어집니다. 요청 수를 줄이는 비용 최적화까지 보고 나면 어떤 로직을 엣지로 옮길지 판단할 기준이 생깁니다.
들어가며
Edge Computing은 코드를 전 세계 CDN 노드에서 실행하여 사용자와 가장 가까운 위치에서 응답을 생성하는 기술입니다. 서울 사용자는 서울 노드에서, 뉴욕 사용자는 뉴욕 노드에서 실행되므로 네트워크 왕복 거리가 짧아집니다.
다만 “엣지에서 실행하면 빨라진다”는 말에는 조건이 붙습니다. 줄어드는 것은 사용자와 코드 사이의 거리뿐이고, 코드가 데이터베이스나 원본 API를 호출하면 그 거리는 그대로 남습니다. 서울 사용자의 요청을 서울 엣지에서 받아도 DB가 미국 버지니아에 있다면, 쿼리를 세 번 보내는 코드는 태평양을 세 번 왕복합니다. 그래서 엣지가 가장 효과적인 작업은 인증 토큰 검증, 리다이렉트, 헤더 조작, 캐시된 데이터로 응답 조립하기처럼 원본에 가지 않고 끝낼 수 있는 일이고, DB를 많이 쓰는 API는 오히려 DB 근처의 리전 서버에서 도는 편이 빠를 수 있습니다. 아래 지연 시간 수치도 이런 조건을 단순화한 예시로 읽어 주세요. 이 글은 Edge Computing의 핵심 개념, 주요 플랫폼 비교 (Cloudflare Workers, Vercel Edge, Deno Deploy), 실전 구현, 제약사항, 최적화 기법을 단계별로 설명합니다.
Edge Computing이란?
아키텍처 비교
전통적인 서버:
사용자 (서울) → 서버 (미국 버지니아) → 응답
지연 시간: 200-300ms
CDN (정적 콘텐츠):
사용자 (서울) → CDN 노드 (서울) → 캐시된 파일
지연 시간: 10-20ms
Edge Computing (동적 콘텐츠):
사용자 (서울) → Edge 노드 (서울) → 코드 실행 → 응답
지연 시간: 30-50ms
핵심 특징
1. 글로벌 분산
- 전 세계 200+ 노드에서 동일 코드 실행
- 사용자와 가장 가까운 노드 자동 선택
- 지역별 트래픽 폭증에 자동 대응 2. 서버리스 실행
- 서버 관리 불필요
- 자동 스케일링
- 사용량 기반 과금 3. 낮은 콜드 스타트
- V8 Isolate 기반 (Cloudflare)
- 컨테이너를 새로 띄우는 대신 이미 실행 중인 V8 프로세스 안에 격리 공간(isolate)만 만들므로, 컨테이너 기반 서버리스보다 콜드 스타트가 훨씬 짧음 4. 제약사항
- 요청당 CPU 시간 제한 (무료 요금제는 특히 짧음)
- 메모리 제한 (Workers는 isolate당 128MB)
- 번들 크기 제한
- Node.js API 일부만 지원
V8 isolate 모델을 이해하면 이 제약들이 왜 생기는지 알 수 있습니다. 하나의 프로세스 안에서 수많은 고객의 코드가 isolate로 나뉘어 돌기 때문에, 한 isolate가 CPU나 메모리를 많이 쓰면 같은 머신의 다른 코드에 영향을 줍니다. 그래서 플랫폼은 벽시계 시간보다 CPU 시간을 엄격하게 제한하고(외부 API 응답을 기다리는 시간은 CPU 시간에 포함되지 않음), 운영체제 수준 기능(파일 시스템, 프로세스 생성, 임의 TCP 소켓)은 기본적으로 막거나 플랫폼 API로 대신 제공합니다.
플랫폼 비교
| 항목 | Cloudflare Workers | Vercel Edge | Deno Deploy |
|---|---|---|---|
| 실행 위치 | Cloudflare 전 세계 PoP | Vercel 엣지 네트워크 | Deno 리전 |
| 런타임 | V8 Isolate (workerd) | V8 Isolate (Edge Runtime) | Deno Runtime |
| 언어 | JS, TS, WASM(Rust 등), Python(베타) | JS, TS, WASM | JS, TS, WASM |
| Node.js 호환 | nodejs_compat 플래그로 상당 부분 | 제한적 | node: 모듈과 npm 지원 |
| 저장소 | KV, D1, R2, Durable Objects, Queues | 마켓플레이스 연동 (Upstash, Neon 등) | Deno KV |
| WebSocket | 지원 (상태 유지는 Durable Objects) | 제한적 | 지원 |
| 강점 | 저장소·큐 등 플랫폼 기능이 한곳에 | Next.js 통합 | 표준 Web API, TypeScript 네이티브 |
요금과 한도(무료 요청 수, CPU 시간, 메모리, 번들 크기)는 세 플랫폼 모두 요금제별로 다르고 자주 바뀌기 때문에 표에 숫자로 적지 않았습니다. 도입 전에 각 공식 요금 페이지를 확인하고, 특히 무엇을 기준으로 과금하는지(요청 수, CPU 시간, 대역폭)를 비교하는 것이 중요합니다. 같은 트래픽이라도 과금 기준에 따라 비용 구조가 완전히 달라집니다. Vercel은 Edge 런타임보다 Node.js 런타임 사용을 권장하는 방향으로 문서를 바꾸었고, 예전의 Vercel KV와 Vercel Postgres는 Upstash, Neon 같은 마켓플레이스 통합으로 옮겨졌으므로, 오래된 튜토리얼을 따라 할 때는 현재 문서와 대조해야 합니다.
선택 가이드
Cloudflare Workers 선택:
- 최저 지연 시간 필요
- 대규모 트래픽 (수백만 요청/일)
- 비용 최적화 중요 Vercel Edge 선택:
- Next.js 프로젝트
- 빠른 배포 및 개발 경험
- Vercel 생태계 활용 Deno Deploy 선택:
- TypeScript 네이티브 개발
- 표준 Web API 선호
- Deno 생태계 활용
Cloudflare Workers
기본 예제
// worker.js
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname === '/api/hello') {
return new Response(JSON.stringify({
message: 'Hello from Edge!',
location: request.cf?.city, // 사용자 위치 (로컬 개발 환경 등에서는 cf가 비어 있을 수 있음)
timestamp: Date.now()
}), {
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Not Found', { status: 404 });
}
};
KV 스토리지 사용
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const key = url.pathname.slice(1); // /key → key
if (request.method === 'GET') {
// KV에서 읽기
const value = await env.MY_KV.get(key);
if (value === null) {
return new Response('Not found', { status: 404 });
}
return new Response(value);
}
if (request.method === 'PUT') {
// KV에 쓰기
const value = await request.text();
await env.MY_KV.put(key, value, {
expirationTtl: 3600 // 1시간 후 만료
});
return new Response('Stored');
}
return new Response('Method not allowed', { status: 405 });
}
};
env.MY_KV는 코드에서 만드는 객체가 아니라 wrangler.toml(또는 wrangler.jsonc)의 kv_namespaces 설정으로 주입되는 바인딩입니다. 설정을 빠뜨리면 Cannot read properties of undefined (reading 'get') 같은 오류가 나는데, 코드 문제가 아니라 바인딩 이름이 설정과 일치하지 않는 경우가 대부분입니다.
KV를 쓸 때 가장 중요한 성질은 최종 일관성(eventual consistency)입니다. KV는 읽기가 많은 데이터를 전 세계 엣지에 캐시하도록 설계되어 있어서, 한 지역에서 쓴 값이 다른 지역에서 보이기까지 최대 수십 초가 걸릴 수 있고, 같은 키에 대한 잦은 쓰기에는 제한이 있습니다. 그래서 설정 값, 기능 플래그, 리다이렉트 목록처럼 “가끔 바뀌고 자주 읽히는” 데이터에 적합하고, 카운터나 재고처럼 정확한 값이 필요한 데이터에는 맞지 않습니다. 그런 데이터는 강한 일관성을 제공하는 Durable Objects나 D1을 씁니다. 이 예제처럼 경로를 그대로 키로 쓰는 PUT 엔드포인트를 인증 없이 공개하면 누구나 값을 덮어쓸 수 있다는 점도 주의하세요.
D1 데이터베이스
export default {
async fetch(request, env, ctx) {
if (request.method === 'GET') {
// 사용자 목록 조회
const { results } = await env.DB.prepare(
'SELECT * FROM users LIMIT 10'
).all();
return Response.json(results);
}
if (request.method === 'POST') {
// 사용자 생성
const { name, email } = await request.json();
await env.DB.prepare(
'INSERT INTO users (name, email) VALUES (?, ?)'
).bind(name, email).run();
return Response.json({ success: true });
}
return new Response('Method not allowed', { status: 405 });
}
};
원래 예제는 GET과 POST가 아닌 요청에서 아무것도 반환하지 않았는데, Workers에서 fetch 핸들러가 Response를 반환하지 않으면 요청이 오류로 끝나므로 마지막에 기본 응답을 추가했습니다. D1은 SQLite를 기반으로 한 서버리스 DB라서, bind()의 ? 자리표시자로 값을 넘기는 방식이 SQL 인젝션을 막는 기본입니다. 문자열을 이어 붙여 쿼리를 만들면 안 됩니다. D1의 주 데이터베이스는 한 위치에 있으므로, 앞서 말한 것처럼 먼 지역의 엣지에서 쿼리를 여러 번 순서대로 보내면 왕복 지연이 쌓입니다. 여러 쿼리는 뒤에 나오는 batch()로 한 번에 보내거나, 읽기 복제본 기능과 캐시를 함께 쓰는 것이 좋습니다. request.json()은 본문이 올바른 JSON이 아니면 예외를 던지므로, 실제 API라면 try/catch로 400 응답을 돌려줘야 합니다.
캐싱 전략
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cacheKey = new Request(url.toString(), request);
const cache = caches.default;
// 캐시 확인
let response = await cache.match(cacheKey);
if (!response) {
// 캐시 미스: 원본 서버에서 가져오기
response = await fetch(request);
// 캐시 저장 (1시간)
response = new Response(response.body, response);
response.headers.set('Cache-Control', 'max-age=3600');
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
}
};
fetch()로 받은 응답의 헤더는 변경할 수 없는(immutable) 상태라서, 헤더를 바꾸려면 이 예제처럼 new Response(response.body, response)로 복사본을 만들어야 합니다. 그렇지 않으면 TypeError: Can't modify immutable headers가 납니다. 캐시 API를 쓸 때 알아 둘 제약도 있습니다. caches.default는 사용자 지정 도메인에 연결된 Worker에서만 실제로 동작하고 *.workers.dev 주소에서는 저장되지 않으며, Set-Cookie 헤더가 있는 응답이나 GET이 아닌 요청은 캐시되지 않습니다. 또 캐시는 데이터센터 단위라서, 서울에서 저장한 캐시를 도쿄 엣지는 보지 못합니다. 전 세계 공통 캐시가 필요하면 KV를, 데이터센터 로컬 캐시로 충분하면 Cache API를 쓰는 식으로 구분합니다. 로그인한 사용자별로 다른 응답에 URL만으로 캐시 키를 만들면 한 사용자의 응답이 다른 사용자에게 보이는 사고가 날 수 있으므로, 개인화된 응답은 캐시하지 않거나 키에 사용자 구분 값을 넣어야 합니다.
Vercel Edge Functions
기본 예제
// app/api/hello/route.ts
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge'; // Edge Runtime 사용
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const name = searchParams.get('name') || 'World';
return NextResponse.json({
message: `Hello, ${name}!`,
location: request.geo?.city,
country: request.geo?.country
});
}
request.geo는 Next.js 15에서 NextRequest에서 제거되었습니다. Vercel에 배포한다면 @vercel/functions 패키지의 geolocation(request)로 같은 정보를 얻을 수 있고, 다른 호스팅에서는 해당 플랫폼이 넣어 주는 헤더(Cloudflare의 CF-IPCountry 등)를 읽어야 합니다. export const runtime = 'edge'를 선언하면 이 라우트는 Edge 런타임에서 실행되므로, 여기서 import하는 모든 모듈도 Node.js 전용 API 없이 동작해야 합니다. 빌드는 통과했는데 배포 후 The edge runtime does not support Node.js 'crypto' module 같은 오류가 나는 경우가 이 제약 때문입니다.
Middleware (Edge에서 실행)
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// A/B 테스트 (이미 버킷이 있으면 유지해야 사용자가 매 요청 다른 버전을 보지 않음)
const bucket = request.cookies.get('bucket')?.value
?? (Math.random() < 0.5 ? 'a' : 'b');
const response = NextResponse.next();
response.cookies.set('bucket', bucket);
// 지역별 리다이렉트
const country = request.geo?.country;
if (country === 'KR' && !request.nextUrl.pathname.startsWith('/ko')) {
return NextResponse.redirect(new URL('/ko', request.url));
}
// 인증 확인
const token = request.cookies.get('auth_token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)']
};
미들웨어는 matcher에 걸리는 모든 요청 앞에서 실행되므로, 여기서 하는 일은 무조건 짧아야 합니다. DB 조회나 외부 API 호출을 넣으면 모든 페이지의 응답 시간이 그만큼 늘어납니다. 위 인증 검사도 쿠키의 존재 여부만 확인할 뿐 토큰이 유효한지는 검증하지 않으므로, 실제 권한 확인은 서버 컴포넌트나 API에서 다시 해야 합니다. 미들웨어만으로 인증을 처리하던 앱에서 특수 헤더로 미들웨어를 건너뛸 수 있었던 취약점(CVE-2025-29927)이 알려진 뒤로, “미들웨어는 1차 필터, 진짜 검사는 데이터 접근 지점”이라는 원칙이 더 강조되고 있습니다. 지역별 리다이렉트는 검색 엔진 크롤러도 특정 국가에서 접속하므로, 강제 리다이렉트보다 언어 선택을 제안하는 방식이 SEO 측면에서 안전합니다.
Vercel KV (Redis)
import { kv } from '@vercel/kv';
export const runtime = 'edge';
export async function GET(request: Request) {
const url = new URL(request.url);
const key = url.searchParams.get('key');
if (!key) {
return new Response('Key required', { status: 400 });
}
// KV에서 읽기
const value = await kv.get(key);
if (value === null) {
return new Response('Not found', { status: 404 });
}
return Response.json({ key, value });
}
export async function POST(request: Request) {
const { key, value, ttl } = await request.json();
// KV에 쓰기
if (ttl) {
await kv.setex(key, ttl, value);
} else {
await kv.set(key, value);
}
return Response.json({ success: true });
}
이 예제의 @vercel/kv는 Vercel KV가 Upstash 마켓플레이스 통합으로 전환되면서 새 프로젝트에서는 @upstash/redis를 직접 쓰는 방식으로 바뀌었습니다. API 형태는 거의 같아서 아래 “Upstash Redis” 절의 코드로 옮기기 쉽습니다. 두 방식 모두 Redis 명령을 HTTP 요청으로 보내는 REST 클라이언트라는 점이 핵심입니다. 엣지 런타임은 요청마다 격리되어 TCP 연결을 오래 유지하는 일반 Redis 클라이언트(ioredis 등)를 쓰기 어렵기 때문에, 명령 하나하나가 HTTP 왕복이 됩니다. 여러 명령을 보낼 때는 파이프라인으로 묶어야 왕복 수를 줄일 수 있습니다.
Deno Deploy
기본 예제
// main.ts
Deno.serve(async (req) => {
const url = new URL(req.url);
if (url.pathname === '/api/hello') {
return new Response(JSON.stringify({
message: 'Hello from Deno Deploy!',
timestamp: new Date().toISOString()
}), {
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Not Found', { status: 404 });
});
Deno KV 사용
const kv = await Deno.openKv();
Deno.serve(async (req) => {
const url = new URL(req.url);
if (req.method === 'GET') {
const key = url.searchParams.get('key');
if (!key) {
return new Response('Key required', { status: 400 });
}
// KV에서 읽기
const entry = await kv.get([key]);
if (entry.value === null) {
return new Response('Not found', { status: 404 });
}
return Response.json({ key, value: entry.value });
}
if (req.method === 'POST') {
const { key, value } = await req.json();
// KV에 쓰기
await kv.set([key], value);
return Response.json({ success: true });
}
return new Response('Method not allowed', { status: 405 });
});
Deno KV의 키가 [key]처럼 배열인 이유는 계층형 키를 지원하기 때문입니다. ["users", userId, "profile"] 같은 키를 쓰면 kv.list({ prefix: ["users", userId] })로 한 사용자의 모든 항목을 조회할 수 있습니다. 또 kv.atomic()으로 버전 검사(check)와 여러 쓰기를 원자적으로 묶을 수 있어서, Cloudflare KV와 달리 카운터 같은 데이터를 안전하게 갱신하는 것도 가능합니다. 로컬에서는 Deno.openKv()가 SQLite 파일을 쓰고 배포 환경에서는 관리형 저장소를 쓰므로, 같은 코드로 개발과 배포를 오갈 수 있다는 점이 장점입니다. 로컬 실행 시 버전에 따라 --unstable-kv 플래그가 필요할 수 있습니다.
표준 Web API 활용
// Fetch API
const response = await fetch('https://api.example.com/data');
const data = await response.json();
// Web Crypto API
const encoder = new TextEncoder();
const data = encoder.encode('hello');
const hash = await crypto.subtle.digest('SHA-256', data);
// Streams API
Deno.serve(async (req) => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue('chunk 1\n');
controller.enqueue('chunk 2\n');
controller.close();
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' }
});
});
Edge 데이터베이스
Cloudflare D1 (SQLite)
export default {
async fetch(request, env, ctx) {
// 쿼리 실행
const { results } = await env.DB.prepare(
'SELECT * FROM posts WHERE published = ? ORDER BY created_at DESC LIMIT 10'
).bind(true).all();
return Response.json(results);
}
};
// 트랜잭션
async function createUser(db, name, email) {
const batch = [
db.prepare('INSERT INTO users (name, email) VALUES (?, ?)').bind(name, email),
db.prepare('INSERT INTO audit_log (action, timestamp) VALUES (?, ?)').bind('user_created', Date.now())
];
await db.batch(batch);
}
D1의 batch()는 여러 문장을 한 번의 왕복으로 보내고 하나의 트랜잭션처럼 실행합니다. 중간 문장이 실패하면 앞서 실행된 문장도 롤백되므로, 위 예제의 사용자 생성과 감사 로그 기록은 둘 다 성공하거나 둘 다 실패합니다. 다만 일반 SQL의 BEGIN ... COMMIT처럼 첫 쿼리 결과를 보고 다음 쿼리를 결정하는 대화형 트랜잭션은 지원하지 않으므로, 그런 로직은 SQL 안에서 조건을 표현하거나 Durable Objects로 옮겨야 합니다. SQLite에는 불리언 타입이 없어서 published = ?에 true를 넘기면 1로 저장·비교된다는 점도 알아 두면 좋습니다.
PlanetScale (MySQL)
// Vercel Edge Function
import { connect } from '@planetscale/database';
export const runtime = 'edge';
export async function GET() {
const conn = connect({
url: process.env.DATABASE_URL
});
const results = await conn.execute(
'SELECT * FROM posts WHERE published = true LIMIT 10'
);
return Response.json(results.rows);
}
Upstash Redis
import { Redis } from '@upstash/redis';
export const runtime = 'edge';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL,
token: process.env.UPSTASH_REDIS_REST_TOKEN
});
export async function GET(request: Request) {
const url = new URL(request.url);
const key = url.searchParams.get('key');
// 캐시 확인
const cached = await redis.get(key);
if (cached) {
return Response.json({ value: cached, cached: true });
}
// 원본 데이터 가져오기
const data = await fetchFromOrigin(key);
// 캐시 저장 (1시간)
await redis.setex(key, 3600, data);
return Response.json({ value: data, cached: false });
}
이 코드는 캐시 미스가 동시에 몰릴 때 모든 요청이 원본으로 가는 캐시 스탬피드를 막지 못합니다. 인기 키가 만료되는 순간 수백 개의 요청이 동시에 fetchFromOrigin을 호출할 수 있으므로, 트래픽이 큰 키라면 만료 전에 미리 갱신하거나 짧은 락 키(SET key NX EX 10)로 한 요청만 원본에 가게 하는 방식이 필요합니다. 또 if (cached)는 캐시된 값이 0이나 빈 문자열이면 미스로 취급하므로, cached !== null로 비교하는 편이 정확합니다. key가 null일 때의 처리도 빠져 있습니다.
제약사항 및 해결
Node.js API 제한
문제: fs, path, crypto (Node.js) 사용 불가 (플랫폼에 따라 호환 계층 제공)
이 제약은 예전보다 많이 느슨해졌습니다. Cloudflare Workers는 nodejs_compat 호환성 플래그를 켜면 node:crypto, node:buffer, node:stream 같은 모듈의 상당 부분을 제공하고, Deno는 node: 접두사로 Node 내장 모듈과 npm 패키지를 지원합니다. 그래도 실제 디스크에 접근하는 fs, 자식 프로세스, 임의 TCP 서버처럼 엣지 실행 모델과 맞지 않는 기능은 없거나 제한적이므로, 의존하는 npm 패키지가 내부적으로 이런 API를 쓰는지 확인해야 합니다.
해결:
// ❌ Node.js API
import fs from 'fs';
import crypto from 'crypto';
// ✅ Web API
const hash = await crypto.subtle.digest('SHA-256', data);
// ✅ Cloudflare Workers API
const file = await env.BUCKET.get('file.txt');
실행 시간 제한
문제: 30초 초과 시 타임아웃 해결:
// ❌ 긴 연산
export default {
async fetch(request) {
const result = await longComputation(); // 1분 소요
return Response.json(result);
}
};
// ✅ 백그라운드 작업으로 분리
export default {
async fetch(request, env, ctx) {
// 즉시 응답
const jobId = crypto.randomUUID();
// 백그라운드 작업 (ctx.waitUntil)
ctx.waitUntil(
env.QUEUE.send({ jobId, data: await request.json() })
);
return Response.json({ jobId, status: 'processing' });
}
};
상태 유지 불가
문제: 요청 간 메모리 공유를 믿을 수 없음 해결:
// ❌ 전역 변수 (isolate마다 따로 존재, 언제 초기화될지 모름)
let counter = 0;
export default {
async fetch(request) {
counter++; // 같은 isolate에서는 누적되지만 다른 isolate·지역과는 공유 안 됨
return Response.json({ counter });
}
};
// △ KV 스토리지 (최종 일관성이라 정확한 카운터에는 부적합)
export default {
async fetch(request, env, ctx) {
const counter = Number(await env.KV.get('counter') ?? 0); // KV 값은 문자열
await env.KV.put('counter', String(counter + 1));
return Response.json({ counter: counter + 1 });
}
};
전역 변수의 동작은 “매번 0에서 시작”보다 더 까다롭습니다. 같은 isolate가 여러 요청을 연속으로 처리하면 전역 변수 값은 남아 있고, 다른 데이터센터나 새로 만들어진 isolate에서는 0부터 시작합니다. 그래서 테스트할 때는 카운터가 잘 오르는 것처럼 보이다가 운영에서 값이 들쭉날쭉해지는, 재현하기 어려운 버그가 됩니다. 전역 변수는 파싱한 설정이나 재사용할 클라이언트 객체처럼 “잃어도 괜찮은 캐시”로만 써야 합니다.
KV 버전도 두 가지 문제가 있습니다. 원래 코드처럼 await env.KV.get('counter') || 0에 1을 더하면 KV가 문자열을 반환하므로 "0" + 1 = "01"이 되는 문자열 연결 버그가 생깁니다. 그리고 읽고 쓰는 사이에 다른 요청이 끼어들면 증가분이 사라지며, 최종 일관성 때문에 다른 지역에서는 옛 값을 읽습니다. 정확한 카운터는 요청을 한 인스턴스로 모아 순서대로 처리하는 Durable Objects가 맞는 도구입니다.
패키지 크기 제한
문제: 번들 크기 제한 (Workers 기준 요금제에 따라 압축 후 수 MB 수준) 해결:
// ❌ 큰 라이브러리
import moment from 'moment'; // 200KB+
// ✅ 작은 대안
import { format } from 'date-fns'; // 10KB (트리 셰이킹)
// ✅ 네이티브 API
const date = new Date().toISOString();
번들 크기는 한도 초과 여부만의 문제가 아닙니다. 번들이 크면 isolate가 새로 만들어질 때마다 스크립트를 파싱하고 초기화하는 시간이 늘어나 콜드 스타트가 길어집니다. wrangler deploy --dry-run --outdir dist로 실제 번들을 만들어 보고 크기를 확인하는 습관이 도움이 됩니다. 날짜 포매팅처럼 흔한 작업은 Intl.DateTimeFormat으로 대부분 해결되므로 라이브러리가 필요 없는 경우가 많습니다.
성능 최적화
캐싱 전략
Edge 캐시 + KV 조합:
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const cacheKey = new Request(url.toString());
const cache = caches.default;
// 1. Edge 캐시 확인 (가장 빠름)
let response = await cache.match(cacheKey);
if (response) {
return response;
}
// 2. KV 확인 (중간)
const cached = await env.KV.get(url.pathname);
if (cached) {
response = new Response(cached, {
headers: { 'Cache-Control': 'max-age=3600' }
});
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
// 3. 원본 서버 (가장 느림)
const data = await fetchFromOrigin(url.pathname);
response = Response.json(data, {
headers: { 'Cache-Control': 'max-age=3600' }
});
// 비동기로 캐시 저장
ctx.waitUntil(Promise.all([
cache.put(cacheKey, response.clone()),
env.KV.put(url.pathname, JSON.stringify(data), { expirationTtl: 3600 })
]));
return response;
}
};
이 3단계 구조에서 각 계층의 역할을 구분해 두면 설계가 쉬워집니다. Cache API는 같은 데이터센터 안에서만 공유되지만 가장 빠르고, KV는 전 세계에서 공유되지만 읽기마다 비용이 들고 쓰기가 전파되는 데 시간이 걸리며, 원본은 가장 정확하지만 가장 멉니다. 원본 데이터가 바뀌었을 때 세 계층을 모두 무효화해야 한다는 점이 이 구조의 비용입니다. Cache API의 항목은 코드에서 cache.delete로 지워도 그 데이터센터 것만 지워지므로, 전역 무효화가 필요하면 짧은 TTL로 설계하거나 캐시 키에 버전 값을 넣어 키 자체를 바꾸는 방식이 현실적입니다.
조건부 요청
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const key = url.pathname;
// ETag 생성
const data = await env.KV.get(key);
const etag = `"${await hashData(data)}"`;
// 클라이언트 ETag 확인
const clientETag = request.headers.get('If-None-Match');
if (clientETag === etag) {
return new Response(null, { status: 304 }); // Not Modified
}
return new Response(data, {
headers: {
'ETag': etag,
'Cache-Control': 'max-age=3600'
}
});
}
};
async function hashData(data: string): Promise<string> {
const encoder = new TextEncoder();
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(data));
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
이 방식은 304 응답으로 전송량을 줄이지만, ETag를 만들기 위해 매번 KV를 읽고 해시를 계산하므로 서버 쪽 작업량은 줄지 않습니다. 더 효율적인 방법은 데이터를 저장할 때 해시나 버전 번호를 함께 저장해 두고(KV의 metadata 기능 등), 요청 시에는 그 값만 비교하는 것입니다. 또 If-None-Match에는 여러 ETag가 쉼표로 들어오거나 W/ 접두사가 붙은 약한 ETag가 올 수 있어서, 문자열 완전 일치만으로는 일부 클라이언트에서 304가 동작하지 않을 수 있습니다. 데이터가 null일 때(KV.get이 키를 찾지 못한 경우) 404를 먼저 돌려주는 처리도 필요합니다.
스트리밍 응답
export default {
async fetch(request) {
const stream = new ReadableStream({
async start(controller) {
// 대용량 데이터를 청크로 전송
for (let i = 0; i < 100; i++) {
const chunk = await fetchChunk(i);
controller.enqueue(new TextEncoder().encode(chunk + '\n'));
// 백프레셔 처리
if (controller.desiredSize <= 0) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
controller.close();
}
});
return new Response(stream, {
headers: { 'Content-Type': 'text/plain' }
});
}
};
이 예제의 백프레셔 처리는 개념만 보여 주는 단순화 버전입니다. start() 안에서 모든 청크를 밀어 넣으면서 desiredSize를 보고 setTimeout으로 기다리는 방식은, 소비자가 느릴 때 얼마나 기다려야 할지 추측에 의존합니다. 표준적인 방법은 start() 대신 pull(controller) 콜백을 구현하는 것으로, 스트림이 데이터를 더 원할 때만 런타임이 pull을 호출하므로 백프레셔가 자동으로 처리됩니다. 원본 API의 응답을 가공해 흘려보내는 경우라면 response.body.pipeThrough(new TransformStream(...))가 가장 간단합니다. 스트리밍의 진짜 이점은 첫 바이트가 빨리 나간다는 것(TTFB)이므로, 전체 데이터를 다 모은 뒤 한 번에 보내는 코드와는 사용자 체감이 다르다는 점을 기억하세요.
실무 사례
API 게이트웨이
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 인증 확인
const token = request.headers.get('Authorization');
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
// Rate limiting (개념 예시: KV는 최종 일관성이라 정확한 제한에는 부적합)
const clientIP = request.headers.get('CF-Connecting-IP');
const rateLimitKey = `rate:${clientIP}`;
const count = Number(await env.KV.get(rateLimitKey) ?? 0);
if (count > 100) {
return new Response('Too Many Requests', { status: 429 });
}
await env.KV.put(rateLimitKey, String(count + 1), { expirationTtl: 60 });
// 백엔드로 프록시
const backendUrl = `https://api.backend.com${url.pathname}`;
const response = await fetch(backendUrl, {
method: request.method,
headers: request.headers,
body: request.body
});
return response;
}
};
이 게이트웨이는 구조를 보여 주는 예시이고, 그대로 쓰기에는 세 가지 문제가 있습니다. 첫째, KV 기반 속도 제한은 앞에서 본 카운터와 같은 이유로 정확하지 않습니다. 동시에 들어온 요청들이 같은 옛 값을 읽고, 다른 지역의 엣지는 서로의 카운트를 보지 못하므로 실제 허용량이 설정의 몇 배가 될 수 있습니다. Cloudflare의 Rate Limiting 바인딩이나 Durable Objects, 또는 WAF의 속도 제한 규칙을 쓰는 것이 맞습니다. 둘째, Authorization 헤더의 존재만 확인하고 있어서 아무 값이나 넣으면 통과합니다. 엣지에서 JWT 서명을 검증하는 것은 Web Crypto로 충분히 가능하므로, 게이트웨이를 두는 이유가 인증이라면 여기서 검증까지 해야 의미가 있습니다. 셋째, request.headers를 그대로 백엔드로 넘기면 Host 등 원래 요청의 헤더가 섞여 들어갑니다. 백엔드가 원본 IP를 알아야 한다면 X-Forwarded-For를 명시적으로 설정하는 편이 좋습니다.
이미지 최적화
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const imageUrl = url.searchParams.get('url');
if (!imageUrl) {
return new Response('URL required', { status: 400 });
}
// Accept 헤더로 포맷 결정
const accept = request.headers.get('Accept') || '';
// AVIF를 먼저 검사 (AVIF를 지원하는 브라우저는 WebP도 함께 광고하므로)
const format = accept.includes('image/avif') ? 'avif' :
accept.includes('image/webp') ? 'webp' : 'jpeg';
// Cloudflare Image Resizing
const imageRequest = new Request(imageUrl, {
cf: {
image: {
width: 800,
quality: 85,
format: format
}
}
});
const response = await fetch(imageRequest);
return new Response(response.body, {
headers: {
'Content-Type': `image/${format}`,
'Cache-Control': 'max-age=86400',
'Vary': 'Accept'
}
});
}
};
이 코드에서 가장 위험한 부분은 url 쿼리 파라미터로 받은 임의의 주소를 그대로 가져온다는 점입니다. 허용 목록 없이 이렇게 두면 누구나 이 Worker를 자기 이미지의 무료 변환 프록시로 쓸 수 있고, 내부망 주소를 넣어 요청을 대신 보내게 하는 SSRF 공격 경로가 되기도 합니다. 반드시 허용된 도메인이나 자기 버킷의 경로만 받도록 검사해야 합니다. 또 Accept 헤더에 따라 형식이 달라지는 응답은 Vary: Accept를 붙이지 않으면 중간 캐시가 AVIF 응답을 AVIF를 모르는 브라우저에 줄 수 있어서 위 코드에 추가했습니다. cf.image 옵션은 해당 존에서 이미지 변환 기능이 활성화되어 있어야 동작하고 변환 횟수에 따라 과금되며, 원본 응답이 실패했을 때 상태 코드를 확인하지 않고 200으로 감싸 보내는 부분도 실제 코드에서는 고쳐야 합니다.
개인화 콘텐츠
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 사용자 정보 (쿠키 또는 헤더)
const userId = request.headers.get('X-User-ID');
const country = request.cf.country;
// 개인화 캐시 키
const cacheKey = `content:${url.pathname}:${userId}:${country}`;
// KV에서 개인화 콘텐츠 확인
const cached = await env.KV.get(cacheKey);
if (cached) {
return new Response(cached, {
headers: { 'Content-Type': 'text/html' }
});
}
// 개인화 콘텐츠 생성
const content = await generatePersonalizedContent(userId, country);
// 캐시 저장 (10분)
ctx.waitUntil(
env.KV.put(cacheKey, content, { expirationTtl: 600 })
);
return new Response(content, {
headers: { 'Content-Type': 'text/html' }
});
}
};
개인화 캐시는 키 설계가 전부입니다. 이 예제는 userId를 키에 넣었으므로 사용자 수만큼 캐시 항목이 생기고, 같은 사용자가 10분 안에 같은 페이지를 다시 볼 때만 이득이 있습니다. 적중률이 낮은 캐시는 KV 쓰기 비용만 늘리므로, 실무에서는 사용자별 전체 페이지보다 “국가별 공통 부분”처럼 공유 가능한 조각을 캐시하고 사용자별 부분만 요청 시 조립하는 편이 효율적입니다. 또 X-User-ID 헤더를 클라이언트가 직접 보낸다면 누구나 다른 사용자의 ID를 넣어 그 사람의 개인화 콘텐츠를 볼 수 있으므로, 사용자 식별은 검증된 세션이나 서명된 토큰에서 가져와야 합니다.
서버사이드 A/B 테스트
export default {
async fetch(request, env, ctx) {
// 사용자 ID로 일관된 버킷 할당
const userId = request.headers.get('X-User-ID') ||
request.headers.get('CF-Connecting-IP');
const hash = await hashString(userId);
const bucket = hash % 100 < 50 ? 'A' : 'B';
// 버킷별 다른 응답
const content = bucket === 'A'
? await fetchVariantA()
: await fetchVariantB();
// 분석 이벤트 기록 (Analytics Engine은 blobs/doubles/indexes 형식, 동기 호출)
env.ANALYTICS.writeDataPoint({
blobs: [userId, bucket],
doubles: [Date.now()],
indexes: [bucket]
});
return new Response(content, {
headers: { 'X-AB-Bucket': bucket }
});
}
};
async function hashString(str: string): Promise<number> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.reduce((acc, byte) => acc + byte, 0);
}
엣지에서 A/B 테스트를 하는 이유는 클라이언트 측 A/B 도구의 단점, 즉 페이지가 원래 버전으로 그려졌다가 스크립트가 실행된 뒤 바뀌는 깜빡임(flicker)을 없애기 위해서입니다. 이때 핵심은 같은 사용자가 항상 같은 버킷에 들어가는 일관된 할당입니다. 여기서는 식별자를 해시해 버킷을 정하므로 요청마다 난수를 쓰는 것보다 안정적이지만, IP를 대체 식별자로 쓰면 모바일 네트워크처럼 IP가 자주 바뀌는 환경이나 회사처럼 여러 사람이 한 IP를 공유하는 환경에서 할당이 흔들립니다. 첫 방문 시 버킷을 정해 쿠키로 고정하는 방식을 함께 쓰는 것이 일반적입니다. 해시 바이트를 단순 합산하는 이 함수는 값이 특정 범위(수천 근처)에 몰려 분포가 고르지 않으므로, 첫 4바이트를 정수로 읽는(new DataView(hashBuffer).getUint32(0)) 편이 버킷 비율을 더 정확하게 맞춰 줍니다. 버전별로 응답이 다른 페이지를 CDN이 캐시하지 않도록 캐시 설정도 함께 확인해야 합니다.
트러블슈팅
문제 1: CPU 시간 초과
증상:
Error: CPU time limit exceeded
Workers에서는 이 오류가 대시보드와 wrangler tail 로그에 Exceeded CPU Limit 같은 결과로 나타납니다. 처음 엣지로 옮길 때 이 오류를 가장 자주 보는 곳은 의외로 평범한 코드입니다. 큰 JSON을 JSON.parse하고 다시 JSON.stringify하는 일, 정규식으로 긴 HTML을 가공하는 일, 비밀번호 해시(bcrypt 등)처럼 일부러 느리게 만든 연산이 대표적입니다. 특히 무료 요금제의 CPU 시간 한도는 매우 짧아서, 로컬 개발 서버에서는 문제없던 코드가 배포 후 간헐적으로 실패합니다. 외부 API를 기다리는 시간은 CPU 시간에 포함되지 않으므로 “응답이 오래 걸린다”와 “CPU를 많이 쓴다”는 구분해서 봐야 합니다.
해결:
// ❌ CPU 집약적 작업
function heavyComputation(n: number) {
let sum = 0;
for (let i = 0; i < n * 1000000; i++) {
sum += Math.sqrt(i);
}
return sum;
}
// ✅ 작업 분할 또는 백엔드로 이동
export default {
async fetch(request, env, ctx) {
// Edge에서는 간단한 작업만
const params = await request.json();
// 무거운 작업은 백엔드로
const response = await fetch('https://backend.com/compute', {
method: 'POST',
body: JSON.stringify(params)
});
return response;
}
};
문제 2: 패키지 호환성
증상:
Error: Module "fs" is not available in Workers
해결:
// ❌ Node.js 전용 패키지
import fs from 'fs';
// ✅ Edge 호환 패키지 찾기
// 또는 필요한 기능만 직접 구현
// ✅ 조건부 import
let parser;
if (typeof Deno !== 'undefined') {
parser = await import('./deno-parser.ts');
} else {
parser = await import('./edge-parser.ts');
}
문제 3: 콜드 스타트 느림
증상: 첫 요청이 느림 해결:
// 1. 번들 크기 최소화
// ❌ 전체 라이브러리 import
import _ from 'lodash';
// ✅ 필요한 함수만
import { debounce } from 'lodash-es';
// 2. 동적 import 사용
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/heavy') {
// 필요할 때만 로드
const { processHeavy } = await import('./heavy.js');
return await processHeavy(request);
}
return new Response('OK');
}
};
// 3. 워밍업 요청은 isolate 기반 엣지에서는 효과가 거의 없음
// (요청이 어느 데이터센터·isolate로 갈지 정할 수 없음)
컨테이너 기반 서버리스(AWS Lambda 등)에서는 주기적인 워밍업 요청이 콜드 스타트를 줄이는 흔한 기법이지만, 엣지에서는 전 세계 수많은 데이터센터 중 어디로 요청이 갈지 알 수 없으므로 한두 곳을 데워 두는 것은 의미가 없습니다. Cloudflare는 TLS 핸드셰이크가 진행되는 동안 Worker를 미리 로드해 콜드 스타트를 숨기는 방식을 쓰므로, 개발자가 할 수 있는 가장 효과적인 일은 번들을 작게 유지하고 전역 스코프에서 무거운 초기화를 하지 않는 것입니다. 전역 스코프의 코드는 isolate가 만들어질 때 실행되므로, 거기서 큰 데이터를 파싱하면 그 비용이 콜드 스타트에 그대로 더해집니다.
비용 최적화
요청 수 절감
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// 1. 정적 파일은 CDN 캐시 활용
if (url.pathname.match(/\.(js|css|png|jpg)$/)) {
return fetch(request); // 오리진으로 (CDN 캐시됨)
}
// 2. API 응답 캐싱
const cacheKey = new Request(url.toString());
const cache = caches.default;
let response = await cache.match(cacheKey);
if (response) {
return response; // Worker는 실행되지만 원본 호출과 처리 비용은 절약
}
// 3. 실제 처리
response = await processRequest(request, env);
// 4. 캐시 저장
response = new Response(response.body, response);
response.headers.set('Cache-Control', 'max-age=3600');
ctx.waitUntil(cache.put(cacheKey, response.clone()));
return response;
}
};
주석에서 흔히 오해하는 부분을 바로잡으면, 코드 안에서 cache.match로 캐시 적중을 확인하더라도 Worker 자체는 이미 실행된 것이라 요청 과금은 그대로 발생합니다. 절약되는 것은 원본 서버 호출과 응답 생성에 드는 CPU 시간입니다. Worker 호출 자체를 없애고 싶다면 정적 파일처럼 Worker가 필요 없는 경로를 라우트 설정에서 아예 제외하거나(Workers Static Assets, 라우트 패턴 조정), CDN 캐시 규칙으로 Worker 앞에서 응답하게 구성해야 합니다. 첫 번째 분기의 정적 파일 fetch(request)도 같은 이유로 Worker 요청으로 과금되므로, 라우트에서 빼는 편이 비용 면에서 낫습니다.
마무리
Edge Computing은 글로벌 저지연 서비스를 구축하는 핵심 기술입니다:
핵심 장점:
-
낮은 지연: 원본에 가지 않고 끝나는 요청일수록 효과가 큼
-
자동 스케일링: 트래픽 급증에 자동 대응
-
글로벌 분산: 전 세계 동일한 성능
-
비용 효율: 사용량 기반 과금 적합한 사용 사례:
-
API 게이트웨이, 인증/인가
-
개인화 콘텐츠, A/B 테스트
-
이미지 최적화, 리사이징
-
지역별 리다이렉트, 라우팅 부적합한 사용 사례:
-
긴 연산 (30초 이상)
-
대용량 파일 처리
-
레거시 Node.js 패키지 의존성
-
상태 유지 연결 (WebSocket 제한적) 시작 가이드:
- 프로토타입: Vercel Edge (Next.js 통합)
- 프로덕션: Cloudflare Workers (비용, 성능)
- TypeScript 중심: Deno Deploy (표준 API) 다음 학습:
-
WebAssembly 실전로 성능 극대화
-
Node.js 시리즈에서 백엔드 기초
-
RAG 가이드로 AI 통합 참고 자료:
자주 묻는 질문 (FAQ)
Q. Edge 함수에서 Node.js의 fs나 crypto 모듈을 쓰면 왜 실패하나요?
A. Cloudflare Workers나 Vercel Edge 같은 Edge 런타임은 Node.js가 아니라 Web 표준 API 기반이라 fs, path, Node의 crypto 모듈을 제공하지 않습니다. 해시는 crypto.subtle.digest 같은 Web Crypto API로, 파일은 R2 같은 플랫폼 스토리지 바인딩으로 바꿔야 합니다. 이런 모듈에 의존하는 npm 패키지도 같은 이유로 실패하므로, Edge로 옮기기 전에 의존성 목록을 먼저 점검하는 편이 안전합니다.