fetch()는 이제 모든 최신 브라우저와 Node.js 18+에 내장되어 있습니다.
XMLHttpRequest를 대체했으며 대부분의 프로젝트에서 Axios의 필요성을 없앴습니다.
하지만 모든 튜토리얼에서 보여주는 기본 사용법 — fetch(url).then(r => r.json())
— 은 오류 처리가 없고, 타임아웃도 없으며, 실제 프로덕션 환경에서는 제대로 동작하지 않습니다.
이 가이드는 실제로 견고하게 동작하는 패턴들을 다룹니다.
기초 — GET과 POST
fetch()는
Response 객체로
이행(resolve)되는 Promise를 반환합니다.
GET 요청은 간단합니다:
const response = await fetch('https://api.example.com/products');
const products = await response.json();JSON 본문이 있는 POST 요청은 약간 더 많은 설정이 필요합니다:
const newProduct = {
name: 'Wireless Keyboard',
price: 79.99,
category: 'electronics'
};
const response = await fetch('https://api.example.com/products', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`
},
body: JSON.stringify(newProduct)
});
const created = await response.json();
console.log(`Created product with id: ${created.id}`);Content-Type: application/json을
빠뜨리는 것입니다. 이것이 없으면 많은 서버 프레임워크가 본문을 파싱하지 않아 400 Bad Request나
유용한 오류 메시지 없이 빈 요청 본문을 받게 됩니다.2단계 오류 확인 — response.ok
이것이 가장 중요하게 숙지해야 할 패턴입니다. fetch()는 네트워크 오류
(연결 없음, DNS 실패, CORS 차단) 시에만 Promise를 reject합니다. 404, 401, 500
응답은 여전히 Promise를 이행(resolve)합니다 — response.ok가
false로 설정된 채로요. 이를 확인하지 않으면 오류 응답이 조용히
response.json()으로 전달됩니다:
// ❌ Broken — 404 and 500 responses appear to succeed
async function getProduct(id) {
const response = await fetch(`/api/products/${id}`);
return await response.json(); // parses the error body as if it were data
}
// ✅ Correct — check response.ok before parsing
async function getProduct(id) {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
// Try to get the error message from the body if it's JSON
const errorBody = await response.json().catch(() => null);
const message = errorBody?.message ?? `HTTP ${response.status}: ${response.statusText}`;
throw new Error(message);
}
return await response.json();
}response.ok는 상태 코드 200–299에 대해 true입니다.
그 외의 것 — 301 리다이렉트(자동 추적이 안 될 경우), 400 오류, 500 오류 — 는
false로 설정됩니다. 파싱하기 전에 항상 확인하세요.
AbortController — 타임아웃과 취소
fetch()에는 내장 타임아웃이 없습니다. 서버가 전송 중에 응답을 멈추면
요청이 무기한 중단될 수 있습니다. 해결책은
AbortController입니다:
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
// Usage
try {
const data = await fetchWithTimeout('/api/products', {}, 5000);
} catch (err) {
console.error(err.message); // "Request timed out after 5000ms"
}AbortController는 사용자가 페이지를 떠나거나 이전 검색이 완료되기 전에 새 검색을 수행할 때 진행 중인 요청을 취소하는 데도 유용합니다:
let searchController = null;
async function searchProducts(query) {
// Cancel any previous search request
if (searchController) {
searchController.abort();
}
searchController = new AbortController();
try {
const response = await fetch(
`/api/products/search?q=${encodeURIComponent(query)}`,
{ signal: searchController.signal }
);
return await response.json();
} catch (err) {
if (err.name !== 'AbortError') throw err;
return null; // request was cancelled — that's OK
}
}지수 백오프를 이용한 재시도
네트워크 요청은 일시적으로 실패하기도 합니다 — 한 번의 재시도에서 503이 나와도 다음에는 성공하는 경우가 많습니다. 지수 백오프는 표준 전략으로, 과부하된 서버를 계속 두드리는 것을 피하기 위해 재시도 사이에 점점 더 길게 기다리는 방식입니다:
async function fetchWithRetry(url, options = {}, retries = 3, baseDelayMs = 500) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
const response = await fetch(url, options);
// Don't retry client errors (4xx) — they won't fix themselves
if (response.status >= 400 && response.status < 500) {
throw new Error(`Client error ${response.status} — not retrying`);
}
if (!response.ok) {
throw new Error(`Server error ${response.status}`);
}
return await response.json();
} catch (err) {
const isLastAttempt = attempt === retries;
if (isLastAttempt || err.message.includes('Client error')) {
throw err;
}
// Exponential backoff with jitter: 500ms, 1000ms, 2000ms + random
const delay = baseDelayMs * 2 ** (attempt - 1) + Math.random() * 100;
console.warn(`Attempt ${attempt} failed, retrying in ${Math.round(delay)}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Usage
const products = await fetchWithRetry('/api/products', {}, 3, 500);Math.random() * 100을
추가하면 "thundering herd" 문제를 방지할 수 있습니다 — 서버 장애 후 수천 개의 클라이언트가
정확히 같은 순간에 모두 재시도하는 상황입니다. 작은 랜덤 오프셋이지만 신뢰성에 큰 도움이 됩니다.인터셉터 패턴 — fetch() 래핑
Axios는 인터셉터 개념을 대중화했습니다: 모든 요청 전과 모든 응답 후에 실행되는 훅입니다.
fetch()를 얇게 감싸는 방식으로 동일한 것을 구현할 수 있습니다:
// api.js — your project's fetch wrapper
const API_BASE = 'https://api.example.com';
function getAuthToken() {
return localStorage.getItem('authToken');
}
async function apiFetch(path, options = {}) {
const url = `${API_BASE}${path}`;
// Request interceptor — add auth header to every request
const headers = {
'Content-Type': 'application/json',
...options.headers
};
const token = getAuthToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(url, { ...options, headers });
// Response interceptor — handle auth expiry globally
if (response.status === 401) {
logout(); // token expired — redirect to login
throw new Error('Session expired');
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.message ?? `API error ${response.status}`);
}
// Return null for 204 No Content
if (response.status === 204) return null;
return response.json();
}
// Usage — clean, no repeated boilerplate
const products = await apiFetch('/products');
const created = await apiFetch('/products', {
method: 'POST',
body: JSON.stringify({ name: 'New Product', price: 29.99 })
});이 패턴은 인증, 오류 처리, 기본 URL 설정을 중앙화합니다. 코드베이스의 모든 API 호출이 동일한 동작을 자동으로 얻습니다. 요구 사항이 변경될 때 — 새 헤더 추가나 JWT에서 세션 쿠키로 전환 — 한 곳만 수정하면 됩니다.
다양한 응답 유형 처리
모든 API가 JSON을 반환하는 것은 아닙니다. fetch()는 모든 일반적인
응답 유형에 대한 메서드를 제공합니다:
// JSON (most common)
const data = await response.json();
// Plain text (CSV, HTML, logs)
const csvText = await response.text();
// Binary data — download a file
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = blobUrl;
downloadLink.download = 'export.csv';
downloadLink.click();
URL.revokeObjectURL(blobUrl); // clean up
// ArrayBuffer — for WebAssembly or typed arrays
const buffer = await response.arrayBuffer();
const byteArray = new Uint8Array(buffer);
// Form data (multipart responses)
const formData = await response.formData();CORS — 개발자가 알아야 할 것
CORS(Cross-Origin Resource Sharing)는 서버가 아닌 브라우저에서 적용됩니다.
app.example.com의 프론트엔드가 api.example.com에서 데이터를 가져올 때,
브라우저는 Origin 헤더를 보내고 응답에서 권한을 확인합니다.
클라이언트 측에서 제어할 수 있는 내용은 다음과 같습니다:
// credentials: 'include' — send cookies/session tokens cross-origin
// The server must also respond with Access-Control-Allow-Credentials: true
const response = await fetch('https://api.example.com/account', {
credentials: 'include'
});
// credentials: 'same-origin' — default, only sends credentials to same origin
// credentials: 'omit' — never send credentials (useful for public CDN requests)
// mode: 'no-cors' — fire-and-forget for cross-origin requests
// You get a "opaque" response — no status, no body, no error
await fetch('https://analytics.example.com/event', {
method: 'POST',
mode: 'no-cors',
body: JSON.stringify({ event: 'page_view' })
});
// Use for analytics pings where you don't need the responseCORS 오류가 발생한다면 수정은 거의 항상 서버 측에서 이루어집니다: 서버가
응답 헤더에 Access-Control-Allow-Origin을 추가해야 합니다.
MDN의 CORS 가이드가
설정 방법에 대한 가장 완전한 참고 자료입니다.
유용한 도구
fetch 응답을 디버깅할 때 JSON 포매터는 API 페이로드를 읽기 쉽게 만들고, JSON 검증기는 잘못된 형식의 응답을 잡아냅니다. 쿼리 파라미터를 안전하게 인코딩하려면 JSON URL 인코더가 도움이 됩니다. MDN Using Fetch 가이드는 모든 fetch 옵션과 응답 메서드에 대한 최고의 단일 참고 자료입니다.
마무리
fetch()는 JavaScript에서 HTTP 요청을 위한 탄탄한 기반이지만,
기본 설정만으로는 프로덕션에 필요한 모든 것이 빠져 있습니다. 파싱하기 전에 항상
response.ok를 확인하세요. AbortController로 타임아웃을 추가하세요.
인증 헤더, 오류 정규화, 401 리다이렉트를 한 곳에서 처리하는 얇은 래퍼 함수를 만드세요.
일시적으로 실패할 수 있는 요청에 지수 백오프 재시도 로직을 추가하세요. 이 네 가지 습관이
데모 코드와 프로덕션 코드의 차이를 만듭니다.