Fetch API와 HTTP
Fetch API는 네트워크 요청을 위한 현대적인 인터페이스입니다. XMLHttpRequest를 대체하며 Promise 기반으로 동작하여 async/await과 자연스럽게 연동됩니다.
fetch 기본 사용법
// 기본 GET 요청
const response = await fetch('https://api.example.com/users');
const users = await response.json();
console.log(users);
// fetch는 두 단계로 진행됨
// 1. 응답 헤더 수신: Response 객체 반환 (상태 코드 포함)
// 2. 응답 바디 파싱: .json(), .text(), .blob() 등 호출
// 중요: HTTP 오류 (4xx, 5xx)에서 fetch는 reject되지 않음!
const response = await fetch('https://api.example.com/not-found');
console.log(response.ok); // false (404)
console.log(response.status); // 404
// 네트워크 오류(인터넷 끊김 등)에서만 reject
Response 객체
const response = await fetch('/api/data');
// 상태 정보
console.log(response.status); // 200, 404, 500 등
console.log(response.statusText); // 'OK', 'Not Found' 등
console.log(response.ok); // 200-299이면 true
console.log(response.url); // 최종 URL (리다이렉트 후)
console.log(response.redirected); // 리다이렉트 여부
// 바디 읽기 (한 번만 가능!)
const jsonData = await response.json(); // JSON 파싱
const textData = await response.text(); // 텍스트
const blobData = await response.blob(); // Blob (이미지 등)
const bufferData = await response.arrayBuffer(); // ArrayBuffer
const formData = await response.formData(); // FormData
// 바디는 한 번만 읽을 수 있음
// 두 번 읽으려면 clone() 사용
const response = await fetch('/api/data');
const cloned = response.clone();
const json = await response.json();
const text = await cloned.text(); // 가능 (clone을 읽음)
Headers 객체
// Headers 객체 생성
const headers = new Headers({
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
'X-Custom-Header': 'value'
});
// 헤더 조작
headers.set('Content-Type', 'text/plain'); // 설정 (기존 값 대체)
headers.append('Accept', 'application/json'); // 추가 (같은 헤더 여러 값)
headers.get('Content-Type'); // 'text/plain'
headers.has('Authorization'); // true
headers.delete('X-Custom-Header');
// 헤더 순회
for (const [name, value] of headers) {
console.log(`${name}: ${value}`);
}
// 응답 헤더 읽기
const response = await fetch('/api/data');
console.log(response.headers.get('Content-Type')); // 'application/json'
console.log(response.headers.get('Cache-Control')); // 'max-age=3600'
console.log(response.headers.get('X-Rate-Limit-Remaining'));
// 응답 헤더 순회
response.headers.forEach((value, name) => {
console.log(`${name}: ${value}`);
});
Request 객체 커스터마이징
// Request 객체 직접 생성
const request = new Request('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ name: 'Alice', email: 'alice@example.com' }),
mode: 'cors', // 'cors', 'no-cors', 'same-origin'
credentials: 'include', // 'omit', 'same-origin', 'include'
cache: 'no-cache', // 'default', 'no-store', 'reload', 'no-cache', 'force-cache'
redirect: 'follow', // 'follow', 'error', 'manual'
referrerPolicy: 'no-referrer-when-downgrade'
});
const response = await fetch(request);
// 각 HTTP 메서드별 사용
// GET
const getResponse = await fetch('/api/users');
// POST
const postResponse = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice' })
});
// PUT
const putResponse = await fetch('/api/users/1', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Alice Updated' })
});
// PATCH
const patchResponse = await fetch('/api/users/1', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'new@example.com' })
});
// DELETE
const deleteResponse = await fetch('/api/users/1', {
method: 'DELETE'
});
AbortController로 요청 취소
// AbortController로 fetch 취소
const controller = new AbortController();
const { signal } = controller;
// 요청 시작
fetch('/api/large-data', { signal })
.then(r => r.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('요청이 취소되었습니다');
} else {
console.error('요청 실패:', err);
}
});
// 취소
setTimeout(() => controller.abort(), 3000); // 3초 후 취소
// 또는 사용자 액션으로 취소
cancelButton.addEventListener('click', () => controller.abort());
// async/await 방식
async function fetchWithCancel(url) {
const controller = new AbortController();
const cancelButton = document.querySelector('#cancel');
cancelButton.addEventListener('click', () => controller.abort(), { once: true });
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (err) {
if (err.name === 'AbortError') {
console.log('사용자가 취소했습니다');
return null;
}
throw err;
}
}
// 타임아웃 구현
function fetchWithTimeout(url, options = {}, timeout = 5000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
}
// 이전 요청 취소 후 새 요청 (검색 등에서 유용)
let currentController = null;
async function search(query) {
// 이전 요청 취소
currentController?.abort();
currentController = new AbortController();
try {
const response = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
});
return await response.json();
} catch (err) {
if (err.name !== 'AbortError') throw err;
return null;
}
}
XMLHttpRequest vs fetch 비교
// XMLHttpRequest (구식)
function xhrRequest(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url);
xhr.setRequestHeader('Accept', 'application/json');
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('네트워크 오류'));
xhr.ontimeout = () => reject(new Error('타임아웃'));
xhr.timeout = 5000;
xhr.send();
});
}
// fetch (현대적)
async function fetchRequest(url) {
const response = await fetch(url, {
headers: { 'Accept': 'application/json' }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
// 비교표:
// | 특성 | XHR | fetch |
// |---------------|----------------------|--------------------|
// | 문법 | 이벤트/콜백 기반 | Promise 기반 |
// | 에러 처리 | 복잡 | try/catch |
// | 요청 취소 | xhr.abort() | AbortController |
// | 진행률 추적 | onprogress 지원 | 직접 구현 필요 |
// | 스트리밍 | 지원 안 함 | ReadableStream 지원|
// | 쿠키 | withCredentials | credentials 옵션 |
// | 브라우저 지원 | IE6+ | Chrome 42+ |
// XHR이 여전히 유용한 경우: 업로드 진행률 추적
function uploadWithProgress(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
};
xhr.onload = () => {
if (xhr.status === 200) resolve(JSON.parse(xhr.responseText));
else reject(new Error(`업로드 실패: ${xhr.status}`));
};
xhr.onerror = () => reject(new Error('네트워크 오류'));
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
}
에러 처리 패턴
네트워크 에러 vs HTTP 에러
async function safeFetch(url, options = {}) {
let response;
try {
response = await fetch(url, options);
} catch (err) {
// 네트워크 에러 (인터넷 끊김, DNS 실패 등)
if (err instanceof TypeError) {
throw new NetworkError('네트워크 연결을 확인해주세요', { cause: err });
}
if (err.name === 'AbortError') {
throw new AbortError('요청이 취소되었습니다');
}
throw err;
}
// HTTP 에러 (4xx, 5xx)
if (!response.ok) {
let errorData = {};
try {
errorData = await response.json();
} catch {
// JSON 파싱 실패 무시
}
throw new HttpError(
errorData.message ?? `HTTP 오류: ${response.status}`,
response.status,
errorData
);
}
return response;
}
class HttpError extends Error {
constructor(message, status, data = {}) {
super(message);
this.name = 'HttpError';
this.status = status;
this.data = data;
}
}
class NetworkError extends Error {
constructor(message, options) {
super(message, options);
this.name = 'NetworkError';
}
}
// 사용
try {
const response = await safeFetch('/api/users');
const users = await response.json();
} catch (err) {
if (err instanceof HttpError) {
if (err.status === 401) redirectToLogin();
else if (err.status === 403) showForbiddenMessage();
else if (err.status === 404) showNotFoundMessage();
else showErrorMessage(err.message);
} else if (err instanceof NetworkError) {
showOfflineMessage();
} else {
console.error('예상치 못한 에러:', err);
}
}
재시도 로직과 타임아웃
// 지수 백오프 재시도 + 타임아웃
async function fetchWithRetry(url, options = {}) {
const {
retries = 3,
baseDelay = 1000,
maxDelay = 30000,
timeout = 10000,
shouldRetry = (err) => !(err instanceof HttpError && err.status < 500)
} = options;
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new HttpError(`HTTP ${response.status}`, response.status);
}
return response;
} catch (err) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
throw new Error(`요청 타임아웃 (${timeout}ms)`);
}
if (attempt === retries || !shouldRetry(err)) {
throw err;
}
// 지수 백오프 + 랜덤 지터
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
const jitter = Math.random() * 0.3 * delay;
console.log(`재시도 ${attempt + 1}/${retries}, ${Math.round(delay + jitter)}ms 후...`);
await new Promise(resolve => setTimeout(resolve, delay + jitter));
}
}
}
// 사용
const response = await fetchWithRetry('/api/data', {
retries: 3,
timeout: 5000,
baseDelay: 500
});
const data = await response.json();
실전: API 클라이언트
class ApiClient {
#baseUrl;
#defaultHeaders;
#interceptors = { request: [], response: [] };
constructor(baseUrl, options = {}) {
this.#baseUrl = baseUrl.replace(/\/$/, '');
this.#defaultHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers
};
}
// 인터셉터 등록
addRequestInterceptor(fn) {
this.#interceptors.request.push(fn);
}
addResponseInterceptor(fn) {
this.#interceptors.response.push(fn);
}
async #request(method, path, options = {}) {
let requestOptions = {
method,
headers: {
...this.#defaultHeaders,
...options.headers
},
...options
};
if (options.body && typeof options.body === 'object') {
requestOptions.body = JSON.stringify(options.body);
}
// 요청 인터셉터 실행
for (const interceptor of this.#interceptors.request) {
requestOptions = await interceptor(requestOptions);
}
let response;
try {
response = await fetchWithRetry(
`${this.#baseUrl}${path}`,
requestOptions
);
} catch (err) {
throw err;
}
// 응답 인터셉터 실행
for (const interceptor of this.#interceptors.response) {
response = await interceptor(response);
}
if (response.status === 204) return null;
return response.json();
}
get(path, options) { return this.#request('GET', path, options); }
post(path, body, options) { return this.#request('POST', path, { ...options, body }); }
put(path, body, options) { return this.#request('PUT', path, { ...options, body }); }
patch(path, body, options) { return this.#request('PATCH', path, { ...options, body }); }
delete(path, options) { return this.#request('DELETE', path, options); }
}
// 사용
const api = new ApiClient('https://api.example.com');
// 인증 토큰 자동 추가
api.addRequestInterceptor(async (options) => ({
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${await getAccessToken()}`
}
}));
// 401 응답 시 토큰 갱신
api.addResponseInterceptor(async (response) => {
if (response.status === 401) {
await refreshAccessToken();
// 재시도 로직...
}
return response;
});
// API 호출
const users = await api.get('/users?page=1&limit=10');
const newUser = await api.post('/users', { name: 'Alice', email: 'alice@example.com' });
파일 업로드와 FormData
// 단일 파일 업로드
async function uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
formData.append('name', file.name);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
// Content-Type 헤더를 설정하지 않음!
// 브라우저가 multipart/form-data와 boundary를 자동 설정
});
return response.json();
}
// 파일 입력에서 업로드
document.querySelector('#file-input').addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
const result = await uploadFile(file);
console.log('업로드 완료:', result.url);
}
});
// fetch로 업로드 진행률 추적 (ReadableStream 사용)
async function uploadWithFetchProgress(file, onProgress) {
const response = await fetch('/api/upload', {
method: 'POST',
body: file,
headers: {
'Content-Type': file.type,
'Content-Length': file.size
}
});
const reader = response.body.getReader();
const total = Number(response.headers.get('Content-Length')) || 0;
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
loaded += value.length;
onProgress(total ? (loaded / total) * 100 : 0);
}
}
고수 팁
1. 응답 캐싱 전략
const cache = new Map();
async function cachedFetch(url, ttl = 60000) {
const cached = cache.get(url);
if (cached && Date.now() - cached.timestamp < ttl) {
return cached.data;
}
const response = await fetch(url);
const data = await response.json();
cache.set(url, { data, timestamp: Date.now() });
return data;
}
2. 요청 중복 제거 (Deduplication)
const pendingRequests = new Map();
async function deduplicatedFetch(url) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url); // 진행 중인 요청 재사용
}
const promise = fetch(url).then(r => r.json()).finally(() => {
pendingRequests.delete(url);
});
pendingRequests.set(url, promise);
return promise;
}
3. 스트리밍 응답 처리 (SSE, ChatGPT 방식)
async function* streamResponse(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
for (const line of lines) {
const data = line.slice(6);
if (data === '[DONE]') return;
try {
yield JSON.parse(data);
} catch { /* 파싱 실패 무시 */ }
}
}
} finally {
reader.releaseLock();
}
}