
회사에서 API 연동 작업을 마쳤는데, QA 과정에서 문제가 발생했다.
분명 정상적으로 API 연동을 했는데 정말 간헐적으로 서버에서 502 에러와 CORS 에러를 보내는 것이었다.
그래서 분명 에러처리를 했지만 테이블 데이터가 보이지 않는 문제가 발생했다.
그래서 캐싱 처리를 더 붙여야하나 고민을 하며 구글링을 하다가 axios-retry라는 라이브러리를 알게되었다.
실제 환경에선 API가 한 번만 끊겨도 사용자는 바로 불편함을 느낀다.
지하철에서 인터넷 연결이 순간적으로 불안정하거나, 서버가 잠깐 500번대 에러를 내거나,
429(레이트 리밋) 에러가 반환되면 새로고침 -> 재시도 -> 실패의 악순환이 계속된다.
하지만 항상 난감한 점은 이런 문제는 재현하기가 어렵다는 것이다
QA 환경에서는 멀쩡한데 실제 사용자 네트워크에서는 간헐적으로만 문제가 발생한다.
그래서 보통 try/catch나 while로 직접 재시도 로직을 붙이지만, 호출하는 곳마다 중복 코드가 늘어나면서 유지보수도 어려워진다.
그럴 때 axios-retry 라이브러리를 사용해서 전역 axios 인스턴스에 재시도 설정을 추가해 사용자가 이탈하는 것을 막고,
자동 api 재시도를 해 실패를 하더라도 깔끔한 UX를 제공하게 하는 방법을 쓸 수 있다.
axios-retry는 axios 전용 자동 재시도 미들웨어이다.
전역 axios 인스턴스에 적용하면 네트워크 오류나 특정 상태 코드에서 요청을 자동으로 다시 시도하게 할 수 있다.
가장 많이 쓰는 옵션은 3가지이다.
retries : 최대 재시도 횟수retryDelay: 재시도 간격(백오프). 일정한 간격, 배수 간격, 무작위(지터) 중에서 선택할 수 있다.retryCondition : 어떤 에러에 재시도할지 조건을 지정한다. 기본값은 네트워크 오류 + 500번대 에러지만 필요에 따라 설정할 수 있다.retryCondition는 보통 아래와 같은 간격을 많이 사용한다.
재시도 대상은 보통 네트워크 오류/타임아웃/500번대 에러/429 에러이고, 대부분 클라이언트 실수인 400번대 에러는 제외한다.
axios-retry는 NPM Weekly 다운로드 수가 450만일 정도로 활발히 사용되고 있는 라이브러리이기 때문에 인터넷에 정보도 많다.

아래는 전역 axios 인스턴스에 최소한의 설정을 추가해 재시도를 적용한 코드이다.
보통 이 정도만 해도 API 안정성이 크게 향상된다.
# npm으로 설치
npm install axios axios-retry
# yarn으로 설치
yarn add axios axios-retry
// api/client.ts
import axios, { AxiosError } from 'axios';
import axiosRetry, { isNetworkOrIdempotentRequestError } from 'axios-retry';
export const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 8000,
});
// 네트워크 오류/500번대 에러에서 3회, 1초->2초->3초
axiosRetry(api, {
retries: 3,
shouldResetTimeout: true,
retryDelay: (retryCount) => retryCount * 1000,
retryCondition: (error: AxiosError) -> {
// 기본 조건에 429에러 추가
const status = error.response?.status;
return isNetworkOrIdempotentRequestError(error) || status === 429;
},
});
이후엔 원래 사용하던 것과 똑같이 axios 인스턴스를 전역에서 사용하면 된다.
타입스크립트에서는 axios-retry 옵션에 자동완성이 나오고, AxiosError 제네릭으로 response.data 타입을 정확히 지정하면 코드가 더 깔끔해진다.
실제 사용할때는 무작위 보정이 효과적이다.
서버가 불안정할 때나 같은 타이밍에 몰리는 재시도를 분산시키려면 무작위 보정을 사용해야 한다.
// 배수 + 무작위 보정: 기준딜레이 * 2^(n-1) 사이 랜덤
const EXP_BASE = 300; // ms
const EXP_MAX = 5000; // ms
const expJitterDelay = (retryCount: number) => {
const exp = Math.min(EXP_BASE * 2 ** (retryCount - 1), EXP_MAX);
return Math.floor(Math.random() * exp);
};
axiosRetry(api, {
retries: 4,
retryDelay: expJitterDelay,
retryCondition: (error) => {
const status = error.response?.status;
const method = error.config?.method?.toUpperCase();
// 멱등 요청 위주(GET, HEAD, OPTIONS), 429/500번대 에러/네트워크 오류만
const idempotent = !method
? true
: ['GET', 'HEAD', 'OPTIONS'].includes(method);
const retriableStatus = status === 429 || (status ? status >= 500 : false);
return (
axiosRetry.isNetworkOrIdempotentRequestError(error) ||
(idempotent && retriableStatus)
);
},
onRetry: (retryCount, error, reqConfig) => {
console.info('[retry]', {
retryCount,
url: reqConfig.url,
status: error.response?.status,
});
},
});
여기서 에러별 분기 처리를 하는 것도 중요하다.
예를 들어, 인증 관련 API는 재시도보다 토큰 갱신이나 로그아웃 처리를 하는게 우선이다.
반면에 429 에러는 Retry-After 헤더를 읽어서 대기 시간에 대한 처리를 하는 것이 우선이다.
// Retry-After 헤더 처리
const delayFromRetryAfter = (error: any, fallbackMs: number) => {
const ra = error?.response?.headers?.['retry-after'];
if (!ra) return fallbackMs;
const sec = Number(ra);
if (!Number.isNaN(sec)) return sec * 1000;
const date = new Date(ra).getTime() - Date.now();
return date > 0 ? date : fallbackMs;
};
그리고 재시도 중이더라도 타임아웃이나 요청을 취소하는 것도 고려해야한다.
만약 사용자가 화면을 떠난다면 재시도 요청은 바로 멈춰야 한다.
const controller = new AbortController();
const p = api.get('/items', { signal: controller.signal });
// 사용자가 뒤로가기/탭 전환 시
controller.abort();
// 재시도 중에도 signal이 abort되면 즉시 중단
axiosRetry(api, {
retries: 3,
retryCondition: (error) => {
if (error.config?.signal && (error.config.signal as AbortSignal).aborted)
return false;
return (
axiosRetry.isNetworkOrIdempotentRequestError(error) ||
error.response?.status === 429
);
},
});
마지막으로 재시도에 리밋을 두는 것이 중요하다.
최대 지연 시간(EXP_MAX), 전체 요청 처리 시간, 동시에 허용할 재시도 횟수 등을 제한해야 한다.
// query/client.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // 네트워크 재시도는 axios-retry에 넘김
staleTime: 30_000,
gcTime: 5 * 60_000,
useErrorBoundary: (err) => !!(err as any)?.response?.status >= 500,
},
},
});
그리고 실제로는 API 별로 재시도를 분리하는 것도 중요하다.
Retry-After 처리// 비멱등 POST에 멱등키를 넘겨 서버가 중복 처리를 방지하도록 한다.
import { v4 as uuid } from 'uuid';
await api.post('/orders', payload, {
headers: { 'Idempotency-Key': uuid() },
});
환경별 설정도 분리하는 것이 좋다.
재시도 결과를 얼마나 성공적으로 처리했는지 파악하려면 로깅과 모니터링이 필요하다.
간단하게는 인터셉터에서 재시도 관련 정보를 출력하는 방식이 있다.
api.interceptors.response.use(
(res) => {
if ((res.config as any).__retryCount) {
console.log('[retry-success]', {
url: res.config.url,
count: (res.config as any).__retryCount,
});
}
return res;
},
(err) => {
// 재시도가 모두 실패했을 때 처리할 로직 (에러 토스트, 재시도 버튼 등)
return Promise.reject(err);
}
);
재시도를 무조건 많이 하는 것이 좋은 것은 아니다.
retries와 retryDelay에 제한을 두고, 전체 요청 시간에도 제한을 걸어야한다.Retry-After 값도 대기한다.실제로 서버에서 정말 간헐적으로 502에러와 CORS 에러가 떠서 적용한 라이브러리인데,
작은 설정으로 UX를 해치는 것을 줄일 수 있다는게 좋았다.