무엇인가?
-
브라우저 표준 fetch API는 원래 클라이언트 환경을 위한 것이었다.
-
Next.js는 이를 서버 환경에서 확장하여, 자동 캐싱과 재검증 기능을 내장시켰다.
-
응답은 서버 측 Data Cache에 저장되며, 이 캐시는 요청 간, 배포 간에도 영구적으로 유지된다.
-
Data Cache는 단순한 메모리 캐시가 아니라, 파일 시스템 수준에서 지속되는 영구 저장소다.
-
여기서 중요한 점은 “
fetch확장 기능과Data Cache관련 내용은 오직 서버 사이드에 해당된다는 것”이다.
클라이언트 사이드에서의 fetch
-
클라이언트 컴포넌트에서
fetch를 사용하면 Next.js의 확장 캐싱이 적용되지 않는다. -
브라우저 표준
fetchAPI가 그대로 동작하므로, Data Cache에 저장되지 않고 매 호출마다 네트워크 요청이 발생한다. -
cache: 'force-cache',next: { revalidate },next: { tags }등의 옵션을 붙여도 무시된다.// ❌ 클라이언트 컴포넌트 — Next.js 캐싱 옵션이 전혀 동작하지 않음 'use client' useEffect(() => { fetch('https://api.example.com/posts', { next: { revalidate: 60 }, // 무시됨 cache: 'force-cache', // 무시됨 }) }, []) -
클라이언트에서 데이터를 캐싱하고 싶다면
tanstack-query,swr같은 클라이언트 사이드 캐싱 라이브러리를 사용한다.// ✅ 클라이언트 컴포넌트에서 캐싱이 필요한 경우 'use client' import { useQuery } from '@tanstack/react-query' export default function Posts() { const { data } = useQuery({ queryKey: ['posts'], queryFn: () => fetch('https://api.example.com/posts').then(r => r.json()), staleTime: 60 * 1000, // 60초 동안 캐시 유효 }) }
fetch 캐싱 방법
options.cache-
여기서부터는 서버 컴포넌트, 서버 환경을 가정한다.
-
fetch요청에cache옵션을 붙여 캐싱 동작을 제어한다. -
기본값은
'force-cache'로, 캐시가 존재하면 그것을 반환하고 없으면 새로 요청 후 저장한다.// force-cache: 캐시 우선 전략 (기본값) const data = await fetch('https://api.example.com/posts', { cache: 'force-cache', // 캐시 있음 → 캐시 반환 // 캐시 없음 → fetch 후 Data Cache에 저장 }); -
'no-store'를 사용하면 캐시를 전혀 사용하지 않고 매 요청마다 새로fetch한다.// no-store: 캐시 완전 비활성화 const data = await fetch('https://api.example.com/posts', { cache: 'no-store', // 항상 새로 fetch, 결과를 캐시에 저장하지 않음 }); -
'force-cache'는 변경이 드문 정적 데이터에 적합하고,'no-store'는 실시간 데이터(주식, 재고 등)에 사용한다. -
next: { revalidate: 0 }도'no-store'와 동일하게 동작하므로 혼용하지 않도록 주의한다.
재검증1) 시간 기반
options.next.revalidate-
next.revalidate옵션은 일정 시간(초) 이후 캐시를 자동으로 재검증하도록 예약한다. -
이 방식은 stale-while-revalidate 전략을 따른다.
-
즉, 캐시가 만료되어도 즉시 새 데이터를 기다리지 않고, 우선 오래된 캐시를 반환한 뒤 백그라운드에서 갱신한다.
// 60초마다 재검증 const data = await fetch('https://api.example.com/posts', { next: { revalidate: 60 }, // 0초 → no-store와 동일 (캐시 없음) // 60 → 60초 동안 캐시 유효 // false → 무기한 캐싱 (force-cache와 동일) });
시간 기반 재검증은 정적 라우트와 동적 라우트에서 동작 방식이 다르다.
-
정적 라우트에서는 빌드 시
fetch를 한 번 실행하고 그 결과를 캐시에 저장한다. -
revalidate시간이 지난 뒤 다음 요청이 들어오면, ISR(Incremental Static Regeneration) 방식으로 페이지 전체를 백그라운드에서 재생성한다. -
동적 라우트에서는 페이지 자체가 매 요청마다 렌더링되지만,
fetch응답은 여전히 Data Cache에서 꺼내 쓴다. -
즉, 동적 라우트라도
revalidate시간 안에는 새로fetch하지 않고 캐시된 응답을 재사용한다. -
핵심 차이는 정적 라우트는 페이지 HTML 자체가 캐시되고, 동적 라우트는 페이지 HTML은 매번 생성되되
fetch응답 데이터만 캐시된다는 점이다.
재검증2) 온디맨드
-
시간 기반 방식은 일정 주기로 갱신되므로, 데이터가 변경되어도 주기가 끝날 때까지 오래된 데이터가 제공될 수 있다.
-
온디맨드 재검증은 이 문제를 해결하기 위해, 특정 이벤트 발생 시 즉시 캐시를 무효화한다.
-
Next.js는 이를 위해
revalidatePath와revalidateTag두 가지 함수를 제공한다. -
경로 기반 재검증은 특정 URL 경로의 캐시 전체를 무효화한다.
// app/actions.ts (Server Action) 'use server' import { revalidatePath } from 'next/cache' export async function createPost() { // DB에 새 글 저장 로직... revalidatePath('/blog') // → /blog 경로와 연관된 모든 캐시 즉시 무효화 // → 다음 요청 시 새로 fetch하여 캐시 재저장 } -
이때 중요한 점은,
revalidatePath는 서버 측의 Data Cache만 지우는 것이 아니라, 브라우저에 저장되어 있는 Router Cache도 함께 무효화한다. -
태그 기반 재검증은 특정 태그가 붙은
fetch캐시만 선택적으로 무효화한다.// 1단계: fetch 시 태그 지정 (캐시 그룹화) const posts = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] }, // 이 응답은 'posts' 태그로 Data Cache에 저장됨 }) const user = await fetch('https://api.example.com/user/1', { next: { tags: ['posts', 'user-1'] }, // 두 태그를 동시에 붙일 수 있음 }) // 2단계: 필요한 시점에 태그로 무효화 import { revalidateTag } from 'next/cache' export async function updatePost() { // 업데이트 로직... revalidateTag('posts') // → 'posts' 태그가 달린 모든 fetch 캐시 즉시 무효화 // → posts, user-1 응답 모두 무효화됨 } -
revalidatePath는 특정 경로와 연결된 모든 캐시(fetch, 라우트 캐시 등)를 무효화하고,revalidateTag는 태그가 일치하는fetch캐시만 선택적으로 무효화한다는 점에서 차이가 있다. -
페이지 단위로 갱신하고 싶을 때는
revalidatePath, 데이터 단위로 정밀하게 제어하고 싶을 때는revalidateTag를 선택한다.
왜 revalidatePath 는 Data Cache, Router Cache 두 가지를 무효화할까?
-
Data Cache는 서버에 존재하며,
fetch응답 데이터를 저장한다. -
Router Cache는 브라우저에 존재하며, 이미 방문한 페이지의 RSC 페이로드를 저장한다.
-
사용자가
/blog를 한 번 방문하면, 브라우저는 그 페이지의 렌더링 결과를 Router Cache에 담아둔다. -
이후 같은 경로로 이동할 때 Next.js는 서버에 요청하지 않고 Router Cache에서 꺼내 즉시 보여준다.
-
즉, 서버의 Data Cache가 아무리 최신 데이터로 갱신되어 있어도, 브라우저가 Router Cache를 먼저 꺼내 쓰면 사용자는 새 데이터를 볼 수 없다.
-
만약 Data Cache만 무효화했을 때, 서버의
fetch응답은 최신 데이터로 갱신된다. -
그러나 브라우저의 Router Cache는 여전히 이전 페이지 결과를 들고 있다.
-
사용자가
/blog로 이동하면 브라우저는 서버에 요청하지 않고 Router Cache를 그대로 보여준다. -
Router Cache 유지 시간을 섲렁할 수 있고, 그 시간이 지나야 비로소 새 데이터가 보인다.
서버 Data Cache → ✅ 최신 데이터 브라우저 Router Cache → ❌ 이전 페이지 결과 그대로 사용자가 /blog 이동 → Router Cache 히트 → 이전 목록 표시 -
반대로 Router Cache만 무효화했을 때, 브라우저는 캐시가 없으므로 서버에 새로 요청을 보낸다.
-
그러나 서버의 Data Cache가 살아있으므로, 서버는
fetch를 새로 실행하지 않고 캐시된 응답을 반환한다. -
결과적으로 브라우저는 분명 새로 요청했지만, 받은 데이터는 여전히 갱신 전 데이터다.
revalidateTag 는 왜 Data Cache만 무효화시킬까?
-
Data Cache는
fetch요청 URL과 옵션을 기준으로 응답 데이터를 저장하며, 태그는 이 항목에 붙이는 레이블이다. -
Router Cache는 URL 경로(route path)를 기준으로 페이지 전체의 RSC 페이로드를 저장한다.
-
핵심은 태그와 경로가 1:1로 대응하지 않는다는 점이다.
-
예를 들어
'posts'태그 하나가/blog,/blog/[slug],/admin/posts등 여러 경로에 걸쳐 사용될 수 있다. -
반대로 하나의 경로에는
'posts','user','category'등 여러 태그가 붙은fetch가 혼재할 수 있다.// /blog/page.tsx 안에 여러 태그의 fetch가 공존 const posts = await fetch('/api/posts', { next: { tags: ['posts'] } }) const user = await fetch('/api/user', { next: { tags: ['user'] } }) const cats = await fetch('/api/cats', { next: { tags: ['category'] } }) -
따라서
revalidateTag('posts')를 호출해도, 이 태그가 어느 경로의 Router Cache와 연결되어 있는지 Next.js가 특정할 수 없다. -
Router Cache는 경로 단위로만 무효화할 수 있고, 태그는 경로 정보를 담고 있지 않으므로 Router Cache를 건드릴 수단이 없다.
-
반면
revalidatePath('/blog')는 경로를 명시하므로, 그 경로의 Router Cache 항목을 정확히 찾아 무효화할 수 있다. -
결국
revalidateTag의 설계 의도 자체가 "어느 경로를 다시 그릴지"가 아니라 "어떤 데이터를 무효화할지"이다. -
데이터가 무효화되면, Router Cache가 만료된 이후 브라우저가 서버에 재요청할 때 서버는 새로
fetch하여 최신 데이터를 반환한다. -
만약 데이터 갱신과 동시에 Router Cache까지 즉시 제거하고 싶다면,
revalidateTag후revalidatePath를 함께 호출하거나revalidatePath만 사용하면 된다.export async function updatePost() { revalidateTag('posts') // Data Cache만 무효화 revalidatePath('/blog') // Router Cache까지 함께 무효화하고 싶을 때 추가 }
라우트 세그먼트 설정 옵션
-
fetch단위가 아니라, 라우트 파일 전체에 캐시 동작을 일괄 적용하고 싶을 때 사용한다. -
layout.tsx또는page.tsx상단에 특정 변수를export하는 것만으로 설정된다.// app/blog/page.tsx 상단에 선언 // 렌더링 방식 제어 export const dynamic = 'auto' // 'auto' → fetch 옵션에 따라 자동 결정 (기본값) // 'force-static' → cookies, headers 등 동적 함수도 무시하고 강제 정적 렌더링 // 'force-dynamic'→ 항상 동적 렌더링, 모든 fetch를 no-store처럼 처리 // 'error' → 동적 렌더링 시도 시 에러 발생 (정적 보장) // 라우트 전체 revalidate 설정 export const revalidate = 60 // 이 파일 안의 모든 fetch에 revalidate: 60 일괄 적용 // false → 무기한 캐싱 / 0 → no-store와 동일 // 전체 fetch의 cache 전략 고정 export const fetchCache = 'auto' // 'auto' → 각 fetch의 cache 옵션 존중 (기본값) // 'force-cache' → 모든 fetch를 force-cache로 강제 // 'force-no-store'→ 모든 fetch를 no-store로 강제 -
첫 번째는 페이지 전체를 완전 정적으로 고정하는 경우다.
// 마케팅 랜딩 페이지, 약관 페이지 등 export const dynamic = 'force-static' // → cookies(), headers() 같은 동적 함수도 무시하고 무조건 정적 빌드 // → 배포 시 HTML이 완성되므로 가장 빠름 -
두 번째는 페이지 전체에 공통 갱신 주기를 주는 경우다.
// 블로그 목록, 상품 목록 등 — 자주 바뀌지 않지만 최신이어야 하는 경우 export const revalidate = 3600 // 1시간 // → 이 파일 안에 있는 모든 fetch에 revalidate: 3600 일괄 적용 // → 개별 fetch에 더 짧은 revalidate가 있으면 그것이 우선 -
세 번째는 페이지를 항상 동적으로 강제하는 경우다.
// 대시보드, 로그인 사용자 전용 페이지 등 export const dynamic = 'force-dynamic' // → 모든 fetch가 no-store처럼 동작 // → revalidate 선언이 있어도 무시됨 — 같이 쓰지 않는다 -
라우트 전체의 재검증 시간은 해당 라우트 내에 있는 모든
revalidate설정값 중 가장 낮은 값으로 결정된다. -
만약 개별
fetch에 더 짧은revalidate가 설정되어 있으면, 라우트 레벨 설정보다 개별 설정이 우선 적용된다. -
반대로 개별
fetch에 더 긴revalidate가 있어도, 라우트 설정이 더 짧다면 라우트 설정이 기준이 된다. -
fetchCache는 외부 라이브러리나 서드파티fetch가 섞여 있어 개별 옵션을 일일이 붙이기 어려울 때 전체를 강제하는 용도로 쓴다. -
대부분의 페이지는
dynamic하나 또는revalidate하나만 선언하는 것으로 충분하다.