성능개선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를 수행하며 값을 반환 할 수 있습니다.
- 👉 “데이터가 있으면 가져오고, 없으면 fetch해서 채워준 뒤 돌려준다.” (반환값 있음) 즉,
(2) gcTime 그리고 staleTime
staleTime- 쿼리가 fresh(신선) 상태로 유지되는 시간(ms).
- 기본값은 0
gcTime- 쿼리가 캐시에서 제거되기 전까지 유지되는 시간(ms).
- 기본 값은 5분
(3) useQuery vs useSuspenseQuery
- 둘다 데이터를 조회 하지만 동작에 대한 차이점이 존재합니다.
- useSuspenseQuery
- 👉 “초기 요청 중”엔 컴포넌트를 렌더하지 않고 서스펜스에 걸립니다. 그래서 초기 fetch 동안의
isFetching: true프레임을 볼 기회가 없습니다.
- 👉 “초기 요청 중”엔 컴포넌트를 렌더하지 않고 서스펜스에 걸립니다. 그래서 초기 fetch 동안의
- 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시간 이상이어야 합니다.
- 퍼시스트 캐시 유효 시간의 기본값이 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를 추가한다고 모든 문제가 해결되는 게 아니고, 구조적인 상황에 따라 처리 방안은 달라질 것이라 생각이 들었고 조금 더 깊게 톺아 볼 필요 또한 이유가 생긴 듯 합니다.
그래서 지금 일단 중요한 건,
-
prefetchQuery와ensureQueryData의 차이를 이해하고 언제 어떤 게 최적화인지 구분하는 것, -
staleTime,gcTime, 그리고persist같은 기본 옵션의 의미를 정확히 아는 것, -
필요하다면
keepPreviousData같은 보조 장치를 활용해 UX 흐름을 더 매끄럽게 만드는 것,이라고 생각합니다.