본문 바로가기

React

React에서 REST API 에러 처리 및 재시도 로직 구현하기

프론트엔드에서 네트워크는 언제든 실패합니다. 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 등으로 실패 시나리오를 테스트합니다.