React Query의 Hydrate란?

MochaChoco·2023년 8월 3일
0
post-thumbnail

회사에서 새로운 Next.js 프로젝트를 개발하면서 데이터 fetching을 보다 더 편리하게 관리하기 위해 React Query를 도입하였다.
구현 당시 대다수의 페이지를 useQuery hook을 이용해서 데이터를 불러왔었는데, 페이지가 화면에 보여지기 전 Server Side에서 목록 API를 호출하여 데이터를 받아왔음에도 불구하고 Client Side에서 다시 한번 동일한 API를 요청하는 이슈가 있었다. 따라서 오늘은 이를 해결하는 과정을 포스팅하고자 한다.

React Query란?

React Query란 서버에서 받아온 데이터 fetching, 동기화, 캐싱 등을 보다 편리하게 관리해주는 라이브러리이다.

Next.js 프로젝트에서의 React Query의 사용

Next.js는 페이지 진입을 요청받게 되면 Server Side에서 해당 페이지를 데이터와 함께 Pre Rendering하고, 이를 props를 통하여 Client Side에 전달하며 Client Side에서는 전달받은 데이터를 기반으로 페이지를 출력하는 구조이다.

만약 어떤 페이지가 서버에서 받아온 데이터를 목록 형태(Paging 포함)로 출력시켜야 한다면, 이를 Next.js + React Query로 구현 시에 아래와 같은 형태로 만들 수 있다.

  1. Server Side에서는 첫번째 page 목록에 관련된 데이터 정보를 API 통신으로 받아온 후 Client Side에 props로 넘겨준다.
  2. Client Side에서는 사용자의 인터렉션에 따라 두번째 이후의 page 정보를 React Query의 useQuery Hook을 이용하여 호출한다.

하지만 useQuery Hook은 enabled 옵션을 별도로 설정하지 않는 이상, Client Side에서 페이지가 출력된 후 적어도 한번은 동일한 API가 재호출되기 때문에 서버에서 내려받은 데이터와 똑같은 데이터를 또 다시 호출한다는 문제점이 있었다.

해결방안

1) Server Side에서 API 호출 생략

결론부터 말하자면 이 방식은 좋지 않다.
Client Side에서 첫번째 page 정보를 호출하게 되면 사용자의 화면에서는 페이지가 부분적으로 로딩이 되는 것처럼 보이기 때문에 이를 보완하기 위해서 기존 CSR(React, Vue.. 등등) 프로젝트처럼 목록에 적용될 스켈레톤 이미지를 추가해야 할 수도 있기 때문이다.

2) useQuery Hook의 enabled 옵션 사용

위에서 언급한 방법이다.
enabled옵션에 false가 되는 조건을 줘서 Client Side에서 첫번째 page 데이터를 호출하는 문제점을 수정할 수 있지만, 이 옵션을 사용하면 해당 useQuery 자체가 비활성화 되기 때문에 별도로 true 값을 주는 작업이 필요하다.

const { data: queryData } = useQuery(["getListQuery"], getListApi, {
  enabled: form.pg !== "1",
  refetchOnWindowFocus: false,
  onSuccess: (response) => {
    console.log("success", response);
  },
  onError: (error) => {
    console.log("onError", error);
  },
  staleTime: Infinity,
});

3) initialData 사용

useQuery Hook에서는 initialData 옵션 또한 존재하는데, Server Side에서 내려받은 props을 해당 useQuery Hook의 initialData에 넣어주면 초기 호출을 방지할 수 있다. 하지만 해당 hook이 중첩된 구조 안에 있는 컴포넌트 속에 있다면 해당 hook이 위치한 컴포넌트까지 props drilling을 해주어야 한다는 번거로움이 존재한다. 또한 다음 페이지 정보를 호출하기 위해선 별도로 initialData를 비활성화하는 작업을 해야한다.

export default function InitialData(props: any) {
  // props.data는 server side에서 받아온 데이터
  return <Overlap1 data={props.data} />;
}

function Overlap1(props: any) {
  return <Overlap2 data={props.data} />;
}

function Overlap2(props: any) {
  return <Overlap3 data={props.data} />;
}

function Overlap3(props: any) {
  return <Overlap4 data={props.data} />;
}

function Overlap4(props: any) {
const { data: queryData } = useQuery(["getListQuery"], getListApi, {
    refetchOnWindowFocus: false,
    onSuccess: (response) => {
      console.log("success", response);
    },
    onError: (error) => {
      console.log("onError", error);
    },
    initialData: form.pg === "1" ? props.data : undefined, // Server Side에서 받아온 데이터를 여기까지 넘겨주어야 함.
    staleTime: Infinity,
  });
}

4) hydrate 사용

오늘 포스트의 메인이 되는 주제이며, 앞서 언급한 문제를 해결하기 위해 React Query 공식 홈페이지에서 이 방법을 추천하고 있다. hydrate를 사용한다면 초기 설정을 해야 하지만 이후에는 별도로 관리를 하지 않아도 된다는 장점이 있다.

hydrate 소개 및 적용방법

앞서 언급했듯이 Next.js에서는 페이지가 로드될때 Server Side에서 Client Side로 데이터를 전달하는데, Pre Rendering된 정적 페이지와 번들링된 javascript 데이터를 각각 따로 전달하는 방식을 가지고 있다.

따라서 Client Side에서 처음에 전달받은 정적 페이지에는 아무런 Javascript 요소(이벤트 리스너, 변수 및 함수 등등)들이 없는 상태인데, 전달받은 Javascript 데이터들을 DOM에 덧붙여서 정상적인 기능을 하는 페이지를 만들어내게 된다.
이러한 과정을 hydrate라고 한다.

React Query에서도 Next.js의 hydrate 처럼 Server Side에서 미리 데이터를 prefetch한 후, 캐시된 데이터를 Client Side에서 내려받아 재사용하는 방식을 지원한다.
Next.js에서 React Query의 hydrate를 사용하려면 우선 _app.tsx 파일에 반드시 Hydrate 컴포넌트를 추가해야 한다.

// pages/_app.tsx
export default function App({ Component, pageProps }: AppPropsWithLayout) {
  const queryClient = new QueryClient();

  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </Hydrate>
    </QueryClientProvider>
  );
}

그리고 Server Side에서 api를 handling하는 부분에도 queryClient를 추가하여 데이터를 preFetching 하도록 설정한다.

// pages/api/with-hydrate.tsx
async function getData(req: NextApiRequest, res: NextApiResponse) {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery(["getListQuery"], getListApi, {
    retry: 0,
  });

  const json = {
    dehydratedState: dehydrate(queryClient),
  };

  res.send(json);
}

async function handler(req: NextApiRequest, res: NextApiResponse) {
  await getData(req, res);
}

export default handler;

그리고 Server Side에서 Client Side에서 props을 내려줄때 반드시 dehydratedState를 넘겨주도록 한다.

// pages/with-hydrate.tsx

// Server Side

export const getServerSideProps = async ({ ctx }) => {
  const fetchAPI = async () => {
    const res = await fetch(`http://localhost:3000/api/with-hydrate`);

    return await res.json();
  };

  const data = await fetchAPI();

  return {
    props: {
      dehydratedState: data.dehydratedState,
    },
  };
};


// Client Side
const WithHydrate: NextPageWithLayout = (props) => {
  const { data: queryData } = useQuery(["getListQuery"], getListApi, {
    retry: 0,
    refetchOnWindowFocus: false,
    onSuccess: (response) => {
      console.log("success", response);
    },
    onError: (error) => {
      console.log("onError", error);
    },
    staleTime: Infinity,
  });

  useEffect(() => {
    console.log("queryData", queryData);
  }, [queryData]);

  return (
    <div>
      <h1>Using dehydratedState</h1>
      <ul>
        {queryData?.map((item: MockDataItemType, index) => (
          <li key={index}>
            <span>{item.id + " // " + item.email}</span>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default WithHydrate;

※ 주의사항

  • useQuery Hook의 옵션 중에 staleTime이 있는데 이는 해당 Query가 "신선한(fresh)" 시간을 의미한다. 따라서 staleTime을 너무 짧게 지정하면 useQuery Hook은 해당 Query가 신선하지 않은 상태라고 판단하여 새로 API를 호출하게 된다. 따라서 적당한 시간을 설정해주자.

결과


왼쪽이 기존 코드, 오른쪽이 hydrate를 적용한 코드이다. 확연히 페이지 로딩 속도가 개선된 모습을 볼 수 있다. 만약 API 응답시간이 더 길면 이러한 차이는 더욱 더 두드러질 것이다.

샘플 코드

github 저장소 이동 (https://github.com/MochaChoco/react-query-hydrate)

참고 자료

React Query와 SSR
Next.js Hydrate 개념
tanStack Query 공식 문서

profile
길고 가늘게

0개의 댓글