Workers에서 외부 API를 호출했는데, 응답이 돌아오지 않거나 알 수 없는 에러가 뜬 경험이 있으신가요? 저는 처음으로 Workers에서 fetch()를 써본 날, 로컬 Node.js에서는 멀쩡히 작동하던 API 호출이 Workers에서만 실패해서 한참을 헤맸습니다. 에러 메시지도 불친절하기 짝이 없어서, 구글링해도 딱 맞는 답을 찾기가 어려웠거든요.
그 경험 이후로 Workers의 fetch()가 일반적인 Node.js의 fetch()와 어떻게 다른지, 어떤 함정들이 있는지를 정리해 두게 되었습니다. 같은 삽질을 반복하지 않기 위해서요.
📑 목차
대표적인 fetch() 에러 메시지
Workers에서 fetch()를 사용할 때 마주치는 에러들은 크게 몇 가지로 나뉩니다.
연결 자체가 안 되는 경우
Error: Network connection lost.
Error: fetch failed
cause: ConnectError: Connection refused
이 에러를 처음 봤을 때 “내 코드가 문제인가, 서버가 문제인가” 판단이 안 돼서 꽤 답답했습니다. 결론부터 말하면 대부분 호출 대상 서버 쪽 문제이거나, Workers 환경 특유의 제한에 걸린 경우입니다.
SSL/TLS 관련 에러
Error: fetch API cannot load: TLS handshake failed.
HTTPS 통신에서 인증서 검증이 실패하면 나타납니다. 로컬 개발 환경에서는 자체 서명 인증서를 무시하는 옵션이 있지만, Workers에서는 그런 게 없습니다. 보안 정책이 훨씬 엄격하거든요.
응답 본문 처리 에러
TypeError: Body has already been consumed.
이건 진짜 처음 보면 당황스러운 에러입니다. response.json()을 두 번 호출하면 발생하는데, 디버깅하려고 console.log(await response.text())를 먼저 찍고 나서 response.json()을 호출하면 이 에러가 터집니다. 저도 이걸로 20분은 날렸습니다.
원인 분석: Workers의 fetch()는 뭐가 다른가

Workers의 fetch()는 웹 표준 Fetch API를 따르지만, 일반적인 브라우저나 Node.js 환경과는 몇 가지 중요한 차이가 있습니다.
첫째, Workers는 서버 사이드에서 실행됩니다. 브라우저의 fetch()와 달리, Workers의 요청은 Cloudflare 엣지 서버에서 출발합니다. 그래서 브라우저에서는 CORS로 막히는 요청이 Workers에서는 자유롭게 가능하고, 반대로 브라우저에서 되던 것이 Workers에서 안 되는 경우도 있습니다.
둘째, localhost나 내부 IP로의 요청이 차단됩니다. 보안상의 이유로 Workers에서는 127.0.0.1, 192.168.x.x, 10.x.x.x 같은 프라이빗 IP로 fetch를 보낼 수 없습니다. 처음에 “왜 로컬 서버 테스트가 안 되지?” 하고 혼란스러웠는데, 이게 원인이었습니다.
셋째, 요청당 CPU 시간 제한이 있습니다. 무료 플랜 기준 10ms, 유료 플랜 기준 30초입니다. fetch 자체의 대기 시간(I/O)은 이 제한에 포함되지 않지만, 응답을 받아서 처리하는 시간은 포함됩니다. 대용량 JSON을 파싱하거나 복잡한 변환을 하면 제한에 걸릴 수 있습니다.
넷째, Subrequest 제한이 있습니다. 하나의 Worker 실행에서 호출할 수 있는 fetch 횟수가 정해져 있습니다. 무료 플랜은 50회, 유료 플랜은 1,000회입니다. “설마 50번이나 호출하겠어?” 싶지만, 반복문 안에서 API를 호출하다 보면 생각보다 빨리 도달합니다.
상황별 해결 방법
외부 API 호출이 실패하는 경우
가장 먼저 확인할 것은 요청 URL과 헤더입니다.
// ❌ 흔한 실수: 프로토콜 누락
const res = await fetch("api.example.com/data");
// ✅ 올바른 형태: https:// 포함
const res = await fetch("https://api.example.com/data");
우습게 들리겠지만, 이 실수를 하는 분이 생각보다 많습니다. 저도 한 번은 복사 붙여넣기 하면서 프로토콜을 빼먹은 적이 있는데, 에러 메시지가 “Invalid URL”이 아니라 알 수 없는 fetch 에러(네트워크 에러)로 나와서 원인 찾는 데 시간이 걸렸습니다.
API 키나 인증 헤더가 필요한 경우, Workers의 환경변수(Secrets)를 활용합니다.
export default {
async fetch(request, env) {
const apiResponse = await fetch("https://api.example.com/data", {
headers: {
"Authorization": `Bearer ${env.API_KEY}`,
"Content-Type": "application/json",
},
});
if (!apiResponse.ok) {
return new Response(
`API Error: ${apiResponse.status} ${apiResponse.statusText}`,
{ status: apiResponse.status }
);
}
const data = await apiResponse.json();
return Response.json(data);
},
};
환경변수 설정이 안 되어 있으면 env.API_KEY가 undefined가 되어서, 인증 에러가 발생합니다. wrangler deploy 에러 해결 글에서 환경변수 설정 방법을 다뤘으니 참고해 주세요.
CORS 관련 에러
Workers가 중간 프록시 역할을 할 때, 브라우저에서 Workers를 호출하면 CORS 헤더가 필요합니다.
// Workers에서 CORS 헤더를 추가하는 패턴
function corsHeaders(origin) {
return {
"Access-Control-Allow-Origin": origin || "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
}
export default {
async fetch(request, env) {
// Preflight 요청 처리
if (request.method === "OPTIONS") {
return new Response(null, {
headers: corsHeaders(request.headers.get("Origin")),
});
}
// 실제 요청 처리
const apiResponse = await fetch("https://api.example.com/data");
const data = await apiResponse.text();
return new Response(data, {
headers: {
"Content-Type": "application/json",
...corsHeaders(request.headers.get("Origin")),
},
});
},
};
CORS는 브라우저 → Workers 구간에서 필요한 거지, Workers → 외부 API 구간에서는 필요 없습니다. 이걸 헷갈려서 외부 API 요청에 CORS 헤더를 붙이는 실수를 하는 경우가 있는데, 그건 아무 효과가 없습니다. 저도 처음에 이 구분이 안 돼서 한참 헤맸어요.
타임아웃과 응답 지연
Workers 자체에는 fetch() 타임아웃 옵션이 없습니다. 하지만 AbortController를 활용하면 직접 구현할 수 있습니다.
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (error.name === "AbortError") {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
// 사용 예시: 5초 타임아웃
const res = await fetchWithTimeout("https://slow-api.example.com/data", {}, 5000);
외부 API가 느릴 때 이 패턴이 없으면, Workers가 응답 없이 계속 대기하다가 Cloudflare 측의 전체 실행 제한(무료 플랜 기준 Wall Time 30초)에 걸려서 죽습니다. 프로덕션에서 이 상황을 한 번 겪고 나니까 타임아웃을 안 넣는 게 오히려 무섭더군요.
리다이렉트 처리
Workers의 fetch()는 기본적으로 리다이렉트를 자동으로 따라갑니다. 하지만 때로는 이게 문제가 됩니다.
// 리다이렉트를 따라가지 않고 직접 처리
const res = await fetch("https://example.com/short-url", {
redirect: "manual", // "follow" (기본값), "manual", "error"
});
if (res.status >= 300 && res.status < 400) {
const location = res.headers.get("Location");
console.log(`Redirect to: ${location}`);
// 필요하면 여기서 다시 fetch
}
redirect: "manual"로 설정하면 301/302 응답을 직접 받아서 처리할 수 있습니다. 특히 인증 플로우에서 리다이렉트 URL을 확인해야 할 때 유용합니다.
실전에서 쓰는 안전한 fetch() 패턴
위의 문제들을 종합해서, 저는 프로덕션에서 아래와 같은 래퍼 함수를 만들어 쓰고 있습니다. 한번 만들어 두면 모든 프로젝트에서 재사용할 수 있어서 편합니다.
async function safeFetch(url, options = {}) {
const {
timeoutMs = 10000,
retries = 2,
retryDelay = 1000,
...fetchOptions
} = options;
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
// 5xx 에러는 재시도
if (response.status >= 500 && attempt < retries) {
await new Promise((r) => setTimeout(r, retryDelay * (attempt + 1)));
continue;
}
return response;
} catch (error) {
clearTimeout(timeoutId);
if (attempt === retries) {
throw new Error(
`Fetch failed after ${retries + 1} attempts: ${error.message}`
);
}
await new Promise((r) => setTimeout(r, retryDelay * (attempt + 1)));
}
}
}
// 사용 예시
const res = await safeFetch("https://api.example.com/data", {
timeoutMs: 5000,
retries: 2,
headers: { "Authorization": "Bearer token" },
});
타임아웃, 재시도, 5xx 에러 처리를 한 함수에 담았습니다. 이 패턴을 도입한 이후로 프로덕션에서 "원인 불명의 fetch 실패" 리포트가 확 줄었습니다. 대부분 일시적인 네트워크 문제는 재시도로 해결되거든요.
추가 팁과 디버깅 방법
wrangler dev에서 디버깅하기
로컬에서 wrangler dev를 실행하면 fetch 요청의 상세 로그를 볼 수 있습니다.
// 응답 상태와 헤더를 로깅
const res = await fetch("https://api.example.com/data");
console.log("Status:", res.status);
console.log("Headers:", Object.fromEntries(res.headers));
// 본문을 확인하면서도 이후에 다시 사용하고 싶을 때
const cloned = res.clone();
console.log("Body preview:", await cloned.text());
const data = await res.json(); // 원본은 그대로 사용 가능
res.clone()은 "Body has already been consumed" 에러를 피하는 핵심 패턴입니다. 디버깅할 때 응답 본문을 먼저 확인하고 싶으면, 반드시 clone한 복사본으로 확인하세요.
Subrequest 제한 관리
반복문에서 여러 API를 호출해야 한다면, Promise.all()로 병렬 처리하면 효율적입니다. 다만 무료 플랜의 50회 제한을 넘지 않도록 주의해야 합니다.
// ❌ 순차 호출 — 느리고 비효율적
for (const id of ids) {
const res = await fetch(`https://api.example.com/item/${id}`);
// ...
}
// ✅ 병렬 호출 — 빠르고 효율적
const responses = await Promise.all(
ids.slice(0, 40).map((id) => // 50회 제한 여유분 확보
fetch(`https://api.example.com/item/${id}`)
)
);
Workers 간 통신 (Service Bindings)
여러 Workers가 서로 호출해야 하는 경우, 일반 fetch() 대신 Service Bindings를 쓰면 Subrequest 제한에 포함되지 않고 속도도 훨씬 빠릅니다. 이건 아키텍처가 복잡해지면 알아두면 좋은 패턴입니다.
마무리
Workers의 fetch()는 강력하지만, 일반적인 환경과의 미묘한 차이 때문에 예상치 못한 에러를 만나기 쉽습니다. 프라이빗 IP 차단, Subrequest 제한, Body 소비 규칙 — 이 세 가지만 기억해도 대부분의 삽질을 피할 수 있습니다. safeFetch 같은 래퍼를 하나 만들어두면 프로덕션에서의 안정성도 크게 올라가니, 꼭 활용해 보시기 바랍니다. 자세한 내용은 Cloudflare Workers Fetch API 공식 문서에서 확인할 수 있습니다.