프론트엔드에서 네트워크는 언제든 실패합니다. React 앱에서 REST API 에러를 예측하고, 안전한 재시도 로직과 사용자 친화적 피드백을 제공하면 안정성과 전환율이 크게 향상됩니다. 이 글은 실무에서 바로 쓸 수 있는 fetch/axios/react-query 기반 재시도 구현, 지수 백오프, Retry-After 처리, 타임아웃, 취소, 접근성까지 압축해서 안내합니다.
1. 실패 유형 분류와 정책
상태코드별 정책이 핵심입니다. 4xx(특히 400/404/422)는 보통 재시도하지 않습니다. 401/403은 토큰 갱신 플로우가 따로 필요합니다. 408/429/5xx는 재시도 대상입니다. POST/PUT과 같은 비멱등 요청은 서버가 멱등성을 보장하거나 Idempotency-Key가 있는 경우에만 재시도합니다. 지수 백오프+지터를 적용하고, 서버가 Retry-After 헤더를 주면 이를 존중합니다.
2. fetch 기반 재시도 유틸리티 구현
AbortController로 취소와 타임아웃을 처리하고, 408/429/5xx 및 네트워크 오류에 대해 지수 백오프와 지터를 적용합니다. Retry-After 헤더도 반영합니다.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const parseRetryAfterMs = (response) => {
const h = response.headers.get('retry-after');
if (!h) return null;
const seconds = Number(h);
if (!Number.isNaN(seconds)) return seconds * 1000;
const date = new Date(h).getTime();
const now = Date.now();
if (!Number.isNaN(date) && date > now) return Math.min(60_000, date - now);
return null;
};
export async function retryFetch(input, init = {}) {
const {
retries = 3,
backoffBase = 300,
backoffFactor = 2,
maxBackoff = 8000,
timeout = 8000,
respectRetryAfter = true,
jitter = true,
shouldRetry = (res) => res.status === 408 || res.status === 429 || (res.status >= 500 && res.status <= 599),
onRetry = ({ attempt, delay, error, response }) => {},
signal,
...rest
} = init;
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(new DOMException('Timeout','AbortError')), timeout);
const onAbort = () => ac.abort();
try {
if (signal) {
if (signal.aborted) throw new DOMException('Aborted','AbortError');
signal.addEventListener('abort', onAbort, { once: true });
}
const res = await fetch(input, { ...rest, signal: ac.signal });
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', onAbort);
if (res.ok) return res;
if (!shouldRetry(res) || attempt === retries) {
return res;
}
let delay = Math.min(backoffBase * Math.pow(backoffFactor, attempt), maxBackoff);
if (respectRetryAfter) {
const ra = parseRetryAfterMs(res);
if (ra) delay = Math.min(ra, maxBackoff);
}
if (jitter) delay = Math.round(delay * (0.8 + Math.random() * 0.4));
onRetry({ attempt, delay, response: res });
await sleep(delay);
} catch (err) {
clearTimeout(timer);
if (signal) signal.removeEventListener('abort', onAbort);
lastError = err;
const isAbort = err && err.name === 'AbortError';
if (isAbort) throw err;
if (attempt === retries) throw err;
const base = Math.min(backoffBase * Math.pow(backoffFactor, attempt), maxBackoff);
const delay = jitter ? Math.round(base * (0.8 + Math.random() * 0.4)) : base;
onRetry({ attempt, delay, error: err });
await sleep(delay);
}
}
throw lastError;
}
3. useApi 훅으로 컴포넌트에서 사용
언마운트 시 setState 경고를 피하기 위해 AbortController로 요청을 취소합니다. 실패 시 retry 함수를 UI에 노출합니다.
import { useEffect, useRef, useState, useCallback } from 'react';
import { retryFetch } from './retryFetch';
export function useApi(url, options) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const abortRef = useRef(null);
const optsRef = useRef(options);
const load = useCallback(async () => {
if (!url) return;
if (abortRef.current) abortRef.current.abort();
const ac = new AbortController();
abortRef.current = ac;
setLoading(true);
setError(null);
try {
const res = await retryFetch(url, {
...optsRef.current,
signal: ac.signal,
onRetry: ({ attempt, delay }) => {
// console.debug('retry', attempt, 'in', delay, 'ms');
},
});
const json = await res.json();
if (!ac.signal.aborted) setData(json);
} catch (e) {
if (!ac.signal.aborted) setError(e);
} finally {
if (!ac.signal.aborted) setLoading(false);
}
}, [url]);
useEffect(() => {
load();
return () => {
if (abortRef.current) abortRef.current.abort();
};
}, [load]);
return { data, error, loading, retry: load };
}
// 예시 컴포넌트 (JSX는 이스케이프하여 표시)
function Users() {
const { data, error, loading, retry } = useApi('/api/users', { retries: 2, timeout: 6000 });
if (loading) return <p>불러오는 중입니다...</p>;
if (error) return (
<div role='alert' aria-live='polite'>
<p>네트워크 오류가 발생했습니다.</p>
<button onClick={retry}>다시 시도</button>
</div>
);
return <ul>{data.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
4. Axios 인터셉터로 재시도하기
axios 인스턴스에 응답 인터셉터를 추가해 408/429/5xx에 대해 재시도합니다. Retry-After를 존중하고 지수 백오프를 적용합니다.
import axios from 'axios';
const api = axios.create({ baseURL: '/api', timeout: 8000 });
api.interceptors.response.use(
(res) => res,
async (error) => {
const cfg = error.config || {};
cfg.__retryCount = cfg.__retryCount || 0;
const status = error.response?.status;
const should = !cfg.__noRetry && (status === 429 || status === 408 || (status >= 500 && status < 600));
if (!should || cfg.__retryCount >= (cfg.retries ?? 3)) return Promise.reject(error);
cfg.__retryCount += 1;
let delay = Math.min(300 * 2 ** (cfg.__retryCount - 1), 8000);
const ra = error.response?.headers?.['retry-after'];
if (ra) {
const s = Number(ra);
if (!Number.isNaN(s)) delay = Math.min(s * 1000, 8000);
}
await new Promise((r) => setTimeout(r, Math.round(delay * (0.8 + Math.random() * 0.4))));
return api(cfg);
}
);
export default api;
5. React Query로 간단 설정
React Query는 재시도와 백오프를 옵션으로 제공합니다. 상태코드 기반으로 재시도 여부를 제어하면 됩니다.
import { useQuery } from '@tanstack/react-query';
function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('/api/posts');
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
},
retry: (failureCount, error) => {
const status = error?.status || Number(String(error.message).split(' ').pop());
if (status && status < 500 && status !== 408 && status !== 429) return false;
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 8000),
});
}
6. UX: 메시지, 재시도 버튼, 오프라인
에러는 role=alert와 aria-live로 사용자에게 즉시 알립니다. 오프라인이면 재시도 대신 연결 복구 안내가 좋습니다.
const useOnline = () => {
const [online, setOnline] = React.useState(navigator.onLine);
React.useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
return online;
};
7. Error Boundary는 어디에 쓰나
Error Boundary는 렌더링/라이프사이클 오류를 포착합니다. 네트워크 오류는 훅/상태로 처리하고, 예기치 못한 컴포넌트 오류는 경계로 격리합니다.
import React from 'react';
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error, info) {
// log to service
}
render() {
if (this.state.hasError) return this.props.fallback ?? null;
return this.props.children;
}
}
8. 관측과 로깅: Sentry 연동
재시도 이벤트와 최종 실패를 로깅해 장애를 조기에 감지합니다.
import * as Sentry from '@sentry/react';
Sentry.init({
dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0',
integrations: [new Sentry.BrowserTracing()],
tracesSampleRate: 0.1,
});
export const logApiError = (ctx) => {
Sentry.captureMessage('API retry', { level: 'warning', extra: ctx });
};
9. 테스트: MSW로 실패→성공 시나리오 검증
MSW로 500 후 200을 응답하도록 하여 재시도 로직을 통과하는지 테스트합니다.
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { retryFetch } from './retryFetch';
const server = setupServer(
rest.get('/api/ping', (req, res, ctx) => res(ctx.status(500)))
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('retries once then succeeds', async () => {
let called = 0;
server.use(
rest.get('/api/ping', (req, res, ctx) => {
called += 1;
return called < 2
? res(ctx.status(500))
: res(ctx.status(200), ctx.json({ ok: true }));
})
);
const res = await retryFetch('/api/ping', { retries: 2, backoffBase: 10 });
const json = await res.json();
expect(json.ok).toBe(true);
});
10. 마무리 체크리스트
1) 어떤 상태코드에 재시도할지 명확히 정의합니다. 2) 지수 백오프+지터를 적용하고 Retry-After를 존중합니다. 3) 멱등성 보장 없는 쓰기 요청은 재시도하지 않거나 Idempotency-Key를 사용합니다. 4) AbortController로 타임아웃/취소를 구현합니다. 5) 사용자에게 명확한 오류 메시지와 재시도 버튼, 오프라인 안내를 제공합니다. 6) 로깅과 모니터링으로 실패를 추적합니다. 7) MSW 등으로 실패 시나리오를 테스트합니다.
'React' 카테고리의 다른 글
| React에서 컴포넌트 렌더링 우선순위 제어하기 (0) | 2026.06.25 |
|---|---|
| React 앱에 GraphQL Federation 아키텍처 적용하기 (0) | 2026.06.24 |
| React 앱에서 서버 로그 데이터를 실시간 모니터링하기 (0) | 2026.06.23 |
| React에서 복수 언어 번역 관리 자동화 툴 구축하기 (0) | 2026.06.23 |
| React 앱에서 사용자 입력 데이터 자동 저장(Auto Save) 기능 구현하기 (0) | 2026.06.22 |