Next.js 14에서 React-query 사용하기

GY·2023년 12월 10일
10

Next.js에서 React-query를 사용하려면 React 프로젝트와는 조금 다르게 환경을 세팅해야 합니다. 13버전 이후부터는 RSC가 도입되어 가장 상위 모듈인 layout에서 QueryProvider로 QueryClient를 props로 내려줄 수 없는 것이 가장 큰 이유인데요,

사용하기에 앞서 Next.js에서 react-query는 필요한가?에 대해 생각해보게 됩니다.

아래는 You Might Not Need React Query라는 글인데, 이 글에서는 react-query는 next.js에서 필요하지 않을 수 있으나, 과도기를 거쳐가는 현재 시점에서 통합을 위한 하나의 솔루션은 될 수 있다고 이야기합니다.

https://tkdodo.eu/blog/you-might-not-need-react-query

리액트 쿼리를 사용하는 이유 중 하나가 바로 캐싱 기능이었는데, Next.js에서는 cache 옵션을 사용해 렌더링 방식을 적용하도록 하고 잇기 때문에 어느 정도 대체할 수 있는 부분도 있는 듯합니다.

정리하면 단순한 data fetch 및 캐싱 기능을 사용하는 목적으로는 굳이 react-query가 필요하지 않을 듯합니다. 다만 무한스크롤이나 로딩/에러 상태에 대한 선언적인 처리 등 조금 더 UX를 고려한 구현에 있어서는 여전히 매력적인 선택지가 될 수 있을거라 나름대로 결론을 내렸습니다.

어떻게 사용할 수 있는지에 대해 정리해보겠습니다.

사용방법

react query를 app router환경에서 사용하기 위해서는 크게 2가지 방법을 사용할 수 있습니다.

권장하는 접근 방법: Client Stream Hydration 사용하기 (react-query-next-experimental)

이전까지는 Next.js 13의 app directory에서 react query를 사용하기 위한 권장 가이드라인이 별도로 없었습니다.
따라서 리액트 쿼리 커뮤니티 내에서 직접 hydration을 하거나 서버 컴포넌트로부터 클라이언트 컴포넌트로 초기 데이터를 전달해주는 방법을 사용했는데요,
하지만 이후 react query 팀에서 최근 실험 단계의 패키지를 내놓았습니다.

이 패키지를 사용하면 초기 data fetch 요청 시 서버에서 fetch되는데, 달리 말하면 useQuery훅을 통해 발생한 api 요청은 서버에서 초기화가 됩니다. 데이터가 불러와지면 자동으로 클라이언트에서 QueryClient에서 사용할 수 있도록 처리합니다.

이 방법을 사용해볼게요.

설치

먼저 해당 패키지를 설치해줍니다.

# For yarn
yarn add @tanstack/react-query-next-experimental

# For PNPM
pnpm add @tanstack/react-query-next-experimental

# For NPM
npm i @tanstack/react-query-next-experimental

Query Client Provider 선언

이 패키지를 사용하기 위해서는 Next.js 애플리케이션의 엔트리 포인트 지점을 ReactQueryStreamedHydrationQueryClientProvider 컴포넌트로 감싸주어야 합니다.
하지만 app directory에 속하는 모든 하위 컴포넌트들은 기본적으로 서버 컴포넌트로 동작하기 때문에, 이렇게 할 때 에러가 발생할 수 있습니다. QueryClientProvider는 클라이언트에서 초기화되어야 하기 때문이죠.

따라서 클라이언트 사이드에서 동작하는 provider컴포넌트를 별도로 만들어 초기화해주어야 합니다. 그 다음 해당 provider컴포넌트가 하위 컴포넌트를 감싸도록 해 하위 컴포넌트에 원하는 props를 전달해주도록 하면 되겠죠.

아래는 provider 컴포넌트의 예시입니다.

"use client";

import React from "react";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";

function Providers({ children }: React.PropsWithChildren) {
  const [client] = React.useState(new QueryClient());

  return (
    <QueryClientProvider client={client}>
      <ReactQueryStreamedHydration>{children}</ReactQueryStreamedHydration>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

export default Providers;

ReactQueryStreamedHydration?

리액트 기반 프로젝트에서와 달리 처음보는 코드가 보이는데요, 앞서 언급했던 ReactQueryStreamedHydration입니다. 이 컴포넌트는 서버로부터 클라이언트로 데이터가 스트리밍될 수 있도록 하는 역할을 합니다.

만들어준 provider컴포넌트를 최상위 layout 파일에 넣어줍니다.

import Providers from "@/utils/provider";
import React from "react";
// import "./globals.css";

export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

이제 Next.js에서 useQuery를 사용할 수 있습니다!
단, suspense:trueoption을 설정해주어야 합니다.

"use client";

import { User } from "../types";
import { useQuery } from "@tanstack/react-query";
import React from "react";

async function getUsers() {
  return (await fetch("https://jsonplaceholder.typicode.com/users").then(
    (res) => res.json()
  )) as User[];
}

export default function ListUsers() {
  const [count, setCount] = React.useState(0);
  const { data } = useQuery<User[]>({
    queryKey: ["stream-hydrate-users"],
    queryFn: () => getUsers(),
    suspense: true,
    staleTime: 5 * 1000,
  });

  React.useEffect(() => {
    const intervalId = setInterval(() => {
      setCount((prev) => prev + 1);
    }, 100);

    return () => {
      clearInterval(intervalId);
    };
  }, []);

  return (
    <>
      <p>{count}</p>
      {
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "1fr 1fr 1fr 1fr",
            gap: 20,
          }}
        >
          {data?.map((user) => (
            <div
              key={user.id}
              style={{ border: "1px solid #ccc", textAlign: "center" }}
            >
              <img
                src={`https://robohash.org/${user.id}?set=set2&size=180x180`}
                alt={user.name}
                style={{ width: 180, height: 180 }}
              />
              <h3>{user.name}</h3>
            </div>
          ))}
        </div>
      }
    </>
  );
}

suspense:true을 추가해주었기 때문에 컴포넌트로 선언한 컴포넌트를 감싸 렌더링해줍니다.
이렇게 해서 서버로부터 클라이언트로의 데이터 스트리밍이 가능하도록 합니다.

import Counter from "./counter";
import ListUsers from "./list-users";
import { Suspense } from "react";

export default async function Page() {
  return (
    <main style={{ maxWidth: 1200, marginInline: "auto", padding: 20 }}>
      <Counter />
      <Suspense
        fallback={
          <p style={{ textAlign: "center" }}>loading... on initial request</p>
        }
      >
        <ListUsers />
      </Suspense>
    </main>
  );
}

단점: SEO 측면에서 불리

Suspense boundary를 사용해 서버로부터 클라이언트로의 데이터 스트리밍을 사용했기 때문에, 개발자도구에서 확인했을 때 로딩중인 컴포넌트가 fallback 함수를 사용하는 것을 볼 수 있습니다. 미리 완성된 html 영역을 렌더링하는게 아니기 때문에 SEO 측면에서 불리하다는 것이 단점입니다.

이러한 이슈는 해당 패키지의 이후 릴리즈에서 다루어질 계획인 듯 한데, 아무래도 아직 실험 단계이다 보니 완전하지 않은 것이겠죠. 그러나 Next.js를 사용하는 가장 큰 이유 중 하나인 SEO를 포기하기엔 득보다 실이 큰 느낌입니다. ㅠ

이 경우 이전까지 리액트 쿼리 커뮤니티에서 논의하여 주로 사용해왔던 다른 방법들을 사용할 수도 있습니다.

이전 방법

크게 2가지 방법이 있습니다.

  1. data를 직접 prefetch해 initialData에 전달

사용법이 더 간단하지만, 클라이언트 사이드에서 해당 데이터를 사용하는 컴포넌트까지 props로 넘겨주어야 하는 비효율적인 작업이 동반된다는 단점이 있습니다.

  1. 서버에서 query를 fetch > cache를 dehydrate > client에서 rehydrate
  • 비효율적인 작업은 없지만 프론트에서 셋업해야 할 것들이 있습니다.

다음 글에서는 이 방법에 대해 알아보겠습니다.

Reference

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글