try-catch만으로는 부족하다
이전 글에서 async/await와 try-catch로 비동기 에러를 처리하는 방법을 배웠습니다. 하지만 실전에서는 더 많은 상황을 고려해야 합니다.
- 네트워크가 불안정하면? → 재시도
- 응답이 너무 오래 걸리면? → 타임아웃
- 요청을 취소해야 하면? → AbortController
- 여러 요청의 순서가 뒤바뀌면? → 경쟁 조건 방지
재시도 (Retry)
네트워크 에러는 일시적인 경우가 많습니다. 한 번 실패했다고 포기하지 않고 재시도하는 패턴입니다.
단순 재시도
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url);
if (res.ok) return res;
if (i === retries - 1) throw new Error(`HTTP ${res.status}`);
} catch (err) {
if (i === retries - 1) throw err;
}
}
}fetch는 네트워크 에러에서만 reject됩니다. HTTP 에러(4xx, 5xx)는 정상 응답으로 취급되므로, res.ok를 확인해야 합니다.
마지막 시도에서도 실패하면 에러를 throw합니다. 중간 실패는 무시하고 다음 시도로 넘어갑니다.
지수 백오프 (Exponential Backoff)
재시도 사이에 대기 시간을 지수적으로 증가시킵니다. 서버가 과부하 상태일 때 즉시 재시도하면 상황을 악화시킬 수 있습니다.
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await fetch(url);
} catch (err) {
if (i === retries - 1) throw err;
await delay(1000 * 2 ** i); // 1초, 2초, 4초...
}
}
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}재시도하면 안 되는 경우
모든 에러를 재시도하면 안 됩니다.
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url);
// 4xx 에러는 재시도해도 같은 결과
if (res.status >= 400 && res.status < 500) {
throw new Error(`Client error: ${res.status}`);
}
return res;
} catch (err) {
// 재시도 불가능한 에러는 즉시 throw
if (err.message.startsWith("Client error")) throw err;
if (i === retries - 1) throw err;
await delay(1000 * 2 ** i);
}
}
}- 401, 403, 404 , 인증/권한/경로 문제. 재시도해도 동일한 결과.
- 500, 502, 503 , 서버 문제. 일시적일 수 있으므로 재시도 가치 있음.
- 네트워크 에러 , 연결 끊김. 재시도 가치 있음.
타임아웃 (Timeout)
fetch는 기본적으로 타임아웃이 없습니다. 서버가 응답하지 않으면 무한히 기다립니다.
Promise.race로 타임아웃
async function fetchWithTimeout(url, ms = 5000) {
const response = await Promise.race([
fetch(url),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("타임아웃")), ms)
),
]);
return response;
}간단하지만 문제가 있습니다: 타임아웃 후에도 fetch 요청이 계속 진행 됩니다. 네트워크 리소스를 낭비합니다.
AbortController로 타임아웃
async function fetchWithTimeout(url, ms = 5000) {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(), ms
);
try {
const res = await fetch(url, {
signal: controller.signal
});
return await res.json();
} finally {
clearTimeout(timer);
}
}AbortController는 실제로 요청을 취소 합니다. 브라우저가 TCP 연결을 닫아서 리소스를 확보합니다.
AbortController
AbortController는 비동기 작업을 취소하기 위한 표준 API입니다. fetch뿐만 아니라 모든 비동기 작업에 사용할 수 있습니다.
기본 사용
const controller = new AbortController();
const signal = controller.signal;
// 요청에 signal 전달
fetch("/api/data", { signal })
.then(res => res.json())
.catch(err => {
if (err.name === "AbortError") {
console.log("요청이 취소되었습니다");
}
});
// 언제든 취소 가능
controller.abort();페이지 이동 시 요청 취소
React에서 컴포넌트가 언마운트될 때 진행 중인 요청을 취소하는 패턴:
useEffect(() => {
const controller = new AbortController();
async function loadData() {
try {
const res = await fetch("/api/data", {
signal: controller.signal
});
const data = await res.json();
setData(data);
} catch (err) {
if (err.name !== "AbortError") {
setError(err);
}
}
}
loadData();
return () => controller.abort(); // cleanup
}, []);컴포넌트가 사라진 후에 setData가 호출되는 것을 방지합니다. 이 패턴은 React 공식 문서에서도 권장하는 방식이며, TanStack Query (React Query) 같은 데이터 페칭 라이브러리는 이러한 취소, 재시도, 캐싱 로직을 내부적으로 처리해줍니다.
AbortSignal.timeout
최신 브라우저에서는 타임아웃용 signal을 더 간결하게 만들 수 있습니다.
// AbortController + setTimeout 대신
const res = await fetch("/api/data", {
signal: AbortSignal.timeout(5000)
});경쟁 조건 (Race Condition)
검색 입력창에서 타이핑할 때마다 요청을 보내면, 응답 순서가 요청 순서와 다를 수 있습니다. "re"의 응답이 "react"의 응답보다 늦게 오면, 오래된 결과가 화면에 표시됩니다.
ID 기반 무효화
let currentId = 0;
async function search(query) {
const id = ++currentId;
const res = await fetch("/search?q=" + query);
const data = await res.json();
if (id !== currentId) return; // 오래된 응답 무시
renderResults(data);
}AbortController로 이전 요청 취소
ID 방식보다 확실한 방법: 새 요청을 보낼 때 이전 요청을 취소합니다.
let currentController = null;
async function search(query) {
// 이전 요청 취소
currentController?.abort();
currentController = new AbortController();
try {
const res = await fetch("/search?q=" + query, {
signal: currentController.signal
});
const data = await res.json();
renderResults(data);
} catch (err) {
if (err.name !== "AbortError") throw err;
}
}이전 요청의 네트워크 리소스까지 확보할 수 있어서 더 효율적입니다.
패턴 조합
실전에서는 이 패턴들을 조합합니다.
async function resilientFetch(url, options = {}) {
const {
retries = 3,
timeout = 5000,
signal, // 외부에서 전달받은 signal
} = options;
for (let i = 0; i < retries; i++) {
const controller = new AbortController();
// 외부 signal이 abort되면 내부도 abort
signal?.addEventListener("abort",
() => controller.abort(),
{ once: true }
);
const timer = setTimeout(
() => controller.abort(), timeout
);
try {
const res = await fetch(url, {
signal: controller.signal
});
clearTimeout(timer);
return res;
} catch (err) {
clearTimeout(timer);
// 외부에서 취소한 경우 재시도하지 않음
if (signal?.aborted) throw err;
// 마지막 시도면 throw
if (i === retries - 1) throw err;
await delay(1000 * 2 ** i);
}
}
}재시도 + 타임아웃 + 외부 취소를 하나로 합친 함수입니다.
정리
| 패턴 | 문제 | 해결 |
|---|---|---|
| 재시도 | 일시적 네트워크 실패 | for 루프 + 지수 백오프 |
| 타임아웃 | 무한 대기 | AbortController + setTimeout |
| AbortController | 리소스 낭비, 메모리 누수 | signal로 요청 취소 |
| 경쟁 조건 방지 | 응답 순서 역전 | ID 비교 또는 이전 요청 abort |
다음 단계
비동기 JavaScript의 핵심 개념을 모두 다뤘습니다. 다음 글에서는 실전에서 자주 쓰이는 비동기 유틸리티 패턴 , debounce, throttle, 동시성 제한, 큐잉을 살펴보겠습니다.