Cloudflare Workers 시작하기: KV·D1·R2로 Edge 서버리스 API 만들기

이 글의 핵심

Workers는 컨테이너 대신 V8 Isolate로 실행되어 콜드 스타트가 거의 없지만, 요청당 CPU 시간과 128MB 메모리 제한, Node.js 내장 모듈 호환성이라는 대가가 있습니다. KV는 쓰기 전파가 최대 60초 늦는 결과적 일관성 저장소이고, 강한 일관성이 필요하면 D1이나 Durable Objects를 써야 한다는 점을 예제와 함께 설명합니다.

Cloudflare Workers는 Cloudflare의 엣지 네트워크에서 V8 Isolate 단위로 실행되는 서버리스 함수입니다. 이 글은 콜드 스타트가 짧은 이유와 그 대가(요청당 CPU 시간 제한, Node.js 내장 모듈 부재)를 먼저 짚고, wrangler로 프로젝트를 만드는 과정, REST API 작성, KV·D1·R2 사용법, URL 단축기 예제, AWS Lambda와의 비교, KV 캐싱과 Durable Objects 순으로 다룹니다.

Cloudflare Workers란?

Cloudflare Workers는 2017년 출시된 Edge Computing 플랫폼으로, Cloudflare의 글로벌 네트워크에서 서버리스 함수를 실행합니다.

핵심 특징

1. 콜드 스타트가 거의 없음

AWS Lambda:
- 콜드 스타트: 런타임·패키지 크기에 따라 수백 ms~수 초
- 컨테이너(microVM) 기반

Cloudflare Workers:
- 콜드 스타트: 수 ms 수준 (TLS 핸드셰이크 중에 미리 로딩)
- V8 Isolates 기반

Cloudflare가 “0ms 콜드 스타트”라고 부르는 것은 Isolate 생성이 수 ms로 매우 짧은 데다, TLS 핸드셰이크의 SNI에서 호스트 이름을 보고 요청 본문이 도착하기 전에 Worker를 미리 로딩하기 때문입니다. 다만 스크립트가 크거나 전역 스코프에서 무거운 초기화를 하면 이 여유를 넘어서므로, 번들 크기와 최상위 코드의 작업량은 여전히 관리해야 합니다.

2. Edge에서 실행

  • 전 세계 300개가 넘는 도시의 Cloudflare 데이터센터에 배포
  • 사용자와 가장 가까운 데이터센터에서 실행

엣지 실행이 항상 빠른 것은 아닙니다. Worker가 매 요청마다 특정 리전에 있는 원본 DB(예: 미국 동부의 PostgreSQL)를 호출한다면, 서울 사용자의 요청은 서울 엣지에서 시작해도 결국 태평양을 왕복합니다. 이럴 때는 Smart Placement를 켜서 Worker를 백엔드 가까이에서 실행하게 하거나, 읽기 데이터를 KV·캐시로 엣지에 가까이 두는 설계가 필요합니다.

3. Web Standards API

// 표준 Web API 사용
export default {
  async fetch(request) {
    return new Response('Hello World!');
  }
}

4. 무료 플랜

  • 10만 요청/일
  • CPU 시간: 10ms/요청
  • 계정당 Worker 스크립트 100개
  • KV·D1·R2 무료 티어 포함

(한도는 자주 바뀌므로 도입 전에 공식 문서의 Limits 페이지를 확인하는 것이 좋습니다.)

콜드 스타트가 짧은 이유를 정확히 짚고 넘어갈 필요가 있습니다. Lambda가 새 실행 환경이 필요할 때 microVM과 런타임을 부팅하는 것과 달리, Workers는 이미 떠 있는 프로세스 안에 V8 Isolate라는 훨씬 가벼운 격리 단위를 만듭니다 — 브라우저 탭을 하나 더 여는 것에 가깝다고 보면 됩니다. 다만 이 가벼움은 공짜가 아닙니다: CPU 시간이 무료 플랜에서 요청당 10ms로 빡빡하게 제한되고(유료 플랜은 기본 30초, 설정으로 늘릴 수 있음), 메모리는 Isolate당 128MB이며, Node.js 내장 모듈은 nodejs_compat 플래그를 켜야 일부만 쓸 수 있습니다. 여기서 CPU 시간은 실제로 CPU를 쓴 시간이라 fetch 응답을 기다리는 시간은 포함되지 않습니다. 외부 API를 여러 번 호출하는 Worker는 10ms 안에 충분히 들어가지만, 큰 JSON을 파싱하거나 이미지를 처리하거나 bcrypt 해시를 계산하면 금방 Error 1102: Worker exceeded resource limits를 만납니다. “빠르다”와 “제약이 없다”는 서로 다른 이야기이고, 이 글의 예제 대부분이 Web Standards API로만 짜여 있는 것도 그 제약을 우회하기 위해서입니다.


Cloudflare Workers 시작하기

1️⃣ 계정 생성

  1. dash.cloudflare.com 접속
  2. 회원가입 (무료)
  3. Workers & Pages 섹션으로 이동

2️⃣ Wrangler CLI 설치

# Wrangler 설치 (Cloudflare Workers CLI)
npm install -g wrangler

# 로그인
wrangler login

# 버전 확인
wrangler --version

3️⃣ 첫 Worker 생성

# 새 프로젝트 생성 (wrangler init은 현재 이 명령으로 위임됨)
npm create cloudflare@latest my-worker

# 프로젝트로 이동
cd my-worker

생성된 파일:

my-worker/
├── src/
│   └── index.ts      # Worker 코드
├── wrangler.toml     # 설정 파일 (최신 템플릿은 wrangler.jsonc)
└── package.json

전역 설치 대신 프로젝트마다 wrangler를 devDependency로 두고 npx wrangler로 실행하는 편이 좋습니다. 전역 wrangler 버전과 프로젝트가 기대하는 버전이 다르면 명령 문법이 달라 문서의 명령이 그대로 동작하지 않는 일이 생깁니다. 설정 파일의 compatibility_date는 런타임 동작 변경을 날짜 단위로 고정하는 값이라, 새 기능이 필요할 때만 의도적으로 올려야 합니다. 날짜를 올리는 순간 그 사이에 바뀐 런타임 동작이 한꺼번에 적용되기 때문입니다.


Hello World Worker

Worker 코드 작성

// src/index.ts
export default {
  async fetch(request: Request): Promise<Response> {
    return new Response('Hello from Cloudflare Workers!', {
      headers: {
        'content-type': 'text/plain',
      },
    });
  },
};

로컬 개발 서버

# 개발 서버 시작
wrangler dev

# 브라우저에서 접속
# http://localhost:8787

배포

# 프로덕션 배포
wrangler deploy

# 결과:
# Published my-worker (1.2s)
#   https://my-worker.YOUR_SUBDOMAIN.workers.dev

REST API 만들기

라우팅 처리

// src/index.ts
export default {
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    
    // GET /
    if (url.pathname === '/' && request.method === 'GET') {
      return new Response('Welcome to API!');
    }
    
    // GET /users
    if (url.pathname === '/users' && request.method === 'GET') {
      const users = [
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ];
      return new Response(JSON.stringify(users), {
        headers: { 'content-type': 'application/json' },
      });
    }
    
    // GET /users/:id
    const userMatch = url.pathname.match(/^\/users\/(\d+)$/);
    if (userMatch && request.method === 'GET') {
      const userId = parseInt(userMatch[1]);
      const user = { id: userId, name: `User ${userId}` };
      return new Response(JSON.stringify(user), {
        headers: { 'content-type': 'application/json' },
      });
    }
    
    // POST /users
    if (url.pathname === '/users' && request.method === 'POST') {
      const body = await request.json();
      const newUser = { id: Date.now(), ...body };
      return new Response(JSON.stringify(newUser), {
        status: 201,
        headers: { 'content-type': 'application/json' },
      });
    }
    
    // 404
    return new Response('Not Found', { status: 404 });
  },
};

프레임워크 없이 if 문으로 라우팅하면 의존성이 0이라 번들이 작지만, 라우트가 열 개를 넘기면 순서 실수와 메서드 체크 누락이 금방 생깁니다. 실무에서는 Workers용으로 만들어진 Hono 같은 가벼운 라우터를 쓰는 경우가 많습니다. 위 POST /users에서 request.json()은 본문이 JSON이 아니면 SyntaxError를 던지고, 잡지 않으면 클라이언트는 500 대신 Cloudflare 에러 페이지(Error 1101: Worker threw exception)를 받습니다. 외부 입력을 파싱하는 곳은 try/catch로 감싸 400을 돌려주는 편이 좋습니다.


Workers KV (Key-Value Storage)

KV 네임스페이스 생성

# KV 네임스페이스 생성 (wrangler 3.60+ 문법, 구 문법은 kv:namespace)
npx wrangler kv namespace create MY_KV

# 결과:
# id = "abc123def456"

wrangler.toml 설정

name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[[kv_namespaces]]
binding = "MY_KV"
id = "abc123def456"

KV 사용 예제

// src/index.ts
interface Env {
  MY_KV: KVNamespace;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    
    // GET /cache/:key (메서드를 확인하지 않으면 아래 POST 분기에 도달하지 못함)
    if (url.pathname.startsWith('/cache/') && request.method === 'GET') {
      const key = url.pathname.split('/')[2];
      const value = await env.MY_KV.get(key);
      
      if (value) {
        return new Response(value);
      }
      return new Response('Not found', { status: 404 });
    }
    
    // POST /cache/:key
    if (url.pathname.startsWith('/cache/') && request.method === 'POST') {
      const key = url.pathname.split('/')[2];
      const value = await request.text();
      
      // TTL: 60초
      await env.MY_KV.put(key, value, { expirationTtl: 60 });
      
      return new Response('Saved!');
    }
    
    return new Response('Hello!');
  },
};

여기서 반드시 알아야 할 함정 하나: KV는 “결과적 일관성(eventually consistent)” 저장소입니다. 한 리전에서 put()한 값이 다른 모든 엣지 로케이션에 전파되기까지 최대 60초까지 걸릴 수 있다는 뜻이고, 이는 설정 값·캐시된 API 응답처럼 “1분 안에만 반영되면 되는” 데이터에는 전혀 문제가 안 되지만, “쓰자마자 바로 읽어야 하는” 세션 토큰 같은 데이터에는 치명적입니다. KV에 세션을 저장할 때 흔히 겪는 증상이 “로그인 직후 리다이렉트된 페이지에서 가끔 로그아웃 상태로 보인다”는 것입니다. 로그인 요청과 다음 요청이 서로 다른 데이터센터로 들어가거나, 같은 데이터센터라도 이전에 “없음”을 읽어 둔 캐시가 남아 있으면 방금 쓴 값이 보이지 않습니다. 재현이 불규칙해서 코드 버그로 오인하기 쉬운데, 원인은 KV의 설계 그 자체입니다. 강한 일관성이 필요하면 아래에 나오는 D1이나 Durable Objects를 써야 합니다.

KV의 또 다른 제약은 같은 키에 초당 1회 정도의 쓰기만 허용한다는 점입니다. 조회수 카운터처럼 한 키를 계속 갱신하면 429 Too Many Requests가 나므로, KV는 “자주 읽고 가끔 쓰는” 데이터(설정, 기능 플래그, 렌더링 결과 캐시)에 맞는 저장소로 보는 것이 정확합니다.

KV CLI 명령어

# 키 쓰기 (wrangler 4는 기본이 로컬 저장소, 실제 네임스페이스는 --remote)
npx wrangler kv key put --binding=MY_KV "mykey" "myvalue" --remote

# 키 읽기
npx wrangler kv key get --binding=MY_KV "mykey" --remote

# 키 삭제
npx wrangler kv key delete --binding=MY_KV "mykey" --remote

# 모든 키 조회
npx wrangler kv key list --binding=MY_KV --remote

--remote를 빼먹으면 명령은 성공했다고 나오는데 대시보드에는 값이 없어서 당황하게 됩니다. 로컬 개발 서버(wrangler dev)가 쓰는 .wrangler/state 아래의 로컬 저장소에 기록됐기 때문입니다.


Workers D1 (SQL Database)

D1 데이터베이스 생성

# D1 데이터베이스 생성
wrangler d1 create my-database

# 결과:
# database_id = "xyz789"

wrangler.toml 설정

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xyz789"

스키마 생성

-- schema.sql
CREATE TABLE users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email) VALUES 
  ('Alice', '[email protected]'),
  ('Bob', '[email protected]');
# 스키마 적용: 기본은 로컬 DB, 실제 D1에 적용하려면 --remote
npx wrangler d1 execute my-database --local --file=schema.sql
npx wrangler d1 execute my-database --remote --file=schema.sql

스키마 파일을 매번 execute로 돌리는 방식은 CREATE TABLE이 두 번째 실행에서 table users already exists로 실패하므로 처음 한 번만 쓸 수 있습니다. 변경 이력을 관리하려면 wrangler d1 migrations create로 번호 붙은 마이그레이션 파일을 만들고 wrangler d1 migrations apply로 적용하는 방식이 맞습니다.

D1은 SQLite를 기반으로 하기 때문에 PostgreSQL이나 MySQL과는 다른 제약을 갖습니다. 동시 쓰기가 많은 워크로드에는 잘 맞지 않고(SQLite 자체가 파일 기반 락 모델이라 쓰기가 직렬화됩니다), 복잡한 JOIN이나 대용량 트랜잭션도 전용 RDBMS만큼 강력하지 않습니다. 대신 읽기 위주의 워크로드, 엣지에서 바로 쿼리를 날려야 하는 소규모 애플리케이션에는 별도의 커넥션 풀링 걱정 없이 붙일 수 있다는 게 큰 장점입니다 — Worker 안에서 데이터베이스 커넥션을 직접 관리할 필요가 없다는 것 자체가 “서버리스스러운” 경험입니다.

D1 사용 예제

// src/index.ts
interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    
    // GET /users
    if (url.pathname === '/users' && request.method === 'GET') {
      const { results } = await env.DB.prepare(
        'SELECT * FROM users'
      ).all();
      
      return Response.json(results);
    }
    
    // POST /users
    if (url.pathname === '/users' && request.method === 'POST') {
      const body = await request.json<{ name: string; email: string }>();
      
      const { success } = await env.DB.prepare(
        'INSERT INTO users (name, email) VALUES (?, ?)'
      ).bind(body.name, body.email).run();
      
      if (success) {
        return new Response('Created', { status: 201 });
      }
      return new Response('Error', { status: 500 });
    }
    
    // GET /users/:id
    const match = url.pathname.match(/^\/users\/(\d+)$/);
    if (match && request.method === 'GET') {
      const id = parseInt(match[1]);
      const user = await env.DB.prepare(
        'SELECT * FROM users WHERE id = ?'
      ).bind(id).first();
      
      if (user) {
        return Response.json(user);
      }
      return new Response('Not found', { status: 404 });
    }
    
    return new Response('Hello!');
  },
};

prepare().bind()는 SQL 인젝션을 막는 파라미터 바인딩이므로 사용자 입력은 반드시 이 경로로 넣어야 합니다. email에 UNIQUE 제약이 있어서 같은 이메일로 두 번 가입하면 run()이 success: false를 반환하는 대신 D1_ERROR: UNIQUE constraint failed: users.email 예외를 던집니다. 위 코드처럼 success만 확인하면 예외가 잡히지 않고 1101 에러 페이지가 나가므로, try/catch로 감싸 409를 돌려주는 식의 처리가 필요합니다. 여러 쿼리를 원자적으로 실행해야 할 때는 env.DB.batch([...])를 쓰면 하나의 트랜잭션으로 묶입니다. D1은 BEGIN/COMMIT으로 여러 요청에 걸친 대화형 트랜잭션을 지원하지 않기 때문입니다.


Workers R2 (Object Storage)

R2 버킷 생성

# R2 버킷 생성
wrangler r2 bucket create my-bucket

R2가 S3 대비 가장 크게 내세우는 차이는 egress(아웃바운드 트래픽) 비용이 없다는 점입니다. S3는 저장 비용 자체는 저렴하지만 외부로 데이터를 내보낼 때 대역폭 비용이 붙는데, 이 비용이 이미지·비디오처럼 트래픽이 많은 정적 자산에서는 스토리지 비용보다 훨씬 크게 불어나는 경우가 흔합니다. R2는 API가 S3와 호환되도록 설계되어 있어서 기존 S3 SDK 코드를 거의 그대로 재사용할 수 있다는 것도 실무에서는 마이그레이션 비용을 크게 줄여주는 요소입니다.

wrangler.toml 설정

[[r2_buckets]]
binding = "MY_BUCKET"
bucket_name = "my-bucket"

R2 사용 예제

// src/index.ts
interface Env {
  MY_BUCKET: R2Bucket;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    
    // GET /files/:key
    if (url.pathname.startsWith('/files/') && request.method === 'GET') {
      const key = url.pathname.split('/')[2];
      const object = await env.MY_BUCKET.get(key);
      
      if (object) {
        return new Response(object.body, {
          headers: {
            'content-type': object.httpMetadata?.contentType || 'application/octet-stream',
          },
        });
      }
      return new Response('Not found', { status: 404 });
    }
    
    // POST /files/:key
    if (url.pathname.startsWith('/files/') && request.method === 'POST') {
      const key = url.pathname.split('/')[2];
      const body = await request.arrayBuffer();
      
      await env.MY_BUCKET.put(key, body, {
        httpMetadata: {
          contentType: request.headers.get('content-type') || 'application/octet-stream',
        },
      });
      
      return new Response('Uploaded!');
    }
    
    return new Response('Hello!');
  },
};

GET 쪽은 object.body(ReadableStream)를 그대로 응답에 넘기므로 파일 전체를 메모리에 올리지 않고 스트리밍합니다. 반대로 업로드 쪽의 request.arrayBuffer()는 본문 전체를 메모리에 올리기 때문에 128MB 메모리 한도에 걸릴 수 있어, 큰 파일은 put(key, request.body)로 스트림을 넘기거나 클라이언트가 R2에 직접 올리도록 presigned URL을 발급하는 편이 낫습니다. 또 이 예제는 인증이 없어서 누구나 아무 키로 파일을 덮어쓸 수 있으므로, 실제 서비스라면 업로드 경로에 인증을 반드시 붙여야 합니다. 요청 본문 크기 자체도 플랜별 한도(무료·Pro 100MB)가 있습니다.


실전 프로젝트: URL 단축기

기능 명세

  • 긴 URL → 짧은 코드 생성
  • 짧은 코드 → 원본 URL 리다이렉트
  • 클릭 카운트 추적
  • D1 + KV 활용

데이터베이스 스키마

-- schema.sql
CREATE TABLE urls (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  short_code TEXT UNIQUE NOT NULL,
  long_url TEXT NOT NULL,
  clicks INTEGER DEFAULT 0,
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_short_code ON urls(short_code);

Worker 구현

// src/index.ts
interface Env {
  DB: D1Database;
  URL_CACHE: KVNamespace;
}

function generateShortCode(): string {
  return Math.random().toString(36).substring(2, 8);
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    
    // POST /shorten - URL 단축
    if (url.pathname === '/shorten' && request.method === 'POST') {
      const { longUrl } = await request.json<{ longUrl: string }>();

      // ⚠️ short_code에 UNIQUE 제약이 걸려 있으므로, Math.random() 기반
      // 코드가 우연히 충돌하면 INSERT가 예외를 던집니다. 트래픽이 적을
      // 땐 거의 안 보이지만, 스케일이 커지면 반드시 겪게 되는 버그이므로
      // 충돌 시 재시도하는 로직이 필요합니다.
      let shortCode = generateShortCode();
      let inserted = false;
      for (let attempt = 0; attempt < 5 && !inserted; attempt++) {
        try {
          await env.DB.prepare(
            'INSERT INTO urls (short_code, long_url) VALUES (?, ?)'
          ).bind(shortCode, longUrl).run();
          inserted = true;
        } catch {
          shortCode = generateShortCode(); // 충돌 — 새 코드로 재시도
        }
      }
      if (!inserted) {
        return new Response('Could not generate a unique code', { status: 500 });
      }
      
      // KV 캐시에 저장 (빠른 조회)
      await env.URL_CACHE.put(shortCode, longUrl, {
        expirationTtl: 86400, // 24시간
      });
      
      return Response.json({
        shortUrl: `${url.origin}/${shortCode}`,
        shortCode,
        longUrl,
      });
    }
    
    // GET /:code - 리다이렉트
    const code = url.pathname.substring(1);
    if (code) {
      // 1. KV 캐시에서 먼저 조회
      let longUrl = await env.URL_CACHE.get(code);
      
      if (!longUrl) {
        // 2. D1에서 조회
        const result = await env.DB.prepare(
          'SELECT long_url FROM urls WHERE short_code = ?'
        ).bind(code).first<{ long_url: string }>();
        
        if (result) {
          longUrl = result.long_url;
          // KV 캐시에 저장
          await env.URL_CACHE.put(code, longUrl, {
            expirationTtl: 86400,
          });
        }
      }
      
      if (longUrl) {
        // 클릭 카운트 증가: 응답 후에도 작업이 끝나도록 waitUntil로 등록
        ctx.waitUntil(
          env.DB.prepare(
            'UPDATE urls SET clicks = clicks + 1 WHERE short_code = ?'
          ).bind(code).run()
        );
        
        // 301은 브라우저가 영구 캐시해 이후 클릭이 Worker에 도달하지 않음
        return Response.redirect(longUrl, 302);
      }
    }
    
    return new Response('URL Shortener\n\nPOST /shorten\n{"longUrl":"https://example.com"}');
  },
};

이 예제에서 짚어 둘 점이 세 가지 있습니다. 첫째, 클릭 카운트 업데이트를 await 없이 호출만 해 두면 응답을 돌려주는 순간 Worker 실행이 끝나면서 진행 중이던 Promise가 취소될 수 있습니다. 로컬에서는 대부분 반영되다가 배포 후에 카운트가 일부만 올라가는 식으로 나타나는데, ctx.waitUntil()에 넘겨야 응답 이후에도 작업이 완료될 때까지 실행이 유지됩니다. 둘째, 원래 예제처럼 301을 쓰면 브라우저가 리다이렉트를 영구 캐시해서 같은 사용자의 두 번째 클릭부터는 Worker를 거치지 않으므로 클릭 수가 집계되지 않습니다. 통계가 필요한 단축기는 302나 307을 씁니다. 셋째, longUrl을 검증하지 않으면 javascript: 스킴이나 피싱 사이트 주소도 그대로 단축되므로, new URL(longUrl)로 파싱해 https:/http:만 허용하는 검사가 필요합니다.

KV를 D1 앞의 캐시로 쓰는 구조도 트레이드오프를 이해하고 써야 합니다. 링크를 삭제하거나 대상 URL을 수정해도 KV 캐시에는 최대 24시간(TTL) 동안 이전 값이 남고, 다른 데이터센터에서는 삭제 후에도 잠시 옛 값이 보일 수 있습니다. 단축 URL은 한 번 만들면 거의 바뀌지 않으니 이 정도 지연은 허용되지만, 수정이 잦은 데이터라면 KV 캐시를 빼고 D1 읽기 복제본이나 Cache API를 검토하는 편이 낫습니다.


Cloudflare Workers vs AWS Lambda

항목WorkersLambda
콜드 스타트거의 없음 (수 ms)수백 ms~수 초
실행 위치Edge (300+ 도시)선택한 AWS 리전
격리V8 IsolatesmicroVM (Firecracker)
무료 티어10만 요청/일100만 요청/월
CPU 시간무료 10ms, 유료 기본 30초실행 시간 안에서 제한 없음
메모리128MB최대 10GB
실행 시간HTTP 요청은 CPU 시간 기준, Cron은 최대 15분최대 15분
Node.jsWeb Standards + 부분 호환(nodejs_compat)완전 지원

표에서 가장 과소평가되기 쉬운 항목은 “CPU 시간”과 “메모리”입니다. Workers는 네트워크 대기 시간은 거의 제약하지 않지만, 실제 연산량과 메모리가 빡빡해서 대용량 파일 변환이나 무거운 배치 작업에는 애초에 맞지 않는 모델입니다. 반대로 Lambda의 15분 실행 시간과 넉넉한 메모리, 컨테이너 기반 격리는 무거운 연산이나 Node 전용 라이브러리 의존성이 있는 워크로드에 여전히 더 적합합니다. 이 표를 “Workers가 전반적으로 우월하다”로 읽으면 안 되고, 어떤 워크로드를 어디에 배치할지 결정하는 체크리스트로 써야 합니다.


성능 최적화

1. KV 캐싱 전략

// 읽기 성능 최적화
async function getWithCache(key: string, kv: KVNamespace, db: D1Database) {
  // 1. KV 캐시 확인
  let value = await kv.get(key);
  if (value) return value;
  
  // 2. DB 조회
  const result = await db.prepare('SELECT value FROM data WHERE key = ?')
    .bind(key).first();
  
  if (result) {
    value = result.value;
    // 3. KV 캐시 저장
    await kv.put(key, value, { expirationTtl: 3600 });
    return value;
  }
  
  return null;
}

이 cache-aside 패턴은 KV 읽기가 D1 쿼리보다 싸고 빠를 때 의미가 있습니다. 주의할 점은 없는 키입니다. DB에도 없는 키로 요청이 반복되면 매번 KV 미스 → D1 조회가 일어나므로, 존재하지 않는다는 사실도 짧은 TTL로 캐시하는 negative caching을 고려할 만합니다. 또 if (value)는 빈 문자열을 캐시 미스로 취급하므로, 빈 값이 정상 데이터일 수 있다면 value !== null로 비교해야 합니다. 요청 경로 전체의 응답을 캐시하는 것이 목적이라면 KV 대신 데이터센터 로컬 캐시인 Cache API(caches.default)가 더 싸고 무효화도 빠릅니다.

2. Durable Objects (상태 유지)

Durable Object는 KV와 정반대의 트레이드오프를 갖습니다 — 전 세계에 복제되는 대신 단 하나의 인스턴스만 존재하고, 그 인스턴스로 가는 요청은 순차적으로 처리됩니다. 아래 카운터 예제가 경쟁 조건(race condition) 없이 정확한 값을 유지할 수 있는 이유가 바로 이것입니다: 같은 idFromName('global')으로 들어오는 모든 요청이 항상 같은 인스턴스로 라우팅되므로, this.count++가 여러 요청에서 동시에 실행될 걱정이 없습니다. 대신 그 인스턴스가 위치한 한 곳으로 전 세계 요청이 몰리게 되므로, KV처럼 무한히 수평 확장되는 용도로는 쓸 수 없다는 점을 감안해야 합니다.

// Durable Object 정의
export class Counter {
  state: DurableObjectState;
  count: number = 0;

  constructor(state: DurableObjectState) {
    this.state = state;
    this.state.blockConcurrencyWhile(async () => {
      this.count = (await this.state.storage.get('count')) || 0;
    });
  }

  async fetch(request: Request) {
    const url = new URL(request.url);
    
    if (url.pathname === '/increment') {
      this.count++;
      await this.state.storage.put('count', this.count);
      return new Response(String(this.count));
    }
    
    return new Response(String(this.count));
  }
}

// Worker에서 사용
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const id = env.COUNTER.idFromName('global');
    const stub = env.COUNTER.get(id);
    return stub.fetch(request);
  },
};

정확히 말하면 Durable Object도 요청을 한 줄로 세워 처리하는 것은 아닙니다. 단일 스레드이긴 하지만 await 지점에서는 다른 요청이 끼어들 수 있습니다. 위 카운터가 안전한 이유는 Cloudflare 런타임의 input gate/output gate 덕분입니다. storage.get/put을 기다리는 동안에는 다른 이벤트가 전달되지 않고, 쓰기가 디스크에 확정되기 전에는 응답이 나가지 않습니다. 반면 await fetch(...)처럼 외부 I/O를 기다리는 동안에는 다른 요청이 들어와 this.count를 바꿀 수 있으므로, 읽기-수정-쓰기 사이에 외부 호출을 넣을 때는 주의해야 합니다.

이 예제를 실제로 배포하려면 wrangler 설정에 durable_objects.bindings와 migrations(new_sqlite_classes 또는 new_classes) 항목이 필요하며, 빠뜨리면 배포 단계에서 클래스를 찾을 수 없다는 에러가 납니다. 최신 문서는 import { DurableObject } from "cloudflare:workers"를 상속하는 클래스 형태와 RPC 메서드 호출(stub.increment())을 권장하므로, 새로 작성한다면 그 형태를 따르는 편이 좋습니다.


Workers가 맞는 경우와 맞지 않는 경우

  1. 맞는 경우: 요청당 연산이 가볍고 전 세계 사용자에게 낮은 지연으로 응답해야 하는 API, 인증·리다이렉트·A/B 테스트 같은 엣지 로직
  2. 맞지 않는 경우: 무거운 CPU 연산, 128MB를 넘는 메모리, 네이티브 모듈에 의존하는 Node.js 코드
  3. 저장소 선택: 자주 읽고 가끔 쓰면 KV, 관계형 조회는 D1, 파일은 R2, 강한 일관성과 조정(카운터·채팅방·락)은 Durable Objects
  4. 원본 위치: 백엔드 DB가 한 리전에 있다면 엣지 실행의 이점이 줄어드므로 Smart Placement나 캐시 설계를 함께 고려

같이 보면 좋은 글