Next.JS에서 React-Query v4 + SSR 사용하기

김지원·2023년 4월 14일
5

Frontend

목록 보기
22/27

NextJS의 가장 큰 장점은 SSR이라는 점, 그리고 앱에 있는 페이지들이 pre-rendering 된다는 점이다.

그렇다면 NextJS 와 React-Query를 어떻게 같이 잘 사용할 수 있는가? 궁금해서 잘 정리된 영문 기술 블로그와 공식문서를 참고해서 정리해보았다.

🔴 React Query 의 SSR

React Query는 서버에서 데이터를 미리 가져와(prefetching) queryClient에 전달하는 두 가지 방법을 지원한다.

✨ SSR

  1. data를 직접 prefetch 하여 initialData 에 pass 해준다.
    • 단순한 case인 경우 빠르게 set up 가능
    • 몇 가지 주의사항 있음
  2. 서버에서 query를 prefetch하고, 캐시를 dehydrate하고, client에서 rehydrate한다
    • 약간 더 많은 set up 이 필요함

💡 Next.js와 함께 사용하기

Next.js에서는 두 가지 형태의 pre-rendering을 지원한다.

  • Static Generation(SSG)
  • Server-side Rendering (SSR)

React Query는 위 두 형태의 pre-rendering을 모두 지원한다.

1️⃣ Using initialData

Next.js의 getStaticProps 혹은 getServerSideProps 을 사용하여 fetch한 data를 useQueryinitialData 옵션에 pass 해줄 수 있다.

React Query의 관점에서는 둘 다 동일한 방식으로 통합된다.

export async function getStaticProps() {
  const posts = await getPosts()
  return { props: { posts } }
}

function Posts(props) {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: getPosts,
    initialData: props.posts,  // pass it to initialData
  })
// ...
}

setup이 간단하다는 장점이 있지만, ❗️tradeoff❗️가 존재한다.

  • 더 깊은 component에서 useQuery를 호출하는 경우 initialData를 ❗️Prop drilling❗️해줘야 함.
  • 여러 위치에서 동일한 query로 useQuery를 호출하는 경우 해당하는 모든 위치에 initialData를 전달해야 한다.
  • 서버에서 query를 가져온 시간(time)을 알 수 있는 방법이 없다. 따라서 dataUpdatedAt 및 refetching이 필요한지 여부를 결정하는 것은 페이지가 언제 로드 되는지를 기준으로 한다.

2️⃣ Using Hydration

React Query는 Next.js의 서버에서 ✨여러 query를 미리 가져온 다음, ✨해당 query를 queryClient로 dehydration하는 것을 지원한다.

즉, 1) 서버가 페이지 로드 즉시 사용할 수 있는 markup을 prerender할 수 있고, 2) JS 로드 즉시 React Query는 라이브러리의 전체 기능으로 해당 query를 업그레이드하거나 hydrate 할 수 있다.

서버에 렌더링 된 이후 시간이 지나 stale 상태로 변한 query를 refetch하는 것 또한 포함된다.

서버에서 캐싱 queries과 hydration을 설정하기 위해서는

  • 👉🏻App 안에, 그리고 👉ref 인스턴스(혹은 React state) 를 사용하여 새로운 QueryClient 을 만든다.
    • 이렇게 하면 서로 다른 사용자와 요청 간에 데이터를 공유하지 않고, 컴포넌트 라이프사이클 당 👍🏻한 번만 Queryclient을 만들 수 있다.
  • App 컴포넌트를 <QueryClientProvider> 로 감싸준 뒤 client instance를 넘겨준다.
  • App 컴포넌트를 <Hydrate> 로 감싸준 뒤 pagePropsdehydratedState 를 prop로 넘겨준다.
// _app.jsx
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>
  )
}

이제 getStaticProps(SSG용) 또는 getServerSideProps(SSR용) 을 사용하여 페이지의 일부 데이터를 prefetch할 준비가 되었다.

React Query의 관점에서 이 둘은 동일한 방식으로 integrate된다.

getStaticProps 를 사용하는 경우를 보자.

  • 각 페이지 요청에 대해 새로운 QueryClient 인스턴스를 만든다. 이렇게 하면 사용자와 요청 간에 데이터가 공유되지 않는다.
  • 클라이언트 prefetchQuery 메서드를 사용하여 데이터를 prefetch하고 완료될 때까지 기다린다.
  • dehydrate을 사용하여 query 캐시를 dehydrate하고, dehydratedState prop를 통해 페이지에 전달한다. 이것은 캐시가 _app.js 에서 선택되는 것과 동일한 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({ queryKey: ['posts'], queryFn: 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({
    queryKey: ['posts-2'],
    queryFn: getPosts,
  })

  // ...
}

👍🏻 이처럼, 일부 query를 미리 가져오고 다른 query는 queryClient 에서 가져오도록 하는 것이 좋다.

즉, 특정 query에 대해 prefetchQuery를 추가하거나 제거함으로써 ✨서버가 렌더링할 content를 제어✨할 수 있다.

Caveat for Next.js rewrites

Next.js의 rewritesAutomatic Static Opimization 혹은 getStaticProps 을 함께 사용하는 경우,

React Query에 의해 두 번째 hydration을 일으킨다.

이는 Next.js가 hydration 이후 클라이언트에서 rewrites를 파싱하고 모든 params를 collect함으로써 router.query에서 제공될 수 있도록 해야 하기 때문이다.

🤔 So How to use it?

첫 번째로는 Project를 만든다.

2. Set up Hydration

react query 공식문서에 의하면, _app.js 파일에서 hydration을 설정해줘야 한다.

//pages/_app.js

import { useState } from 'react';
import { Hydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { config } from 'lib/react-query-config';

function MyApp({ Component, pageProps }) {

    // This ensures that data is not shared 
    // between different users and requests
    const [queryClient] = useState(() => new QueryClient(config))

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

}

export default MyApp;

3. Prefetching and dehydrate data

v3에서 React Query는 기본 5분 동안 쿼리 결과를 캐싱한 다음, 해당 데이터를 수동으로 garbage collecting 한다. 이 기본값은 서버 측 React Query에도 적용되었다. 이로 인해 메모리 사용량이 높아지고, 이 수동 가비지 컬렉팅이 완료되기를 기다리는 hanging 프로세스가 발생한다.

하지만 v4에서는 기본적으로 서버 측 cacheTime 이 Infinity(무한)으로 설정되어 수동 가비지 컬렝팅을 효과적으로 비활성화 한다. (NodeJS 프로세스는 request가 완료되면 모든 것을 지운다.)

이제 데이터를 prefetch하고 getServerSideProps 메서드에서 queryClient 를 dehydrate해야 한다.

//pages/posts/[id].js

import { getPost } from 'api/posts';
import { dehydrate, QueryClient } from '@tanstack/react-query';

export const getServerSideProps = async (ctx) => {

    const { id } = ctx.params;

    const queryClient = new QueryClient()

    // prefetch data on the server
    await queryClient.fetchQuery(['post', id], () => getPost(id))

    return {
        props: {
            // dehydrate query cache
            dehydratedState: dehydrate(queryClient),
        },
    }
}

참고: prefetchQuery 는 에러를 throw하지 않거나 어떠한 데이터도 return하지 않으므로 fetchQuery 를 사용했다. 이는 404 상태 코드 처리에서 더 자세히 다뤄보도록 하자.

지금부터는 data를 props 로 전달하지 않고도 prefetched 된 데이터를 페이지에서 쉽게 사용할 수 있다.

명확히 하기 위해 getPost 메소드와 usePost hook 의 구현을 살펴보자.

//api/posts.js

import axios from 'lib/axios';

export const getPost = async id => {
    const { data } = await axios.get('/posts/' + id);
    return data;
}
//hooks/api/posts.js

import { useQuery } from '@tanstack/react-query';
import * as api from 'api/posts';

export const usePost = (id) => {
    return useQuery(['post', id], () => api.getPost(id));
}

이제 우리는 usePost hook을 사용하여 post 데이터를 가져올 수 있다.

//pages/posts/[id].js

import { useRouter } from 'next/router';
import { usePost } from 'hooks/api/posts'
import Loader from 'components/Loader';
import Post from 'components/Post';
import Pagination from 'components/Pagination';

const PostPage = () => {

    const { query: { id } } = useRouter();

    const { data, isLoading } = usePost(id);

    if (isLoading) return <Loader />

    return (
        <>
            <Post id={data.id} title={data.title} body={data.body} />
            <Pagination id={id} />
        </>
    )
}

// getServerSideProps implementation ...
// We talked about it in section 2

4. Shallow Routing

우리는 client에서 data fetching과 caching 메커니즘을 관리하기를 원한다.

매번 getServerSideProps 호출을 방지하기 위해 post 페이지 간 navigating 할 때 Link 컴포넌트에서 shallow=true prop을 사용해야 한다.

이로써 getServerSideProps 메서드는

App 내의 클라이언 사이드 navigation이 일어날 때가 아니라 사용자가 post의 URL을 직접 누를 때만 호출되도록 한다.

page 간 navigate(이동)를 하기 위한 Pagination 컴포넌트가 있으므로 여기에 shallow=true 를 사용한다.

//components/Pagination.jsx

import Link from 'next/link';

function PaginationItem({ index }) {

    return (
        <Link className={itemClassName} href={'/posts/' + index} **shallow={true}**>
            {index}
        </Link>
    )
}

export default PaginationItem;

5. with-CSR HOC

NextJS v12.2 의 shallow routing은 현재 페이지의 URL 변경에 대해서만 작동한다. (참고)

즉, shallow=true/posts/10 에서 /posts/15 로 이동하면 getServerSideProps 가 호출되지 않지만

/home 에서 /posts/15 로 이동하면 shallow routing을 사용하더라도 getServerSideProps 가 호출되고, 사용 가능한 캐시 데이터가 있는데도 불필요한 새로운 데이터를 fetch한다.

getServerSideProps 에 대한 요청이 client 측의 navigation 요청인지 여부를 확인하는 방법은 아래와 같다.
(props로 빈 object를 return하고 서버에서 데이터를 fetching 하는 것을 막는다)

서로 다른 페이지를 navigating 할 때 getServerSideProps 호출을 막을 수는 없지만, getServerSideProps 에서 불필요한 데이터를 가져오는 것은 막을 수 있다.✨

//HOC/with-CSR.js

export const withCSR = (next) => async (ctx) => {

    // check is it a client side navigation 
    const isCSR = ctx.req.url?.startsWith('/_next');

    if (isCSR) {
        return {
            props: {},
        };
    }

    return next?.(ctx)
}

이제 getServerSideProps 를 위의 HOC로 감싸준다.

//pages/posts/[id].js

import { getPost } from 'api/posts';
import { dehydrate, QueryClient } from '@tanstack/react-query';
import { withCSR } from 'HOC/with-CSR'

export const getServerSideProps = withCSR(async (ctx) => {

    const { id } = ctx.params;

    const queryClient = new QueryClient()

    await queryClient.fetchQuery(['post', id], () => getPost(id))

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

다른 페이지에서 post 페이지로 이동하면 getServerSideProps 는 데이터를 fetch하지 않고 props에 빈 object만 반환하게 된다.

6. Handle 404 status code

NextJS는 만약 post가 available 하지 않을 경우 error 페이지를 렌더링하지만 error 상태 코드에 따라 respond하지는 않는다.

이는 404 에러가 보이더라도 page 가 200 코드로 응답하고 있음을 의미한다.
이를 해결하기 위해서는 아래처럼 custom error 컴포넌트를 return 해줄 수 있다.

const Page = ({ isError }) => {

    **//show custom error component if there is an error
    if (isError) return <Error />

    return <PostPage />**

}

export const getServerSideProps = withCSR(async (ctx) => {

    const { id } = ctx.params;

    const queryClient = new QueryClient();

    let isError = false;

    try {
        await queryClient.fetchQuery(['post', id], () => getPost(id));
    } catch (error) {
        isError = true
        ctx.res.statusCode = error.response.status;
    }

    return {
        props: {
            //also passing down isError state to show a custom error component.
            isError,
            dehydratedState: dehydrate(queryClient),
        },
    }
})

export default Page;

참고

profile
Make your lives Extraordinary!

0개의 댓글