요즘 여러군데에서 react-query
를 많이 사용하고 있는듯 하다.
나 또한 react-query
를 계속 사용해 왔고 react-native
, NextJS
에서 많이 사용했었다.
그러다가 문득 Next JS
에서 SSR 시에 react-query
가 어떻게 동작하는지 궁금증을 가지게 되었다.
Using NextJS 에서 보면 NextJS
에서 크게 2가지 방법으로 SSR 에서 사용할 수 있는것으로 보인다.
initialData
내용 그대로 getStaticProps
나 getServerSideProps
에서 원하는 API 를 요청하고 그에 대한 응답을 page
에 props
로 내려주고 그 값을 react-query
에 initialData
로 넣어주는 방법이다.
이 방식은 간단하지만 만약 중첩된 컴포넌트에서 react-query
를 사용하고 있다면 그 컴포넌트 까지 props drilling 해주어야하고, 또한 같은 응답을 원하는 query 가 여러개인 경우 다 넣어줘야한다.
그래서 간단하지만 여러가지 문제들이 존재한다.
따라서 react-query
에서도 두번째 방법을 추천한다.
hydrate
hydrate
방식인데 동일하게 getStaticProps
나 getServerSideProps
여기에서 prefetch
를 통해 데이터를 요청한뒤 queryClient
를 dehydrate
하여 page
에 props
에 dehydratedState
로 내려주면 끝이다.
// 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 에서 확인 하면 된다.
대략적으로 다음과 같이 2가지 방법인데, 이것이 어떻게 동작하는지 궁금해졌다.
먼저 이에 대하여 알아보려면 hydrate
라는 것에 대한 지식이 필요하다.
hydrate
란 간단하게 말하면 다음과 같다. (사실 NextJS
개념은 아니고 React
개념이다)
DOM 요소에 자바스크립트 속성을 매칭 시키기 위한 목적
즉, NextJS
에서는 서버사이드에서 pre rendering 한 html 파일들을 서버사이드 렌더링 형식으로 해서 보내주고, 클라이언트사이드에서 React 코드를 통해 hydrate
진행한다.
또한 hydrate
를 진행해도 단순히 DOM 에 JS 속성을 매칭시키는 일이라서 paint 가 다시 일어나진 않는다.
서버사이드에서 내려주는 HTML은 자바스크립트 이벤트 리스너들이 붙어있지 않은데, Hydrate 단계에서 이런 부분들을 다시 붙여주게된다.
그래서 최종적으로 react-query
가 위와같은 상황에 대하여 어떻게 동작하는지 궁금해서 알아보고 기록을 남기기 위하여 글을 쓴것이다. (이글의 목적!)
NextJS
가 pre rendering 한 html 파일을 넘겨주고 클라이언트에서 hydrate
하는 것은 이해가 되었는데 어떠헥 서버사이드 렌더링 시에 prefetch 된 데이터를 바탕으로 그려줄까 하는것이 궁금했다.dehydrate
를 안해주면 서버사이드 렌더링 시에는 prefetch 때문에 쿼리가 캐싱되어서 데이터를 넘겨주고, 클라이언트에서 hydrate
할 시에는 dehydrate
된것이 없기 때문에 클라이언트에서 hydrate
시 서버사이드에서 pre rendering 한 html 구조가 달라서 다음과 같은 에러
가 생기지 않을까? 다음과 같은 에러 -> NextJS 에서 서버사이드렌더링 한 html 과 hydrate 하는 과정에서 만들어낸 html 과 구조가 다르면 에러가 발생한다.
하지만 2번은 완전 내 생각과 달랐다. prefetch 후에 queryClient
를 dehydrate
하지 않고 dehydratedState
로 props
를 넘겨주지 않으면 서버사이드에서 pre rendering 했을 시에도 useQuery
에 data
가 존재하지 않았고 클라이언트에서 hydrate
진행 할때에도 구조가 달라지지 않아 에러가 나진 않았다.
먼저 queryClient
를 dehydrate
하면 다음과 같다. 실제로 보면 단순 serialize 한것이다.
dehydrate
된 queryClient
{
mutations: [],
queries: [
{ state: [Object], queryKey: [Array], queryHash: '["user"]' },
{ state: [Object], queryKey: [Array], queryHash: '["event",1]' }
]
}
그래서 코드를 뒤져보기로 했다!
// 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);
그래서 다시 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
데이터를 반환하고 있었다.
여기서 state
는 query.state
이고 query
는 위에서 createResult(query)
에 query
였다.
// queryObserver.js
// Use query data
data = state.data;
즉, queryClient
에서 getQueryCache()
를 통해 query
데이터를 가져오고 이를 통하여 데이터를 반환하는것 같았다.
그래서 queryClient
파일에 getQueryCache
를 보니 단순히 queryCashe
를 return 하는 메소드였다.
// queryClient.js
_proto.getQueryCache = function getQueryCache() {
return this.queryCache;
};
그 다음 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
시에 값을 넣어주는것을 확인 할 수 있었다.
다음 코드는 react
에서 <Hydrate state={dehydrateState}>
에 사용되는 코드인데 다음과 같이 dehydrateState
를 state
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 || [];
를 확인 할 수 있는데 이것은 맨처음 queryClient
를 dehydrate
한 값이랑 동일 했다.
// 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);
});
dehydrate
된 queryClient
{
mutations: [],
queries: [
{ state: [Object], queryKey: [Array], queryHash: '["user"]' },
{ state: [Object], queryKey: [Array], queryHash: '["event",1]' }
]
}
그렇기 때문에 무조건 getServerSideProps
나 getStaticProps
에서 queryClient
를 dehydrate
시킨 다음 dehydratedState
props
로 값을 넘겨주어야지 queryCashe
에 세팅이 되고 서버사이드 렌더링시 useQuery
를 만나면 캐싱된 값을 반환해서 그려주는것을 확인 할 수 있었다.
또한 클라이언트에서 hydrate
시에도 dehydrate
된 query
를 찾아서 각각 hydrate
시켜주는것을 알 수 있었다.
결론적으로 정리하자면 스텝은 다음과 같다.
이렇게 한가지 의문점을 가지고 라이브러리를 따라가면서 보다 보니 조금 더 이친구와 친숙해지는 느낌이였고 react query 동작방식에 대략 느낌만 가지고 있었을 때 보단 조금 더 논리적으로 이해할 수 있게 되었다.
👏👏👏👏👏
잘 읽고 가요. :)