sehyun.dev
성능개선React

페이지네이션 최적화 고민 해보기 feat. React-Query 어디 까지 알고 있나…

2025년 8월 27일

React-Query을 통해서 무거운 콘텐츠를 빠르게 캐싱 할 수 있는 방법을 고민 하기

제목은 위와 같이 결정 했지만 사실상 React-Query를 올바르게 활용하고 있는지에 대하여 고민을 하는 글 입니다. 학습 단계 있어 내용이 틀릴 수 있습니다.
혹시 내용이 틀렸거나 더 좋은 방법이 있다면 댓글을 달아주신다면 정말로 감사하겠습니다! 🙂

1. 문제 파악하기

  • **React-Query**를 통해 데이터를 미리 준비(캐싱) 를 반영했으나 다음 페이지 이동 시 로딩 인디케이터가 발생하는 증상으로 인해 UX 저하 발생
  • 기본 gcTime 사용으로 인해 5분 이후 캐싱 초기화 인한 문제 해결 방안 모색하기

2. 문제를 해결하기 전 사전적 지식에 대한 공부

(1) prefetchQuery vs ensureQueryData

  • 둘다 사전적 데이터를 가져옴에 있어 캐싱된 정보를 미리 넣어두는 용도로 동작에 대한 차이점이 존재합니다.
  • prefetchQuery
    • 👉 “데이터를 미리 캐시에 넣어두기만 한다.” 즉, 요청시 내부적으로 fetchQuery 를 사용하며 fresh 상태가 아닌 경우 네트워크 요청을 보내게 됩니다.
  • ensureQueryData
    • 👉 “데이터가 있으면 가져오고, 없으면 fetch해서 채워준 뒤 돌려준다.” (반환값 있음) 즉, getQueryData + fetchQuery 로 캐시된 데이터가 없는 경우에 fetchQuery 를 수행하며 값을 반환 할 수 있습니다.

(2) gcTime 그리고 staleTime

  • staleTime
    • 쿼리가 fresh(신선) 상태로 유지되는 시간(ms).
    • 기본값은 0
  • gcTime
    • 쿼리가 캐시에서 제거되기 전까지 유지되는 시간(ms).
    • 기본 값은 5분

(3) useQuery vs useSuspenseQuery

  • 둘다 데이터를 조회 하지만 동작에 대한 차이점이 존재합니다.
  • useSuspenseQuery
    • 👉  “초기 요청 중”엔 컴포넌트를 렌더하지 않고 서스펜스에 걸립니다. 그래서 초기 fetch 동안의 isFetching: true 프레임을 볼 기회가 없습니다.
  • useQuery
    • 👉 서스펜스 없이 렌더를 계속하므로, 초기 로딩/패칭 상태 전환이 그대로 보입니다.

3. 페이지네이션를 데이터를 가져오는 훅 살펴보기

React-Query__를 통해 데이터를 미리 준비(캐싱) 를 반영했으나 다음 페이지 이동 시 로딩 인디케이터가 발생하는 증상으로 인해 UX 저하 발생을 해결해 보기!

(1) pseudo code 코드 살펴보기

  • 실제 컴포넌트 랩핑 구조나 데이터를 다루는 방법은 다를 수 있습니다.
interface Props {
  searchParams: Promise<{ page?: string; size?: string; text?: string }>;
}
export default async function TestPage({ searchParams }: Props) {
  const queryClient = queryClient();
  const {page, size, text} = await searchParams;
 
  await queryClient.prefetchQuery(getFetchData(page, size, text));
  console.log('여기도 다시 호출이 되나..?');
  
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<로딩중... />}>
        <목록 컴포넌트 />
      </Suspense>
    </HydrationBoundary>
  );
}
 
export function usePagination({
  page,
  size,
  text,
}) {
  const queryClient = useQueryClient();
  const currentPage = Number(page);
  const sizeNum = Number(text);
 
  const { data, isLoading, isFetching } = useSuspenseQuery(
    getFetchData(page, size, text)
  );
 
  const total = data?.total ?? 0;
  const totalPages = Math.max(1, Math.ceil(total / sizeNum));
  const isFirstPage = currentPage === 1;
  const isLastPage = currentPage >= totalPages;
 
  // 굳이 필요한 작업인가?
  useEffect(() => {
    if (isLastPage) return;
 
    queryClient.prefetchQuery(
      getFetchData((currentPage + 1).toString(), size, text-, {
        meta: { prefetch: true },
        gcTime: 24 * 60 * 60 * 1000,
      })
    );
  }, [queryClient, currentPage, size, keyword, isLastPage]);
 
 
  return {
    data,
    currentPage,
    totalPages,
    isFirstPage,
    isLastPage,
  };
}

(2) pseudo code 코드 살펴보기

  • 프로젝트 마다 구성하는 요소들은 다르겠지만 저의 경우는 페이지네이션 데이터 변경 시 url param 또한 같이 갱신되어 searchParams 에서 다시 그 인자를 받고 prefetchQuery 통해 다시 클라이언트로 하이드레이션 되는 구조로 인하여 즉시 전환 UX 유지 할 수 있음에 불구 하고 usePagination 안에서 한번 더 호출되어 불필요하게 isFetching 상태를 만든 것이 일단 가장 큰 원인이 었습니다.
  • 정답으로 접근 하는 방식은 여러가지가 있을 수 있겠지만 usePagination 에서 prefetchQuery 해야하는 일부 클라이언트쪽 상황도 있을 것이라 생각합니다. 그렇다면 prefetchQuery 보단 반환을 따로 하지 않은 상태에서 ensureQueryData 를 사용하는 방법으로 먼저 사전적 캐싱 데이터를 조회하기 때문에 다시 fetchQuery 조회 하는 불상사가 일어나지 않습니다. 혹은 이전 데이터의 더미 데이터를 유지 시켜주는 keepPreviousData 를 반영 해주면 될 것 같습니다.

(3) pseudo code 코드 살펴보기

  • ensureQueryData 또한 RSC에서 제공 해야하는 이유
type Props = {
  params: Promise<{ id: string }>;
};
 
export default async function Page({ params }: Props) {
  const queryClient = QueryClient();
  const { id } = await params;
 
  // 다음 중 작동 하는 코드는 ?
  
  // (A)
  try {
    await queryClient.ensureQueryData(fetchData(id));
  } catch {
    notFound();
  }
  
  // (B)
  try {
    await queryClient.prefetchQuery(fetchData(id));
  } catch {
    notFound();
  }
 
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Suspense fallback={<스켈리톤UI... />}>
        <콘텐츠... />
      </Suspense>
    </HydrationBoundary>
  );
}

(4) pseudo code 코드 살펴보기

  • 정답은 A 입니다
    • prefetchQuery 는 반환을 하지 않기 때문에 throw 조차 되질 않습니다.

4. 전체적은 React-Query 제공자 코드 살펴보기

기본 gcTime 사용으로 인해 5분 이후 캐싱 초기화 인한 문제 해결 방안 모색하기

  • 앞서 이야기한 것처럼 React-Query는 기본 gcTime****(5분) 을 가지고 있어서, 쿼리가 더 이상 구독되지 않으면 5분 뒤 캐시가 초기화됩니다. 즉, 사용자가 페이지를 이동하거나 브라우저를 닫았다가 다시 열면 캐싱된 데이터는 사실상 사라지게 됩니다. 왜냐하면 브라우저를 새로고침하거나 앱을 재시작하는 순간 메모리에 있던 캐시는 날아가 버리기 때문입니다. 이 문제를 해결하기 위해 React-Query에서는 퍼시스트(persist) 기능을 제공합니다.

(1) pseudo code 코드 살펴보기

  • localStorage를 활용하여 캐시 데이터를 저장/복원이 가능하며 IndexedDB를 사용하면 더 큰 데이터도 다룰 수 있습니다.
  • QueryClient 인스턴스를 만들 때 cacheTime 즉, gcTime 을 설정하지 않으면 5분 동안 비활성화된 후 삭제가 됩니다.
    • 퍼시스트 캐시 유효 시간의 기본값이 24시간 인 경우 gcTime 또한 24시간 이상이어야 합니다.
export default function ReactQueryProviders({ children }: { children: React.ReactNode }) {
  const queryClient = QueryClient();
 
  const persister = createAsyncStoragePersister({
    storage: typeof window !== 'undefined' ? window.localStorage : undefined,
  });
 
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister,
        maxAge: 24 * 60 * 60 * 1000, // 퍼시스트 캐시 유효 시간(예: 24시간)
      }}
    >
      <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
      <ReactQueryDevtools initialIsOpen={false} />
    </PersistQueryClientProvider>
  );
}

5. 마무리

이번 글에서는 React-Query로 무거운 콘텐츠를 캐싱하는 과정에서 발생할 수 있는 문제들과 이를 개선하기 위한 방법들을 정리해 보았습니다.

단순히 gcTime을 늘리거나 prefetchQuery를 추가한다고 모든 문제가 해결되는 게 아니고, 구조적인 상황에 따라 처리 방안은 달라질 것이라 생각이 들었고 조금 더 깊게 톺아 볼 필요 또한 이유가 생긴 듯 합니다.

그래서 지금 일단 중요한 건,

  • prefetchQueryensureQueryData의 차이를 이해하고 언제 어떤 게 최적화인지 구분하는 것,

  • staleTime, gcTime, 그리고 persist 같은 기본 옵션의 의미를 정확히 아는 것,

  • 필요하다면 keepPreviousData 같은 보조 장치를 활용해 UX 흐름을 더 매끄럽게 만드는 것,

    이라고 생각합니다.