오늘은 Next JS
에서 useSuspenseQuery
를 이용하는 방법을 적어볼 것이다.
"dependencies": {
"@tanstack/react-query": "^5.29.0",
"next": "14.1.4",
"react": "^18",
"react-dom": "^18",
"react-error-boundary": "^4.0.13"
}
다음과 같은 라이브러리를 사용한다.
Next JS
에는 서버 컴포넌트라는 훌륭한 기술이 존재한다. 그렇다면 useSuspenseQuery
는 왜 필요할까?에 대한 의문이 생길 수 있다. 이에 대해서 제대로 알아보고 싶다면
https://tkdodo.eu/blog/why-you-want-react-query
React 쿼리가 필요한 이유 - TkDodo(react-query 메인테이너)
를 참조해보면 좋을 것 같다. 여기서는 지속적인 갱신에 대한 예시를 다뤄볼 예정이다.
// src/app/page.tsx
async function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, ms);
});
}
async function getRemoteData(): Promise<string> {
await sleep(1000);
return "hello world";
}
export default async function Home() {
return (
<main>
<Content />
</main>
);
}
async function Content() {
const data = await getRemoteData();
return <section>{data}</section>;
}
이 코드는 서버 컴포넌트를 활용해 데이터를 불러와 렌더링하는 예제이다. 서버에서 데이터를 불러와 렌더링하기에 페이지 인터랙티브 속도와 SEO, 그리고 번들의 크기를 개선할 수 있다.
하지만 getRemoteData()
가 긴 지연이 걸린다면 어떨까? 혹은 너무나 많은 데이터를 로드하고 있다고 가정해도 좋다.
페이지의 초기 로드는 해당 데이터 지연만큼 느려질 것이다. 반드시 SEO가 필요한 데이터라면 이렇게 지연을 기다려야 하지만, 중요하지 않은 데이터 때문에 페이지 초기 로드가 지연되는 것은 나쁘다.
이런 경우에는 Suspense
를 통해 React
에게 이 컴포넌트의 렌더링은 중단 가능하다고 알려주면 된다.
// src/app/page.tsx
<Suspense fallback={<h3>loading...</h3>}>
<Content />
</Suspense>
이렇게 중단 가능한 컴포넌트를 Suspense
로 감싸주고, 그 사이에 보여줄 대체 fallback
(최대한 원본과 유사한 스켈레톤이면 좋다!!)을 넣어주면 된다.
그러면 지연 중에는 대체 컴포넌트가 보여진다.
Suspense
는 짧은 지연에 대해서는 바로 해당 컴포넌트를 렌더링한다. 이는 일종의 경쟁 조건처럼 작동하는데 정확한 시점에 대해서는 공개되어 있지 않다.
New Suspense SSR Architecture in React 18
너무 오래 걸린다면 서버가 렌더링을 포기한다.
만약 getRemoteData
로 읽어오는 데이터가 주기적으로 갱신이 필요하다면 어떨까?
지속적으로 갱신되는 데이터에 대해 매번 서버에서 렌더링하고, RSC
와 데이터를 스트리밍하는 것은 무척 비효율적일 것이다.
실시간으로 달라지는 데이터를 가정하기 위해 getRemoteData
를 일부 수정하겠다.
// src/app/page.tsx
async function getRemoteData(): Promise<number[]> {
await sleep(1000);
return new Array(5).fill(0).map(() => Math.random() * 10);
}
// ...
async function Content() {
const data = await getRemoteData();
return (
<section>
{data.map((val, idx) => (
<h4 key={idx}>{val}</h4>
))}
</section>
);
}
이 데이터를 10초마다 사용자에게 업데이트해서 보여줘야 한다고 가정하자. 가장 쉬운 방법은 10초마다 새로고침을 일으키는 것이다. 그러나 이는 부담스러운 해결책이다.
내가 제안하고 싶은 방법은 react-query
를 사용하는 것이다. 해당 부분에 대해서는 코드 스플리팅을 포기해야 하지만 나쁘지 않은 해결책이 될 수 있다. useSuspenseQuery
를 사용한다면 클라이언트 사이드 렌더링을 적절하게 결합해 서버 컴포넌트와 유사하게 사용 가능하다.
https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr
해당 글의 마지막 챕터를 참조하면 된다.
@tanstack/react-query-next-experimental
을 추가해주고
// src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as React from "react";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
// SSR에서는 클라이언트에서 즉각적으로 다시 데이터를 가져오지 않도록 staleTime을 설정해준다.
staleTime: 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined = undefined;
function getQueryClient() {
if (typeof window === "undefined") {
// 서버에서는 항상 새로운 queryClient를 만든다.
return makeQueryClient();
} else {
// 브라우저에서는 없을 경우에만 새로 만든다.
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
}
export function Providers(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
<ReactQueryStreamedHydration>
{props.children}
</ReactQueryStreamedHydration>
</QueryClientProvider>
);
}
이렇게 프로바이더를 만들어 준 후 루트 레이아웃에 프로바이더를 추가해주자.
//src/app/layout.tsx
import { Providers } from "./providers";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
// src/app/Content.tsx
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
async function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, ms);
});
}
async function getRemoteData(): Promise<number[]> {
await sleep(1000);
console.log('data fetch');
return new Array(5).fill(0).map(() => Math.random() * 10);
}
export default function Content() {
const { data } = useSuspenseQuery({
queryKey: ["data"],
queryFn: getRemoteData,
refetchInterval: 10000,
});
return (
<section>
{data.map((val, idx) => (
<h4 key={idx}>{val}</h4>
))}
</section>
);
}
Content
를 클라이언트 컴포넌트로 선언하기 위해 파일을 분리해주었다. 그리고 useSuspenseQuery
내부의 queryFn
으로 getRemoteData
를 지정해 주었다. useSuspenseQuery
는 queryFn
의 비동기 작업이 성공하기 전에는 렌더링을 지연시키므로 data
의 타입은 항상 주어진 타입이다.
주기적인 재검증을 위해 refetchInterval
은 10초로 넣어주었다.
이제 새로고침을 해보자. 이전에 Content
를 Suspense
로 감싸주었기에 초기에는 로딩 문구가 보이고 이후 데이터가 렌더링될 것이다.
흥미로운 점은 data fetch
로그는 서버에서 처음 발생한다. 서버 컴포넌트와 유사하게 첫 데이터는 서버에서 스트리밍 받아오는 것이다. (정확하게는 첫 데이터로 쿼리 캐시를 채운다.)
대신 RSC 페이로드
도 같이 보내지는 것이 아닌 클라이언트 js 코드를 통해 렌더링된다. 데이터 리페칭 이후에는 마찬가지로 클라이언트 js 코드에 의해 렌더링 된다.
useSuspenseQuery
를 사용한 컴포넌트는 서버 컴포넌트와 마찬가지로 Suspenese
가 없다면 Next JS
는 렌더링 지연을 기다린다.
// src/app/page.tsx
import { Suspense } from "react";
import Content from "./Content";
export default async function Home() {
return (
<main>
<Content />
</main>
);
}
이렇게 바꿀 경우 Content
의 지연을 기다린 후 페이지가 로드된다.
getRemoteData
를 일부 변경해 50% 확률로 요청이 실패하도록 해보자.
// src/app/Content.tsx
function simulateFail(rate: number) {
const num = Math.random();
if (rate < num) {
return true;
}
throw new Error("test error");
}
async function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, ms);
});
}
async function getRemoteData(): Promise<number[]> {
await sleep(1000);
simulateFail(50);
console.log("data fetch");
return new Array(5).fill(0).map(() => Math.random() * 10);
}
2가지 경우가 존재한다.
서버에서 발생하는 에러에 대응하기 위해서는 ErrorBoundary
를 추가해줘야 한다. 에러 바운더리는 클라이언트에 의해 처리되기에 에러 바운더리를 사용한 컴포넌트는 클라이언트 컴포넌트로 만들어줘야 한다.
// src/app/Boundary.tsx
"use client";
import { PropsWithChildren } from "react";
import { ErrorBoundary } from "react-error-boundary";
export default function Boundary({ children }: PropsWithChildren) {
return (
<ErrorBoundary
fallback={
<div>
<h3>Error</h3>
</div>
}
>
{children}
</ErrorBoundary>
);
}
그리고 에러가 발생 가능한 컴포넌트(useSuspenseQuery
를 사용하는 Content
)를 감싸준다.
// src/app/page.tsx
import Content from "./Content";
import Boundary from "./Boundary";
export default async function Home() {
return (
<main>
<Boundary>
<Content />
</Boundary>
</main>
);
}
이렇게 한다면 에러 발생 시
렌더가 깨지는 대신 fallback 컴포넌트가 보여진다.
단순 fallback을 보여주는 것으로 끝내지말고 다시 시도 기능을 추가해보자. 아까 바운더리를 조금만 수정해주면 된다.
"use client";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
import { PropsWithChildren } from "react";
import { ErrorBoundary } from "react-error-boundary";
export default function Boundary({ children }: PropsWithChildren) {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
<h3>Error</h3>
<button onClick={resetErrorBoundary}>다시 시도</button>
</div>
)}
>
{children}
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
이렇게 재시도 기능을 추가 가능하다.
QueryErrorResetBoundary
를 사용하면 손쉽게 에러 바운더리와 결합해 내부의 쿼리 에러를 리셋시키고 다시 시도 가능하다.
useSuspenseQuery
는 특정 경우에 서버 컴포넌트 대신 쓸만하다(캐시 관리, 빈번한 재검증, 무한 스크롤 등)Next JS
에서는 useSuspenseQuery
를 Suspense
로 감싸면 데이터 가져오기를 기다리지 않는다. 감싸지 않는다면 기다린다.useSuspenseQuery
를 사용한다면 ErrorBoundary
에 QueryErrorResetBoundary
를 사용해서 에러를 처리해주자.useSuspenseQuery
를 Suspense
로 감싸면 코드가 보기 좋아진다 ^ ^예제 코드는 https://github.com/bluejoyq/react-examples/tree/master/next-use-supense-query 에서 찾을 수 있습니다~