Next.js 환경에서 Cache 적극적으로 활용하기

팀에서 진행한 프로젝트에선 RSC를 활용한 SSR 페이지와 동적으로 데이터 필요한 CSR 페이지를 통하여 캐싱 및 렌더링의 최적화를 구현하였습니다. 이 과정에서 발생한 데이터 불일치 문제를 중심으로 캐싱전략을 어떻게 최적화 할 수 있을지에 대하여 말하고자 합니다.

Next.js Caching

문제 상황


제품의 메인 페이지와 상세 페이지가 동일한 데이터 베이스를 바라 보는데도 서로 다른 데이터를 표시하였습니다.

이는 메인 페이지에서는 useInfiniteQuery를 사용하여 클라이언트 사이드에서 데이터를 가져오고, 상세 페이지에서는 RSC를 사용하여 서버 사이드에서 데이터를 가져오는 환경에서 문제가 발생하였습니다.

기술적 배경

React-Query에서 제공하는 훅에서는 staleTime을 통하여 얼마나 캐싱을 할 지에 대한 설정을 할 수 있습니다. 하지만 본 프로젝트에선 staleTime은 기본값인 0으로 되어있어 항상 새로운 값을 불러옵니다.

그에 반해 RSC는 데이터를 캐싱하여 성능을 최적화합니다. 물론 revalidate를 활용하여 캐싱의 유효 시간을 설정할 수 있으나 매번 새로운 데이터를 바라봐야 한다는 점에서 다른 접근을 필요로 했습니다.


해결 방안

앞서 포스팅한 글에서 만든 customFetch에서 headers의 Cache-Control을 no-cache로 설정하여 캐싱을 해야할 지에 대해서 서버에게 데이터의 최신 여부를 확인하도록 하였습니다.

const headers: HeadersInit = {
  Accept: "application/json",
  "Content-Type": "application/json",
  "Cache-Control": "no-cache",
  ...(token && { Authorization: `Bearer ${token}` }),
};

물론 이 과정을 거친다고 하여 캐싱을 하지 않아서 문제가 발생하지 않는 것은 아닙니다. 하지만 no-cache를 통하여 데이터 정합성을 맞추는데 도움을 줄 수 있다고 판단하였습니다. 또한 fetch에서 revalidate requestOption을 추가하는 경우 force-cache를 통하여 강제 캐싱을 하도록 유도하였습니다.

const requestOptions: RequestInit = {
  method,
  headers,
  cache: revalidate ? "force-cache" : "no-cache",
  ...(revalidate ? { next: { revalidate } } : {}),
  ...(tags ? { next: { tags } } : {}),
  ...(body && typeof body === "object" ? { body: JSON.stringify(body) } : {}),
};

하지만 제가 마주한 상황은 상세 페이지(RSC)에서 제품 구매 페이지로 이동하여 구매한 후 다시 돌아왔을 때 데이터가 맞지 않는 현상이 여전히 발생하였습니다.

이 문제를 해결하기 위하여 force-dynamic을 사용하여 매번 서버에서 새로운 데이터를 가져오도록 하였습니다.

export const dynamic = "force-dynamic";

이를 통해 문제가 해결될 것이라 생각하였으나 여전히 데이터가 맞지 않는 현상이 발생하였습니다. 이는 Next.js router을 사용하는데 있어서 발생한 문제입니다.

Next.js의 router는 페이지 간의 네비게이션을 최적화하기 위해 내부적으로 캐싱을 사용합니다. 다른 페이지에서 이전 페이지로 다시 돌아올 때 캐시된 데이터가 표시되는 문제가 발생할 수 있습니다.

다시 말해 RSC 페이지에서는 force-dynamic을 활용하여 데이터를 가져오고 있지만 그 전에 router가 됐을 때 cache를 먼저 활용했기 때문에 fetching을 새로 하지 않는 문제였습니다.

이러한 문제를 해결하기 위하여 제품을 구매하였을 때 router.refresh()를 호출하여 라우터의 캐시를 초기화 하였습니다.

onSuccess: () => {
    handleCloseModal();
    showPopMessageForDuration('purchase');
    setFromPurchase(true);
    router.push('/');
    router.refresh();
},

이 문제는 현재 Next.js 15에서 해결된다고 합니다.

이처럼 저는 문제를 해결하였습니다. 그러면 어떤 상황에서 Next.js가 제공하는 cache를 적극적으로 활용할 수 있을까요?

App Router에서는 getStaticProps를 사용하지 않고 ISR을 위해 revalidate 옵션을 사용할 수 있습니다.

동적 경로를 미리 생성해야 하는 경우 generateStaticParams를 사용하여 각 경로를 사전 렌더링할 수 있습니다.

이는 SSR과 CSR이 혼합된 환경에서 최적화를 위해 유용하게 활용됩니다.

단일 페이지에서 데이터를 갱신하는 경우

export const revalidate = 3600;
 
export default async function Page() {
  const data = await fetch("https://api.vercel.app/blog");
  const posts = await data.json();
  return (
    <main>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
}

동적 경로를 생성하고 해당 경로별로 페이지를 미리 생성하는 경우

interface Post {
  id: string;
  title: string;
  content: string;
}
 
export const revalidate = 60;
 
export const dynamicParams = false; // dynamicParams를 false로 설정하면 미리 정의된 정적 경로만 생성되며, 이외의 동적 경로에 접근하려 하면 404 에러가 발생합니다.
 
export async function generateStaticParams() {
  const posts: Post[] = await fetch("https://api.vercel.app/blog").then((res) =>
    res.json()
  );
  return posts.map((post) => ({
    id: String(post.id),
  }));
}
 
export default async function Page({ params }: { params: { id: string } }) {
  const post = await fetch(`https://api.vercel.app/blog/${params.id}`).then(
    (res) => res.json()
  );
  return (
    <main>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </main>
  );
}