Next.js (App Router)에서 데이터 페칭과 캐싱

1. 개요

Next.js App Router는 backend 부하를 줄이고 페이지 속도를 높이기 위해 서버에서 fetch 결과를 자동으로 캐싱합니다. 하지만 이 캐싱 메커니즘은 당신이 익숙한 일반적인 HTTP 캐싱과는 완전히 다르며, 명확하게 이해하지 않으면 “데이터가 업데이트되지 않는” 상황이나 반대로 “모든 페이지가 처음부터 다시 렌더링되어 엄청 느린” 상황을 쉽게 만날 수 있습니다.

이 글에서는 Next.js의 캐싱 레이어를 살펴봅니다: fetch 캐싱, 시간 기반 revalidate, 태그 기반 revalidate, 그리고 dynamic rendering. 읽고 나면 각 페이지 타입(static, ISR, dynamic)에 맞는 fetch 전략을 선택하고, “고집 센” 캐시를 디버깅할 수 있게 됩니다.

참고: 이 글은 App Router(Next.js 13+, 14/15 권장)에 적용됩니다. Pages Router는 다른 메커니즘(getStaticProps, getServerSideProps)을 사용하며 이 글의 범위를 벗어납니다.

2. Next.js의 네 가지 캐싱 레이어

Next.js는 4가지 서로 다른 캐싱 메커니즘을 가지고 있으며, 이들을 합쳐서 생각하면 쉽게 혼동될 수 있습니다:

메커니즘 위치 목적
Request Memoization 서버, 1회 렌더링 중 같은 요청 내에서 동일한 fetch 호출 통합
Data Cache 서버, 요청 간 fetch 결과를 장기간 캐싱, revalidate 가능
Full Route Cache 서버, 빌드 시간 정적 route의 HTML + RSC payload 캐싱
Router Cache 클라이언트, 세션 중 뒤로가기/앞으로가기를 빠르게 하기 위해 네비게이션 결과 캐싱

이 글은 Data Cache에 집중합니다. 왜냐하면 이것이 fetch의 옵션을 통해 직접 제어할 수 있는 것이기 때문입니다.

3. 기본 fetch: “무한” 캐싱

Server Component에서 fetch는 Next.js에 의해 수정되어 기본적으로 자동으로 캐싱됩니다:

// app/products/page.tsx
async function getProducts() {
  const res = await fetch("https://api.shop.example.com/products");
  if (!res.ok) throw new Error("상품 API를 호출할 수 없습니다");
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();
  return (
    <ul>
      {products.map((p: { id: string; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}
  • fetch에 cache를 전달하지 않으면 → Next.js는 기본적으로 force-cache를 사용합니다.
  • 결과는 Data Cache에 저장되고 이후의 모든 요청에서 공유되며, revalidate하지 않는 한 만료되지 않습니다.
  • 이 페이지는 Full Route Cache에 포함됩니다: 정적 HTML로 빌드되어 정적 페이지처럼 제공됩니다.

이것이 많은 사람들이 놀라는 이유입니다: backend의 데이터를 수정했는데 페이지가 변하지 않는 것입니다 — Next.js가 이것을 정적 데이터로 간주하기 때문입니다.

4. cache 옵션으로 캐싱 제어하기

항상 최신 데이터를 가져오려면 no-store를 사용하세요:

async function getOrderStatus(orderId: string) {
  const res = await fetch(
    `https://api.shop.example.com/orders/${orderId}`,
    { cache: "no-store" }
  );
  return res.json();
}
  • cache: "no-store"는 이 요청에 대해 Data Cache를 완전히 비활성화합니다.
  • 이 fetch를 포함하는 route는 dynamic으로 표시되며 매번 요청마다 다시 렌더링됩니다.
  • 지속적으로 변하는 데이터에 적합합니다: 진행 중인 주문, 실시간 가격, 대시보드.

참고: 페이지 내의 하나의 fetch만 no-store를 사용해도 전체 페이지가 dynamic rendering으로 전환됩니다 — 같은 페이지의 다른 fetch들은 여전히 force-cache를 사용하더라도 말입니다.

5. 시간 기반 Revalidate (ISR)

캐시를 유지하되 N초 후에 자동으로 새로 고치려면(Incremental Static Regeneration) next.revalidate를 사용하세요:

async function getCategories() {
  const res = await fetch(
    "https://api.shop.example.com/categories",
    { next: { revalidate: 300 } } // 5분
  );
  return res.json();
}
  • revalidate: 300은 데이터가 300초 후 “오래됨”으로 간주됨을 의미합니다.
  • 300초 이후의 첫 요청은 여전히 이전 데이터(stale-while-revalidate)를 반환하지만 Next.js는 백그라운드에서 새로운 fetch를 트리거합니다.
  • 그 다음 요청이 새로운 데이터를 받습니다.

파일 맨 위에 변수를 export하여 전체 route에 적용할 수 있습니다:

// app/blog/[slug]/page.tsx
export const revalidate = 300;

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const res = await fetch(`https://api.blog.example.com/posts/${params.slug}`);
  return <article>{/* ... */}</article>;
}
  • export const revalidate는 fetch가 자신만의 revalidate를 선언하지 않는 한 route의 모든 fetch에 적용됩니다.
  • 특정 fetch가 다른 next.revalidate를 선언하면 그 값이 해당 fetch에 우선합니다.

6. 태그 기반 Revalidate: revalidateTag

시간 기반 revalidate는 주기적으로 변경되는 콘텐츠에는 좋지만, 데이터가 변경되는 바로 그 순간에 새로 고치고 싶을 때는 적합하지 않습니다(예: 관리자가 상품 가격을 방금 업데이트한 경우). 이 경우 캐시 태그를 사용하세요.

// app/products/[id]/page.tsx
async function getProduct(id: string) {
  const res = await fetch(
    `https://api.shop.example.com/products/${id}`,
    { next: { tags: [`product-${id}`] } }
  );
  return res.json();
}

관리자가 상품을 업데이트할 때 Route Handler나 Server Action에서 revalidateTag를 호출하세요:

// app/actions/update-product.ts
"use server";

import { revalidateTag } from "next/cache";

export async function updateProduct(id: string, formData: FormData) {
  await fetch(`https://api.shop.example.com/products/${id}`, {
    method: "PATCH",
    body: formData,
  });

  revalidateTag(`product-${id}`); // 이 태그가 붙은 캐시 삭제
}
  • tags는 fetch의 캐시 항목에 “레이블”을 붙입니다.
  • revalidateTag(tag)는 어느 route가 사용하든 관계없이 그 태그를 가진 모든 Data Cache 항목을 삭제합니다.
  • 다음 접근은 새로운 데이터를 fetch하고 새로운 태그로 캐싱합니다.

이것은 가장 강력한 메커니즘입니다. “언제 invalidate할지”의 로직을 “어떤 페이지가 데이터를 표시할지”로부터 분리하기 때문입니다 — Spring Cache나 Redis의 캐시 tagging과 비슷합니다.

7. 경로 기반 Revalidate: revalidatePath

태그를 사용하지 않는다면 route 경로에 따라 직접 revalidate할 수 있습니다:

"use server";

import { revalidatePath } from "next/cache";

export async function publishPost(slug: string) {
  // ... publish API 호출 ...
  revalidatePath(`/blog/${slug}`);   // 이 페이지만
  revalidatePath("/blog", "page");    // 또는 전체 목록
}
  • revalidatePath("/blog/[slug]")은 해당 route의 Full Route Cache + Data Cache를 삭제합니다.
  • 두 번째 매개변수("page" 또는 "layout")는 그 레이아웃을 사용하는 하위 route도 함께 revalidate할지 결정합니다.
  • revalidateTag와 다른 점: revalidatePath는 특정 route에 연결되지만 태그는 여러 route에서 사용될 수 있습니다.

참고: revalidateTag/revalidatePath는 Server Action이나 Route Handler 내에서만 실행될 수 있으며, Client Component에서 직접 호출할 수 없습니다.

8. Dynamic Rendering: 캐싱이 더 이상 의미 없을 때

특정 API들은 Next.js가 자동으로 route를 dynamic으로 전환하고 Full Route Cache를 무시하게 만듭니다:

import { cookies, headers } from "next/headers";

export default async function AccountPage() {
  const sessionToken = (await cookies()).get("session")?.value;
  const userAgent = (await headers()).get("user-agent");

  const res = await fetch("https://api.shop.example.com/me", {
    headers: { Authorization: `Bearer ${sessionToken}` },
    cache: "no-store",
  });

  return <div>안녕하세요, {(await res.json()).name}</div>;
}
  • cookies() 또는 headers()를 호출하면 자동으로 route를 dynamic으로 표시합니다 — 데이터가 특정 사용자에게 따라 달라지므로 합리적입니다.
  • cache: "no-store"를 결합하여 데이터가 항상 session에 따라 최신 상태로 유지되도록 합니다.

명시적으로 dynamic을 강제하세요(예: 캐싱할 수 없다는 것이 확실할 때):

export const dynamic = "force-dynamic";

9. Route Handlers도 독립적인 캐싱을 가집니다

Route Handler(app/api/.../route.ts)의 GET 메서드도 Server Component와 같은 캐싱 규칙을 따릅니다:

// app/api/stock/route.ts
export const revalidate = 60;

export async function GET() {
  const res = await fetch("https://api.shop.example.com/stock");
  const data = await res.json();
  return Response.json(data);
}
  • GET이 Request 객체를 사용하지 않으면(query를 위해 request.url을 읽지 않으면) 정적으로 캐싱될 수 있습니다.
  • handler가 request.headers, request.cookies를 사용하거나 GET 이외의 메서드(POST, PUT…)를 사용하면 → 항상 dynamic이며 캐싱되지 않습니다.

10. Client Component: 캐싱은 다른 라이브러리에 속합니다

Next.js의 fetch 캐싱은 서버에만 적용됩니다. Client Component에서는 useEffect를 사용하거나 SWR 같은 자체 캐싱 라이브러리를 사용합니다:

npm install swr
"use client";

import useSWR from "swr";

const fetcher = (url: string) => fetch(url).then((r) => r.json());

export function CartBadge({ userId }: { userId: string }) {
  const { data, isLoading } = useSWR(`/api/cart/${userId}`, fetcher, {
    refreshInterval: 10_000, // 10초마다 poll
  });

  if (isLoading) return <span>...</span>;
  return <span>{data.itemCount}개</span>;
}
  • SWR은 캐싱, 요청 deduplication, focus 시 revalidate를 제공합니다 — 서버의 Data Cache와 완전히 독립적입니다.
  • client에서 데이터를 지속적으로 업데이트해야 하지만 전체 페이지를 다시 로드하고 싶지 않을 때 사용합니다.

11. 캐시 키 관련 로직에 대한 테스트 작성

Next.js의 캐싱 메커니즘 자체는 직접 unit test하기 어렵지만, 태그/경로 생성 로직은 따로 test할 가치가 있습니다 — revalidateTag와 일치하지 않게 하는 태그 오타 오류를 방지합니다.

// lib/cache-keys.ts
export function productTag(id: string): string {
  return `product-${id}`;
}

export function categoryTag(id: string): string {
  return `category-${id}`;
}
// lib/cache-keys.test.ts
import { describe, it, expect } from "vitest";
import { productTag, categoryTag } from "./cache-keys";

describe("cache-keys", () => {
  it("상품 태그를 올바른 형식으로 생성합니다", () => {
    expect(productTag("sku-123")).toBe("product-sku-123");
  });

  it("상품 태그와 카테고리 태그가 충돌하지 않습니다", () => {
    const id = "42";
    expect(productTag(id)).not.toBe(categoryTag(id));
  });
});
  • 태그 생성 함수를 별도 모듈로 분리하면 fetch를 호출하는 곳(태그 추가)과 revalidateTag를 호출하는 곳(태그 삭제)이 항상 동일한 형식을 사용하는지 확인할 수 있습니다.
  • 이것은 실제로 매우 흔한 오류입니다: 한쪽에서 product-${id}를 쓰고 다른 쪽에서 products-${id}를 쓰면 → revalidate가 절대 일치하지 않습니다.
✓ cache-keys > 상품 태그를 올바른 형식으로 생성합니다
✓ cache-keys > 상품 태그와 카테고리 태그가 충돌하지 않습니다

12. 흔한 오류들

  • fetch가 기본적으로 무한 캐싱한다는 것을 잊음: 배포 후 데이터가 변하지 않습니다. 이전 빌드의 이전 캐시(정적 export 사용 시) 또는 첫 번째 fetch(서버 배포 시)를 계속 사용합니다. revalidate 또는 no-store로 해결합니다.
  • 같은 component에서 불필요하게 no-store와 캐싱을 섞음: no-store인 fetch 하나만 있어도 전체 페이지가 dynamic이 되어 다른 fetch들의 캐싱 이점을 잃습니다.
  • Server Action/Route Handler 외부에서 revalidateTag/revalidatePath 호출: 작동하지 않습니다. Next.js는 버전에 따라 오류를 보고하거나 조용히 무시합니다.
  • 타사 라이브러리 내에서 일반 fetch(Next.js 패치 없음) 사용(예: axios 또는 순수 undici를 사용하는 SDK): Data Cache로 관리되지 않습니다. 다른 방법으로 직접 revalidate해야 합니다(예: 다른 계층에서 캐싱하거나 표준 fetch를 호출하는 wrapper 작성).
  • 특정 엔티티별로 태깅하지 않음: 너무 일반적인 태그("products"로 모든 상품)를 사용하면 작은 업데이트 하나로 전체 캐시가 invalidate되어 항목별 캐싱의 이점을 잃습니다.

캐싱하면 안 될 때: session별로 개인화된 데이터(장바구니, 잔액, 알림), 실시간 데이터(주식 가격, 결제 상태), 또는 cookies()/headers()에 따라 달라지는 모든 response. 이러한 경우는 처음부터 no-store 또는 force-dynamic을 사용하세요. 계속 캐싱을 하려고 한 후 계속 revalidate하려고 하지 마세요 — 복잡할 뿐만 아니라 데이터의 특성에도 맞지 않습니다.

13. 결론

Next.js App Router의 캐싱은 여러 계층으로 이루어지지만, 당신이 직접 제어할 수 있는 부분은 fetch의 옵션: cache, next.revalidate, next.tags를 통한 Data Cache입니다. 빠른 선택 규칙: 변경이 적은 데이터 → 시간 기반 revalidate; 특정 이벤트에 따라 변경되는 데이터(관리자 업데이트) → tags를 붙이고 revalidateTag 사용; 사용자별 데이터 또는 실시간 데이터 → no-store/force-dynamic. 이 세 그룹을 명확히 이해하면 “왜 데이터가 업데이트되지 않지?“라고 추측할 일이 거의 없어집니다.

nextjsreactcachingapp-routerserver-components