메인 콘텐츠로 바로가기
노트 목록

Next.js 15 캐시 튜토리얼 — 한 단계씩 직접 만들어 보기

김성박620
목차10

이 튜토리얼은 Next.js의 캐시(cache)를 처음 배우는 사람을 위한 안내서입니다. 작은 예제 프로젝트를 처음부터 함께 만들면서, 네 가지 캐시 동작을 눈으로 확인합니다.

대상: Next.js, Tailwind, TypeScript, React 기초를 배웠고 useState를 써 본 사람. 서버 컴포넌트와 async/await 정도는 들어 봤다고 가정합니다.

다 만들면 이런 화면 4개를 갖게 됩니다.

  • /no-cache — 캐시 안 함
  • /revalidate — 10초마다 자동 갱신
  • /unstable-cache — 함수 결과를 20초 동안 캐시
  • /on-demand — 버튼을 눌러 캐시 즉시 갱신

0. 캐시가 왜 필요한가요?

여러분이 만든 페이지가 매번 데이터베이스에 접속해서 상품 1만 개를 읽어 온다고 합시다. 사용자가 페이지를 새로고침할 때마다 같은 작업을 반복합니다. 10명이 동시에 들어오면 똑같은 일을 10번 합니다. 데이터베이스는 점점 느려지고, 사용자는 빈 화면을 오래 봐야 합니다.

캐시(cache)는 한 번 가져온 결과를 잠깐 저장해 두고, 다음 요청에서 저장된 결과를 그대로 돌려주는 방식입니다.

  • 첫 번째 요청 → 실제로 데이터를 가져오고, 그 결과를 보관함에 넣어 둠
  • 두 번째 요청 → 보관함을 열어 같은 결과를 바로 돌려줌

상품 가격은 1분에 한 번만 바뀝니다. 굳이 1초마다 새로 읽을 필요가 없습니다. 캐시를 쓰면 서버도 빠르고 사용자도 빠릅니다.

다만 "주식 시세"처럼 1초마다 바뀌는 값을 캐시하면 안 됩니다. 사용자는 1분 전 가격을 보고 거래 결정을 내리게 됩니다. 그러니까 언제 캐시를 쓸지, 얼마나 오래 유지할지를 결정하는 게 핵심입니다.

Next.js 15는 캐시를 켜고 끄는 방법을 네 가지로 정리했습니다. 하나씩 만들면서 익혀 봅시다.


1. 프로젝트 만들기

1.1 새 프로젝트 생성

터미널에서 다음을 입력합니다.

npx create-next-app@15.5.4 cache-tutorial \ --typescript --tailwind --app \ --no-src-dir --no-turbopack --no-eslint \ --import-alias "@/*" --use-npm

여러 줄로 적었지만 한 줄짜리 명령입니다. 옵션이 많은 이유는 질문 단계를 건너뛰기 위해서입니다. 끝나면 cache-tutorial 폴더가 생깁니다.

cd cache-tutorial

1.2 포트 변경 (선택 사항)

다른 Next.js 프로젝트와 충돌하지 않도록 우리 프로젝트는 3100번 포트를 쓰겠습니다. package.json의 scripts를 다음처럼 바꿉니다.

"scripts": { "dev": "next dev -p 3100", "build": "next build", "start": "next start -p 3100" }

이제 dev 서버는 http://localhost:3100에서 동작합니다.

1.3 레이아웃 단순화

app/layout.tsx를 다음으로 바꿉니다. 폰트를 떼고 어두운 배경을 적용합니다.

// app/layout.tsx import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { title: "캐시 튜토리얼", description: "Next.js 15 캐시 학습용 예제", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="ko"> <body className="bg-stone-950 text-stone-100 antialiased"> {children} </body> </html> ); }

1.4 홈 페이지 만들기

app/page.tsx를 다음으로 바꿉니다. 네 개의 데모로 이동하는 링크 목록입니다.

// app/page.tsx import Link from "next/link"; type LinkCard = { href: string; title: string; desc: string; }; const links: LinkCard[] = [ { href: "/no-cache", title: "1. 캐시 안 함", desc: "fetch에 cache: 'no-store'를 넣으면 새로고침할 때마다 시각이 바뀝니다.", }, { href: "/revalidate", title: "2. 시간 기반 재검증", desc: "fetch에 next: { revalidate: 10 }을 넣으면 10초마다 시각이 갱신됩니다.", }, { href: "/unstable-cache", title: "3. unstable_cache", desc: "fetch가 아닌 일반 함수의 결과를 캐시합니다. 20초마다 갱신됩니다.", }, { href: "/on-demand", title: "4. on-demand 재검증", desc: "버튼을 눌러 revalidateTag와 revalidatePath로 즉시 캐시를 갱신합니다.", }, ]; export default function HomePage() { return ( <main className="min-h-screen px-6 py-16"> <div className="mx-auto max-w-3xl space-y-8"> <header> <h1 className="text-4xl font-black">Next.js 15 캐시 튜토리얼</h1> <p className="mt-2 text-stone-400"> 네 가지 캐시 동작을 한 페이지씩 직접 확인합니다. </p> </header> <ul className="space-y-4"> {links.map((link) => ( <li key={link.href}> <Link href={link.href} className="block rounded-2xl border border-stone-800 bg-stone-900 p-5 transition hover:border-amber-400" > <h2 className="text-xl font-bold text-amber-300">{link.title}</h2> <p className="mt-1 text-stone-300">{link.desc}</p> </Link> </li> ))} </ul> </div> </main> ); }

1.5 시각을 돌려주는 mock API 만들기

캐시 동작을 눈으로 확인하려면 "지금 몇 시인지" 알 수 있어야 합니다. 호출될 때마다 현재 시각을 돌려주는 작은 API를 만듭니다.

app/api/time/route.ts 파일을 만듭니다 (폴더가 없으면 같이 만드세요).

// app/api/time/route.ts import { NextResponse } from "next/server"; export async function GET() { const now = new Date(); const generatedAt = new Intl.DateTimeFormat("ko-KR", { dateStyle: "long", timeStyle: "medium", timeZone: "Asia/Seoul", }).format(now); return NextResponse.json({ generatedAt, epochMs: now.getTime(), message: "이 시각은 서버가 API를 호출한 순간에 만든 값입니다.", }); }

이 API는 호출될 때마다 새로운 시각을 만듭니다. 캐시를 거치면 "1초 전에 호출한 결과"가 나오고, 캐시를 안 거치면 "방금 호출한 결과"가 나옵니다. 이 차이로 캐시가 동작하는지 확인합니다.

1.6 dev 서버 실행

npm install # 처음 한 번만 npm run dev

브라우저에서 http://localhost:3100을 엽니다. 카드 4개가 보입니다. 아직 누르면 404가 뜹니다. 페이지를 하나씩 만들면서 채워 갑시다.


2. 캐시 안 함 — cache: "no-store"

2.1 핵심 개념

Next.js 15에서 서버 컴포넌트의 fetch는 기본적으로 캐시하지 않습니다. 옵션을 안 적어도 매번 새로 가져옵니다. 그래도 cache: "no-store"를 명시하면 의도가 더 분명해집니다.

cache: "no-store"는 영어 그대로 "저장하지 마" 입니다. 매번 새로 가져옵니다. 실시간 시세, 사용자의 장바구니처럼 항상 최신이어야 하는 데이터에 씁니다.

2.2 페이지 만들기

app/no-cache/page.tsx 파일을 만듭니다.

// app/no-cache/page.tsx import Link from "next/link"; type TimeData = { generatedAt: string; epochMs: number; message: string; }; async function getTime(): Promise<TimeData> { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3100"; const response = await fetch(`${baseUrl}/api/time`, { cache: "no-store", }); return response.json(); } export default async function NoCachePage() { const data = await getTime(); const renderedAt = new Intl.DateTimeFormat("ko-KR", { dateStyle: "long", timeStyle: "medium", timeZone: "Asia/Seoul", }).format(new Date()); return ( <main className="min-h-screen px-6 py-16"> <div className="mx-auto max-w-3xl space-y-6"> <Link href="/" className="text-amber-300 hover:underline"> ← 홈으로 </Link> <header> <h1 className="text-3xl font-black">1. 캐시 안 함 (no-store)</h1> <p className="mt-2 text-stone-400"> fetch에 cache: &quot;no-store&quot;를 넣었습니다. 새로고침할 때마다 API를 새로 호출합니다. </p> </header> <section className="grid gap-4 sm:grid-cols-2"> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">페이지 렌더링 시각</h2> <p className="mt-2 text-lg font-mono">{renderedAt}</p> </article> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">API 응답 시각</h2> <p className="mt-2 text-lg font-mono">{data.generatedAt}</p> </article> </section> <p className="rounded-2xl border border-amber-300/30 bg-amber-300/5 p-4 text-amber-100"> 새로고침해 보세요. 두 시각이 모두 매번 바뀝니다. </p> </div> </main> ); }

2.3 코드 한 줄씩 풀어 보기

export default async function NoCachePage() {

함수 이름 앞에 async가 붙어 있습니다. 서버 컴포넌트는 async로 만들 수 있고, 안에서 await로 데이터를 가져올 수 있습니다. 일반 React 컴포넌트와 다른 점입니다.

const response = await fetch(`${baseUrl}/api/time`, { cache: "no-store", });

fetch의 두 번째 인자가 옵션 객체입니다. cache: "no-store"를 넣었습니다. 이게 캐시를 끄는 신호입니다.

왜 절대 URL을 쓰나요? 서버 컴포넌트의 fetch는 브라우저가 아니라 Node.js에서 실행됩니다. 브라우저는 페이지 주소를 기준으로 /api/time을 풀어 주지만, 서버에는 그런 기준이 없습니다. 그래서 http://localhost:3100/api/time처럼 전체 주소를 적어야 합니다.

const renderedAt = new Intl.DateTimeFormat("ko-KR", { ... }).format(new Date());

이 줄은 "지금 페이지가 만들어지는 순간"의 시각을 기록합니다. 캐시와는 무관합니다. 그래서 새로고침할 때마다 무조건 바뀝니다.

화면에는 두 시각이 보입니다.

  • 페이지 렌더링 시각 (renderedAt) — 페이지를 그릴 때마다 새로 만들어진 시각
  • API 응답 시각 (data.generatedAt) — API가 응답을 만든 시각

cache: "no-store"라서 둘은 거의 같은 시각입니다.

2.4 직접 확인

브라우저에서 http://localhost:3100/no-cache를 엽니다. 새로고침을 두세 번 눌러 보세요. 두 시각이 모두 바뀝니다.


3. 시간 기반 재검증 — revalidate

3.1 핵심 개념

"매번 새로 가져오기"는 부담이 큽니다. 반대로 "한 번 가져오면 영원히 캐시"는 데이터가 오래되어 위험합니다. 그 가운데 절충안이 **시간 기반 재검증(revalidate)**입니다.

revalidate: 10은 이렇게 작동합니다.

  1. 캐시가 비어 있으면 fetch를 실제로 호출하고 결과를 저장합니다 (T=0).
  2. 10초 안에 들어오는 요청은 모두 저장된 결과를 그대로 돌려줍니다. fetch는 호출되지 않습니다.
  3. 10초가 지나면 캐시가 "만료(stale)" 상태가 됩니다.
  4. 다음 요청이 들어오면 저장된(오래된) 결과를 일단 돌려주고, 백그라운드에서 새 데이터를 가져와 캐시를 업데이트합니다.
  5. 그 다음 요청부터는 새 데이터를 봅니다.

이걸 stale-while-revalidate 패턴이라고 부릅니다. 어려운 이름이지만 의미는 단순합니다. "오래됐어도 일단 보여 주고, 그 사이에 몰래 새로 가져온다."

3.2 페이지 만들기

app/revalidate/page.tsx 파일을 만듭니다.

// app/revalidate/page.tsx import Link from "next/link"; type TimeData = { generatedAt: string; epochMs: number; message: string; }; async function getTime(): Promise<TimeData> { const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3100"; const response = await fetch(`${baseUrl}/api/time`, { next: { revalidate: 10 }, }); return response.json(); } export const dynamic = "force-dynamic"; export default async function RevalidatePage() { const data = await getTime(); const renderedAt = new Intl.DateTimeFormat("ko-KR", { dateStyle: "long", timeStyle: "medium", timeZone: "Asia/Seoul", }).format(new Date()); return ( <main className="min-h-screen px-6 py-16"> <div className="mx-auto max-w-3xl space-y-6"> <Link href="/" className="text-amber-300 hover:underline"> ← 홈으로 </Link> <header> <h1 className="text-3xl font-black">2. 시간 기반 재검증 (revalidate: 10)</h1> <p className="mt-2 text-stone-400"> fetch에 next: {"{"} revalidate: 10 {"}"}을 넣었습니다. 10초 동안은 같은 응답이 돌아오고, 10초가 지난 뒤 새로고침하면 새 응답을 가져옵니다. </p> </header> <section className="grid gap-4 sm:grid-cols-2"> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">페이지 렌더링 시각</h2> <p className="mt-2 text-lg font-mono">{renderedAt}</p> <p className="mt-1 text-xs text-stone-500">새로고침할 때마다 바뀜</p> </article> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">API 응답 시각 (캐시됨)</h2> <p className="mt-2 text-lg font-mono">{data.generatedAt}</p> <p className="mt-1 text-xs text-stone-500">10초마다 바뀜</p> </article> </section> <p className="rounded-2xl border border-amber-300/30 bg-amber-300/5 p-4 text-amber-100"> 새로고침을 빠르게 여러 번 눌러보세요. 왼쪽은 매번 바뀌지만 오른쪽은 그대로입니다. 10초 후 새로고침하면 오른쪽도 바뀝니다. </p> </div> </main> ); }

3.3 코드 한 줄씩 풀어 보기

await fetch(`${baseUrl}/api/time`, { next: { revalidate: 10 }, });

cache 옵션 자리에 next: { revalidate: 10 }가 들어갔습니다. next는 Next.js 전용 옵션 묶음입니다. revalidate: 10은 "10초 동안 캐시한 뒤 다시 가져온다"는 뜻입니다. 단위는 초입니다.

export const dynamic = "force-dynamic";

이 줄이 없으면 Next.js가 "이 페이지는 데이터가 캐시되니까 페이지 전체도 통째로 캐시해 버리자"라고 판단해서 페이지 렌더링 시각까지 고정합니다. 그러면 캐시 동작을 확인하기 어려워집니다. force-dynamic은 "페이지는 매번 새로 그리되, 데이터만 캐시에서 가져와라"라는 신호입니다.

3.4 직접 확인

브라우저에서 http://localhost:3100/revalidate를 엽니다.

  1. 처음 들어가면 두 시각이 비슷합니다 (캐시가 처음 생긴 시점).
  2. 새로고침을 빠르게 여러 번 합니다. 왼쪽(페이지 렌더링 시각)은 매번 바뀌고, 오른쪽(API 응답 시각)은 그대로입니다.
  3. 10초쯤 기다린 뒤 새로고침합니다. 오른쪽도 새 시각으로 바뀝니다.

한 번 새로고침 했는데도 오른쪽이 안 바뀌면 한 번 더 눌러 보세요. 위에서 설명한 stale-while-revalidate 때문에 "10초가 지난 직후의 첫 새로고침"에는 아직 오래된 캐시가 보일 수 있습니다. 다음 새로고침에서 새 값이 들어옵니다.

3.5 옵션 세 가지 비교

옵션동작언제 쓰나
cache: "no-store"매번 새로 가져옴실시간 시세, 장바구니
cache: "force-cache"한 번 가져온 뒤 영원히 재사용빌드 시 고정되는 데이터
next: { revalidate: N }N초마다 갱신가끔 바뀌는 상품 목록, 블로그 글

cache와 next: { revalidate }를 동시에 쓰면 안 됩니다. 둘 다 캐시 동작을 결정하는 옵션이므로 하나만 골라야 합니다.


4. unstable_cache — 함수 결과 캐시하기

4.1 왜 또 다른 캐시?

fetch의 revalidate는 HTTP 요청 결과만 캐시합니다. 그러면 이런 경우는요?

  • mock 데이터를 정렬하고 가공하는 함수
  • 파일을 읽어 JSON으로 만드는 함수
  • 여러 API를 호출해 합쳐서 돌려주는 함수

이런 함수의 반환값을 캐시하고 싶으면 unstable_cache를 씁니다. next/cache에서 가져옵니다. 이름에 "unstable"이 붙어 있지만, 동작이 불안정한 게 아니라 이름이 나중에 바뀔 수도 있다는 표시입니다. 실무에서 다들 씁니다.

4.2 캐시할 함수 만들기

lib/cached-quote.ts 파일을 만듭니다 (lib 폴더가 없으면 같이 만드세요).

// lib/cached-quote.ts import { unstable_cache } from "next/cache"; export type Quote = { topic: string; text: string; generatedAt: string; }; const quotes: Record<string, string> = { effort: "오늘 한 줄이 내일의 큰 차이를 만듭니다.", rest: "잘 쉬는 것도 실력입니다.", curiosity: "질문이 많은 사람이 가장 빨리 자랍니다.", }; function wait(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } async function buildQuote(topic: string): Promise<Quote> { // DB 조회나 외부 API 호출처럼 시간이 걸리는 작업을 흉내냅니다. await wait(800); const safeTopic = topic in quotes ? topic : "effort"; return { topic: safeTopic, text: quotes[safeTopic], generatedAt: new Intl.DateTimeFormat("ko-KR", { dateStyle: "long", timeStyle: "medium", timeZone: "Asia/Seoul", }).format(new Date()), }; } export const getCachedQuote = unstable_cache( async (topic: string) => buildQuote(topic), ["quote"], { revalidate: 20, tags: ["quote"], }, );

4.3 unstable_cache 인자 풀어 보기

unstable_cache( async (topic: string) => buildQuote(topic), // 첫 번째 인자 ["quote"], // 두 번째 인자 { revalidate: 20, tags: ["quote"] }, // 세 번째 인자 );
  • 첫 번째 인자: 캐시할 함수. 이 함수의 반환값이 캐시됩니다. 반드시 async 함수여야 합니다.
  • 두 번째 인자: 캐시 키 배열. 캐시를 구분하는 "내부 이름표"입니다. 캐시들을 서로 구분할 때 씁니다.
  • 세 번째 인자: 옵션.
     - revalidate: 20 — 20초마다 캐시를 새로 만듭니다 (fetch의 revalidate와 같은 의미).
     - tags: ["quote"] — "이 캐시에는 'quote'라는 태그를 붙여 둔다"는 표시입니다. 이 태그는 다음 절(4번 데모)에서 쓸 겁니다.

unstable_cache는 새로운 함수를 돌려줍니다. 이 함수를 호출하면

  • 캐시에 결과가 있으면 → 바로 돌려줍니다 (buildQuote를 실행하지 않음, 0.8초 대기도 없음).
  • 캐시에 없거나 만료됐으면 → buildQuote를 실행하고 결과를 캐시에 넣은 뒤 돌려줍니다.

4.4 페이지 만들기

app/unstable-cache/page.tsx 파일을 만듭니다.

// app/unstable-cache/page.tsx import Link from "next/link"; import { getCachedQuote } from "@/lib/cached-quote"; type PageProps = { searchParams: Promise<{ topic?: string }>; }; export const dynamic = "force-dynamic"; const topics = [ { key: "effort", label: "노력" }, { key: "rest", label: "휴식" }, { key: "curiosity", label: "호기심" }, ]; export default async function UnstableCachePage({ searchParams }: PageProps) { const params = await searchParams; const topic = typeof params.topic === "string" && params.topic.trim() ? params.topic.trim() : "effort"; const quote = await getCachedQuote(topic); const renderedAt = new Intl.DateTimeFormat("ko-KR", { dateStyle: "long", timeStyle: "medium", timeZone: "Asia/Seoul", }).format(new Date()); return ( <main className="min-h-screen px-6 py-16"> <div className="mx-auto max-w-3xl space-y-6"> <Link href="/" className="text-amber-300 hover:underline"> ← 홈으로 </Link> <header> <h1 className="text-3xl font-black">3. unstable_cache</h1> <p className="mt-2 text-stone-400"> fetch가 아닌 일반 함수를 unstable_cache로 감쌌습니다. 함수 결과를 20초 동안 캐시합니다. </p> </header> <nav className="flex flex-wrap gap-2"> {topics.map((t) => ( <Link key={t.key} href={`/unstable-cache?topic=${t.key}`} className={`rounded-full px-4 py-2 text-sm font-semibold transition ${ topic === t.key ? "bg-amber-300 text-stone-950" : "border border-stone-700 bg-stone-900 text-stone-200 hover:border-amber-400" }`} > {t.label} </Link> ))} </nav> <section className="grid gap-4 sm:grid-cols-2"> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">페이지 렌더링 시각</h2> <p className="mt-2 text-lg font-mono">{renderedAt}</p> <p className="mt-1 text-xs text-stone-500">새로고침할 때마다 바뀜</p> </article> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">함수 실행 시각 (캐시됨)</h2> <p className="mt-2 text-lg font-mono">{quote.generatedAt}</p> <p className="mt-1 text-xs text-stone-500">20초마다 바뀜</p> </article> </section> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-6"> <h2 className="text-sm font-bold text-stone-400">오늘의 문장</h2> <p className="mt-3 text-xl">{quote.text}</p> </article> <p className="rounded-2xl border border-amber-300/30 bg-amber-300/5 p-4 text-amber-100"> 새로고침을 빠르게 여러 번 눌러보세요. 함수에 0.8초 대기가 있는데도 두 번째 로딩이 빠릅니다. 캐시된 결과를 쓰기 때문입니다. 주제 버튼을 바꾸면 그 주제만의 캐시가 따로 만들어집니다. </p> </div> </main> ); }

4.5 코드 한 줄씩 풀어 보기

type PageProps = { searchParams: Promise<{ topic?: string }>; };

Next.js 15부터 searchParams는 Promise입니다. 그래서 사용하려면 await로 풀어야 합니다.

const params = await searchParams; const topic = typeof params.topic === "string" && params.topic.trim() ? params.topic.trim() : "effort";

URL이 ?topic=rest면 params.topic === "rest"가 됩니다. 값이 없거나 빈 문자열이면 기본값 "effort"를 씁니다.

const quote = await getCachedQuote(topic);

이 한 줄에 캐시 동작이 모두 들어 있습니다. 처음 호출하면 buildQuote("effort")가 실행되어 0.8초 기다린 뒤 결과를 돌려줍니다. 두 번째 호출부터는 캐시에서 즉시 돌려줍니다. 20초가 지나면 다시 0.8초 기다립니다.

4.6 직접 확인

브라우저에서 http://localhost:3100/unstable-cache를 엽니다.

  1. 첫 진입에서 화면이 잠깐 비어 있다가 뜹니다 (0.8초 대기).
  2. 새로고침을 누릅니다. 빠릅니다. 페이지 렌더링 시각은 바뀌지만 함수 실행 시각은 그대로입니다.
  3. "휴식" 버튼을 누릅니다. 다시 0.8초가 걸립니다. 주제마다 캐시가 따로이기 때문입니다.
  4. 다시 "노력" 버튼을 누릅니다. 빠릅니다. 노력 캐시는 아직 살아 있습니다.
  5. 20초 기다린 뒤 새로고침합니다. 0.8초 대기가 다시 발생합니다. 캐시가 만료됐기 때문입니다.

4.7 fetch의 revalidate와 unstable_cache의 revalidate

두 옵션은 같은 방식으로 작동합니다. 지정한 시간이 지나면 새로 가져옵니다. 차이는 무엇을 캐시하느냐 뿐입니다.

도구캐시 대상어디에 옵션을 넣나
fetchHTTP 응답fetch(url, { next: { revalidate: N } })
unstable_cache함수 반환값unstable_cache(fn, key, { revalidate: N })

5. on-demand 재검증 — 캐시를 지금 당장 갱신

5.1 시간 기반의 한계

revalidate: 20을 걸어 두면 자동으로 20초마다 갱신됩니다. 평상시엔 이걸로 충분합니다.

그런데 관리자가 방금 상품 가격을 수정했다고 합시다. 가격 수정은 즉시 반영돼야 하는데, "20초 뒤에 자동 갱신"을 기다리면 그 사이 사용자가 잘못된 가격을 보게 됩니다.

이럴 때 쓰는 게 on-demand 재검증입니다. 코드에서 "지금 이 캐시를 지워!"라고 직접 명령합니다. Next.js는 두 가지 함수를 줍니다.

  • revalidateTag("quote") — "quote" 태그가 붙은 캐시를 모두 지움
  • revalidatePath("/on-demand") — /on-demand 경로에 걸린 캐시를 모두 지움

둘 다 next/cache에서 가져오고, 서버에서만 호출할 수 있습니다. 보통 Route Handler 안에서 호출합니다.

5.2 Route Handler 만들기

app/api/revalidate/route.ts 파일을 만듭니다.

// app/api/revalidate/route.ts import { revalidatePath, revalidateTag } from "next/cache"; import { NextResponse, type NextRequest } from "next/server"; export async function GET(request: NextRequest) { const mode = request.nextUrl.searchParams.get("mode") ?? "tag"; const topic = request.nextUrl.searchParams.get("topic") ?? "effort"; if (mode === "path") { revalidatePath("/on-demand"); } else { revalidateTag("quote"); } const redirectUrl = new URL( `/on-demand?topic=${encodeURIComponent(topic)}&revalidated=${mode}`, request.url, ); return NextResponse.redirect(redirectUrl); }

5.3 Route Handler 풀어 보기

Route Handler는 app/api/.../route.ts에 만드는 작은 API입니다. export async function GET(...)을 내보내면 그 경로로 GET 요청이 들어왔을 때 실행됩니다.

const mode = request.nextUrl.searchParams.get("mode") ?? "tag"; const topic = request.nextUrl.searchParams.get("topic") ?? "effort";

URL의 ?mode=...&topic=... 부분을 꺼냅니다. ??는 "왼쪽이 null이나 undefined면 오른쪽을 써라"는 연산자입니다. 그래서 mode가 없으면 "tag"가 기본값입니다.

if (mode === "path") { revalidatePath("/on-demand"); } else { revalidateTag("quote"); }

mode에 따라 두 함수 중 하나를 호출합니다. 이 한 줄이 캐시를 즉시 날려 버립니다.

return NextResponse.redirect(redirectUrl);

캐시를 지웠으니 사용자를 다시 /on-demand 페이지로 보냅니다. 보낼 때 ?revalidated=tag 같은 표시를 붙여서 페이지에서 "방금 재검증했다"는 알림을 띄울 수 있게 합니다.

5.4 페이지 만들기

app/on-demand/page.tsx 파일을 만듭니다.

// app/on-demand/page.tsx import Link from "next/link"; import { getCachedQuote } from "@/lib/cached-quote"; type PageProps = { searchParams: Promise<{ topic?: string; revalidated?: string }>; }; export const dynamic = "force-dynamic"; const topics = [ { key: "effort", label: "노력" }, { key: "rest", label: "휴식" }, { key: "curiosity", label: "호기심" }, ]; export default async function OnDemandPage({ searchParams }: PageProps) { const params = await searchParams; const topic = typeof params.topic === "string" && params.topic.trim() ? params.topic.trim() : "effort"; const revalidated = params.revalidated; const quote = await getCachedQuote(topic); const renderedAt = new Intl.DateTimeFormat("ko-KR", { dateStyle: "long", timeStyle: "medium", timeZone: "Asia/Seoul", }).format(new Date()); return ( <main className="min-h-screen px-6 py-16"> <div className="mx-auto max-w-3xl space-y-6"> <Link href="/" className="text-amber-300 hover:underline"> ← 홈으로 </Link> <header> <h1 className="text-3xl font-black">4. on-demand 재검증</h1> <p className="mt-2 text-stone-400"> 버튼을 누르면 revalidateTag 또는 revalidatePath를 호출해서 캐시를 즉시 갱신합니다. 20초를 기다리지 않아도 됩니다. </p> </header> {revalidated ? ( <p className="rounded-2xl border border-emerald-400/40 bg-emerald-400/10 p-4 text-emerald-100"> {revalidated === "path" ? "revalidatePath로 /on-demand 경로의 캐시를 갱신했습니다." : "revalidateTag로 'quote' 태그가 붙은 캐시를 갱신했습니다."} </p> ) : null} <nav className="flex flex-wrap gap-2"> {topics.map((t) => ( <Link key={t.key} href={`/on-demand?topic=${t.key}`} className={`rounded-full px-4 py-2 text-sm font-semibold transition ${ topic === t.key ? "bg-amber-300 text-stone-950" : "border border-stone-700 bg-stone-900 text-stone-200 hover:border-amber-400" }`} > {t.label} </Link> ))} </nav> <section className="grid gap-4 sm:grid-cols-2"> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">페이지 렌더링 시각</h2> <p className="mt-2 text-lg font-mono">{renderedAt}</p> </article> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-5"> <h2 className="text-sm font-bold text-stone-400">함수 실행 시각 (캐시됨)</h2> <p className="mt-2 text-lg font-mono">{quote.generatedAt}</p> </article> </section> <article className="rounded-2xl border border-stone-800 bg-stone-900 p-6"> <h2 className="text-sm font-bold text-stone-400">오늘의 문장</h2> <p className="mt-3 text-xl">{quote.text}</p> </article> <div className="flex flex-wrap gap-3"> <Link href={`/api/revalidate?mode=tag&topic=${topic}`} className="rounded-full bg-amber-300 px-5 py-3 text-sm font-semibold text-stone-950 hover:bg-amber-400" > revalidateTag(&quot;quote&quot;) 호출 </Link> <Link href={`/api/revalidate?mode=path&topic=${topic}`} className="rounded-full bg-sky-300 px-5 py-3 text-sm font-semibold text-stone-950 hover:bg-sky-400" > revalidatePath(&quot;/on-demand&quot;) 호출 </Link> </div> <p className="rounded-2xl border border-amber-300/30 bg-amber-300/5 p-4 text-amber-100"> 버튼을 누르면 함수 실행 시각이 즉시 바뀝니다. 캐시가 20초 만료를 기다리지 않고 바로 갱신되는 것을 확인하세요. </p> </div> </main> ); }

5.5 동작 흐름

사용자가 "revalidateTag" 버튼을 눌렀을 때 일어나는 일을 시간 순서로 봅니다.

  1. 사용자가 노란 버튼을 클릭 → 브라우저가 /api/revalidate?mode=tag&topic=effort로 이동.
  2. GET 핸들러가 실행 → revalidateTag("quote") 호출 → "quote" 태그가 붙은 모든 캐시가 즉시 무효화.
  3. 핸들러가 /on-demand?topic=effort&revalidated=tag로 리다이렉트.
  4. /on-demand 페이지가 다시 그려질 때 getCachedQuote("effort")를 호출. 캐시가 없으니 buildQuote가 실제로 실행되어 새 시각으로 새 결과를 만듭니다.
  5. 화면에는 새 "함수 실행 시각"과 초록색 배너가 보입니다.

5.6 직접 확인

브라우저에서 http://localhost:3100/on-demand를 엽니다.

  1. 들어가서 "함수 실행 시각"을 메모합니다.
  2. 새로고침을 몇 번 합니다. "함수 실행 시각"은 그대로입니다 (캐시 살아 있음).
  3. 노란색 "revalidateTag("quote") 호출" 버튼을 누릅니다. 페이지가 다시 로드되고 초록 배너가 뜹니다. "함수 실행 시각"이 방금 시간으로 바뀌었습니다.
  4. 파란색 "revalidatePath("/on-demand") 호출" 버튼도 눌러 보세요. 같은 효과입니다. 캐시가 즉시 갱신됩니다.

5.7 revalidateTag vs revalidatePath — 언제 어느 걸 쓰나

함수캐시를 고르는 기준어울리는 상황
revalidateTag태그 이름같은 데이터를 여러 페이지에서 쓰는 경우. 예: 상품 데이터를 목록 페이지와 상세 페이지에서 같이 쓸 때, 둘 다 같은 태그를 붙여 두면 revalidateTag 한 번이면 전부 갱신.
revalidatePathURL 경로한 페이지의 캐시를 통째로 갱신하고 싶을 때. 태그를 안 붙인 캐시도 한꺼번에 정리.

두 함수를 같은 Route Handler에서 함께 쓸 수도 있습니다.

5.8 주의

  • revalidateTag와 revalidatePath는 서버에서만 호출할 수 있습니다. 클라이언트 컴포넌트("use client" 붙인 컴포넌트) 안에서 직접 호출하면 안 됩니다. 보통 Route Handler 안에서 호출합니다.
  • 태그 이름이 정확히 일치해야 합니다. revalidateTag("Quote")(대문자)와 revalidateTag("quote")(소문자)는 다른 태그입니다.
  • 동적 경로는 실제 URL을 넣어야 합니다. revalidatePath("/products/[id]")(X) → revalidatePath("/products/42")(O).

6. 정리 — 언제 어떤 캐시를 쓸까?

지금까지 만든 네 가지 방법을 한 표로 정리합니다.

도구캐시 대상갱신 방식어울리는 상황
cache: "no-store"fetch 응답매번 새로 가져옴실시간 시세, 본인 장바구니
next: { revalidate: N }fetch 응답N초마다 자동상품 목록, 블로그, 뉴스
unstable_cache함수 반환값N초마다 + 수동DB 조회 결과, 가공한 mock 데이터
revalidateTag / revalidatePath위 두 가지 모두코드에서 호출하는 즉시데이터 변경 직후 즉시 반영

실무 감각 잡기:

  • 모르겠으면 일단 revalidate: 60 (1분)으로 시작합니다. 사용자 영향이 어떤지 보고 더 길게 늘리거나 짧게 줄입니다.
  • 사용자별로 다른 데이터(프로필, 장바구니)는 반드시 캐시 안 함.
  • 관리자 페이지에서 데이터를 수정할 때는 마지막 단계에서 on-demand 재검증을 호출해 즉시 반영.

7. 자주 막히는 곳

7.1 캐시가 동작 안 하는 것 같습니다

  • dev 서버에서는 코드를 저장할 때마다 캐시가 초기화될 수 있습니다. 정확한 확인은 npm run build && npm start로 프로덕션 모드에서 봅니다.
  • export const dynamic = "force-dynamic"이 없으면 페이지 자체가 통째로 캐시되어 "페이지 렌더링 시각"도 바뀌지 않습니다.

7.2 페이지에서 자체 API를 fetch할 때 URL이 이상합니다

서버 컴포넌트에서 fetch("/api/time")처럼 상대 경로를 쓸 수는 없습니다. 절대 URL이 필요합니다. 이 튜토리얼에서는 http://localhost:3100을 하드코딩했지만, 실제 서비스에서는 환경 변수로 분리합니다. 그래서 코드에 process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3100"처럼 적었습니다.

7.3 revalidate: 0을 넣어도 되나요?

revalidate: 0은 cache: "no-store"와 같은 의미입니다. "0초 동안 캐시"는 캐시를 안 쓰는 것과 같으니까요. 굳이 0을 적기보다는 cache: "no-store"로 쓰는 편이 의도가 분명합니다.

7.4 옵션을 동시에 쓸 수 있나요?

// 잘못된 사용 await fetch(url, { cache: "force-cache", next: { revalidate: 60 } });

cache와 next: { revalidate }는 같은 자리에 들어가는 옵션이라서 같이 쓰면 안 됩니다. 하나만 고르세요.


8. 다음 단계

  • 캐시 키에 변수를 넣어 보세요. 예: unstable_cache(fn, ["product", productId], opts) — 상품마다 캐시가 따로 만들어집니다.
  • Server Action 안에서 revalidatePath를 호출하는 패턴도 자주 씁니다. 폼 제출 후 자동 갱신에 어울립니다.
  • 캐시는 메모리에 저장됩니다. 서버가 여러 대면 캐시도 서버별로 따로입니다. 큰 서비스에서는 Redis 같은 외부 캐시를 함께 씁니다.

여기까지 따라왔다면, 글 첫머리에 있던 표를 다시 보세요. 단어 하나하나가 어떤 동작을 가리키는지 자기 손가락으로 확인했을 겁니다. 캐시는 처음엔 복잡하지만, 직접 두 시각을 비교하면서 익히면 어렵지 않습니다.

댓글

불러오는 중...

댓글을 작성하려면 이 필요합니다.