React Query의 Prefetch란?

특정 데이터가 필요하다는 것을 알고 있는 경우 또는 예측할 수 있는 경우 prefetching를 통해, 미리 해당 데이터로 캐시를 채워 더 빠른 유저 경험을 제공할 수 있습니다.

 

몇 가지 `Prefetch` 패턴이 존재합니다.

  1. 이벤트 핸들러에서
  2. 컴포넌트에서
  3. Router Integration을 통해서
  4. 서버 렌더링 동안 

이번 글에서는 4번은 제외하고 설명을 하도록 하겠습니다! 너무 길어질 수도 있고, 공식문서에서도 3번까지도 설명하고 있기 때문입니다

 

`Prefetching`을 왜 쓸까요? 

바로 Request Waterfall을 방지하기 위함, 더 빨리 데이터 요청을 하기 위함입니다. 

 

`prefetchQuery / prefetchInfiniteQuery` 메서드

별도의 구성없이, `staleTime`은 `queryClient`에 설정된 함수가 됩니다.
💡 `staleTime`은 cache에 존재하는 데이터가 fresh일 수 있는 시간을 결정해주는 숫자 값입니다.

만약 `staleTime`을 설정하고자 한다면 `prefetchQuery`의 옵션으로 넘겨주면 됩니다.
1️⃣ 여기서 설정한 `staleTime`은 오직 `prefetch`를 위한 `staleTime`이며, `useQuery`의 `staleTime`과 다릅니다!
2️⃣ 만약 서버에서 prefetching을 하게 된다면 (SSR), `queryClient`의 `staleTime`을 0보다 큰 값으로 설정해준다면 매 prefetch 메서드 호출에 대한 `staleTime`을 전달할 필요가 없어집니다

`prefetched query`로 가져온 쿼리에 해당하는 `useQuery`의 쿼리 인스턴스이 없다면 `gcTime`으로 설정한 시간 이후에 캐시에서 삭제됩니다.

`prefetch` 메서드는 절대 에러를 반환(throw)하지 않습니다. 못 가져오더라도 `useQuery`가 fallback 함수 역할을 하며, `useQuery`로 결국 재요청을 할 것이기 때문입니다. 만약 error를 catch하고 싶은 경우 `fetchQuery`를 써야 합니다.

1. 이벤트 핸들러에서의 Prefetch

이벤트 핸들러에서의 Prefetch라 함은 클릭 등의 이벤트가 발생하기 전에 발생하는 이벤트들, 예를 들면 mouseEnter 등의 이벤트가 발생할 때, 데이터를 미리 fetch하는 것을 의미합니다. 

 

먼저 데이터를 불러오는 함수를 구현해보도록 합시다. `mockQueryFn` 함수는 `setTimeout` api를 통해 약 1.5초 후에 `Promise`를 리턴해주는 함수입니다.

 

// utils/mockQueryFn.ts
type Mock = {
  name: string;
  age: number;
};
export const mockQueryFn = () =>
  new Promise<Mock[]>((resolve) => {
    setTimeout(() => {
      resolve([
        { name: "John", age: 25 },
        { name: "Jane", age: 24 },
      ]);
    }, 1500);
  });

 

이 함수를 이용해서 data를 prefetch해주는 함수를 작성하여 코드의 재사용성을 높여주도록 하겠습니다.

// queries/mockQuery.ts
import { mockQueryFn } from "../utils/mockQueryFn";
import { mockQueryFn } from "../utils/mockQueryFn";
import { queryClient } from "../api/queryClient";

const QUERY_KEY = ["mock"];

export function useMockQuery() {
  return useSuspenseQuery({ queryKey: QUERY_KEY, queryFn: mockQueryFn });
}

export function prefetchMockQuery() {
  return queryClient.prefetchQuery({
    queryKey: QUERY_KEY,
    queryFn: mockQueryFn,
  });
}

 

그 다음, Mock data를 사용하려는 컴포넌트를 만들어 보겠습니다

import DataComponent from "./DataComponent";
import { useMockQuery } from "../queries/mockQuery";

export default function MockComponent() {
  const { data } = useMockQuery();
  
  return <DataComponent data={data} />;
}

 

이렇게만 사용하게 된다면 prefetch를 사용하지 않고, 데이터를 로딩하게 됩니다. 

 

만약 버튼을 누른 경우, 데이터를 가져오는 컴포넌트를 다음과 같이 작성한다고 가정해봅시다.

 

import { Suspense, useState } from "react";
import MockComponent from "./MockComponent";
import { prefetchMockQuery } from "../queries/mockQuery";

export default function ParentComponent() {
  const [show, setShow] = useState(false);
  const toggleShowComponent = () => setShow((prev) => !prev);

  return (
    <div>
      {show && (
        <Suspense fallback={<div>Loading...</div>}>
          <MockComponent />
        </Suspense>
      )}
      <button onClick={toggleShowComponent}>toggle</button>
    </div>
  );
}


function App() {
  return (
    <ParentComponent />
  );
}

 

 

이렇게 컴포넌트를 작성했다면 다음과 같이 데이터를 가져오게 될 것입니다.

0

 

 

버튼을 클릭해야 데이터를 fetch 합니다.

 

그렇다면 prefetch를 사용하여 코드를 개선해봅시다!

 

`ParentComponent` 컴포넌트에 `prefetchQuery`를 사용한 함수를 사용하면 됩니다.

export default function ParentComponent() {
  const [show, setShow] = useState(false);
  const toggleShowComponent = () => setShow((prev) => !prev);

  const handleMouseEnter = () => {
    prefetchMockQuery();
  };

  return (
    <div>
      {show && (
        <Suspense fallback={<div>Loading...</div>}>
          <MockComponent/>
        </Suspense>
      )}
      <button onClick={toggleShowComponent} onMouseEnter={handleMouseEnter}>
        toggle
      </button>
    </div>
  );
}

 

이렇게 변경을 한다면 다음과 같이 마우스를 버튼에 올려만 두어도 fetch를 하여 사용자가 클릭 시 더 빠른 데이터를 볼 수 있게 됩니다.

0

2. 컴포넌트에서 Prefetch

컴포넌트 라이프사이클 중의 Prefetching은 우리가 일부 자손 컴포넌트가 특정 data가 필요하다는 것을 알 고 있을 때 매우 유용하지만, 다른 몇몇 query의 로딩이 끝나기 전까지 렌더링할 수 없습니다. 

다음 예시를 보죠. 이 예시는 한 페이지에서 2개 이상의 query가 호출되고, 컴포넌트 간 의존적인 query를 가지고 있는 경우에 해당합니다.

 

먼저 컴포넌트부터 봅시다.

import { Suspense } from "react";
import {
  useCommentMockQueryById,
  usePostMockQueryById,
} from "../queries/mockQuery";

type Props = {
  id: number;
};

export default function NewComponent({ id }: Props) {
  const { data, isPending } = usePostMockQueryById(id);
  if (isPending) {
    return <div>Post Loading...</div>;
  }

  return (
    <>
      <div>제목: {data.id}</div>
      <Comment id={id} />
    </>
  );
}

function Comment({ id }: Props) {
  const { data, isPending } = useCommentMockQueryById(id);
  if (isPending) return <div>Comment Loading...</div>;
  return (
    <>
      <div>이메일: {data.email}</div>
    </>
  );
}


function App() {
  return (
    <NewComponent id={1} />
  );
}

 

즉, NewComponent 컴포넌트 아래의 Comment 컴포넌트까지 감안하면 한 페이지에서 2개의 query 함수가 존재하게 됩니다. 

이  예시는 Post가 있는 경우에 한해서 댓글을 받아오려고 하는 경우에 대한 로직이므로,  Post에 대한 요청이 정상적일 경우 Comment를 mount시키게 되는 것이죠.

 

이를 실행하면 다음과 같이 동작합니다. 

0

 

이 원인을 확인하기 위해 Network 탭을 켜서 보니 저희가 말했던 waterfall 현상이 발생했었네요

 

1로 되어있는 요청(post, comment)이 2번에 나뉘어 직렬로 처리가 됩니다. 이것을 Waterfall 현상이라고 합니다. 그렇다면 두 요청을 병렬로 처리할 수 있는 방법은 없을까요? 

 

공식문서에서는 다음과 같이 간단하게 먼저 요청을 함께 보내면 waterfall 현상을 해결할 수 있다고 합니다.

 

type Props = {
  id: number;
};
export default function NewComponent({ id }: Props) {
  const { data, isPending } = usePostMockQueryById(id);

  // Prefetch
  useCommentMockQueryById(id);

  if (isPending) {
    return <div>Post Loading...</div>;
  }

  return (
    <>
      {data && <div>제목: {data.id}</div>}
      <Comment id={id} />
    </>
  );
}

function Comment({ id }: Props) {
  const { data, isPending } = useCommentMockQueryById(id);
  if (isPending) return <div>Comment Loading...</div>;
  return (
    <>
      <div>이메일: {data.email}</div>
    </>
  );
}

 

이렇게 코드를 작성하면 prefetch가 가능해집니다

0

 

Network 탭을 보도록 합시다 그럼 waterfall 현상이 해결되었음을 볼 수 있습니다!

 

 

만약 Suspense를 사용했을 때 prefetch를 하고 싶은 경우가 존재할 겁니다. 이 때, `useSuspenseQueries`를 prefetch 용도로 사용할 수 없습니다.  왜냐하면 prefetch가 컴포넌트의 렌더링을 막기 때문입니다.  또한 `useQuery`도 사용할 수 없는데, useQuery는 suspenseful query가 resolve될 때까지 prefetch를 시작할 수 없기 때문입니다. 그렇다면 어떻게 해야 Suspense 내부에서

 

  1. `usePrefetchQuery` 함수를 구현하여 Suspense prefetch 구현 (Use `useQuery` or `useSuspenseQueries` and ignore the result)
  2. 부모 컴포넌트의 useQuery의 queryFn 내부에서 prefetchQuery 메소드 호출 (Prefetch inside the query function)
  3. 부모 컴포넌트의 `useEffect()`함수에서 prefetchQuery 메소드 호출 (Prefetch in an effect)

 

1) `usePrefetchQuery`를 만드는 경우

export const usePrefetchQuery = (
  options: FetchQueryOptions<unknown, Error, unknown, QueryKey, never>
) => {
  const queryClient = useQueryClient();
  // render 단계에서 발생하지만, ensureQueryData는 쿼리의 캐시에 데이터가 없는 경우에만 fetch하므로 안전함
  // 쿼리의 캐시에 데이터가 없다는 것은 데이터를 보는 observer가 없다는 것이고, side effect를 observe할 수 없으므로 안전한 것이죠
  queryClient.ensureQueryData(options);
};

 

이전의 코드 중 Post를 query하는 함수를 `useQuery` 대신 `useSuspenseQuery`로 변경합시다.

export function usePostMockQueryById(id: number) {
  return useSuspenseQuery({
    queryKey: ["age", id],
    queryFn: () => postMockQueryFnById(id),
  });
}

export function useCommentMockQueryById(id: number) {
  return useSuspenseQuery({
    queryKey: ["comment", id],
    queryFn: () => commentMockQueryFnById(id),
  });
}

 

그리고 이를 호출하는 컴포넌트를 `<Suspense>`로 감싸줍니다.

function App() {
  return (
    <Suspense fallback={<>New Component 로딩 중...</>}>
      <NewComponent id={1} />
    </Suspense>
  );
}

 

그리고 다음과 같이 컴포넌트를 변경해보죠.

 

type Props = {
  id: number;
};
export default function NewComponent({ id }: Props) {
  const { data } = usePostMockQueryById(id);

  // Prefetch
  usePrefetchQuery({
    queryKey: ["comment", id],
    queryFn: () => commentMockQueryFnById(id),
  });

  return (
    <>
      {data && <div>제목: {data.id}</div>}
      <Suspense fallback={<div>Comment Loading...</div>}>
        <Comment id={id} />
      </Suspense>
    </>
  );
}

function Comment({ id }: Props) {
  const { data, isPending } = useCommentMockQueryById(id);
  if (isPending) return <div>Comment Loading...</div>;
  return (
    <>
      <div>이메일: {data.email}</div>
    </>
  );
}

 

 

`Suspense`로 컴포넌트를 감싸는 순간, 해당 컴포넌트 내부의 모든 query들은 직렬로 호출되어 waterfall 현상이 발생합니다.
일단 지금은 이 문제를 해결하는 것이 아닌 prefetch를 해결하고자 하는 것 입니다. 
또한, 이 문제를 해결하려면 `useQueries`를 사용하면 됩니다
관련 글: https://velog.io/@jay/suspense-useQueries

 

 

2) 부모 컴포넌트의 useQuery의 queryFn 내부에서 prefetchQuery 메소드 호출

부모 컴포넌트에서 다음과 같이 호출하면 Suspense에서도 prefetch가 됩니다.

const queryClient = useQueryClient()
const { data } = useQuery({
  queryKey: ["article", id],
  queryFn: () => {
    queryClient.prefetchQuery({
      queryKey: ["comment", id],
      queryFn: () => commentMockQueryFnById(id),
    });

    return postMockQueryFnById(id)
  },
});

 

 

 

3) 부모 컴포넌트의 `useEffect()`함수에서 prefetchQuery 메소드 호출

부모 컴포넌트에 다음과 같이 작성해도 Suspense 내부에서 prefetch가 됩니다.

const queryClient = useQueryClient()

useEffect(() => {
  queryClient.prefetchQuery({
    queryKey: ["comment", id],
    queryFn: () => commentMockQueryFnById(id),
  });
}, [queryClient, id])

 

 

3. Router Integration

컴포넌트 트리 자체에서 data를 fetching하는 것 자체가 쉽게 waterfall 문제를 발생시킬 수 있습니다. 때문에 prefetching을 통합시키는 방법은 router level에서 이러한 prefetch 코드를 통합하는 것입니다. 컴포넌트 트리에 필요한 데이터가 무엇인지를 각 경로에 대해 미리 명시적으로 선언하자는 생각에서 비롯된 것입니다. 전통적인 SSR에서는 Rendering이 시작되기 전에 필요한 데이터를 로드했었는데 이러한 아이디어에서 착안된 생각인 거죠.

 

라우터 수준에서 통합할 때, 모든 데이터가 있을 때까지 해당 경로의 렌더링을 block하거나 결과를 await하지 않는 prefetch를 시작하도록 선택할 수 있습니다. 또는 2가지 접근 방식을 혼합하여, 중요한 데이터가 들어올 때까지는 block하고 그 외의 데이터가 로드되기 전에 렌더링을 시작할 수도 있습니다. [예제 링크]