Next.js 앱 라우터에서 데이터 캐싱 및 재검증 이해하기
Grace Collins
Solutions Engineer · Leapcell

소개
빠르게 발전하는 웹 개발 세계에서 빠르고 반응성이 뛰어난 사용자 경험을 제공하는 것이 가장 중요합니다. 이를 달성하는 가장 효과적인 방법 중 하나는 지능적인 데이터 캐싱입니다. 그러나 이 강력한 최적화는 예상치 못한 동작을 유발할 수도 있으며, 개발자들은 "내 fetch 요청이 왜 캐시되는 거지?"라고 외치게 만듭니다. 이는 특히 최신 프레임워크인 Next.js를 사용할 때, 특히 앱 라우터가 도입된 이후에 흔히 발생하는 혼란스러운 지점입니다. Next.js가 데이터 캐싱 및 재검증을 처리하는 방식을 이해하는 것은 성능이 뛰어나고 예측 가능한 애플리케이션을 구축하는 데 중요하며, 이것이 바로 이 글에서 탐구할 내용입니다. Next.js가 데이터를 관리하기 위해 사용하는 정교한 전략을 자세히 살펴보고 사용자가 보는 것을 항상 제어할 수 있도록 할 것입니다.
핵심 개념
Next.js의 캐싱 메커니즘의 복잡한 부분을 살펴보기 전에, 우리의 논의에서 중심이 될 몇 가지 기본 용어를 정의해 보겠습니다.
- 캐싱(Caching): 나중에 해당 데이터에 대한 요청을 더 빠르게 처리할 수 있도록 데이터 또는 파일의 복사본을 임시 저장 위치에 저장하는 프로세스입니다.
 - 재검증(Revalidation): 캐시된 데이터의 최신 상태를 확인하고 필요한 경우 새 데이터를 가져와 캐시를 업데이트하는 프로세스입니다.
 - 정적 렌더링(Static Rendering - SSG): 페이지는 빌드 시점에 미리 렌더링되어 CDN에서 직접 제공되는 정적 HTML 파일을 생성합니다. 이러한 페이지는 새 빌드가 완료될 때까지 본질적으로 캐시됩니다.
 - 동적 렌더링(Dynamic Rendering - SSR): 페이지는 요청 시점에 서버에서 렌더링됩니다. 이를 통해 동적 데이터 가져오기가 가능하지만, 서버는 여전히 내부 
fetch호출의 결과를 캐시할 수 있습니다. - 서버 컴포넌트(Server Component): React(및 Next.js 앱 라우터)의 새로운 패러다임으로, 컴포넌트를 서버에서 직접 렌더링하여 클라이언트 측 JavaScript를 줄이고 초기 페이지 로드를 개선할 수 있습니다. 이러한 컴포넌트는 데이터를 직접 
fetch할 수 있습니다. - 데이터 캐시(Data Cache): Next.js가 서버에서 수행된 
fetch요청의 결과를 저장하는 데 사용하는 스토리지 메커니즘(종종 메모리 또는 디스크)입니다. - 전체 라우트 캐시(Full Route Cache): 데이터 캐시를 기반으로 렌더링된 라우트의 전체 HTML 페이로드를 저장하는 캐시입니다.
 
Next.js 앱 라우터의 데이터 캐싱 및 재검증 전략
Next.js 앱 라우터는 매우 정교하고 의견이 강한 캐싱 시스템을 도입했습니다. 핵심적으로 서버 컴포넌트 및 기타 서버 측 컨텍스트 내에서 수행되는 데이터 요청을 지능적으로 캐시합니다. 이 캐싱은 단순한 키-값 저장소가 아니라 렌더링 라이프사이클과 깊이 통합되어 있으며 웹의 fetch API를 기반으로 구축되었습니다.
기본적으로 서버 컴포넌트 내의 fetch 요청은 캐시 가능하다고 가정합니다. fetch 요청이 기본 cache: 'force-cache' 옵션(지정하지 않은 경우 기본값)으로 수행되면 Next.js는 응답을 데이터 캐시에 저장합니다. 후속 동일한 fetch 요청(동일한 URL, 동일한 옵션)은 이 캐시에서 제공되어 데이터 검색 속도를 크게 향상시킵니다.
이를 예시로 설명해 보겠습니다.
// app/page.tsx async function getPosts() { const res = await fetch('https://jsonplaceholder.typicode.com/posts'); // 'cache: "force-cache"' 옵션이 여기서는 내포되어 있습니다. if (!res.ok) { throw new Error('Failed to fetch data'); } return res.json(); } export default async function Page() { const posts = await getPosts(); return ( <div> <h1>Posts</h1> <ul> {posts.map((post: any) => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }
이 시나리오에서 getPosts()는 API에서 데이터를 가져옵니다. 이 라우트가 처음 렌더링될 때 Next.js는 실제 API 호출을 수행하고 응답을 캐시합니다. 사용자가 짧은 시간 내에 다시 이 페이지에 액세스하거나 서버 컴포넌트가 다시 렌더링되는 경우(예: 다른 사용자로 인해 데이터가 공유되는 경우) Next.js는 jsonplaceholder.typicode.com에 대한 다른 네트워크 요청을 하지 않고 데이터 캐시에서 직접 데이터를 제공합니다.
언제 캐시가 무효화되거나 재검증되나요?
이것이 Next.js의 유연성이 빛을 발하는 부분입니다. 재검증을 관리하는 데는 여러 가지 방법이 있습니다.
- 
시간 기반 재검증 (
next.revalidate):next.revalidate옵션을 사용하여 특정fetch요청에 대한 캐시를 일정 시간 후에 재검증하도록 Next.js에 알릴 수 있습니다.async function getPosts() { const res = await fetch('https://jsonplaceholder.typicode.com/posts', { next: { revalidate: 60 }, // 60초마다 재검증 }); if (!res.ok) { throw new Error('Failed to fetch data'); } return res.json(); }revalidate: 60을 사용하면 데이터가 캐시된 후 60초가 지나면 다음 요청은 백그라운드 재검증을 트리거합니다. stale 데이터는 즉시 제공되며, Next.js는 백그라운드에서 새 데이터를 조용히 가져와 후속 요청을 위해 캐시를 업데이트합니다. - 
명시적 캐시 무효화 (
cache: 'no-store'):fetch요청이 캐시되지 않도록 절대적으로 방지하려면 명시적으로 옵트아웃할 수 있습니다.async function getLiveStockPrices() { const res = await fetch('https://api.example.com/stock-prices', { cache: 'no-store', // 항상 최신 데이터 가져오기 }); if (!res.ok) { throw new Error('Failed to fetch data'); } return res.json(); }이는 자주 변경되고 stale 데이터를 표시하는 것이 용납되지 않는 매우 동적인 데이터(예: 실시간 주가, 사용자별 장바구니 내용)에 이상적입니다.
 - 
라우트 세그먼트의 캐싱 옵트아웃: 전체 라우트 세그먼트를 동적으로 구성하여 해당 세그먼트 내의 모든
fetch요청이 기본적으로no-store로 설정되고 라우트 자체가 동적으로 렌더링되도록 할 수 있습니다. 이는layout.tsx또는page.tsx파일에서dynamic옵션을 내보내서 수행됩니다.// app/dashboard/page.tsx 또는 app/dashboard/layout.tsx export const dynamic = 'force-dynamic'; // 이 세그먼트의 모든 fetch를 'no-store'처럼 동작하게 만듭니다.다른 옵션으로는
'auto'(기본값),'error','force-static'이 있습니다.dynamic = 'force-dynamic'으로 설정하는 것은 애플리케이션의 전체 부분에 걸쳐 최신 상태를 보장하는 강력한 방법입니다. - 
요청 시 재검증 (
revalidatePath,revalidateTag): 외부 이벤트(예: CMS 업데이트, 전자 상거래 주문)에 따라 데이터가 변경되는 경우 프로그래밍 방식으로 재검증을 트리거할 수 있습니다.revalidatePath(path: string): 특정 경로의 데이터 캐시를 재검증합니다.revalidateTag(tag: string): 특정 문자열로 태그가 지정된 모든fetch요청을 재검증합니다.
revalidateTag를 사용하려면fetch요청에 태그를 지정해야 합니다.// app/products/page.tsx async function getProducts() { const res = await fetch('https://api.example.com/products', { next: { tags: ['products'] }, // 이 fetch 요청에 태그 지정 }); if (!res.ok) { throw new Error('Failed to fetch products'); } return res.json(); } // api/revalidate-products.ts (재검증 트리거용 API 경로) import { NextRequest, NextResponse } from 'next/server'; import { revalidateTag } from 'next/cache'; export async function GET(request: NextRequest) { const tag = request.nextUrl.searchParams.get('tag'); // 예: ?tag=products if (tag) { revalidateTag(tag); return NextResponse.json({ revalidated: true, now: Date.now() }); } return NextResponse.json({ revalidated: false, message: 'Missing tag' }); }이를 통해 웹훅 또는 관리 인터페이스를 구축하여 특정 캐시 항목을 프로그래밍 방식으로 삭제하고 전체 애플리케이션을 다시 빌드하지 않고도 즉각적인 업데이트를 제공할 수 있습니다.
 
캐시 무효화 트리거 이해하기
Next.js의 데이터 캐시 무효화 트리거에 대한 몇 가지 사항을 인지하는 것이 중요합니다.
- 배포: Next.js 애플리케이션의 새 배포는 모든 데이터 캐시를 삭제합니다.
 next.revalidate시간 초과: 위에서 논의했듯이 이는 백그라운드 재검증으로 이어집니다.- 요청 시 재검증: 
revalidatePath또는revalidateTag가 명시적으로 호출됩니다. - 개발 모드: 개발 모드(
npm run dev)에서는 Next.js가 일반적으로 덜 공격적인 캐싱을 수행하여 항상 최신 변경 사항을 즉시 볼 수 있도록 합니다. 프로덕션과 동일한 캐싱 동작을 관찰하지 못할 수도 있습니다. 
결론
Next.js 앱 라우터에서 "내 fetch가 왜 캐시되는 거지?"라는 난제는 강력하지만 때로는 보이지 않는 최적화 전략의 증거입니다. 서버 컴포넌트 내에서 fetch의 기본 캐싱 동작을 이해하고, next.revalidate, cache: 'no-store', export const dynamic 및 요청 시 재검증 함수와 같은 재검증 도구를 마스터함으로써 개발자는 데이터 최신 상태를 정확하게 제어하고 고성능 사용자 경험을 제공할 수 있습니다. Next.js의 데이터 캐싱 및 재검증 메커니즘을 효과적으로 활용하는 것이 빠르고 동적이며 안정적인 웹 애플리케이션을 구축하는 데 핵심입니다.