Edge Computing 실전 가이드 | Cloudflare Workers, Vercel Edge, Deno Deploy

Edge Computing 실전 가이드 | Cloudflare Workers, Vercel Edge, Deno Deploy

이 글의 핵심

Edge Computing의 핵심 개념과 실전 활용법. Cloudflare Workers, Vercel Edge Functions, Deno Deploy 비교, 구현 예제, 제약사항, 성능 최적화, 실무 사례를 다룹니다.

들어가며

Edge Computing은 코드를 전 세계 CDN 노드에서 실행하여 사용자와 가장 가까운 위치에서 응답을 생성하는 기술입니다. 서울 사용자는 서울 노드에서, 뉴욕 사용자는 뉴욕 노드에서 실행되므로 지연 시간이 50ms 이하로 낮아집니다.

이 글은 Edge Computing의 핵심 개념, 주요 플랫폼 비교 (Cloudflare Workers, Vercel Edge, Deno Deploy), 실전 구현, 제약사항, 최적화 기법을 단계별로 설명합니다.


목차

  1. Edge Computing이란?
  2. 플랫폼 비교
  3. Cloudflare Workers
  4. Vercel Edge Functions
  5. Deno Deploy
  6. Edge 데이터베이스
  7. 제약사항 및 해결
  8. 성능 최적화
  9. 실무 사례
  10. 트러블슈팅
  11. 마무리

Edge Computing이란?

아키텍처 비교

전통적인 서버:

사용자 (서울) → 서버 (미국 버지니아) → 응답
지연 시간: 200-300ms

CDN (정적 콘텐츠):

사용자 (서울) → CDN 노드 (서울) → 캐시된 파일
지연 시간: 10-20ms

Edge Computing (동적 콘텐츠):

사용자 (서울) → Edge 노드 (서울) → 코드 실행 → 응답
지연 시간: 30-50ms

핵심 특징

1. 글로벌 분산

  • 전 세계 200+ 노드에서 동일 코드 실행
  • 사용자와 가장 가까운 노드 자동 선택
  • 지역별 트래픽 폭증에 자동 대응

2. 서버리스 실행

  • 서버 관리 불필요
  • 자동 스케일링
  • 사용량 기반 과금

3. 낮은 콜드 스타트

  • V8 Isolate 기반 (Cloudflare)
  • 콜드 스타트: ~5ms (일반 Lambda: ~100-500ms)

4. 제약사항

  • 실행 시간: 10-30초
  • 메모리: 128MB
  • CPU 제한
  • Node.js API 일부만 지원

플랫폼 비교

항목Cloudflare WorkersVercel EdgeDeno Deploy
노드 수300+20+35+
런타임V8 IsolateV8 IsolateDeno Runtime
언어JS, TS, Rust, C++ (WASM)JS, TSJS, TS
실행 시간30초 (무료: 10ms)30초30초
메모리128MB128MB512MB
무료 요청100K/일100K/월100K/일
가격$5/1000만 요청$20/100만 요청$10/100만 요청
DB 통합D1, KV, Durable ObjectsVercel Postgres, KVDeno KV
WebSocketDurable Objects제한적지원
특징가장 빠름, 저렴Next.js 통합 우수표준 API, TypeScript 네이티브

선택 가이드

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,  // 사용자 위치
                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 });
    }
};

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 });
        }
    }
};

캐싱 전략

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;
    }
};

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
    });
}

Middleware (Edge에서 실행)

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
    // A/B 테스트
    const bucket = 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).*)']
};

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 });
}

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 });
    }
});

표준 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);
}

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 });
}

제약사항 및 해결

1. Node.js API 제한

문제: fs, path, crypto (Node.js) 사용 불가

해결:

// ❌ 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');

2. 실행 시간 제한

문제: 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' });
    }
};

3. 상태 유지 불가

문제: 요청 간 메모리 공유 불가

해결:

// ❌ 전역 변수 (요청 간 공유 안 됨)
let counter = 0;

export default {
    async fetch(request) {
        counter++;  // 매번 0에서 시작
        return Response.json({ counter });
    }
};

// ✅ KV 스토리지 사용
export default {
    async fetch(request, env, ctx) {
        const counter = await env.KV.get('counter') || 0;
        await env.KV.put('counter', counter + 1);
        
        return Response.json({ counter: counter + 1 });
    }
};

4. 패키지 크기 제한

문제: 번들 크기 1MB 제한

해결:

// ❌ 큰 라이브러리
import moment from 'moment';  // 200KB+

// ✅ 작은 대안
import { format } from 'date-fns';  // 10KB (트리 셰이킹)

// ✅ 네이티브 API
const date = new Date().toISOString();

성능 최적화

1. 캐싱 전략

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;
    }
};

2. 조건부 요청

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('');
}

3. 스트리밍 응답

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' }
        });
    }
};

실무 사례

1. 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
        const clientIP = request.headers.get('CF-Connecting-IP');
        const rateLimitKey = `rate:${clientIP}`;
        const count = await env.KV.get(rateLimitKey) || 0;
        
        if (count > 100) {
            return new Response('Too Many Requests', { status: 429 });
        }
        
        await env.KV.put(rateLimitKey, 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;
    }
};

2. 이미지 최적화

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') || '';
        const format = accept.includes('image/webp') ? 'webp' : 
                      accept.includes('image/avif') ? 'avif' : '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'
            }
        });
    }
};

3. 개인화 콘텐츠

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' }
        });
    }
};

4. 서버사이드 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();
        
        // 분석 이벤트 전송
        ctx.waitUntil(
            env.ANALYTICS.writeDataPoint({
                userId,
                bucket,
                timestamp: Date.now()
            })
        );
        
        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);
}

트러블슈팅

문제 1: CPU 시간 초과

증상:

Error: CPU time limit exceeded

해결:

// ❌ 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. 워밍업 요청
// 정기적으로 더미 요청 전송하여 콜드 스타트 방지

비용 최적화

요청 수 절감

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;  // Edge 함수 실행 안 함 (무료)
        }
        
        // 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;
    }
};

마무리

Edge Computing은 글로벌 저지연 서비스를 구축하는 핵심 기술입니다:

핵심 장점:

  • 낮은 지연: 30-50ms (전통적 서버: 200-300ms)
  • 자동 스케일링: 트래픽 급증에 자동 대응
  • 글로벌 분산: 전 세계 동일한 성능
  • 비용 효율: 사용량 기반 과금

적합한 사용 사례:

  • API 게이트웨이, 인증/인가
  • 개인화 콘텐츠, A/B 테스트
  • 이미지 최적화, 리사이징
  • 지역별 리다이렉트, 라우팅

부적합한 사용 사례:

  • 긴 연산 (30초 이상)
  • 대용량 파일 처리
  • 레거시 Node.js 패키지 의존성
  • 상태 유지 연결 (WebSocket 제한적)

시작 가이드:

  1. 프로토타입: Vercel Edge (Next.js 통합)
  2. 프로덕션: Cloudflare Workers (비용, 성능)
  3. TypeScript 중심: Deno Deploy (표준 API)

다음 학습:

  • WebAssembly 가이드로 성능 극대화
  • Node.js 시리즈에서 백엔드 기초
  • RAG 가이드로 AI 통합

참고 자료: