[React] Next JS에서 useSuspenseQuery 스트리밍 사용하기

bluejoy·2024년 4월 7일
0

React

목록 보기
19/19

개요

오늘은 Next JS에서 useSuspenseQuery를 이용하는 방법을 적어볼 것이다.

준비

package.json

"dependencies": {
  "@tanstack/react-query": "^5.29.0",
  "next": "14.1.4",
  "react": "^18",
  "react-dom": "^18",
  "react-error-boundary": "^4.0.13"
}

다음과 같은 라이브러리를 사용한다.

SSR과 데이터 페칭

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
너무 오래 걸린다면 서버가 렌더링을 포기한다.

react-query가 필요한 이유

가정: 만약 지속적으로 데이터 갱신이 필요하다면?

만약 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를 사용한다면 클라이언트 사이드 렌더링을 적절하게 결합해 서버 컴포넌트와 유사하게 사용 가능하다.

react-query

스트리밍 설정하기

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를 지정해 주었다. useSuspenseQueryqueryFn의 비동기 작업이 성공하기 전에는 렌더링을 지연시키므로 data의 타입은 항상 주어진 타입이다.
주기적인 재검증을 위해 refetchInterval은 10초로 넣어주었다.

관찰


이제 새로고침을 해보자. 이전에 ContentSuspense로 감싸주었기에 초기에는 로딩 문구가 보이고 이후 데이터가 렌더링될 것이다.
흥미로운 점은 data fetch 로그는 서버에서 처음 발생한다. 서버 컴포넌트와 유사하게 첫 데이터는 서버에서 스트리밍 받아오는 것이다. (정확하게는 첫 데이터로 쿼리 캐시를 채운다.)
대신 RSC 페이로드도 같이 보내지는 것이 아닌 클라이언트 js 코드를 통해 렌더링된다. 데이터 리페칭 이후에는 마찬가지로 클라이언트 js 코드에 의해 렌더링 된다.

서버 컴포넌트처럼 Suspense 없이!

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가지 경우가 존재한다.

  • 서버에서 초기 렌더링 시 에러가 발생한다면, 클라이언트에서 다시 재시도 한다. 클라이언트에서도 실패 한다면 에러가 throw되고 렌더가 깨진다.
  • 서버 초기 렌더링은 성공적으로 일어나고, 클라이언트에서 재시도 중 에러가 발생한다면, 쿼리 캐시의 내용을 유지한다.

서버에서 발생하는 에러에 대응하기 위해서는 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에서는 useSuspenseQuerySuspense로 감싸면 데이터 가져오기를 기다리지 않는다. 감싸지 않는다면 기다린다.
  • useSuspenseQuery를 사용한다면 ErrorBoundaryQueryErrorResetBoundary를 사용해서 에러를 처리해주자.
  • 일반 리액트에서는 useSuspenseQuerySuspense로 감싸면 코드가 보기 좋아진다 ^ ^

예제 코드는 https://github.com/bluejoyq/react-examples/tree/master/next-use-supense-query 에서 찾을 수 있습니다~

profile
개발자 지망생입니다.

0개의 댓글