NestBook / Cloudflare + D1

왜 Request/Response API를 깊이 알아야 하는가

Workers의 모든 것은 RequestResponse로 시작하고 끝난다. KV를 읽든, D1에 쿼리하든, 외부 API를 호출하든 — 결국 요청을 어떻게 해석하고 응답을 어떻게 만드느냐가 Worker의 핵심 로직이다.

이 챕터를 제대로 이해하면 Hono 같은 프레임워크 없이도 간단한 REST API를 Workers만으로 구현할 수 있다.


Request 객체 완전 분해

Request는 Web API 표준 인터페이스다. Node.js의 req 객체와 다르게 읽기 전용이며 비동기 메서드로 바디를 읽는다.

기본 프로퍼티

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // URL 정보
    const url = new URL(request.url);
    console.log(url.pathname);     // "/api/users"
    console.log(url.hostname);     // "my-worker.example.workers.dev"
    console.log(url.searchParams.get('page')); // "2"

    // HTTP 메서드
    console.log(request.method);   // "GET", "POST", "PUT", "DELETE"

    // 헤더
    const contentType = request.headers.get('Content-Type');
    // → "application/json" 또는 null

    const authHeader = request.headers.get('Authorization');
    // → "Bearer eyJhbGc..." 또는 null

    // Cloudflare 전용 정보 (CF 데이터)
    const cf = request.cf;
    console.log(cf?.country);    // "KR" (요청자 국가 코드)
    console.log(cf?.city);       // "Seoul"
    console.log(cf?.timezone);   // "Asia/Seoul"

    return new Response('OK');
  },
};

바디 읽기 — 메서드에 따라 다르다

바디는 스트림(stream)으로 전달되므로 한 번만 읽을 수 있다. 그리고 비동기(async/await)로 읽어야 한다.

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const method = request.method;

    if (method === 'POST') {
      const contentType = request.headers.get('Content-Type') ?? '';

      if (contentType.includes('application/json')) {
        // JSON 바디 파싱
        const body = await request.json<{ name: string; age: number }>();
        console.log(body.name); // "홍길동"
        console.log(body.age);  // 30

      } else if (contentType.includes('application/x-www-form-urlencoded')) {
        // HTML form 데이터 파싱
        const formData = await request.formData();
        const name = formData.get('name'); // "홍길동"

      } else if (contentType.includes('text/plain')) {
        // 일반 텍스트
        const text = await request.text();
        console.log(text); // "안녕하세요"

      } else {
        // 바이너리 데이터 (이미지 등)
        const buffer = await request.arrayBuffer();
        console.log(buffer.byteLength); // 바이트 크기
      }
    }

    return new Response('처리 완료');
  },
};
⚠️ 바디는 한 번만 읽을 수 있다
await request.json()을 한 번 호출하면 스트림이 소비된다. 같은 request로 다시 request.text()를 호출하면 에러가 난다. 여러 곳에서 바디를 읽어야 한다면 request.clone()을 먼저 만들어 사용한다.

URL 라우팅 — pathname으로 분기

Workers에서 라우팅은 프레임워크 없이 pathname으로 직접 분기하면 된다.

기본 if-else 라우터

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const pathname = url.pathname;
    const method = request.method;

    // GET /api/users — 사용자 목록
    if (pathname === '/api/users' && method === 'GET') {
      return handleGetUsers(request, env);
    }

    // GET /api/users/123 — 특정 사용자
    // pathname.startsWith()로 동적 세그먼트 처리
    if (pathname.startsWith('/api/users/') && method === 'GET') {
      const userId = pathname.split('/')[3]; // "123"
      return handleGetUser(userId, env);
    }

    // POST /api/users — 사용자 생성
    if (pathname === '/api/users' && method === 'POST') {
      return handleCreateUser(request, env);
    }

    // DELETE /api/users/123
    if (pathname.startsWith('/api/users/') && method === 'DELETE') {
      const userId = pathname.split('/')[3];
      return handleDeleteUser(userId, env);
    }

    // 매칭 없음
    return Response.json({ error: 'Not Found' }, { status: 404 });
  },
};

// 각 핸들러 함수 (분리하면 코드가 깔끔해진다)
async function handleGetUsers(request: Request, env: Env): Promise<Response> {
  // 실제로는 KV나 D1에서 데이터를 가져온다
  const users = [
    { id: 1, name: '홍길동' },
    { id: 2, name: '김철수' },
  ];
  return Response.json(users);
}

async function handleGetUser(userId: string, env: Env): Promise<Response> {
  if (!userId || isNaN(Number(userId))) {
    return Response.json({ error: '유효하지 않은 사용자 ID' }, { status: 400 });
  }
  return Response.json({ id: Number(userId), name: '홍길동' });
}

async function handleCreateUser(request: Request, env: Env): Promise<Response> {
  const body = await request.json<{ name: string }>();
  if (!body.name) {
    return Response.json({ error: 'name 필드는 필수입니다' }, { status: 400 });
  }
  // DB 저장 로직 (D1 챕터에서 다룸)
  return Response.json({ id: 3, name: body.name }, { status: 201 });
}

async function handleDeleteUser(userId: string, env: Env): Promise<Response> {
  return new Response(null, { status: 204 }); // No Content
}

쿼리 파라미터 파싱

// 요청: GET /api/posts?page=2&limit=10&tag=투자
const url = new URL(request.url);
const searchParams = url.searchParams;

const page = Number(searchParams.get('page') ?? '1');     // 2
const limit = Number(searchParams.get('limit') ?? '20');  // 10
const tag = searchParams.get('tag');                       // "투자" 또는 null

// 같은 키로 여러 값: GET /api/filter?status=active&status=pending
const statuses = searchParams.getAll('status'); // ["active", "pending"]

// 파라미터 존재 여부 확인
const hasSort = searchParams.has('sort'); // true/false

Response 만들기

다양한 응답 타입

// 1. 일반 텍스트
const textRes = new Response('안녕하세요');

// 2. JSON 응답 (권장 방식 — Content-Type 자동 설정)
const jsonRes = Response.json({ ok: true, data: { id: 1 } });

// 3. 상태 코드와 헤더 포함
const customRes = new Response('Unauthorized', {
  status: 401,
  headers: {
    'Content-Type': 'text/plain; charset=utf-8',
    'WWW-Authenticate': 'Bearer',
  },
});

// 4. HTML 응답
const htmlRes = new Response('<h1>안녕하세요</h1>', {
  headers: { 'Content-Type': 'text/html; charset=utf-8' },
});

// 5. 빈 응답 (DELETE 성공 등)
const emptyRes = new Response(null, { status: 204 });

// 6. 리다이렉트
const redirectRes = Response.redirect('https://example.com', 301);

JSON 응답 헬퍼 패턴

매번 Response.json()을 반복 작성하는 대신 헬퍼 함수를 만들면 코드가 깔끔해진다:

// 공통 응답 헬퍼 함수
function ok<T>(data: T, status = 200): Response {
  return Response.json({ ok: true, data }, { status });
}

function error(message: string, status = 400): Response {
  return Response.json({ ok: false, error: message }, { status });
}

// 사용 예시
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === '/api/ping') {
      return ok({ message: 'pong', time: Date.now() });
    }

    if (url.pathname === '/api/secret') {
      const token = request.headers.get('Authorization');
      if (!token) return error('인증이 필요합니다', 401);
      return ok({ secret: '🔐 비밀 데이터' });
    }

    return error('요청한 리소스를 찾을 수 없습니다', 404);
  },
};

CORS 헤더 설정

프론트엔드(브라우저)에서 Workers API를 직접 호출할 때 CORS 문제가 발생한다. Workers에서 CORS 헤더를 직접 설정한다.

// CORS 헤더 상수 정의
const CORS_HEADERS = {
  'Access-Control-Allow-Origin': '*',              // 모든 도메인 허용 (개발용)
  // 'Access-Control-Allow-Origin': 'https://myapp.com',  // 특정 도메인만 (프로덕션)
  'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400',               // Preflight 캐시 24시간
};

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // OPTIONS 요청(Preflight)에 즉시 응답
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: CORS_HEADERS,
      });
    }

    // 실제 API 처리
    const url = new URL(request.url);
    let response: Response;

    if (url.pathname === '/api/data' && request.method === 'GET') {
      response = Response.json({ items: [1, 2, 3] });
    } else {
      response = Response.json({ error: 'Not Found' }, { status: 404 });
    }

    // 모든 응답에 CORS 헤더 추가
    // (Response는 읽기 전용이므로 새 Response를 만든다)
    return new Response(response.body, {
      status: response.status,
      headers: {
        ...Object.fromEntries(response.headers),
        ...CORS_HEADERS,
      },
    });
  },
};

헤더 조작

요청 헤더 읽기

// 헤더는 대소문자를 가리지 않는다 (case-insensitive)
const auth = request.headers.get('authorization');     // "Bearer token..."
const accept = request.headers.get('Accept');          // "application/json"
const ua = request.headers.get('user-agent');          // "Mozilla/5.0..."

// 모든 헤더 순회
for (const [key, value] of request.headers) {
  console.log(`${key}: ${value}`);
}

// 헤더를 객체로 변환
const headersObj = Object.fromEntries(request.headers);

응답에 헤더 추가

// 방법 1: Response 생성 시 headers 옵션
const res = new Response('응답 본문', {
  status: 200,
  headers: {
    'Content-Type': 'application/json',
    'Cache-Control': 'public, max-age=3600',  // 1시간 캐시
    'X-Request-ID': crypto.randomUUID(),       // 고유 요청 ID
  },
});

// 방법 2: Headers 객체 사용 (동적으로 추가할 때 유용)
const headers = new Headers();
headers.set('Content-Type', 'application/json');
headers.set('X-Custom-Header', '값');
headers.append('Set-Cookie', 'session=abc; HttpOnly; Secure'); // append로 여러 쿠키

const res2 = new Response(JSON.stringify({ ok: true }), { headers });

실전 패턴: 미니 라우터 만들기

프로젝트가 커지면 if-else 체인이 복잡해진다. 간단한 라우터 패턴을 만들어두면 가독성이 훨씬 좋아진다.

// 핸들러 타입 정의
type Handler = (request: Request, env: Env, params?: Record<string, string>) => Promise<Response>;

// 라우트 매처
function matchRoute(
  pathname: string,
  pattern: string
): Record<string, string> | null {
  // 정적 경로: /api/users
  if (pattern === pathname) return {};

  // 동적 경로: /api/users/:id
  const patternParts = pattern.split('/');
  const pathParts = pathname.split('/');

  if (patternParts.length !== pathParts.length) return null;

  const params: Record<string, string> = {};
  for (let i = 0; i < patternParts.length; i++) {
    if (patternParts[i].startsWith(':')) {
      // :id → params.id = "123"
      params[patternParts[i].slice(1)] = pathParts[i];
    } else if (patternParts[i] !== pathParts[i]) {
      return null; // 매칭 실패
    }
  }
  return params;
}

// 라우트 등록
const routes: Array<{ method: string; pattern: string; handler: Handler }> = [
  {
    method: 'GET',
    pattern: '/api/users',
    handler: async (req, env) => {
      return Response.json([{ id: 1, name: '홍길동' }]);
    },
  },
  {
    method: 'GET',
    pattern: '/api/users/:id',
    handler: async (req, env, params) => {
      return Response.json({ id: params!.id, name: '홍길동' });
    },
  },
  {
    method: 'POST',
    pattern: '/api/users',
    handler: async (req, env) => {
      const body = await req.json<{ name: string }>();
      return Response.json({ id: 99, name: body.name }, { status: 201 });
    },
  },
];

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const { pathname } = new URL(request.url);
    const method = request.method;

    // 라우트 매칭
    for (const route of routes) {
      if (route.method !== method) continue;
      const params = matchRoute(pathname, route.pattern);
      if (params !== null) {
        return route.handler(request, env, params);
      }
    }

    return Response.json({ error: 'Not Found' }, { status: 404 });
  },
};
📌 프레임워크를 쓸 것인가, 직접 구현할 것인가?
  • 라우트가 5개 이하이거나 단순 용도: 위 패턴으로 직접 구현이 간단하다
  • 라우트가 많고, 미들웨어·유효성 검사가 필요한 경우: Hono 프레임워크를 사용한다 (3단계 챕터에서 다룸)
  • Hono는 Workers에 최적화된 초경량 웹 프레임워크로, Express와 비슷한 API를 제공한다

초보자 흔한 오해와 주의사항

오해 1: “request.body를 직접 쓰면 된다” request.bodyReadableStream이다. 직접 읽으려면 복잡한 스트림 API가 필요하다. 항상 request.json(), request.text(), request.formData() 등 래퍼 메서드를 사용한다.

오해 2: “Response 헤더는 나중에 추가할 수 있다” Response 객체는 생성 후 불변(immutable)이다. 헤더를 추가하려면 new Response(response.body, { headers: {...} }) 로 새 Response를 만들어야 한다.

오해 3: “에러 처리 없이도 된다” await request.json()은 바디가 유효한 JSON이 아니면 예외를 던진다. 반드시 try-catch로 감싸야 한다:

try {
  const body = await request.json<{ name: string }>();
  // ...
} catch {
  return Response.json({ error: '유효하지 않은 JSON 형식입니다' }, { status: 400 });
}

다음 챕터에서는 Workers KV를 다룬다. Workers 안에서 글로벌 분산 키-값 스토리지를 사용해 데이터를 영구적으로 저장하고 읽는 방법을 배운다. 지금까지 만든 라우팅 패턴 위에 실제 데이터 저장소를 얹는 것이다.