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

이 튜토리얼은 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: "no-store"를 넣었습니다. 새로고침할 때마다 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은 이렇게 작동합니다.
- 캐시가 비어 있으면 fetch를 실제로 호출하고 결과를 저장합니다 (T=0).
- 10초 안에 들어오는 요청은 모두 저장된 결과를 그대로 돌려줍니다. fetch는 호출되지 않습니다.
- 10초가 지나면 캐시가 "만료(stale)" 상태가 됩니다.
- 다음 요청이 들어오면 저장된(오래된) 결과를 일단 돌려주고, 백그라운드에서 새 데이터를 가져와 캐시를 업데이트합니다.
- 그 다음 요청부터는 새 데이터를 봅니다.
이걸 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를 엽니다.
- 처음 들어가면 두 시각이 비슷합니다 (캐시가 처음 생긴 시점).
- 새로고침을 빠르게 여러 번 합니다. 왼쪽(페이지 렌더링 시각)은 매번 바뀌고, 오른쪽(API 응답 시각)은 그대로입니다.
- 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를 엽니다.
- 첫 진입에서 화면이 잠깐 비어 있다가 뜹니다 (0.8초 대기).
- 새로고침을 누릅니다. 빠릅니다. 페이지 렌더링 시각은 바뀌지만 함수 실행 시각은 그대로입니다.
- "휴식" 버튼을 누릅니다. 다시 0.8초가 걸립니다. 주제마다 캐시가 따로이기 때문입니다.
- 다시 "노력" 버튼을 누릅니다. 빠릅니다. 노력 캐시는 아직 살아 있습니다.
- 20초 기다린 뒤 새로고침합니다. 0.8초 대기가 다시 발생합니다. 캐시가 만료됐기 때문입니다.
4.7 fetch의 revalidate와 unstable_cache의 revalidate
두 옵션은 같은 방식으로 작동합니다. 지정한 시간이 지나면 새로 가져옵니다. 차이는 무엇을 캐시하느냐 뿐입니다.
| 도구 | 캐시 대상 | 어디에 옵션을 넣나 |
|---|---|---|
fetch | HTTP 응답 | 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("quote") 호출 </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("/on-demand") 호출 </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" 버튼을 눌렀을 때 일어나는 일을 시간 순서로 봅니다.
- 사용자가 노란 버튼을 클릭 → 브라우저가
/api/revalidate?mode=tag&topic=effort로 이동. GET핸들러가 실행 →revalidateTag("quote")호출 → "quote" 태그가 붙은 모든 캐시가 즉시 무효화.- 핸들러가
/on-demand?topic=effort&revalidated=tag로 리다이렉트. /on-demand페이지가 다시 그려질 때getCachedQuote("effort")를 호출. 캐시가 없으니buildQuote가 실제로 실행되어 새 시각으로 새 결과를 만듭니다.- 화면에는 새 "함수 실행 시각"과 초록색 배너가 보입니다.
5.6 직접 확인
브라우저에서 http://localhost:3100/on-demand를 엽니다.
- 들어가서 "함수 실행 시각"을 메모합니다.
- 새로고침을 몇 번 합니다. "함수 실행 시각"은 그대로입니다 (캐시 살아 있음).
- 노란색 "revalidateTag("quote") 호출" 버튼을 누릅니다. 페이지가 다시 로드되고 초록 배너가 뜹니다. "함수 실행 시각"이 방금 시간으로 바뀌었습니다.
- 파란색 "revalidatePath("/on-demand") 호출" 버튼도 눌러 보세요. 같은 효과입니다. 캐시가 즉시 갱신됩니다.
5.7 revalidateTag vs revalidatePath — 언제 어느 걸 쓰나
| 함수 | 캐시를 고르는 기준 | 어울리는 상황 |
|---|---|---|
revalidateTag | 태그 이름 | 같은 데이터를 여러 페이지에서 쓰는 경우. 예: 상품 데이터를 목록 페이지와 상세 페이지에서 같이 쓸 때, 둘 다 같은 태그를 붙여 두면 revalidateTag 한 번이면 전부 갱신. |
revalidatePath | URL 경로 | 한 페이지의 캐시를 통째로 갱신하고 싶을 때. 태그를 안 붙인 캐시도 한꺼번에 정리. |
두 함수를 같은 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 같은 외부 캐시를 함께 씁니다.
여기까지 따라왔다면, 글 첫머리에 있던 표를 다시 보세요. 단어 하나하나가 어떤 동작을 가리키는지 자기 손가락으로 확인했을 겁니다. 캐시는 처음엔 복잡하지만, 직접 두 시각을 비교하면서 익히면 어렵지 않습니다.

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