Next.js는 서버 사이드에서 데이터 Prefetching이 가능하다. Prefetch 하게 될 데이터는 HTML 페이지가 클라이언트에게 전송되기 전에 준비되어 HTML에 포함되어 렌더링된다.
따라서 한 번 사이트가 로딩된 후에는 로딩 시간이 크게 단축된다는 점, SEO에 좋다는 아주 유명한 장점이 있다.
React-Query는 Next.js의 서버 사이드에서 데이터를 Prefetch하여 queryClient로 넘겨주는 기능을 제공한다.
회사에서 신규 서비스를 개발하면서 서버로부터 Fetch한 데이터를 더 효율적으로 다룰 방법에 대해 고민해보았다.
먼저 사용자가 우리 서비스에 최초로 진입한 후, 가장 빠른 시간 안에 접근할 것으로 예상되는 페이지에 대해 데이터를 Prefetch 할 니즈가 있었다.
또한 똑같은 데이터를 다른 페이지에서도 사용할 수 있으며, 데이터의 종류에 따라 빠른 로딩 속도와 데이터의 최신성 등등에 대응해야 할 니즈가 있었다.
따라서 이러한 니즈에 효율적으로 대응하기 위해 React Query 도입을 결정했다.
Next.js 환경이기 때문에 필요에 따라 클라이언트 사이드와 서버 사이드에서 필요에 따라 사용하기로 했다.
공식 문서의 SSR 항목과 여러 기술 블로그를 정독하면서 적용해보았다.
React Query는 SSG와 SSR 모두에서 사용할 수 있으며 크게 2가지 방법이 있다.
export async function getStaticProps() {
const posts = await getPosts()
return { props: { posts } }
}
function Posts(props) {
const { data } = useQuery(['posts'], getPosts, { initialData: props.posts })
// ...
}
심플한 방법이지만 몇 가지 주의할 점이 있다.
dataUpdatedAt
과 해당 쿼리의 refetch 필요 여부는 페이지가 로드 된 시점에 따라서 결정된다.서버 사이드에서 1개 이상의 쿼리를 실행할 수 있다. 이렇게 prefetch한 쿼리를 queryClient에 dehydrate 할 수 있다.
서버는 페이지가 로드될 때 즉시 사용할 수 있도록 미리 마크업을 할 수 있으며, 자바스크립트가 사용 가능해지는 즉시 React Query는 해당 쿼리를 업그레이드 또는 hydrate 할 수 있다.
만약 Prefetch한 해당 쿼리가 Stale 상태가 되었다면 refetch를 진행할 수도 있다.
// _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를 예시로, 동작 과정은 다음과 같다.
// 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를 적용하거나 제거하는 방식으로 서버가 어떤 내용을 렌더링할 것인지를 조절할 수 있다.
크몽의 기술 블로그를 참고했다.
- 모든 사용자가 무조건 접속하게 될 페이지.
- 주로 사용하게 될 주력 서비스 페이지.
에 대해 상위 방식을 적용하였다.
1번 페이지에서 사용할 데이터는 한 번 받아오면 업데이트가 필요 없는 데이터이다. 즉, 빌드 타임에 딱 한번만 최신화하면 되는 데이터이기 때문에 getStaticProps를 사용한 SSG 방식을 채택했다.
2번 주력 서비스 페이지에서 사용할 데이터는 사용자가 많아질 시 (아직 런칭은 안했지만 😂) 데이터의 최신성이 중요할 것으로 판단하여 SSR 방식을 채택했다. 사용자가 적을 경우 ISR로 대응이 가능하겠지만 언젠가는 사용자가 증가할 경우를 대비했다.
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번 페이지의 경우 사용자가 자주 접속할 수록 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
와 같은 라이브러리를 사용할 수도 있겠다.