React-Query with Next.js 서버 사이드 렌더링

윤상준·2022년 10월 30일
17

React

목록 보기
2/4
post-thumbnail

Next.js의 Data Prefetching

Next.js는 서버 사이드에서 데이터 Prefetching이 가능하다. Prefetch 하게 될 데이터는 HTML 페이지가 클라이언트에게 전송되기 전에 준비되어 HTML에 포함되어 렌더링된다.

따라서 한 번 사이트가 로딩된 후에는 로딩 시간이 크게 단축된다는 점, SEO에 좋다는 아주 유명한 장점이 있다.

React-Query + Next.js

도입 배경

React-Query는 Next.js의 서버 사이드에서 데이터를 Prefetch하여 queryClient로 넘겨주는 기능을 제공한다.

회사에서 신규 서비스를 개발하면서 서버로부터 Fetch한 데이터를 더 효율적으로 다룰 방법에 대해 고민해보았다.

먼저 사용자가 우리 서비스에 최초로 진입한 후, 가장 빠른 시간 안에 접근할 것으로 예상되는 페이지에 대해 데이터를 Prefetch 할 니즈가 있었다.

또한 똑같은 데이터를 다른 페이지에서도 사용할 수 있으며, 데이터의 종류에 따라 빠른 로딩 속도와 데이터의 최신성 등등에 대응해야 할 니즈가 있었다.

따라서 이러한 니즈에 효율적으로 대응하기 위해 React Query 도입을 결정했다.

Prefetching data with React Query

Next.js 환경이기 때문에 필요에 따라 클라이언트 사이드와 서버 사이드에서 필요에 따라 사용하기로 했다.

공식 문서의 SSR 항목과 여러 기술 블로그를 정독하면서 적용해보았다.

React Query는 SSG와 SSR 모두에서 사용할 수 있으며 크게 2가지 방법이 있다.

  1. InitialData 사용하기.
  2. Hydration 사용하기.

1. InitialData 사용하기

  1. Next.js의 getStaticProps 또는 getServerSideProps에서 데이터를 받아온 후 props로 전달한다.
  2. 클라이언트는 전달 받은 props를 useQuery 훅의 initialData 옵션에 할당한다.
export async function getStaticProps() {
  const posts = await getPosts()
  return { props: { posts } }
}

function Posts(props) {
  const { data } = useQuery(['posts'], getPosts, { initialData: props.posts })

  // ...
}

심플한 방법이지만 몇 가지 주의할 점이 있다.

  • getStaticProps, getServerSideProps 등은 페이지에서만 동작한다. 즉, pages 폴더 내부의 파일에서만 동작한다. 이 때 useQuery를 사용하려는 컴포넌트가 페이지 내부에서 몇 단계 밑으로 내려가있다면 props를 그 컴포넌트까지 drilling 해야한다.
  • 만약 여러 곳에서 동일한 key를 가진 useQuery를 실행하려면, 모든 useQuery에 initialData를 설정해야한다.
  • 해당 쿼리가 서버에서 정확히 언제 fetch되어 내려올 지 모르기 때문에, dataUpdatedAt과 해당 쿼리의 refetch 필요 여부는 페이지가 로드 된 시점에 따라서 결정된다.

2. Hydration 사용하기

서버 사이드에서 1개 이상의 쿼리를 실행할 수 있다. 이렇게 prefetch한 쿼리를 queryClient에 dehydrate 할 수 있다.

서버는 페이지가 로드될 때 즉시 사용할 수 있도록 미리 마크업을 할 수 있으며, 자바스크립트가 사용 가능해지는 즉시 React Query는 해당 쿼리를 업그레이드 또는 hydrate 할 수 있다.

만약 Prefetch한 해당 쿼리가 Stale 상태가 되었다면 refetch를 진행할 수도 있다.

  • 새로운 queryClient 인스턴스를 ref 또는 state 에 선언한다. 이유는 컴포넌트 라이프 사이클마다 새로운 QueryClient가 생성되기 때문에 다른 사용자 또는 요청과 겹칠 일이 없도록 하기 위해서이다.
  • Component를 QueryClientProvider로 감싼다.
  • QueryClientProvider 하단부터 Hydrate로 감싸고, dehydratedState props를 전달한다. dehydratedState는 pageProps로부터 받아올 수 있다.
// _app.tsx
import { Hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query'

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = React.useState(() => new QueryClient())

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

아까 언급했듯 이 방식은 SSR과 SSG 모두 가능하며 같은 방식으로 동작한다.

getStaticProps를 예시로, 동작 과정은 다음과 같다.

  1. 매 요청마다 새로운 QueryClient가 생성된다. 따라서 데이터가 겹치거나 꼬일 일이 없다.
  2. getStaticProps 안에서 prefetchQuery를 통해 데이터를 prefetch한다.
  3. dehydrate를 통해 해당 쿼리를 캐싱하고 dehydratedState Prop으로 페이지에 넘겨준다.
// pages/posts.jsx
import { dehydrate, QueryClient, useQuery } from '@tanstack/react-query';

export async function getStaticProps() {
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery(['posts'], getPosts)

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

function Posts() {
  // This useQuery could just as well happen in some deeper child to
  // the "Posts"-page, data will be available immediately either way
  const { data } = useQuery(['posts'], getPosts)

  // This query was not prefetched on the server and will not start
  // fetching until on the client, both patterns are fine to mix
  const { data: otherData } = useQuery(['posts-2'], getPosts)

  // ...
}

즉, _app.jsx 에서 prefetch한 쿼리를 pages/posts.jsx 에서 더욱 빠르게 사용할 수 있다는 것.

모든 쿼리를 prefetch 할 필요는 없으며 목적에 따라 적절하게 사용할 수 있다.
따라서 특정 쿼리에 prefetchQuery를 적용하거나 제거하는 방식으로 서버가 어떤 내용을 렌더링할 것인지를 조절할 수 있다.

여러 개의 쿼리를 한 번에 prefetch

크몽의 기술 블로그를 참고했다.

  1. 모든 사용자가 무조건 접속하게 될 페이지.
  2. 주로 사용하게 될 주력 서비스 페이지.

에 대해 상위 방식을 적용하였다.

1번 페이지에서 사용할 데이터는 한 번 받아오면 업데이트가 필요 없는 데이터이다. 즉, 빌드 타임에 딱 한번만 최신화하면 되는 데이터이기 때문에 getStaticProps를 사용한 SSG 방식을 채택했다.

2번 주력 서비스 페이지에서 사용할 데이터는 사용자가 많아질 시 (아직 런칭은 안했지만 😂) 데이터의 최신성이 중요할 것으로 판단하여 SSR 방식을 채택했다. 사용자가 적을 경우 ISR로 대응이 가능하겠지만 언젠가는 사용자가 증가할 경우를 대비했다.

1번 페이지 (SSG)

export const getStaticProps: GetStaticProps = async () => {
  const queryClient = new QueryClient();
  
  try {
    await Promise.all([
      queryClient.prefetchQuery(['tempQueryKey1'], () => FETCH_TEMP_DATA1()),
      queryClient.prefetchQuery(['tempQueryKey2'], () => FETCH_TEMP_DATA2(), {
        staleTime: Infinity, refetchOnMount: false,
      }),
    ]);
    
    return {
      props: {
        dehydratedState: dehydrate(queryClient),
      },
    };
  } catch (e) {
    return {
      notFound: true,
    };
  } finally {
    queryClient.clear();
  }
};

다수의 쿼리를 prefetch 하기 위해 Promise.all()을 사용했으며 finally 에서 queryClient를 clear하여 가비지 컬렉팅을 유도했다.

2번 페이지 (SSR)

다만 2번 페이지의 경우 사용자가 자주 접속할 수록 prefetch와 pre-render가 많아질 것이기 때문에 매번 QueryClient 인스턴스를 생성하게 되면 클라이언트, 서버 모두에게 부담이 갈 것이다.

따라서 queryClient 인스턴스를 getServerSideProps 외부에 선언하여 여러 쿼리들이 공유할 수 있도록 했다.

queryClient에 설정된 staleTime 동안에는 캐시 데이터를 사용하고 그 이후 refetch를 진행할 것이다.

번외로 staleTime을 얼마나 잡아야 할지 도무지 감을 잡기 어려울 때가 많다...


export default function TempPage() { ... }

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,
    },
  },
});

export const getServerSideProps: GetServerSideProps = async () => {
  try {
    await queryClient.prefetchQuery(["tempUniqueQueryKey"], () => FETCH_TEMP_DATA3()]);

    return {
      props: {
        dehydratedState: dehydrate(queryClient),
      },
    };
  } catch (e) {
    return {
      props: {},
    };
  } finally {
    queryClient.clear();
  }
};

다만 여러 쿼리에서 하나의 인스턴스를 공유하면 데이터가 꼬일 우려가 있으니 key를 꼭 unique하게 작성해야한다.

key가 겹치게되면 다른 사람의 데이터를 보게 될 수 있기 때문이다.

정말 철저한 unique key를 생성하기 어렵다면 uuid 와 같은 라이브러리를 사용할 수도 있겠다.

profile
하고싶은건 많은데 시간이 없다!

0개의 댓글