2024.06.19 - [Next.js] - Next.js에서 Data Fetching & Caching 알아보기

이전 글에서는 Next.js에서 data fetching과 caching에 대해서 다뤘습니다.


이번 글에서는 Next.js 에서 데이터 페칭 시 고민한 문제들에 대해서 이야기해 보겠습니다.

App Router를 사용하면서 기존에 사용하던 tanstack-query를 제거하고 오직 fetch 만을 사용하여 데이터 캐시 관리를 했습니다.

  1. fetch의 기능이 강력하므로 tanstack-query가 꼭 필요하지 않았습니다.
  2. tanstack-query 공식 문서에서 아래의 권장사항을 발견했습니다.

It's hard to give general advice on when it makes sense to pair React Query with Server Components and not. If you are just starting out with a new Server Components app, we suggest you start out with any tools for data fetching your framework provides you with and avoid bringing in React Query until you actually need it. This might be never, and that's fine, use the right tool for the job!

🤔 위와 같은 이유로 라이브러리 도움 없이 fetch를 사용하다가 다음과 같은 고민이 생겼습니다.

 


1. API 호출 비용을 최대한 줄이면서 유저에게 빠르게 데이터를 보여줄 수 있는 방법이 있을까?

  • 이전 글에서 언급한 대로, 개인화된 요청은 원격 서버에 캐싱되어서는 안 됩니다. 따라서 fetch를 사용할 때 no-store 옵션을 설정합니다. 이 경우, 새로고침 및 라우트 캐시가 만료될 때마다 API 호출이 발생하므로 client-side에서 tanstack-query를 사용하여 브라우저 메모리에 개인화된 요청에 대한 응답을 캐시하고 queryKey와 staleTime으로 캐시를 관리합니다.
  • 개인화되지 않은 요청은 server-side에서 force-cache 옵션을 사용하여 API를 호출하고, 적절한 방법을 통해 갱신합니다.
  개인화 되지 않은 요청  개인화된 요청
server-side fetch x
client-side x tanstack-query

1번 고민에 대한 결과를 정리하면, 개인화된 요청은 검색 엔진에 노출될 필요가 없으므로 client-side에서 tanstack-query로 요청 후 캐시를 관리합니다.

그 반대의 경우 server-side에서 fetch 하면 첫 번째 요청 시 데이터가 원격 서버에 캐싱되며 Suspense의 fallback을 이용하여 유저에게 데이터를 가져오는 중임을 알립니다. 두 가지 방법을 적절히 섞어 사용하면 API 호출 비용을 줄이고, 유저에게 빠르게 데이터를 보여줄 수 있습니다.

이때, 2번의 고민이 발생합니다.


2. server-side에서 fetching 한 데이터를 사용하는 곳까지 props drilling 하는 것을 최소화할 수 있는 방법이 있을까?

props drilling 방식으로 데이터를 전달하면 상위 컴포넌트가 변경되면 하위 컴포넌트까지 변경해야 하는 문제가 있습니다. 이는 유지보수 관점에서 좋지 않습니다.

 

2-1. tanstack-query를 사용하여 prefetch 및 de/hydrating 하기

개발환경

(next14.2.2, react18^, typescript^5.1.3, tanstack-query^5.40.0)

초기 설정

import { Query, defaultShouldDehydrateQuery } from "@tanstack/react-query";

const queryClientOptions = {
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 1,
      refetchOnMount: false,
      refetchOnWindowFocus: false,
    },
    dehydrate: {
      // per default, only successful Queries are included,
      // this includes pending Queries as well
      shouldDehydrateQuery: (query: Query) =>
        defaultShouldDehydrateQuery(query) || query.state.status === "pending",
    },
  },
};

export default queryClientOptions;

import { QueryClient } from "@tanstack/react-query";
import queryClientOptions from "@/configs/tanstack-query/query-client-options";

function makeQueryClient() {
  return new QueryClient(queryClientOptions);
}

let browserQueryClient: QueryClient | undefined = undefined;
export default function getQueryClient() {
  if (typeof window === "undefined") {
    // Server: always make a new query client
    return makeQueryClient();
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}

"use client";

import { PropsWithChildren } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import getQueryClient from "@/configs/tanstack-query/get-query-client";

function AppProvider({ children }: PropsWithChildren) {
  const queryClient = getQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

export default AppProvider;

Prefetch + de/hydrating data

서버에서는 마크업을 생성/렌더링 하기 전에 data를 prefetch 하고, 해당 데이터를 마크업에 포함할 수 있는 직렬화 가능한 형식으로 de/hydration 하며, 클라이언트에서는 해당 데이터를 React 쿼리 캐시로 hydration 합니다.

PhotoPrefetchQuery 클래스를 생성하고, QueryClient 인스턴스를 받습니다.

import { QueryClient } from "@tanstack/react-query";
import {
  RequestFnReturn,
  QueryHookParams,
} from "../@types/tanstack-query-type";
import { todoApi } from "./Todo.api";
import { QUERY_KEY_TODO_API } from "./Todo.query";

export class TodoPrefetchQuery {
  private queryClient: QueryClient;

  constructor(queryClient: QueryClient) {
    this.queryClient = queryClient;
  }

  /**
   * No description
   *
   * @tags todos
   * @name TodosList
   * @summary Todos 목록 조회
   * @request GET:/todos
   * @secure */

  public useTodoListPrefetchQuery = <
    TData = RequestFnReturn<typeof todoApi.todoList>
  >(
    params?: QueryHookParams<typeof todoApi.todoList, TData>
  ) => {
    const queryKey = QUERY_KEY_TODO_API.LIST(params?.variables);
    return this.queryClient.prefetchQuery({
      queryKey,
      queryFn: () => todoApi.todoList(params?.variables),
      ...params?.options,
    });
  };

  /**
   * No description
   *
   * @tags todos
   * @name TodosRetrieve
   * @summary Todos 상세 조회
   * @request GET:/todos/{id}
   * @secure */

  public useTodoRetrievePrefetchQuery = <
    TData = RequestFnReturn<typeof todoApi.todoRetrieve>
  >(
    params: QueryHookParams<typeof todoApi.todoRetrieve, TData>
  ) => {
    const queryKey = QUERY_KEY_TODO_API.RETRIEVE(params.variables);
    return this.queryClient.prefetchQuery({
      queryKey,
      queryFn: () => todoApi.todoRetrieve(params.variables),
      ...params?.options,
    });
  };
}

"use server";

import { TodoPrefetchQuery } from "@/apis/Todo/Todo.prefetchQuery";
import {
  HydrationBoundary,
  QueryClient,
  dehydrate,
} from "@tanstack/react-query";
import QueryTodoList from "@/components/Todo/QueryTodoList";

export default async function HydratedTodoList() {
	// Next.js는 이미 fetch()를 활용하는 요청을 중복 제거하므로 data를 fetch 하는 각 서버 컴포넌트에
	// 새로운 queryClient를 만듭니다.
  const queryClient = new QueryClient();
  const todoPrefetchQuery = new TodoPrefetchQuery(queryClient);
  todoPrefetchQuery.useTodoListPrefetchQuery({
    variables: {
      params: {
        cache: "force-cache",
      },
    },
  });

  return (
    // Neat! Serialization is now as easy as passing props.
    // HydrationBoundary is a Client Component, so hydration will happen there.
    <HydrationBoundary state={dehydrate(queryClient)}>
      <QueryTodoList isSuspense />
    </HydrationBoundary>
  );
}

"use client";

import { useTodoListQuery } from "@/apis/Todo/Todo.query";
interface QueryTodoListProps {
  isSuspense?: boolean;
}
const QueryTodoList = ({ isSuspense }: QueryTodoListProps) => {	
  // This useQuery could just as well happen in some deeper
  // child to <TodoList>, data will be available immediately either way
  const { data: todos } = useTodoListQuery({
    options: {
      staleTime: 1000 * 5,
      suspense: isSuspense,
    },
  });

  return (
    <div className="flex flex-col gap-2">
      {todos?.map(({ userId, title, completed }) => (
        <div
          key={`${userId}_${title}`}
          className="flex flex-col h-36 rounded border border-gray-400 justify-center p-5"
        >
          <p>userId: {userId}</p>
          <p>title: {title}</p>
          <p>completed: {String(completed)}</p>
        </div>
      ))}
    </div>
  );
};
import ListSkeleton from "@/components/ListSkeleton";
import ParentA from "app/dehydrate-with-streaming/_source/components/ParentA";
import HydratedPhotoList from "app/dehydrate-with-streaming/_source/components/HydratedPhotoList";
import Link from "next/link";
import { Suspense } from "react";

export default async function DehydrateWithStreamingPage() {
  return (
    <div className="flex flex-col items-center p-6">
      <p className="text-5xl font-bold mb-6">dehydrate with streaming</p>
      <Link href="/" className="text-blue-500 mb-8 hover:underline">
        Go to Home
      </Link>
      <div className="flex w-full max-w-screen-xl gap-10 justify-center flex-wrap">
        <div className="flex flex-col items-center w-full max-w-md">
          <p className="text-2xl font-medium mb-4">Todo List</p>
          <Suspense fallback={<ListSkeleton />}>
            <ParentA />
          </Suspense>
        </div>
        <div className="flex flex-col items-center w-full max-w-md">
          <p className="text-2xl font-medium mb-4">Photo List</p>
          <Suspense fallback={<ListSkeleton />}>
            <HydratedPhotoList />
          </Suspense>
        </div>
      </div>
    </div>
  );
}

"use server";
import ParentB from "./ParentB";

export default function ParentA() {
  return <ParentB />;
}

///////////////////////////////////////////////////////////
"use server";
import ParentC from "./ParentC";

export default function ParentB() {
  return <ParentC />;
}

///////////////////////////////////////////////////////////
"use server";
import HydratedTodoList from "./Todo/hydratedTodoList";

export default function ParentC() {
  return <HydratedTodoList />;
}

ParentC 컴포넌트(사용하는 곳과 가까운 위치)에서 prefetch 및 de/hydrate를 수행하여 props drilling을 피하고 코드를 더 깔끔하게 유지할 수 있습니다.

하지만 이 방법은 보일러 플레이트 코드가 길어지고, fetch와 tanstack-query 두 곳에서 캐시 관리를 해줘야 하는 단점이 있습니다.

그렇다면 좀 더 좋은 방법이 있을까요?

 

2-2. 서버와 클라이언트 컴포넌트 인터리빙 하기

이 접근 방식을 사용하면 <ClientComponent>및 <ServerComponent>가 분리되어 독립적으로 렌더링 될 수 있습니다.

이 경우 children은 클라이언트에서 렌더링 되기 훨씬 전에 서버에서 렌더링 될 수 있습니다.

import { Suspense } from "react";
import { revalidateTag } from "next/cache";
import Link from "next/link";

import ClientComponent from "app/fetch-with-streaming/_source/components/ClientComponent";
import ListSkeleton from "@/components/ListSkeleton";
import FetchPhotoList from "@/components/Photo/FetchPhotoList";
import PromiseResolveHelper from "@/components/PromiseResolveHelper";
import RevalidateButton from "app/fetch-with-streaming/_source/components/RevalidateButton";
import FetchTodoList from "@/components/Todo/FetchTodoList";
import { todoApi } from "@/apis/Todo/Todo.api";
import { PhotoType } from "@/apis/Todo/types/model/photo";
import { photoApi } from "@/apis/Photo/Photo.api";

export default async function FetchWithStreamingPage() {
  async function revalidateTodo() {
    "use server";
    revalidateTag("TODO_LIST");
  }

  return (
    <div className="flex flex-col items-center p-6">
      <p className="text-5xl font-bold mb-6">fetch with streaming</p>
      <Link href="/" className="text-blue-500 mb-6">
        go to example list
      </Link>
      <ClientComponent>
        <div className="flex w-full max-w-screen-xl gap-10 justify-center flex-wrap">
          <div className="flex flex-col items-center w-full max-w-md">
            <div className="flex flex-col items-center gap-4 w-full mb-4">
              <p className="text-2xl font-medium">Todo List</p>
              <div className="flex gap-2">
                <RevalidateButton revalidate={revalidateTodo} />
              </div>
            </div>
            <Suspense fallback={<ListSkeleton />}>
              <FetchTodoList
                todosPromise={todoApi.todoList({
                  params: {
                    cache: "force-cache",
                  },
                })}
              />
            </Suspense>
          </div>
          <div className="flex flex-col items-center w-full max-w-md">
            <p className="text-2xl font-medium mb-4">Photo List</p>
            <Suspense fallback={<ListSkeleton />}>
              <FetchPhotoList />
            </Suspense>
          </div>
        </div>
      </ClientComponent>
    </div>
  );
}
"use client";

import { Fragment, PropsWithChildren, useState } from "react";

export default function ClientComponent({ children }: PropsWithChildren) {
  const [count, setCount] = useState(0);
  return (
    <Fragment>
      <p>{count}</p>
      <button
        className="bg-blue-500 text-white px-4 py-2 rounded"
        onClick={() => setCount((prev) => prev + 1)}
      >
        up
      </button>
      {children}
    </Fragment>
  );
}

use를 사용하여 클라이언트 컴포넌트에서 프로미스 해제하기

컴포넌트 내부에서 아래 항목을 사용하면 클라이언트 컴포넌트로 전환되어 use를 사용하여 프로미스를 해제합니다.

  • Event listeners
  • State and Lifecycle Effects
  • Use of browser-only APIs
  • Custom hooks that depend on state, effects, or browser-only APIs
  • React Class components
"use client";

import { TodoType } from "@/apis/Photo/types/model/todo";
import { use } from "react";

interface FetchTodoListProps {
  todosPromise: Promise<TodoType[]>;
}

const FetchTodoList = ({ todosPromise }: FetchTodoListProps) => {
  // Streaming data from the server to the client
  const todos = use(todosPromise);
  return (
    <div className="flex flex-col gap-2">
      {todos?.map(({ userId, title, completed }) => (
        <div
          key={`${userId}_${title}`}
          className="flex flex-col h-36 rounded border border-gray-400 justify-center p-5"
        >
          <p>userId: {userId}</p>
          <p>title: {title}</p>
          <p>completed: {String(completed)}</p>
        </div>
      ))}
    </div>
  );
};

export default FetchTodoList;

서버 컴포넌트에서 프로미스 해제하기

서버 컴포넌트를 사용할 수 있는 제약 조건에 위배되지 않는 경우 서버에서 프로미스를 해제합니다. 해당 코드는 자바스크립트 번들에 포함되지 않습니다.

import { PhotoType } from "@/apis/Todo/types/model/photo";
import Image from "next/image";

/**
 * @description
 * Restricted Items in Server Component
 *
 * - Event listeners
 * - State and Lifecycle Effects
 * - Use of browser-only APIs
 * - Custom hooks that depend on state, effects, or browser-only APIs
 * - React Class components
 */
export default async function FetchPhotoList() {
  const photos = await photoApi.photoList();
  return (
    <div className="flex flex-col gap-2">
      {photos?.map(({ id, albumId, title, url }) => (
        <div
          key={id}
          className="flex flex-row h-36 border border-gray-400 rounded justify-between p-5 items-center gap-4"
        >
          <div className="flex flex-col justify-between flex-1 h-full">
            <p>albumId: {albumId}</p>
            <p>title: {title}</p>
          </div>
          <div className="relative w-16 h-16">
            <Image src={url} alt="image" fill sizes="60px" />
          </div>
        </div>
      ))}
    </div>
  );
}

결론

여러 가지 시행착오를 겪은 후 fetch와 tanstack-query를 Win-Win 하면서 사용하는 방법은 아래와 같습니다.

  • Case 1 - Authorization을 포함한 GET 요청
    • fetch cache option no-store + tanstack-query staleTime 설정 및 Client-Side에서 useQuery 사용을 권장합니다.
  • Case 2 - Authorization을 포함하지 않는 GET 요청
    • fetch를 독립적으로 사용하는 것을 권장합니다.
  • Case 3 - mutate 요청
    • fetch cache option no-store + Client-Side에서 useMutation 사용을 권장합니다.
  • Case 4 - props drilling 깊은 컴포넌트
    • 서버와 클라이언트 컴포넌트의 InterLeaving을 권장합니다.
    • tanstack-query Dehydrate
      • 해당 방식은 사용 가능하지만, 보일러 플레이트 코드가 길어지고, 별도의 캐시 관리가 필요하므로 권장하지 않습니다.
  • 다만 Case 1Case 3의 경우 상황(ex. 다국적 서비스)에 따라서 모두 fetch 만을 사용할 수도 있을 것 같습니다.
    만약 백엔드와 프런트엔드 서버가 서울이고 엔드 유저가 해외에 있거나 네트워크 환경이 좋지 않다면 latency가 발생합니다.
    이 경우 fetch나 server action을 사용하면 내 서버에 요청하기 때문에 latency가 줄어듭니다.

이와 같이, 상황에 맞게 적절한 전략을 선택할 수 있을 것 같습니다.

 

좀 더 자세한 코드는 아래에서 확인하실 수 있습니다. 

https://github.com/Eunkyung-Son/fetch-with-tanstack-query-practice

+ Recent posts