[ 공모전 ] SSR : React-Query

최문길·2024년 8월 1일
1

공모전

목록 보기
39/46
post-thumbnail

Next.js에서 React-Query 설정

// NEVER DO THIS:
// const queryClient = new QueryClient()
//
// Creating the queryClient at the file root level makes the cache shared
// between all requests and means _all_ data gets passed to _all_ users.
// Besides being bad for performance, this also leaks any sensitive data.
export default function App({ Component, pageProps }: AppProps) {
  
// Instead do this, which ensures each request has its own cache:
  const [queryClient] = useState(()=>new QueryClient()) 
  // or
  const queryClientRef = useRef<QueryClient>()
  if(!queryClientRef.current) queryClientRef.current = new QueryClient()
  <QueryClientProvider client={queryClient}>
          <Component {...pageProps} />
    </QueryClientProvider>

_app.tsx 에서 QueryClient설정에 2가지 방법이 있는데, 이는 컴포넌트 라이프사이클 당 QueryClient 를 오직 한 번만 생성하여 데이터가 서로 다른 사용자와 요청 간에 공유되지 않게 하기 위함이다.

  • useState
  • useRef
  • cache ( React의 서버 컴포넌트에서만 사용가능 )

위의 3가지 방법으로 한 번만 생성 하도록 할 수 있다.

SSR과 React Query

React-Query의 공식 문서에서는 SSR환경에서 데이터를 받아올 때 2가지의 방법을 소개한다.

1. initialData

Next.js의 getStaticPropsgetServerSideProps 함수를 통해 fetch한 데이터를 useQueryinitialData 옵션을 통해서 pre-fetching 후 Props를 내려 줄 수 있다.

const [queryClient] = useState(()=>new QueryClient({
  defaultOptions:{
    queries: {
      staleTime:60000// 전반적인 query의 staleTime을 조절  
    }
  }
})) 
// or
<QueryClientProvider client={queryClient}>
  <Component {...pageProps} />
</QueryClientProvider>

staleTime을 설정 후

const ReactQueryInitialData = ({ data }: { data: AxiosResponse }) => {
  const query = useQuery({
    queryKey: ["react-query"],
    queryFn: getInitalData,
    initialData: data, // initialData는 캐시에서 유지된다. 
  });
//...생략
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  const { data } = await getInitalData();
  return { props: { data } };
}

위와 같이 하면 Client단에서 다시 호출하는 일 없이, 데이터를 pre-fetching하여 사용 할 수 있다.

그러나 React-Query 공식문서상에서 나와있듯이

단점

  1. 데이터 전달의 복잡성

    • 전달해야 할 initialData의 depths가 깊으면, 해당 지점까지 도달해야 하는데, 여러 위치에서 동일한 쿼리를 호출 할 경우 앱 변경 시 오류가 발생 할 수 있다. ->useQuery 를 같은 query로 여러 위치 호출하면, 호출한 지점 모두에 initialData 넘겨줘야함

  2. 데이터 신선도 문제

    • 쿼리가 서버에서 언제 데이터를 가져왔는지 알 수 없기 때문에,
      useQueryClient().getQueryState(["react-query"]) = {dataUpdatedAt:...} 과 같은 이 메소드를 사용해서 쿼리를 다시 가져와야 하는지 판단 해야 한다.
    • 페이지가 로드된 시점을 기준으로 캐시에 이미 데이터가 있는 경우, 새로운 데이터가 이전 데이터보다 최신이더라도 초기 데이터가 이 데이터를 덮어쓰지 않는다.
    • getServerSideProps를 사용하는 경우, 페이지를 여러 번 이동 할 때마다 새로운 데이터를 가져오지만, 초기 데이터 옵션을 사용하면 클라이언트 캐시와 데이터가 업데이트되지 않는다.

그래도 쓰고 싶다면,

물론 쿼리에 staleTime : 30초 를 설정한 경우 초기 데이터를 동기적으로 얻을 수 있으며, 해당 데이터는 30초간 유효하기 때문에 backend에 요청할 필요가 없지만

initialDataUpdatedAt 을 사용하여 해결 할 수는 있다.
-> initialData 가 생성되었을 때 React-query에 알려 백그라운드 다시 가져오기가 트리거 된다.

const queryClient = useQueryClient();
  const query = useQuery({
    queryKey: ["react-query"],
    queryFn: () => getInitalData(1),
    initialData: data,
    staleTime: 8000,
    initialDataUpdatedAt: () => {
      return queryClient.getQueryState(["react-query"])?.dataUpdatedAt;
    },
  });
  console.log(queryClient.getQueryState(["react-query"])); // 8초 이후 다시 호출 된다. 

2. Hydration

hydrate 방식에는 초기 설정을 해야하는데,

import {
  HydrationBoundary,
  QueryClient,
  QueryClientProvider,
  useQuery,
} from "@tanstack/react-query";

export default function App({ Component, pageProps }: AppProps) {
  <QueryClientProvider client={queryClient}>
      <HydrationBoundary state={pageProps.dehydrateState}> // HydrationBoundary로 v5에서 바뀜
            <Component {...pageProps} />
      </HydrationBoundary>
    </QueryClientProvider>

위와 같이 HydrationBoundary로 Component를 감싸주고, pageProps의 프로퍼티로 state통에 넣는 로직을 짜주면 끝이다.

그리고 각 Page에서 getServerSideProps 또는 getStaticProps의 return 값을 주면 된다.
여기서 주의해야 할 점은 Serialized 될 data가 undefined이면 serialized가 되지 않으므로 data || null로 return 해줘야 한다.

아래는 임의의 Page에서 사용한 예시코드이다.

interface Data {
  userId: 1;
  id: 1;
  title: "delectus aut autem";
  completed: false;
}
const getInitalData = async (query: number) => {
  const { data } = await axios(
    `https://jsonplaceholder.typicode.com/todos/${query}`,
  );
  return data ?? null; // dehydrate method에서는 undefined가 허용이 안된다. 
};
const ReactQueryHydration = ({ data }: { data: Data }) => {
//...생략
};

export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ["hydration"],
    queryFn: () => getInitalData(1),
  });

  return { props: { dehydrateState: dehydrate(queryClient) } };
}

Serialization

위와 같이 dehydrate 메소드안에 queryClient를 넣어 주는 이유는 dehydrate(queryClient)를 반환하면 queryClient는 직렬화로 변경되기 때문이다.

이때 직렬화가 가능하지 않은 값은 undefined , Date , Map , Set , BigInt , Infinity , NaN , -0, 정규식 표현등이 있다.

따라서

  • dehydrate : 서버 사이드에서 React Query의 캐시를 직렬화하여 클라이언트로 전달할 수 있는 형태로 만들고
  • hydrate : 클라이언트 단에서 서버 사이드에서 전달된 직렬화된 데이터를 복원하여 React-Query의 캐시로 사용한다.

Hydration(SSR) 원리

export async function getServerSideProps(ctx: GetServerSidePropsContext) {
  const queryClient = new QueryClient();
  await queryClient.prefetchQuery({
    queryKey: ["hydration"],
    queryFn: () => getInitalData(1),
  });

  console.log(dehydrate(queryClient));

console로 확인해보면
직렬화된(JSON) 값이 보인다.

//node_modules>@tanstack>react-query>src>HydrationBoundary
 HydrationBoundary = ({
  state,
  queryClient,
}: HydrationBoundaryProps) => {
  const client = useQueryClient(queryClient) // QueryClientProvider 아래에 써줘야 하는 이유
//...생략
  
 const queryCache = client.getQueryCache()
 const queries = (state as DehydratedState).queries || []
 
 const newQueries: DehydratedState['queries'] = []
 const existingQueries: DehydratedState['queries'] = []
 if (state) {
      if (typeof state !== 'object') {
        return // 1.
      }
      for (const dehydratedQuery of queries) {
        const existingQuery = queryCache.get(dehydratedQuery.queryHash) // 2.

        if (!existingQuery) {
          newQueries.push(dehydratedQuery)// 3.
        } else {
          const hydrationIsNewer =
            dehydratedQuery.state.dataUpdatedAt >
            existingQuery.state.dataUpdatedAt // 4. 
          const queryAlreadyQueued = hydrationQueue?.find( // 5.
            (query) => query.queryHash === dehydratedQuery.queryHash,
          )

          if ( // 6.
            hydrationIsNewer &&
            (!queryAlreadyQueued ||
              dehydratedQuery.state.dataUpdatedAt >
                queryAlreadyQueued.state.dataUpdatedAt)
          ) {
            existingQueries.push(dehydratedQuery)
          }
        }
            
    if (newQueries.length > 0) { // 3
        // It's actually fine to call this with queries/state that already exists
        // in the cache, or is older. hydrate() is idempotent for queries.
        hydrate(client, { queries: newQueries }, optionsRef.current)
      }
    if (existingQueries.length > 0) {
        setHydrationQueue((prev) =>
          prev ? [...prev, ...existingQueries] : existingQueries,
        )
      }
     }
  1. HydrationBoundary 컴포넌트가 실행되고, dehydrateState 값이 없다면 return

  1. state로 넣어줬던 직렬화된 dehydrateState는 Client의 Cache 에 있는지를 판별하여
  2. 없으면 새로운 newQueries라는 배열로 집어 넣고, 직렬화를 풀어주고(hydrate()) Querylient안에 넣어준다.

반대로,
  1. dehydrateState 가 있다면 데이터가 이전 데이타 보다 최신화인지를 판별하고,
  2. hydrationQueeu 안에 이미 있는지를 queryHash값으로 판별한다.
    QueryClient에 dehydrtae 된 상태값을 다시 넣어 hydrate 한다.

그리고,

  1. 새로운 값(hydrationIsNewer)인지 그리고 값의 상태가 이전 값보다 fresh하면 existingQueries에 넣어준다.

즉 클라이언트에서도 서버에서 pre-fetching 한 데이터가 QueryClient안에 캐싱되게 한다. 그리고 SSR을 통해 받아온 값이 어디있는지를 보려면 아래와 같이 확인하면 된다.

console.log(useQueryClient().getQueryCache())

위의 사진을 보면 QueryCache안의 queries에 캐싱된 SSR데이터가 있는 것이 보인다.

왜 prefetch ?

왜 서버측에서 prefetch를 사용하는지, 나는 궁금했다. "fetchQuery 사용하면 돼지않나? 아니면 useQuery는?" 라는 의구심이 든다.

queryClient.fetchQuery

queryClient.fetchQuery({...}) => Promise<TData>
// not has options, otherwise has options in useQuery

이렇게 되어있기 때문이다.

queryClient.prefetchQuery

queryClient.prefetch({...}) => Promise<voide>

prefetch와 fetchQuery는 정확이 같지만, 단지 반환 값의 유무를 다루지 않는다.

conclusion

사실 적절히 둘 중에 하나 사용하면 된다고 생각한다.
단순히 query를 미리 하냐,
아니면 query를 통한 값에 따라 SSR을 하고 싶냐에 차이라 생각한다 .

TkDodo의 stack-overflow에서도 prefetch관련 내용을 다루고 아래와 같이 다루고 있다.

이를 미루어 생각해본 결과, 상황에 따라 prefetch, fetch를 사용 하면 될것 같다 .

참고 & 마무리

Next.js에서 SSR을 할 때, Hydrate , Serialize , queryClient.cache 개념을 찾아보고, 정리하고, 이해하고, 해석하는데 참 많은 시간을 기울인것같다.

공부를 하면서 느낀거지만, React-Query의 방대한 기능들을 다 사용할 수 있을까? 라는 생각도 들고,
prefetch , fetch 메소드와 같이 상황에 맞추어 사용하는 것도 중요하다고 생각이든다.

무조건 무엇을 써야지가 아닌, 유동적으로 적재적소에서 코드를 '올바르게 작성' 하는 것이 중요하다고 느꼈다.

0개의 댓글

관련 채용 정보