요청·응답 처리
왜 Request/Response API를 깊이 알아야 하는가
Workers의 모든 것은 Request와 Response로 시작하고 끝난다. 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.body는 ReadableStream이다. 직접 읽으려면 복잡한 스트림 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 안에서 글로벌 분산 키-값 스토리지를 사용해 데이터를 영구적으로 저장하고 읽는 방법을 배운다. 지금까지 만든 라우팅 패턴 위에 실제 데이터 저장소를 얹는 것이다.