요즘 여러군데에서 react-query 를 많이 사용하고 있는듯 하다. 나 또한 react-query 를 계속 사용해 왔고 react-native, NextJS 에서 많이 사용했었다. 그러다가 문득 Next JS 에서 SSR 시에 react-query 가 어떻게 동작하는지 궁금증을 가지게 되었다.

Using NextJS 에서 보면 NextJS 에서 크게 2가지 방법으로 SSR 에서 사용할 수 있는것으로 보인다.

React Query 에서 NextJS SSR

1. Using initialData

내용 그대로 getStaticPropsgetServerSideProps 에서 원하는 API 를 요청하고 그에 대한 응답을 pageprops 로 내려주고 그 값을 react-queryinitialData 로 넣어주는 방법이다. 이 방식은 간단하지만 만약 중첩된 컴포넌트에서 react-query 를 사용하고 있다면 그 컴포넌트 까지 props drilling 해주어야하고, 또한 같은 응답을 원하는 query 가 여러개인 경우 다 넣어줘야한다. 그래서 간단하지만 여러가지 문제들이 존재한다. 따라서 react-query 에서도 두번째 방법을 추천한다.

2. Using hydrate

hydrate 방식인데 동일하게 getStaticPropsgetServerSideProps 여기에서 prefetch 를 통해 데이터를 요청한뒤 queryClientdehydrate 하여 pagepropsdehydratedState 로 내려주면 끝이다.

 // pages/posts.jsx
 import { dehydrate, QueryClient, useQuery } from '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.js 에서 설정이 조금 필요한데 다음과 같다.

 // _app.jsx
 import { Hydrate, QueryClient, QueryClientProvider } from '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>
   )
 }

위와 같이 설정하면 중첩된 컴포넌트에서도 prefetch 했던 query key 와 같은 query key 로 useQuery 하고 있다면 서버사이드 렌더링 시에 데이터를 유지한다. 위의 예제에서 보면 posts 가 같은 query key 로 사용된것을 볼 수 있다. 자세한 내용은 Using hydrate 에서 확인 하면 된다.

궁금증

Hydrate 란

대략적으로 다음과 같이 2가지 방법인데, 이것이 어떻게 동작하는지 궁금해졌다. 먼저 이에 대하여 알아보려면 hydrate 라는 것에 대한 지식이 필요하다. hydrate 란 간단하게 말하면 다음과 같다. (사실 NextJS 개념은 아니고 React 개념이다)

DOM 요소에 자바스크립트 속성을 매칭 시키기 위한 목적

즉, NextJS 에서는 서버사이드에서 pre rendering 한 html 파일들을 서버사이드 렌더링 형식으로 해서 보내주고, 클라이언트사이드에서 React 코드를 통해 hydrate 진행한다. 또한 hydrate 를 진행해도 단순히 DOM 에 JS 속성을 매칭시키는 일이라서 paint 가 다시 일어나진 않는다. 서버사이드에서 내려주는 HTML은 자바스크립트 이벤트 리스너들이 붙어있지 않은데, Hydrate 단계에서 이런 부분들을 다시 붙여주게된다.

[Next.js] Hydrate란?

그래서...?

그래서 최종적으로 react-query 가 위와같은 상황에 대하여 어떻게 동작하는지 궁금해서 알아보고 기록을 남기기 위하여 글을 쓴것이다. (이글의 목적!)

  1. 뭔가 NextJS 가 pre rendering 한 html 파일을 넘겨주고 클라이언트에서 hydrate 하는 것은 이해가 되었는데 어떠헥 서버사이드 렌더링 시에 prefetch 된 데이터를 바탕으로 그려줄까 하는것이 궁금했다.
  2. 또한 만약 내가 prefetch 후에 dehydrate 를 안해주면 서버사이드 렌더링 시에는 prefetch 때문에 쿼리가 캐싱되어서 데이터를 넘겨주고, 클라이언트에서 hydrate 할 시에는 dehydrate 된것이 없기 때문에 클라이언트에서 hydrate 시 서버사이드에서 pre rendering 한 html 구조가 달라서 다음과 같은 에러가 생기지 않을까?

다음과 같은 에러 -> NextJS 에서 서버사이드렌더링 한 html 과 hydrate 하는 과정에서 만들어낸 html 과 구조가 다르면 에러가 발생한다.

하지만 2번은 완전 내 생각과 달랐다. prefetch 후에 queryClientdehydrate 하지 않고 dehydratedStateprops 를 넘겨주지 않으면 서버사이드에서 pre rendering 했을 시에도 useQuerydata 가 존재하지 않았고 클라이언트에서 hydrate 진행 할때에도 구조가 달라지지 않아 에러가 나진 않았다.

먼저 queryClientdehydrate 하면 다음과 같다. 실제로 보면 단순 serialize 한것이다.

  • dehydratequeryClient
{
  mutations: [],
  queries: [
      { state: [Object], queryKey: [Array], queryHash: '["user"]' },
      { state: [Object], queryKey: [Array], queryHash: '["event",1]' }
    ]
}

그래서 코드를 뒤져보기로 했다!

1. useQuery

// useQuery.js
function useQuery(arg1, arg2, arg3) {
  var parsedOptions = (0, _utils.parseQueryArgs)(arg1, arg2, arg3);
  return (0, _useBaseQuery.useBaseQuery)(parsedOptions, _core.QueryObserver);
}

useQuery 를 보면 useBaseQuery 를 통해서 동작을 한다. 따라서 useBaseQuery 를 보았는데, 크게 SSR 이나 hydrate 같은건 찾아 볼 수 없었다. 대신에 result 란 값을 return 하는데 그 값에 쿼리에 대한 return 값들이 들어있었다. 그래서 result 가 어떻게 초기화 되는지 찾아보았는데 observer 란 친구였다.

// useBaseQuery.js
var _React$useState2 = _react.default.useState(function () {
  return new Observer(queryClient, defaultedOptions);
}),
    observer = _React$useState2[0];

var result = observer.getOptimisticResult(defaultedOptions);

2. observer - QueryObserver

그래서 다시 useQuery 함수로 돌아가면 _core.QueryObserver 란 친구를 넣어주는것을 볼 수 있다. 그래서 QueryObserver 란 파일을 보면 다음과 같은 메소드를 발견 할 수 있는데, 여기서 this.client 는 위에 서 queryClient 이다.

// queryObserver.js
_proto.getOptimisticResult = function getOptimisticResult(options) {
  var defaultedOptions = this.client.defaultQueryObserverOptions(options);
  var query = this.client.getQueryCache().build(this.client, defaultedOptions);
  return this.createResult(query, defaultedOptions);
};

createResult 를 먼저 확인해봤는데 단순히 hydrate 나 SSR 관련된 로직은 전혀 없었고 옵션과 query 만 비교하여 결과를 반환하고 있었고, 딱히 큰 옵션이 없는 이상 그저 받아온 query 데이터를 반환하고 있었다. 여기서 statequery.state 이고 query 는 위에서 createResult(query)query 였다.

// queryObserver.js
// Use query data
data = state.data;

즉, queryClient 에서 getQueryCache() 를 통해 query 데이터를 가져오고 이를 통하여 데이터를 반환하는것 같았다.

3. queryClient

그래서 queryClient 파일에 getQueryCache 를 보니 단순히 queryCashe 를 return 하는 메소드였다.

// queryClient.js
_proto.getQueryCache = function getQueryCache() {
  return this.queryCache;
};

4. queryCashe

그 다음 queryCashe 를 찾아보았는데, 여기서 눈에 띄는 점이 있었다.

// queryCashe.js
_proto.build = function build(client, options, state) {
  var _options$queryHash;

  var queryKey = options.queryKey;
  var queryHash = (_options$queryHash = options.queryHash) != null ? _options$queryHash : (0, _utils.hashQueryKeyByOptions)(queryKey, options);
  var query = this.get(queryHash);

  if (!query) {
    query = new _query.Query({
      cache: this,
      queryKey: queryKey,
      queryHash: queryHash,
      options: client.defaultQueryOptions(options),
      state: state,
      defaultOptions: client.getQueryDefaults(queryKey),
      meta: options.meta
    });
    this.add(query);
  }

  return query;
};

바로 state 를 넣어주는 것이였다. 이 state 를 언제 넣어주느냐에 따라서 queryCashe 에 대한 값을 셋 해주고 이에 대한 값을 반환하는것 같았다. 하지만, 단순히 useQuery 에서는 state 를 넣어주지 않고 있었다.

// queryObserver.js
var query = this.client.getQueryCache().build(this.client, defaultedOptions);

그래서 언제 이에대한 값 (state) 를 찾던 와중에 hydrate 시에 값을 넣어주는것을 확인 할 수 있었다.

5. hydrate

다음 코드는 react 에서 <Hydrate state={dehydrateState}> 에 사용되는 코드인데 다음과 같이 dehydrateStatestate props 로 넣어주면 _core.hydrate 함수를 호출 하는것을 볼 수 있다.

// Hydrate.js
function useHydrate(state, options) {
  var queryClient = (0, _QueryClientProvider.useQueryClient)();

  var optionsRef = _react.default.useRef(options);

  optionsRef.current = options; // Running hydrate again with the same queries is safe,
  // it wont overwrite or initialize existing queries,
  // relying on useMemo here is only a performance optimization.
  // hydrate can and should be run *during* render here for SSR to work properly

  _react.default.useMemo(function () {
    if (state) {
      (0, _core.hydrate)(queryClient, state, optionsRef.current);
    }
  }, [queryClient, state]);
}

var Hydrate = function Hydrate(_ref) {
  var children = _ref.children,
      options = _ref.options,
      state = _ref.state;
  useHydrate(state, options);
  return children;
};

_core.hydrate 함수 중 일부인데 여기서 보면 var queries = dehydratedState.queries || []; 를 확인 할 수 있는데 이것은 맨처음 queryClientdehydrate 한 값이랑 동일 했다.

// hydration.js
var queries = dehydratedState.queries || [];
queries.forEach(function (dehydratedQuery) {
  var _options$defaultOptio2;

  var query = queryCache.get(dehydratedQuery.queryHash); // Do not hydrate if an existing query exists with newer data

  if (query) {
    if (query.state.dataUpdatedAt < dehydratedQuery.state.dataUpdatedAt) {
      query.setState(dehydratedQuery.state);
    }

    return;
  } // Restore query


  queryCache.build(client, (0, _extends2.default)({}, options == null ? void 0 : (_options$defaultOptio2 = options.defaultOptions) == null ? void 0 : _options$defaultOptio2.queries, {
    queryKey: dehydratedQuery.queryKey,
    queryHash: dehydratedQuery.queryHash
  }), dehydratedQuery.state);
});
  • dehydratequeryClient
{
  mutations: [],
  queries: [
      { state: [Object], queryKey: [Array], queryHash: '["user"]' },
      { state: [Object], queryKey: [Array], queryHash: '["event",1]' }
    ]
}

그렇기 때문에 무조건 getServerSidePropsgetStaticProps 에서 queryClientdehydrate 시킨 다음 dehydratedState props 로 값을 넘겨주어야지 queryCashe 에 세팅이 되고 서버사이드 렌더링시 useQuery 를 만나면 캐싱된 값을 반환해서 그려주는것을 확인 할 수 있었다. 또한 클라이언트에서 hydrate 시에도 dehydratequery 를 찾아서 각각 hydrate 시켜주는것을 알 수 있었다.

결론

결론적으로 정리하자면 스텝은 다음과 같다.

  1. prefetch 해서 queryClient 를 dehydrate 함
  2. 서버사이드 렌더링시 prefetch 할 때와 같은 key 를 가지는 query 를 만나면 캐싱된 데이터를 반환하고 이에 따라 렌더링을 진행함
  3. 서버사이드 렌더링을 진행한 html 파일을 클라이언트로 보내줌
  4. 클라이언트에서 hydrate 진행시 queryClient 에서 dehydrate 했던 데이터를 바탕으로 hydrate 함

이렇게 한가지 의문점을 가지고 라이브러리를 따라가면서 보다 보니 조금 더 이친구와 친숙해지는 느낌이였고 react query 동작방식에 대략 느낌만 가지고 있었을 때 보단 조금 더 논리적으로 이해할 수 있게 되었다.