제가 처음으로 Cloudflare Workers에 JSON API를 배포했을 때, 밤 11시에 노트북에서 배포 명령을 쳤고 차를 다 마시기도 전에 300개 이상의 데이터 센터에서 돌고 있었습니다. Dockerfile도 없고, Kubernetes 클러스터도 없고, 콜드 스타트 소동도 없었죠. 단 한 번의 wrangler deploy와 1.2KB짜리 번들이 전부였습니다. 그 경험 이후로 JSON-in, JSON-out 서비스 — 웹훅, 프록시, API 애그리게이터, 엣지 인증 — 에는 Workers가 제 기본 선택이 되었습니다. 백엔드의 80%가 "JSON 파싱하고, 뭔가 하고, JSON 반환"이라면 이 글은 제가 처음 시작할 때 있었으면 했던 그 글입니다.

Cloudflare Worker는 본질적으로 Cloudflare 엣지의 V8 isolate 위에서 실행되는 단일 JavaScript(또는 TypeScript) 함수입니다. Request를 받고, Response를 반환하며, 표준 Fetch API에 접근할 수 있습니다. 브라우저의 fetch()를 써봤다면 런타임의 90%는 이미 아는 셈입니다. 모르는 것은 장난감 Worker와 실제 프로덕션에서 돌릴 수 있는 Worker를 가르는 작은 패턴 세트입니다. 이 글이 다루는 게 바로 그겁니다.

첫 번째 JSON 엔드포인트

가장 작지만 쓸모 있는 JSON 엔드포인트입니다. 타임스탬프와 메시지가 담긴 객체 하나를 반환하죠. Wrangler 프로젝트에서 src/index.ts로 저장하세요:

js
export default {
  async fetch(request, env, ctx) {
    const payload = {
      message: 'Hello from the edge',
      servedAt: new Date().toISOString(),
      colo: request.cf?.colo ?? 'unknown',
    };

    return Response.json(payload);
  },
};

두 가지 짚고 가세요. 첫째, Response.json()은 객체를 직렬화하고 Content-Type: application/json을 자동으로 설정해주는 정적 헬퍼입니다. 커스텀 콘텐츠 타입이 필요한 게 아니라면 직접 new Response(JSON.stringify(x))를 작성하지 마세요 — 언젠가 헤더 빠뜨립니다. 둘째, request.cf.colo는 어느 Cloudflare 데이터 센터가 요청을 처리 중인지 알려줍니다. 베를린에서 온 요청은 FRA, 도쿄에서 온 요청은 NRT로 표시됩니다. "엣지"라는 슬로건이 필드 하나에 담겨 있는 셈이죠.

JSON 요청 본문 파싱하기

POST 엔드포인트는 본문을 읽어야 합니다. Fetch API는 request.json()을 제공하는데, 본문 스트림을 읽고 한 번에 파싱해줍니다:

js
export default {
  async fetch(request) {
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    const body = await request.json();

    // body is now a regular JavaScript object
    const { email, plan } = body;

    return Response.json({
      received: { email, plan },
      ok: true,
    });
  },
};

깔끔해 보이지만, 이 코드에는 배포 후 24시간 안에 마주칠 버그가 있습니다. 클라이언트가 빈 본문이나 잘못된 JSON을 보내면 request.json()SyntaxError를 던지고, Worker가 터지고, Cloudflare가 평범한 500을 반환합니다. 고객 앞에 내놓고 싶은 응답은 아니죠.

잘못된 JSON 처리 — 500으로 떨어지게 두지 마세요

본문 파싱은 항상 try/catch로 감싸고 제대로 된 400을 반환하세요. 제가 모든 Worker에서 쓰는 패턴입니다:

js
async function readJson(request) {
  try {
    return { ok: true, data: await request.json() };
  } catch (err) {
    return {
      ok: false,
      error: 'Invalid JSON body',
      detail: err.message,
    };
  }
}

export default {
  async fetch(request) {
    const result = await readJson(request);

    if (!result.ok) {
      return Response.json(result, { status: 400 });
    }

    const { email, plan } = result.data;

    if (!email || !plan) {
      return Response.json(
        { ok: false, error: 'email and plan are required' },
        { status: 422 },
      );
    }

    return Response.json({ ok: true, email, plan });
  },
};
디버깅 팁: 클라이언트가 "너희 API가 망가졌다"고 주장하는데 Worker는 400을 보여주고 있다면, 열에 아홉은 그쪽 JSON이 잘못된 것입니다 — 후행 콤마, 따옴표 없는 키, 시작 부분의 실수로 들어간 BOM 문자. 페이로드를 JSON Validator에 붙여넣어 보라고 하면 보통 1분도 안 되어 문제가 드러납니다.

JSON API를 위한 CORS

Worker가 다른 오리진의 브라우저에서 호출된다면 — 이게 일반적인 경우죠 — CORS 헤더가 필요합니다. 브라우저는 단순 GET이 아닌 모든 요청에 대해 실제 요청 전에 OPTIONS 프리플라이트를 보냅니다. 둘을 한 곳에서 처리하세요:

js
const CORS_HEADERS = {
  'Access-Control-Allow-Origin': 'https://app.example.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400',
};

export default {
  async fetch(request) {
    if (request.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: CORS_HEADERS });
    }

    const data = { ping: 'pong', at: Date.now() };

    return Response.json(data, { headers: CORS_HEADERS });
  },
};

자격 증명을 읽거나 사용자 데이터를 반환하는 어떤 것에도 Access-Control-Allow-Origin: *는 피하세요. 개발에서는 무해해 보이다가 프로덕션에서 보안 사고로 돌변하는 지름길 중 하나입니다. 실제로 서비스하는 오리진을 하드코딩하거나 env의 허용 목록에서 읽어오세요.

JSON을 업스트림 API로 포워딩하기

Worker의 가장 흔한 용도 중 하나는 얇은 프록시입니다: API 키를 숨기고, 응답을 재구성하고, 클라이언트가 필요 없는 필드를 벗겨내고, 두 개의 업스트림 호출을 하나로 엮는 것이죠. 아래 Worker는 업스트림 서비스를 호출하고, 관심 있는 필드만 골라내서, 더 깨끗한 JSON 페이로드를 반환합니다:

js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const userId = url.searchParams.get('id');

    if (!userId) {
      return Response.json({ error: 'id required' }, { status: 400 });
    }

    const upstream = await fetch(
      `https://api.internal.example.com/users/${userId}`,
      {
        headers: { 'Authorization': `Bearer ${env.UPSTREAM_TOKEN}` },
      },
    );

    if (!upstream.ok) {
      return Response.json(
        { error: 'upstream failed', status: upstream.status },
        { status: 502 },
      );
    }

    const full = await upstream.json();

    // Strip internal fields before returning to the client
    const safe = {
      id: full.id,
      displayName: full.display_name,
      avatarUrl: full.avatar_url,
      joinedAt: full.created_at,
    };

    return Response.json(safe);
  },
};

두 가지 주의할 점. 첫째, .json()을 호출하기 전에 항상 upstream.ok를 확인하세요 — 업스트림의 500 응답은 HTML이나 에러 페이지를 담고 있고, 그걸로 .json()을 부르면 다른 잘못된 JSON과 똑같이 터집니다. 둘째, UPSTREAM_TOKEN 같은 시크릿은 Wrangler 시크릿에 두세요 (wrangler secret put UPSTREAM_TOKEN) — wrangler.toml에 넣거나 git에 커밋하지 마세요.

엣지에서 JSON 응답 캐싱하기

업스트림이 느리거나 비쌀 때, Cache API를 쓰면 엣지에서 JSON을 메모이즈할 수 있습니다. 각 데이터 센터가 자신만의 캐시를 가지므로, 프랑크푸르트의 첫 사용자는 업스트림 비용을 치르고, 그 다음 10,000명은 근처 RAM에서 5ms 이하로 받아갑니다:

js
export default {
  async fetch(request, env, ctx) {
    const cache = caches.default;
    const cacheKey = new Request(request.url, request);

    let response = await cache.match(cacheKey);
    if (response) {
      return response;
    }

    const upstream = await fetch('https://api.example.com/popular-items');
    const data = await upstream.json();

    response = Response.json(data, {
      headers: {
        'Cache-Control': 'public, max-age=60',
      },
    });

    // Don't block the response on the cache write
    ctx.waitUntil(cache.put(cacheKey, response.clone()));

    return response;
  },
};

ctx.waitUntil()이 티 나지 않는 부분입니다. 이게 없으면 cache.put()이 await 되면서 응답이 신경 쓰지 않아도 되는 디스크 쓰기를 기다리게 됩니다. waitUntil을 쓰면 런타임이 백그라운드에서 캐시 쓰기를 살려두는 동안 즉시 응답을 반환할 수 있습니다. 애널리틱스 비콘, 로그 포워딩 같은 fire-and-forget 작업에도 똑같이 쓰는 패턴입니다.

Wrangler로 로컬 개발하기

반복 개발에 Cloudflare 계정이 필요하지는 않습니다. Wrangler를 설치하고 프로젝트를 뼈대 잡으면, 프로덕션과 거의 같은 로컬 Workers 런타임이 생깁니다:

bash
npm create cloudflare@latest my-json-api
cd my-json-api
npm run dev

# Worker is now live at http://localhost:8787
# Hit it from another terminal:
curl -X POST http://localhost:8787 \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","plan":"pro"}'

로컬 런타임은 Cloudflare가 프로덕션에서 돌리는 것과 같은 엔진인 workerd를 씁니다. 차이가 나는 동작들(KV 레이턴시, 캐시 시맨틱, request.cf 필드)은 문서화가 잘 되어 있고 단순한 JSON API에서는 거의 물지 않습니다. wrangler deploy로 배포하면 같은 코드가 몇 초 안에 전 세계에 뜹니다.

Worker JSON API 만들 때 유용한 도구들

JSON을 다루는 Worker를 만들 때 제가 상습적으로 찾는 몇 가지 도구: 리버스 엔지니어링하려는 못생긴 업스트림 응답을 보기 좋게 찍어주는 JSON Formatter, POST 페이로드가 실패해서 어디가 문제인지 정확히 알아야 할 때의 JSON Validator, 필드 고르기 로직을 코딩 전에 계획할 때의 JSON Path, 특정 엔드포인트에서 와이어 사이즈가 실제로 중요한지 확인하고 싶을 때의 JSON Minifier입니다.

JSON 포맷 자체는 RFC 8259에 명시되어 있습니다. "내 파서가 NaN을 허용하나?"(답: 아뇨) 같은 엣지 케이스를 만나면 훑어볼 가치가 있습니다. Cloudflare 자체의 Workers examples gallery에는 이 글의 기본을 넘어선 뒤 쓸만한 JWT 검증, A/B 테스트, HTML 재작성 등 수십 가지 패턴 레시피가 있습니다.

마무리

Cloudflare Workers는 JSON API에 아주 잘 맞습니다 — 작고, 빠르고, 전 세계에 분산되고, 사이드 프로젝트를 켜둬도 될 만큼 쌉니다. 해피 패스는 그냥 request.json()Response.json()이지만, 프로덕션 패스에는 네 가지 추가 습관이 필요합니다: 본문 파싱을 try/catch로 감싸고, CORS 헤더를 의도적으로 추가하고, 프록시된 응답을 파싱하기 전에 upstream.ok를 확인하고, 캐시 쓰기와 기타 백그라운드 작업에 ctx.waitUntil을 쓰세요. 이 네 가지만 제대로 하면 잘 버티는 Worker를 배포할 수 있습니다.