Next.js App Router 캐시 전략 설계 — fetch cache 옵션, revalidate, unstable_cache를 상황에 맞게 조합하는 법
Next.js App Router로 넘어온 뒤 가장 많이 받는 질문 중 하나가 "캐시를 어떻게 설정해야 하냐"는 것입니다. 솔직히 저도 처음엔 force-cache인지 no-store인지, revalidate는 어디에 붙여야 하는 건지 헷갈렸고, unstable_cache라는 이름에 겁먹어서 한동안 쓰기를 미뤘습니다.
막상 뜯어보면 각 API가 커버하는 영역이 다릅니다. no-store로만 운영하면 TTFB가 2~3배 올라가는 경우도 있고, 반대로 force-cache를 무방비로 쓰다가 수정한 콘텐츠가 사용자에게 한참 뒤에 반영되는 상황도 생깁니다. fetch의 cache 옵션, next.revalidate, unstable_cache를 언제 쓰고 언제 조합하는지 판단 기준을 잡는 것이 이 글의 핵심입니다.
이 글은 Next.js App Router를 이미 써보셨거나 현재 사용 중인 프론트엔드 개발자를 대상으로 합니다. fetch를 서버 컴포넌트에서 호출해봤다면 바로 따라올 수 있는 수준입니다.
핵심 개념
세 가지 캐싱 레이어, 각자의 역할이 있다
Next.js App Router의 캐싱은 단일 시스템이 아닙니다. 크게 세 층으로 나눠서 생각하는 게 편합니다.
레이어
적용 대상
핵심 역할
fetch + cache 옵션
HTTP fetch 호출
Web 표준 기반, Data Cache(서버 사이드 HTTP 응답 캐시) 제어
next: { revalidate }
HTTP fetch 호출
TTL 기반 stale-while-revalidate
unstable_cache
DB 쿼리·ORM·임의 함수
fetch 외 비동기 함수 캐싱
각 레이어는 독립적이지 않고 tags를 공유해서 on-demand 무효화를 연결할 수 있습니다. 이 연결 고리를 이해하면 전체 전략이 훨씬 명확해집니다.
fetch 캐시 옵션 — HTTP 응답을 얼마나 오래 재사용할 것인가
Next.js는 Web 표준 fetch를 확장해서 서버 사이드 Data Cache를 제어합니다. 자주 쓰는 옵션은 세 가지입니다.
typescript
// 빌드 타임에 고정 — 영구 캐시const res = await fetch('https://api.example.com/posts', { cache: 'force-cache',})// 매 요청마다 새로 가져오기 — 캐시 완전 우회const res = await fetch('https://api.example.com/posts', { cache: 'no-store',})// 60초 TTL + 태그 기반 무효화 가능const res = await fetch('https://api.example.com/posts', { next: { revalidate: 60, tags: ['posts'] },})
Next.js 15 기본값 변경: Next.js 14까지는 force-cache가 기본이었지만, 15부터 no-store가 기본값으로 바뀌었습니다. 암묵적으로 캐시에 의존하다 발생하는 문제보다, 명시적으로 선택하는 쪽이 낫다는 방향성입니다. 업그레이드 후 성능이 저하됐다면 이 부분을 먼저 확인해볼 수 있습니다.
revalidate — "오래된 것을 보여주더라도 백그라운드에서 갱신"
next: { revalidate: N }은 ISR(Incremental Static Regeneration, 증분 정적 재생성)을 fetch 레벨에서 적용하는 방식입니다. N초 동안은 캐시된 응답을 바로 돌려주고, 만료 후 첫 요청이 오면 기존 데이터를 먼저 반환하면서 백그라운드에서 새 데이터를 가져옵니다.
stale-while-revalidate: 캐시가 만료돼도 즉시 오래된 값을 반환하고, 동시에 백그라운드에서 갱신을 시작하는 패턴입니다. 사용자 입장에서는 항상 빠른 응답을 받지만, 재검증 직후 한 번은 이전 데이터를 볼 수 있습니다.
저는 이 방식을 처음 썼을 때 TTL을 5분으로 설정했는데, 상품 가격이 수정된 뒤에도 5분간 이전 가격이 그대로 노출되는 상황을 경험했습니다. 실시간성이 중요한 데이터라면 이 특성을 반드시 염두에 두는 것이 좋습니다.
Request Memoization — 같은 렌더링 사이클 안에서 중복 요청 제거
알아두면 의외로 유용한 개념입니다. 하나의 서버 렌더링 사이클 내에서 동일한 URL로 fetch를 여러 컴포넌트가 각자 호출해도 실제 네트워크 요청은 한 번만 나갑니다. React 내부 최적화인 Request Memoization이 중복을 제거해주기 때문입니다. 서버 사이드 Data Cache와는 별개로 동작합니다.
덕분에 "이 데이터를 props로 내려줄까, 각 컴포넌트에서 직접 fetch할까"를 고민할 때 성능 걱정 없이 후자를 선택할 수 있습니다.
unstable_cache — fetch를 쓰지 않는 모든 비동기 함수를 캐싱
Prisma, Drizzle 같은 ORM을 쓰거나 DB 클라이언트로 직접 쿼리할 때는 fetch가 개입하지 않습니다. 이럴 때 unstable_cache가 해결책입니다.
typescript
import { unstable_cache } from 'next/cache'import { db } from './db'export const getCachedPosts = unstable_cache( async () => { return await db.post.findMany({ where: { published: true } }) }, ['posts-list'], // 캐시 키 접두어 { tags: ['posts'], // on-demand 무효화용 태그 revalidate: 3600, // 1시간 TTL })
함수 인자는 JSON 직렬화 방식으로 캐시 키에 자동 포함됩니다. 객체나 배열을 인자로 넘길 때 직렬화 결과가 예상과 다를 수 있으니, 가능하면 문자열이나 숫자처럼 단순한 값을 인자로 쓰는 것이 안전합니다.
이름에 unstable이 붙어서 프로덕션에서 쓰면 안 되는 것 아닌가 싶을 수 있는데, 실제로는 현업에서 널리 쓰이고 있습니다. 공식 문서가 장기적으로 use cache로의 교체를 권고하는 과도기적 위치에 있는 API일 뿐입니다. 캐시 키 설계를 잘못하면 사용자 간 데이터가 섞이는 심각한 문제가 생길 수 있으니 이 부분만 주의하면 됩니다.
On-demand 무효화 — 이벤트가 발생했을 때 즉시 캐시 초기화
시간 기반 재검증만으로는 충분하지 않을 때가 있습니다. CMS에서 글을 발행하거나 관리자가 상품 정보를 수정했을 때 즉시 반영이 필요한 경우입니다. revalidateTag와 revalidatePath가 이 역할을 합니다.
revalidateTag는 fetch의 tags 옵션과 unstable_cache의 tags 옵션 모두에 걸려 있는 캐시를 한 번에 무효화합니다. 태그를 잘 설계하면 하나의 Server Action으로 여러 레이어를 한꺼번에 갱신할 수 있습니다.
실험적 API: use cache (Next.js 15+)
use cache 디렉티브는 앞서 소개한 방식들을 하나의 API로 통합하려는 시도입니다. 활성화하려면 next.config.js에서 experimental.dynamicIO: true를 설정해야 합니다. 이 플래그 없이 코드에 'use cache'만 써두면 아무 일도 일어나지 않습니다.
'use cache'import { cacheLife, cacheTag } from 'next/cache'export async function getBlogPosts() { cacheTag('posts') cacheLife('hours') // stale/revalidate/expire가 미리 정의된 내장 프로파일 return await db.post.findMany()}
cacheLife의 내장 프로파일(seconds, minutes, hours, days, weeks, max)은 각각 stale/revalidate/expire 값이 미리 설정돼 있습니다. next.config.js의 cacheLife 키로 커스텀 프로파일을 추가하는 것도 가능합니다. 아직 실험적이라 on-demand ISR과의 통합이 완전하지 않은 점은 프로덕션 투입 전에 확인해볼 필요가 있습니다.
실전 적용
예시 1: 블로그 포스트 목록 — 빌드 타임 캐시
자주 바뀌지 않는 정적 콘텐츠는 force-cache로 빌드 타임에 고정하는 게 가장 간단합니다. 여기서 한 가지 팁이 있는데, tags를 같이 달아두면 나중에 CMS 웹훅과 연결해서 온디맨드 무효화를 쓸 수 있습니다.
typescript
// app/blog/page.tsxasync function BlogPage() { const res = await fetch('https://cms.example.com/posts', { cache: 'force-cache', next: { tags: ['posts'] }, }) if (!res.ok) { throw new Error('포스트 목록을 불러오지 못했습니다') } const posts = await res.json() return <PostList posts={posts} />}
항목
설명
cache: 'force-cache'
최초 요청 후 캐시에 저장, 이후 캐시 응답 반환
tags: ['posts']
CMS 웹훅에서 revalidateTag('posts') 호출 시 즉시 갱신 가능
force-cache만 쓰면 배포 후 콘텐츠를 수정해도 반영이 안 되는 상황이 생깁니다. tags를 같이 달아두고 CMS 발행 이벤트와 연결해두면 이 문제를 해결할 수 있습니다.
그런데 콘텐츠가 꽤 자주 바뀐다면 어떨까요?
예시 2: 상품 목록 — 시간 기반 재검증
실시간까지는 필요 없지만 몇 분 단위로 갱신이 필요한 경우입니다. 쇼핑몰 상품 목록이나 뉴스 피드가 여기 해당합니다.
typescript
// app/shop/page.tsxasync function ShopPage() { const res = await fetch('https://api.shop.com/products', { next: { revalidate: 300, tags: ['products'] }, // 5분 TTL }) if (!res.ok) { throw new Error('상품 목록을 불러오지 못했습니다') } const products = await res.json() return <ProductList products={products} />}
5분 이내 재방문자는 캐시된 응답을 받고, 5분 후 첫 방문자는 이전 데이터를 받으면서 백그라운드에서 갱신이 시작됩니다. 대부분의 사용자는 최신에 가까운 데이터를 빠르게 받는 셈입니다.
반면 주문 직후 재고 현황처럼 실시간성이 필수인 경우라면 no-store가 맞는 선택입니다.
typescript
// 실시간 재고 확인 — 캐시 완전 우회async function StockPage() { const res = await fetch('https://api.shop.com/stock/current', { cache: 'no-store', }) if (!res.ok) { throw new Error('재고 정보를 불러오지 못했습니다') } const stock = await res.json() return <StockDisplay stock={stock} />}
예시 3: DB 쿼리 캐싱 — unstable_cache 활용
ORM을 쓰는 경우 — Prisma나 Drizzle로 DB에 직접 접근하는 경우를 말합니다 — fetch를 거치지 않기 때문에 unstable_cache가 필요합니다. 이때 캐시 키에 동적 값을 어떻게 포함시키느냐가 핵심입니다.
CMS에서 콘텐츠를 발행할 때 즉시 캐시를 갱신해야 하는 경우입니다. Route Handler로 웹훅 엔드포인트를 만들어두면 됩니다.
typescript
// app/api/revalidate/route.tsimport { revalidateTag } from 'next/cache'import { NextRequest } from 'next/server'export async function POST(req: NextRequest) { const { tag, secret } = await req.json() if (secret !== process.env.REVALIDATE_SECRET) { return Response.json({ error: 'Unauthorized' }, { status: 401 }) } revalidateTag(tag) return Response.json({ revalidated: true, tag })}
CMS 측에서 POST /api/revalidate 요청에 { tag: 'posts', secret: '...' }를 담아 보내면, 해당 태그가 달린 모든 캐시(fetch든 unstable_cache든)가 한 번에 무효화됩니다. 인증 없이 열어두면 누구나 캐시를 날릴 수 있으니 REVALIDATE_SECRET 검증은 빠뜨리지 않는 것이 좋습니다.
Server Action에서 쓸 때는 DB 업데이트가 성공한 이후에만 revalidateTag가 호출되도록 순서를 지키는 것이 중요합니다.
typescript
// app/actions.ts'use server'import { revalidateTag } from 'next/cache'export async function publishPost(id: string) { // 업데이트가 실패하면 예외가 발생하고 아래 줄은 실행되지 않습니다 await db.post.update({ where: { id }, data: { published: true } }) revalidateTag('posts')}
예시 5: 컴포넌트 단위 캐싱 — use cache (Next.js 15+)
use cache가 활성화된 환경이라면 컴포넌트 단위로 캐시를 관리하는 방식도 가능합니다. 상품 카드처럼 개별 항목마다 독립적인 캐시 수명을 갖게 하고 싶을 때 유용합니다.
typescript
// components/ProductCard.tsx'use cache'import { cacheTag, cacheLife } from 'next/cache'export async function ProductCard({ productId }: { productId: string }) { cacheTag(`product-${productId}`) // 개별 상품 단위 태그 cacheLife({ revalidate: 300, expire: 86400 }) // 5분 재검증, 1일 만료 const product = await db.product.findUnique({ where: { id: productId } }) if (!product) return null return <div>{product.name}</div>}
cacheTag를 product-${productId} 형태로 설계해두면 특정 상품만 선택적으로 무효화할 수 있습니다. revalidateTag('product-abc123') 한 줄로 해당 상품 카드만 갱신되고 다른 카드는 건드리지 않습니다.
실무에서 가장 흔한 실수
사용자 데이터를 전역 캐시에 저장하는 경우 — unstable_cache 키에 userId를 빠뜨리면 A 사용자의 프로필이 B 사용자에게 보이는 심각한 버그가 생깁니다. 개인화된 데이터는 반드시 키에 식별자를 포함시키는 것이 좋습니다.
on-demand 무효화만 믿는 경우 — 웹훅 실패, 네트워크 오류 등으로 revalidateTag 호출이 누락될 수 있습니다. 시간 기반 revalidate를 폴백으로 함께 설정해두는 것이 안전합니다.
force-cache와 no-store를 같은 Route Segment(Next.js의 라우트 단위)에서 혼용하는 경우 — 이때 오류가 throw되는 것이 아니라, 해당 경로 전체가 동적 렌더링으로 폴백됩니다. 의도하지 않은 성능 저하가 생길 수 있으니 한 경로 안에서 캐시 전략을 통일하는 것이 좋습니다.
장단점 분석
장점
항목
내용
fetch + cache: 'force-cache'
Web 표준 API, 자동 Request Memoization으로 같은 URL 중복 요청 제거
revalidate
ISR을 fetch 레벨에서 바로 적용, 사용자는 항상 빠른 응답 수신
unstable_cache
ORM·SDK 등 fetch 외 모든 비동기 함수에 적용 가능
revalidateTag
이벤트 기반 즉시 갱신, fetch와 unstable_cache를 태그로 묶어 일괄 무효화
use cache
함수·컴포넌트·파일 레벨 통합 캐싱, unstable_cache보다 타입 안전
단점 및 주의사항
항목
내용
대응 방안
force-cache 기본값 변경
Next.js 14→15 업그레이드 후 캐시가 꺼져 성능 저하 가능
필요한 곳에 명시적으로 캐시 옵션 추가
stale-while-revalidate 특성
재검증 직후 요청은 이전 데이터 반환
실시간성 필요 데이터는 no-store 사용
unstable_cache 키 오염
키에 userId 미포함 시 사용자 간 데이터 혼재
동적 값은 반드시 캐시 키에 포함
너무 넓은 태그
revalidateTag('all') 식의 광범위 무효화
posts, post-{id} 계층적 태그 설계
use cache 실험적 상태
on-demand ISR과의 통합 미완성, dynamicIO 플래그 필요
프로덕션 투입 전 동작 검증 필요
마치며
캐싱 전략의 핵심은 "이 데이터가 얼마나 오래 유효한가"에 대한 명시적 선언입니다. 이 선언이 없으면 Next.js 15 기본값인 no-store 아래서 모든 요청이 새로 서버를 거치게 되고, 도입부에서 이야기한 TTFB 차이가 바로 거기서 옵니다. 반대로 데이터 특성에 맞게 캐시를 명시적으로 선언해두면 서버 부하는 낮추면서 사용자에게는 예측 가능하고 빠른 응답을 제공할 수 있습니다.
지금 바로 시작해볼 수 있는 3단계:
현재 프로젝트에서 fetch를 호출하는 곳을 찾아 cache 옵션이 명시돼 있는지 확인해볼 수 있습니다. Next.js 14에서 15로 올렸다면 기본값 변경의 영향을 받은 곳이 있을 수 있습니다.
ORM이나 DB 클라이언트를 직접 호출하는 함수가 있다면 unstable_cache로 감싸고 tags 옵션을 달아보시면 좋습니다. 태그 이름은 posts, post-{id}처럼 계층적으로 설계하면 무효화 범위를 세밀하게 조절할 수 있습니다.
CMS나 관리자 기능이 있다면 revalidateTag를 Server Action 또는 Route Handler에 연결해두면 수동 배포 없이 콘텐츠를 즉시 반영할 수 있습니다.