이전에 React Query meets React Router라는 글을 읽고 데이터를 효율적으로 가져올 수 있는 방법이라고 생각되어 노션에 정리해 두었는데, 최근 사용자 경험을 개선하는 작업을 하면서 적용해 보았습니다. 이 과정에서 알게 된 내용과 고민했던 지점들을 공유하려고 합니다.
Preloading? Prefetching?
필요한 리소스를 미리 다운로드한다는 의미에서 preload와 prefetch가 헷갈릴 수 있다고 생각되어 먼저 알아보겠습니다. 브라우저의 리소스 로드 우선순위 지정 유형 가운데 link 태그의 preload와 prefetch를 살펴보겠습니다.
<link rel="preload" as="script" href="critical.js">
preload는 현재 페이지에서 즉시 필요로 하는 리소스를 우선적으로 가져오는 작업입니다. 이 리소스는 브라우저의 주요 렌더링 절차 이전에 요청되어 더 빨리 사용할 수 있고, 페이지의 렌더링을 막을 가능성이 낮아져 성능을 향상시킵니다. preload는 폰트나 이미지 등 CSS 내부에서 사용하는 리소스, 크기가 큰 이미지와 비디오 파일처럼 리소스가 늦게 발견되거나 리소소의 크기가 큰 경우에 도움이 될 수 있습니다.
<link rel="prefetch" href="/app/style.css" />
<link rel="prefetch" href="https://example.com/landing-page" />
prefetch는 사용자가 가까운 미래에 사용할 가능성이 있는 리소스를 백그라운드에서 미리 가져오는 작업입니다. preload 리소스보다 우선순위가 낮으며, 현재 페이지보다는 다음 페이지 이동이나 페이지 로드에 사용될 리소스들을 미리 로딩하기 위해 사용됩니다. 이를 통해 유저가 페이지를 이동할 시 로드 시간을 대폭 줄일 수 있습니다. 예를 들어, Next.js가 Link에 hover시 리소스를 미리 가져오거나 유저가 검색할 시에 검색 결과 데이터를 미리 가져오는 사례를 생각해 볼 수 있습니다.
이제 React에서 이처럼 필요한 데이터를 미리 가져오는 prefetching을 통해 사용자 경험을 개선하는 방법을 알아보겠습니다. prefetch를 하는 대표적 방법인 TanStack Query의 prefetchQuery와 React Router의 loader를 비교하며 어떤 것을 사용할지 함께 결정해 보겠습니다.
TanStack Query의 prefetchQuery
const prefetchTodos = async () => {
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
TanStack Query는 데이터 fetch, 캐싱, 업데이트를 위한 훅을 제공하는 서버 상태 관리 라이브러리로, TanStack Query에서 제공하는 prefetchQuery를 통해 prefetching을 적용할 수 있습니다. prefetchQuery는 useQuery로 렌더하거나 쿼리가 필요한 상황 이전에 쿼리를 prefetch 하여하여 캐시에 결과를 저장하는 비동기 메서드입니다. 유의할 점은 이 메서드는 data를 반환하거나 에러를 던지지 않고, 대신 캐시에 결과를 저장한다는 것입니다. stack overflow에도 이 부분에 대해 혼란스러워하는 사람들이 많은 것으로 보였습니다.(링크)
prefetchQuery의 장점은 라우트 변경뿐만 아니라 유저 상호작용 이벤트 발생, 컴포넌트 또는 부모 컴포넌트 마운트 시와 같은 다양한 상황에 prefetching을 할 수 있다는 점입니다. 또한 이를 통해 Render-as-you-fetch 모델을 구현할 수 있습니다.
Fetch-on-render와 Render-as-you-fetch(공식문서)
React Query는 추가적인 설정 없이도 기본적으로 'suspense' 모드에서 Fetch-on-render 방식으로 잘 작동합니다. 즉, 컴포넌트가 마운트를 시도할 때 쿼리를 가져오고 일시 중단되지만, 이는 컴포넌트를 가져와 마운트 한 이후에만 동작합니다.
한 단계 더 나아가 Render-as-you-fetch 모델을 구현하고자 한다면 라우팅 콜백 및 사용자 인터랙션 이벤트에 Prefetching을 적용하여 쿼리를 마운트 하기 전에, 빠르면 부모 컴포넌트를 가져오거나 마운트 하기도 전에 쿼리 로딩을 시작하기를 권장합니다.
React Router의 loader
React Router는 React의 라우팅 라이브러리로, React Router에서 제공하는 loader를 통해 Prefetching를 할 수 있습니다. loader는 라우트에 해당하는 페이지가 렌더 되기 전 실행되는 함수로, 해당 페이지에 필요한 데이터를 로드하여 useLoaderData 훅으로 가져옵니다. 이 메서드는 직관적으로 라우팅과 연동하여 사용할 수 있고, 새로운 라우트의 데이터가 준비될 때까지 이전 라우트의 컴포넌트를 표시해 자동으로 로딩 처리를 해줍니다. 또한, 코드 스플리팅과 사용하면, 컴포넌트 코드와 필요한 데이터를 병렬로 Prefetch 하여 성능을 개선할 수 있습니다.
loader를 사용하면 컴포넌트가 페이지 렌더 이전에 모든 필요한 데이터를 가져오므로 컴포넌트 마운트 이후에는 로딩 상태를 보여줄 필요가 없다는 특징이 있습니다. 즉, 모든 데이터를 가져온 다음 렌더(fetch-then-render)하기 때문에 유저는 이전 페이지에 머무르다 로딩 화면 없이 바로 준비된 다음 라우트로 이동하게 됩니다.
Prefetch와 loader 함께 사용하기
TanStack Query의 prefetchQuery와 React Router의 loader는 모두 필요한 시점 이전에 데이터를 로딩하여 애플리케이션이 매끄럽고 사용자의 동작에 대해 빠른 피드백을 제공하도록 합니다. 그렇다면 둘의 차이점은 무엇일까요? 크게 세 가지 정도로 볼 수 있을 것 같습니다.
1. 적용 범위
loader는 라우트 변경 시에 데이터를 가져오므로 페이지 단위의 데이터 로딩에 적합한 반면, prefetchQuery는 라우팅뿐만 아니라 더 다양한 상황에서 유연하게 사용할 수 있다.
2. 캐싱과 데이터 관리
loader의 데이터를 전달받는 useLoaderData의 경우 캐싱이 되지 않아 리렌더링 시에 매번 새롭게 데이터를 가져옵니다. 반면, TanStack Query는 데이터 캐싱, 업데이트 및 백그라운드 refetch를 제공하기 때문에 효과적으로 데이터를 관리할 수 있습니다.
3. 로딩 상태
loader는 이전 페이지를 유지하는 방법으로 로딩 상태를 처리합니다. TanStack Query에서는 Suspense를 통해 fallback UI를 보여줄 수 있습니다.
결론적으로, 라우트 기반의 prefetching을 하고 싶다면, React Router의 loader를 사용하면 직관적입니다. 만약 데이터 가져오기, 캐싱, 업데이트처럼 다양한 조건에서 데이터를 prefetch 하기를 원하는 경우 TanStack Query가 강력한 해결책이 될 수 있습니다.
둘의 이점을 모두 활용하는 방법도 있는데, loader를 사용해 라우트 기반으로 데이터를 로드하고, TanStack Query로 좀 더 세부적인 prefetching과 데이터 관리를 하는 방법입니다. 이후 나올 코드에서는 prefetchQuery 대신 TanStack Query의 ensureQueryData를 사용합니다. ensureQueryData는 아래처럼 캐싱된 데이터가 존재하는지 확인하고, 없으면 데이터를 가져와 반환하는 메서드입니다.
// v4.0 이전
queryClient.getQueryData(query.queryKey) ?? (await queryClient.fetchQuery(query))
// v4.0 이후
await queryClient.ensureQueryData(query)
prefetchQuery 대신 ensureQueryData를 사용하는 이유는 loader 함수에서 데이터를 반환하고, staleTime과 관계없이 캐시에서 데이터를 가져오도록 하기 위해서입니다. 이때 전달하는 query는 queryKey와 queryFn을 묶은 객체입니다.
코드 살펴보기
프로젝트에 loader와 prefetchQuery를 적용한 코드를 살펴보겠습니다. 캐러셀에서 사용하는 이미지 로드 시간이 오래 걸렸기 때문에 캐러셀을 사용하고 있는 <Main /> 페이지로 이동할 시 필요한 데이터를 prefetch 하였습니다.
1. loader
export const categorizedRecipesQuery = (category: string) => ({
queryKey: [...categorizedRecipesKey, category],
queryFn: getRecipes(category),
});
const categorizedRecipesLoader = (queryClient: QueryClient) => async () => {
// 데이터 fetching을 기다렸다가 반환
const categorizedRecipes = await Promise.all(
categories.map((category: string) => {
const query = categorizedRecipesQuery(category);
return queryClient.ensureQueryData(query);
}),
);
return categorizedRecipes;
};
우선, 데이터를 가져오기 위한 loader를 만들어보겠습니다. loader는 데이터를 쿼리에 캐싱하기 위해 라우터를 정의할 때 queryClient를 전달받습니다. 해당 페이지에서는 카테고리에 따른 레시피 배열 데이터를 카테고리를 쿼리 키로 하여 각각의 쿼리로 관리하고 있습니다. 따라서 ensureQueryData에 필요한 각 쿼리를 넘겨주어 캐싱된 데이터가 있는 경우 가져오고, 아닌 경우 fetchQuery 한 결과물인 Promise 배열을 Promise.all에서 병렬적으로 처리합니다. 이를 기다렸다가 모든 데이터를 가져왔으면 데이터를 반환합니다.
이 부분은 사용자에게 화면이 멈춘 듯한 느낌을 줄 수 있기 때문에 아래에서 개선해 보도록 하겠습니다.
2. 라우트와 연동하기
import { createBrowserRouter } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { loadLazy, categorizedRecipesLoader } from '../utils/index';
const useRouter = () => {
const queryClient = useQueryClient(); // queryClient 가져오기
return createBrowserRouter([
{
path: '/',
element: loadLazy('Root'),
children: [
{
path: '/',
element: loadLazy('Home'),
},
{
path: 'main',
element: loadLazy('Main'),
loader: categorizedRecipesLoader(queryClient), // 여기
},
// ...
],
},
]);
};
export default useRouter;
해당 라우트의 loader에 작성한 로더 함수를 넘겨줍니다. 이때 loader에서 받은 데이터를 쿼리에 캐싱하기 위해 queryClient를 전달합니다. 유의할 점은 캐싱된 데이터를 안정적으로 가져와 사용하기 위해서는 QueryClient를 새로 생성하는 것이 아니라, App에서 생성한 queryClient를 가져오는 useQueryClient를 사용해야 한다는 것입니다. (자세히 보기)
3. useQuery로 캐싱된 데이터 활용하기
// hooks/useCategorizedHooks.ts
const useCategorizedRecipes = (category: string) =>
useQuery({
...categorizedRecipesQuery(category),
select: (data: RecipeData) => processRecipesData(data),
staleTime: 1000 * 60 * 10,
});
// components/Carousel.tsx
const Carousel = ({ category }: { category: string }) => {
const { data } = useCategorizedRecipes(category);
// ...
return (
<Container aria-label={`${category}-recipes-carousel`}>
<CarouselTitle>{capitalizeFirstLetter(category)}</CarouselTitle>
<CarouselWindow>
<CarouselSlides $currentpage={currentPage}>
{data && data.length > 0 ? (
data
.map((recipe: Recipe, idx) => (
<RecipeCard
key={recipe.recipeId}
recipe={recipe}
/>
))
) : (
<p>No Recipe Available</p>
)}
</CarouselSlides>
// ...
</CarouselWindow>
</Container>
);
};
export default Carousel;
prefetch 할 때 사용했던 쿼리와 동일한 쿼리를 useQuery에 넘겨주어 캐싱된 데이터를 사용할 수 있도록 합니다.
4. (+추가) 코드 스플리팅
코드 스플리팅은 번들을 나누어 필요한 시점에 지연 로딩할 수 있도록 하여 성능을 향상합니다. 애플리케이션의 코드 양을 줄이지 않고도 사용자가 필요하지 않은 코드는 불러오지 않도록 하여 초기 로딩 비용을 줄여주는 장점이 있습니다.
const loadLazy = (element: string) => {
const LazyElement = lazy(() => import(`../pages/${element}.tsx`));
return (
<Suspense fallback={<Loader />}>
<LazyElement />
</Suspense>
);
};
const useRouter = () => {
const queryClient = useQueryClient();
return createBrowserRouter([
{
path: '/',
element: loadLazy('Root'),
children: [
{
path: '/',
element: loadLazy('Home'),
},
{
path: 'main',
element: loadLazy('Main'),
loader: categorizedRecipesLoader(queryClient),
},
// ...
],
},
]);
};
위와 같이 추가적으로 코드 스플리팅을 적용하여 각 라우트에서 필요한 코드와 데이터를 병렬로 로딩할 수 있습니다.
모두 적용하였을 때 결과는 아래처럼 레시피 데이터를 로드합니다.
자, 이렇게 하면 loader를 이용해 비교적 일찍 로딩을 시작하고, 데이터를 캐싱하여 리렌더 시에 캐싱된 데이터를 사용할 수 있습니다. 이때 문제는 아까 언급했던 것처럼 데이터를 받아오는 동안 화면이 정지한 듯한 느낌이 들어 사용자가 계속해서 링크를 클릭할 수 있다는 점입니다. 이를 해결하기 위해 이전 페이지에서 기다리지 않고, prefetch 하며 다음 페이지로 이동한 후 로딩 화면을 보여주는 방식(Render-as-you-fetch)으로 수정해 보겠습니다. 이렇게 하면 물론 데이터가 모두 캐싱되어 준비된 상태는 아니지만, 일반적인 데이터 fetching보다는 빠르게 로드를 시작하면서 사용자에게도 데이터를 가져오고 있음을 알릴 수 있습니다.
Render-as-you-fetch
React에서 렌더링 시 비동기 작업을 처리하는 방법에는 크게 3가지가 있습니다. 이를 잘 보여주는 다이어그램이 있어 가져와봤습니다.
1. Fetch-on-render: 컴포넌트를 렌더링 한 후 데이터를 로드하는 방식 ex) useEffect, useQuery
2. Fetch-then-render: 데이터를 모두 로드한 후 렌더하는 방식 ex) loader
3. Render-as-you-fetch: 데이터를 미리 fetch하고 즉시 초기 상태(fallback)를 렌더한 후 데이터가 로드되면 리렌더링 하는 방식 ex) TanStack Query의 prefetching과 Suspense
이 가운데 Render-as-you-fetch 모델은 다음 화면에 필요한 데이터를 최대한 빨리 가져오기 시작하고, 새로운 화면을 거의 즉시 렌더해 데이터가 로드되면 새로운 데이터로 리렌더 하는 방식으로, 사용자에게 즉각적인 반응을 제공하고 빠르게 데이터를 로딩하여 더 나은 경험을 제공합니다. 현재 프로젝트에서는 사용자에게 빠른 피드백을 주고, 초기 로딩 속도를 단축하고자 해당 방법을 사용하였습니다.
이를 위해서는 로더를 조금만 변경해 주면 됩니다. 이전과 비교했을 때 await로 데이터 로드를 기다리던 부분을 삭제하였습니다. 이렇게 하면 prefetch는 시작하지만 화면 렌더를 멈추지는 않습니다. 또한 Promise가 모두 이행되지 않은 채로 페이지로 이동하더라도 useQuery가 해당 쿼리를 이어서 가져오기 때문에 불필요한 refetch가 발생하지 않습니다. 해당 내용은 아래에서 자세히 살펴보겠습니다.
const categorizedRecipesLoader = (queryClient: QueryClient) => () => {
// background에서 fetching
Promise.all(
categories.map((category: string) => {
const query = categorizedRecipesQuery(category);
return queryClient.ensureQueryData(query);
}),
);
return null;
};
loader는 undefined를 제외한 값을 반환(링크)해야하기 때문에 우선적으로 반환 값으로 유효한 null을 반환해주었습니다.
그리고 데이터를 가져오는 컴포넌트를 Suspense로 묶어주고, fallback에 만들어둔 스켈레톤을 넣어줍니다.
const MainContent = () => {
return (
<Container aria-label="main-content">
{categories.map(category => (
<Suspense key={category} fallback={<CarouselSkeleton category={category} />}>
<Carousel category={category} />
</Suspense>
))}
</Container>
);
};
이때 주의할 점은 아래 코드처럼 Promise.all()을 반환할 경우 모든 Promise가 이행된 결과를 사용하기 때문에 Suspense가 동작하지 않는다는 점입니다.
const categorizedRecipesLoader = (queryClient: QueryClient) => async () => {
return Promise.all(
categories.map((category: string) => {
const query = categorizedRecipesQuery(category);
return queryClient.ensureQueryData(query);
}),
);
};
이를 적용한 결과는 다음과 같습니다. 페이지를 바로 이동하고 Supsense의 fallback UI를 표시하는 것을 볼 수 있습니다.
불필요한 refetch?
이제 모두 완료된 것 같은데, 뭔가 마음에 걸리는 점이 있었습니다. 만약 prefetch를 하는 동안 useQuery를 사용하는 컴포넌트가 마운트 된다면 불필요하게 refetch를 하는 것은 아닐까? 하는 의문이 들었기 때문입니다. 그래서 Tanstack Query에서 쿼리를 fetch 하는 코드를 확인해 보았습니다.
fetch(
options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
fetchOptions?: FetchOptions,
): Promise<TData> {
if (this.state.fetchStatus !== 'idle') {
if (this.state.dataUpdatedAt && fetchOptions?.cancelRefetch) {
// Silently cancel current fetch if the user wants to cancel refetch
this.cancel({ silent: true })
} else if (this.#promise) {
// make sure that retries that were potentially cancelled due to unmounts can continue
this.#retryer?.continueRetry()
// Return current promise if we are already fetching
return this.#promise
}
}
// ...
}
- 쿼리의 fetch 상태가 idle이 아닌 경우(fetching 등)
- 이미 업데이트 한 기록이 있고, invalidateQuery 또는 refetchQuery에서 cancelRefetch 옵션을 준 경우에는 현재 진행 중인 fetch를 취소
- 진행 중인 프로미스(this.#promise)가 있는 경우, 예를 들어 prefetch가 진행 중이었던 경우, 실패하면 이전 진행 상태에서 retry 하고, 아닌 경우 진행 중이던 프로미스를 반환하여 useQuery에서 이어서 fetch 처리
따라서, 만약 useQuery를 사용하는 페이지에 진입했을 때 prefetch가 진행 중이라고 해도 새롭게 refetch를 하는 것이 아니라, 기존에 진행하던 프로미스를 이어서 처리하여 캐시에 저장하는 것을 알 수 있었습니다.
마치며
이번 포스팅에서는 prefetch와 preload에 대해 알아보고, TanStack Query의 prefetchQuery와 ensureQueryData, 그리고 React Router의 loader를 통해 이를 프로젝트에 적용해 보았습니다. 이때 데이터가 준비될 때까지 라우트에 대한 렌더링을 차단하는 fetch-then-render 방식을 사용할 수도 있고, 기다리지 않고 즉시 화면을 렌더한 후 로드한 데이터로 리렌더하는 render-as-you-fetch 방식을 사용할 수도 있습니다. 그러면 당연히 후자를 선택하여 fallback UI를 보여주면서 사용자의 상호작용에 빠르게 응답하는 것이 좋지 않은가 생각할 수도 있습니다. 하지만 실제로 애플리케이션들을 살펴보면 fetch-then-render 방식에 프로그레스 바를 적용한 사례를 흔히 볼 수 있습니다.
이에 대해 조금 더 구체적으로 생각해 보면, 사용자 경험이나 데이터의 중요도, 페이지 로딩 시간과 같은 요소들을 고려해 볼 수 있습니다. 예를 들어, 사용자에게 빠른 피드백과 상호작용을 제공하려면 render-as-you-fetch 방식이, 사용자가 정확하고 완전한 정보를 기다리는 것이 적절하다면 render-then-fetch 방식이 적합할 것입니다. 또는 페이지의 핵심 기능이나 주요 콘텐츠의 경우, link의 preload와 같이 미리 로드하는 것이 중요할 수 있습니다. 이런 경우 loader에서 await를 사용해 데이터 로드를 기다릴 수 있습니다. 이러한 방식을 혼용하여, 중요한 데이터는 미리 로드하고, 중요도가 낮은 데이터의 경우 백그라운드에서 가져오면서 페이지에 진입할 수도 있습니다. 만약 페이지 로딩을 빠르게 하는 것이 중요하다면 render-as-you-fetch를 통해 빠르게 초기 렌더링을 진행할 수 있을 것입니다. 이러한 사항들을 고려하고, 데이터를 효율적으로 관리할 수 있는 캐싱을 사용해 prefetch 하고 캐시 한다면, 사용자 경험을 개선하고 서버 부하를 줄일 수 있습니다.
지금까지 위와 같은 요소들을 종합적으로 고려하여 상황에 맞는 데이터 로딩 및 렌더링 전략을 선택하고, 좋은 사용자 경험을 만들어내고자 노력한 경험을 공유해 보았습니다. 긴 글 읽어주셔서 감사합니다.
REF
https://developer.mozilla.org/en-US/docs/Glossary/Prefetch
https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel/preload
https://tkdodo.eu/blog/react-query-meets-react-router
https://fe-developers.kakaoent.com/2021/211127-211209-suspense/
https://tanstack.com/query/v4/docs/framework/react/guides/prefetching
https://tkdodo.eu/blog/react-query-meets-react-router
'FrontEnd > React' 카테고리의 다른 글
Context API 심층 분석: Context API는 상태 관리 라이브러리를 대체할 수 있을까? (3) | 2024.04.11 |
---|---|
크롬 DevTools 리로드 문제 해결하기([Error] Looks like this page doesn't have React, or it hasn't been loaded yet.) (18) | 2024.03.25 |
[React Query] React Query에서 ErrorBoundary로 에러 핸들링하기 (3) | 2023.12.10 |
[React-Query] React Query로 페이지네이션하기(keepPreviousData 옵션) (0) | 2023.07.04 |
[React-Query] 쿼리 키 정복하기 (3) | 2023.07.03 |