API 중복 호출 최적화 및 fetch 타임아웃 처리

dobby·2026년 3월 4일
post-thumbnail

mov -> gif를 했는데, 영상이 느리게 실행됩니다.

API 중복 호출 문제

영상처럼 버튼을 여러 번 연속해서 클릭할 때, 클릭한 횟수만큼 api 요청이 가는 문제가 있음을 발견했다.

그래서 중복되는 데이터가 생기게 되며, 이로 인해 유저에게 혼란을 줄 수 있다.

get 요청의 경우 react query key에 맞춰 캐시되도록 했기 때문에, get을 제외한 메소드에 대한 api 중복 호출 문제를 해결하기로 했다.

해결 방법 탐색

API 중복 호출 문제를 해결하기 위한 방법은 세 가지 정도가 있다.

  1. debounce
  2. throttling
  3. tanstack query isPending 활용

처음에는 1, 2번 중에서 선택하려고 했다.
POST나 DELETE와 같은 mutate 요청의 경우 API 응답이 왔다고 해도 그 응답에 맞는 처리를 하기까지 시간이 소요될 수 있다.

예를 들어 정상 응답을 받은 후에 페이지 이동이 필요하다면 라우팅을 준비하는 시간이 소요될 수 있다.
그 시간동안은 기존의 페이지에 머물게 되는 것이다.

그러면 유저 입장에선 API를 재호출할 수 있는 환경이 마련되면서 중복된 요청이 발생하게 될 것이라고 생각했다.

하지만 무턱대고 나 혼자만의 생각으로 바로 적용하기 보단, 조금 더 찾아보고 나은 선택을 하는게 좋을 것 같았다.

그렇게 아래의 블로그를 발견했다.
React에서 중복호출(aka. 따닥)을 막는 완벽한 방법

블로그에서 말하는 API 중복 호출 중 debounce에 대한 내용은 다음과 같다.

아래는 블로그 내용

debounce 를 활용한 방식은 해피 케이스에는 문제가 없을 것입니다.
여기서 고민이 되는 부분은 waitMS 를 얼마로 설정할 것이냐 입니다.
API 는 보통 1초 안에 끝나니까 1초로? 여유롭게 3초로? 이런 직관으로 정할 순 없겠죠.
API latency는 서버, DB 상황에 따라서 언제나 달라질 수 있습니다.
이를 고정한다는 것은 엄밀하지 않은 사고입니다.

debounce 를 사용하면, 2가지 케이스가 발생한다는 것을 알 수 있습니다.

  • api latency < debounce wait
  • api latency > debounce wait

1번 케이스부터 보겠습니다. api가 빠르게 응답이 온다면, 보통의 경우에 큰 문제가 없습니다. 문제가 없는 경우는, API 가 성공했을 때입니다.
API 가 성공해서 다음 유저 플로우를 타게 된다면, button disabled 시간(debounce wait - api latency)이 존재해도 문제가 되지 않습니다.

API 가 실패하면 어떻게 될까요?
일정 시간(debounce wait - api latency) 만큼, 사용자는 버튼을 다시 누르지 못 합니다. 그러므로 중복호출을 막기 위한 목적으로 함부로 debounce wait 를 길게 해서는 안 됩니다.

2번 케이스를 보겠습니다.
api latency 가 더 길다면, 정말로 문제 입니다.
debounce wait 가 끝난다면, 다시 clickable 한 상태가 되고, 서버에 중복호출을 할 수 있습니다.
이때는 서버에서 중복호출을 막고 있길 기도해야겠지요.

정리하자면, api latency 과 debounce wait 가 차이가 있기 때문에, debounce로 완벽한 중복호출을 막는 것은 본질적으로 불가능하다는 것입니다. throttle 도 마찬가지 논리이므로 생략합니다.

그럼 debounce, throttle 의 목적은 무엇일까요? 이들은 “중복호출”을 막기 위함이 아니라, “과도한 호출” 을 막기 위함입니다. 검색, 광클이 가능한 버튼 (게임 아이템 주기 등), 스크롤 이벤트 제어 등에 쓰입니다. 이를 중복 호출 방지에 쓰는 것은 적절하지 않습니다.


이 내용을 읽고 너무나도 맞는 말이라 설득을 당해버렸다.
debouncethrottle은 정해둔 시간이 적절하지 않으면 오히려 UX에 악영향을 줄 수 있다.

그래서 다른 방법을 선택하려고 했는데, 블로그에서는 useRef를 활용한 isLoading 관리 방법으로 API 중복 호출을 막도록 했다.

하지만 나는 공통 유틸 함수를 사용하고 있고, 이를 각 API마다 하나 하나 적용하기란 불필요한 비용이 소모될 것이라고 생각했다.

그래서 비슷한 방법인 공통 유틸 함수(mutate)에서 isPending을 활용해 API 중복 호출을 막도록 하고자 했다.

API 중복 호출 제거하기

이를 위한 방법은 간단하다.

tanstack query의 mutate를 사용하는 곳에서 isPending 상태일 경우에는 api를 호출하지 않도록 하는 것이다.

우리 팀은 코드 일관성과 편의를 위해 공통된 곳에서 mutate를 호출해 사용하고 있다.

// useApi.ts
/**
 * POST 요청을 위한 훅
 * @example
 * const { mutate, isPending } = useApiPost('/users', {
 *   onSuccess: (data) => console.log(data),
 * });
 * mutate({ name: 'John' });
 */
export function useApiPost<
  TData = unknown,
  TVariables = Record<string, unknown>,
>(
  endpoint: string,
  options?: UseApiMutationOptions<TData, TVariables> & {
    invalidateKeys?: QueryKey[];
  },
  sendCookie?: boolean,
  headers?: Record<string, string>,
) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (variables: TVariables) => {
      const response = await post<TData>(
        endpoint,
        variables as Record<string, unknown>,
        { headers },
        sendCookie,
      );

      // 에러 응답 처리 (토큰 재발급은 fetchApi에서 자동 처리됨)
      if (!response.success) {
        throw createApiError(response);
      }

      return response;
    },
    onSuccess: (data, variables, context, mutationContext) => {
      if (options?.invalidateKeys) {
        options.invalidateKeys.forEach((key) => {
          queryClient.invalidateQueries({ queryKey: key });
        });
      }
      options?.onSuccess?.(data, variables, context, mutationContext);
    },
    ...options,
  });
}

위와 같이 각 HTTP 메소드별로 유틸함수를 만들어 이를 호출해 사용하고 있다.
그러니 이 공통 유틸 함수만 수정하면 다른 api 로직에 모두 적용이 된다.

여기에 isPending 동안 mutate 호출을 무시하는 가드를 추가하자.

mutate 호출 무시 가드 추가하기

// useApi.ts
/**
 * isPending 중 중복 호출을 방지하는 mutate 가드
 * 버튼 더블 클릭 등으로 인한 중복 API 요청 방지
 */
function withPendingGuard<TData, TError, TVariables, TContext>(
  mutation: UseMutationResult<TData, TError, TVariables, TContext>,
) {
  return {
    ...mutation,
    mutate: (...args: Parameters<typeof mutation.mutate>) => {
      if (mutation.isPending) return;
      mutation.mutate(...args);
    },
  };
}

이렇게 mutation.isPendingtrue 일 경우 return 해주어 실행되지 않도록 해주었다.

그리고 이를 다음처럼 감싸주면 된다.

// useApi.ts
return withPendingGuard(
    useMutation({
			...

이렇게만 적용하고 다시 테스트해봤다.

api 요청의 응답은 하나만 전달되어 데이터가 하나만 생성된 것을 확인할 수 있다.
생성 요청 api인 post 도 하나만 전달되었다.

그런데 눈에 띄는 요청이 있었다.
바로 add 라는 이름의 post 요청인데, 이것도 중복 호출되는 것을 막고자 했다.

먼저, 왜 중복 호출되는지에 대해 알아봤다.

스크립트 중복 요청

분석 결과, 두 개의 Server Action이 동시에 호출되고 있었다.

// useCreateRecord.ts
const invalidateQuery = async (groupId?: string) => {
  await Promise.all([refreshRecordData(), refreshHomeData()]); // ← 2개 동시 호출
  ...
};

이는 next.js의 server action 동작 방식 때문이다.

server action은 현재 페이지 url로 post 요청을 보낸다.
그러니 /add 페이지에서 server action을 호출하면 네트워크에서 POST /add 로 표시된다.

Promise.all 로 server action을 동시에 호출하면

POST /add  ← refreshRecordData() Server Action
POST /add  ← refreshHomeData()  Server Action

두 개의 다른 server action이지만 url이 같아서 네트워크 탭에 add 요청이 2번 나타나게 되는 것이다.

실제로는 next action 헤더로 구분되는 서로 다른 요청이다.
문제는 아니지만, 불필요한 이중 요청이라고 생각했다.

위 두 server action은 페이지 단위로 캐시를 지우고자 사용했다.

// revalidate.ts
'use server';

import { revalidatePath } from 'next/cache';

export async function refreshHomeData() {
  revalidatePath('/', 'page');
}

export async function refreshRecordData() {
  revalidatePath('/my', 'layout');
}

export async function refreshGroupData(groupId: string) {
  revalidatePath(`/group/${groupId}`, 'layout');
}

export async function refreshSharedData() {
  revalidatePath('/shared');
}

이렇게 서버 단에서 캐시를 지우게 할 수도 있기에, 나는 빠르면서 한 번에 캐시를 무효화하기 위한 방법으로 사용하고 있었다.

이를 호출하는 server action들을 하나로 합치면 POST 요청이 1번으로 줄어들게 된다.

export async function refreshRecordAndHomeData() {
  revalidatePath('/', 'page');
  revalidatePath('/my', 'layout');
}

revalidatePath 를 호출하는 함수를 추가하고, 이를 사용하도록 수정해줬다.

이전의 add POST 중복 호출이 사라진 것을 볼 수 있다.

fetch timeout 걸기

네트워크 환경이 불안정한 유저의 경우 요청이 무한정 대기 상태에 빠져 UI가 프리징되는 현상이 발생할 수 있다.

따라서 API 호출 최적화와 네트워크 타임아웃 전략을 도입해 애플리케이션의 견고함을 높이는 작업을 추가로 해주고자 했다.

우리 팀은 HTTP 메소드에 대한 유틸 함수를 따로 둬서 사용하고 있다.
공통된 유틸 함수만을 사용해서 코드 일관성을 유지하기 위함이다.

그래서 해당 유틸 함수에 타임아웃에 대한 로직을 추가해줬다.

// api.ts
async function fetchWithRetry<T>(
  url: string,
  fetchOptions: RequestInit,
  attempt: number,
  maxRetries: number,
  retryDelay: number,
  skipAuth: boolean,
  timeout: number,
) {
  const controller = timeout > 0 ? new AbortController() : undefined;
  const timeoutId = controller
    ? setTimeout(() => controller.abort(), timeout)
    : undefined;

  try {
    const response = await fetch(url, {
      ...fetchOptions,
      signal: controller?.signal,
    });

    clearTimeout(timeoutId);
    ...

이렇게 타임아웃을 위해 AbortController 를 사용하도록 수정해줬다.

그리고 error 가 발생한다면 타임아웃에 대한 에러인지를 판단해주는 코드도 추가해준다.

  // api.ts
  ...
  } catch (error) {
    clearTimeout(timeoutId);

    const err = error instanceof Error ? error : new Error('unknown error');

    // 타임아웃(AbortError) - 재시도 없이 즉시 반환
    if (err.name === 'AbortError') {
      return {
        success: false,
        data: null,
        error: {
          code: 'TIMEOUT',
          message: '요청 시간이 초과되었습니다.',
          details: {},
        },
      };
    }

    ...

AbortController :
하나 이상의 웹 요청을 취소할 수 있게 해주는 인터페이스
AbortController.abort()로 DOM 요청이 완료되기 전에 취소한다.
이를 통해 fetch 요청, 모든 응답 Body 소비, 스트림을 취소할 수 있다.

fetch 요청을 시작할 때, 요청의 옵션 객체 내부에 AbortSignal 옵션({signal})을 전달한다.
신호와 컨트롤러를 fetch 요청과 관계짓고, AbortController.abort() 를 호출해 이를 취소할 수 있게 한다.

AbortController 에 대한 공식문서 예제는 다음과 같다.

var controller = new AbortController();
var signal = controller.signal;

var downloadBtn = document.querySelector('.download');
var abortBtn = document.querySelector('.abort');

downloadBtn.addEventListener('click', fetchVideo);

abortBtn.addEventListener('click', function() {
  controller.abort();
  console.log('Download aborted');
});

function fetchVideo() {
  ...
  fetch(url, {signal}).then(function(response) {
    ...
  }).catch(function(e) {
    reports.textContent = 'Download error: ' + e.message;
  })
}

모든 브라우저에서 지원하기에 호환성도 좋다.

그리고 그 유틸 함수를 가지고 tanstack query를 사용하고 있기 때문에, tanstack query의 전역 에러 핸들러에서 이 타임아웃 작업을 진행해주면 될 것이라 생각했다.

// provider.tsx
export default function Providers({ children }: { children: React.ReactNode }) {
  // 렌더마다 새 QueryClient 생성 방지 (중요)
  const [queryClient] = useState(
    () =>
      new QueryClient({
        queryCache: new QueryCache({
          onError: (error, query) => {
            if (query.meta?.silent) return;
            if ((error as ApiError)?.code === 'TIMEOUT') {
              toast.error('요청 시간이 초과되었습니다.', {
                description: '네트워크 연결을 확인하고 다시 시도해 주세요.',
              });
              return;
            }
            const message = getErrorMessage(error);
            toast.error(message);
          },
        }),
        mutationCache: new MutationCache({
          onError: (error, variables, context, mutation) => {
            if (mutation.meta?.silent) return;
            if ((error as ApiError)?.code === 'TIMEOUT') {
              toast.error('요청 시간이 초과되었습니다.', {
                description: '네트워크 연결을 확인하고 다시 시도해 주세요.',
              });
              return;
            }
            const message = getErrorMessage(error);
            toast.error(message);
          },
        }),
        defaultOptions: {

현재는 10초로 타임아웃 시간을 지정해놨는데, 실제 fetch 요청 시간에 맞춰서 천천히 수정해야할 것 같다.

profile
성장통을 겪고 있습니다.

0개의 댓글