Next.js App Router에서 캐시 제어 및 재검증 마스터하기
Lukas Schneider
DevOps Engineer · Leapcell

소개: 끊임없이 진화하는 데이터 환경
오늘날의 동적인 웹 애플리케이션에서 데이터는 거의 정적이지 않습니다. 실시간 대시보드부터 빠르게 업데이트되는 전자상거래 제품 목록까지, 사용자가 항상 최신 정보를 볼 수 있도록 보장하는 것이 가장 중요합니다. 동시에 빠르고 반응성이 뛰어난 사용자 경험을 제공하려면 효율적인 데이터 가져오기 및 캐싱이 필수적입니다. 강력한 App Router를 갖춘 Next.js는 이러한 섬세한 균형을 맞추기 위한 혁신적인 기능을 제공합니다. 그러나 캐싱 및 재검증 메커니즘에 대한 미묘한 이해 없이는 개발자가 오래된 데이터를 제공하거나 불필요한 서버 부하를 유발할 위험이 있습니다. 이 글에서는 Next.js App Router 내에서 캐싱 및 데이터 재검증에 대한 세분화된 제어를 달성하는 방법을 심층적으로 살펴보고, 성능과 최신 정보를 모두 갖춘 애플리케이션을 구축할 수 있도록 지원합니다.
효과적인 데이터 관리를 위한 핵심 개념
구현 세부 정보에 대해 자세히 알아보기 전에 Next.js App Router의 데이터 처리를 뒷받침하는 주요 개념을 명확히 이해해 봅시다.
- 캐싱: 향후 데이터 요청을 더 빠르게 처리할 수 있도록 데이터 복사본을 저장하는 프로세스입니다. Next.js에서는 브라우저, CDN 및 서버 측 렌더링(SSR) 출력을 포함한 다양한 계층에서 캐싱이 발생합니다.
- 데이터 재검증: 캐시된 데이터가 여전히 신선한지 확인하고, 신선하지 않으면 새 데이터를 가져오는 프로세스입니다. 이는 데이터 일관성을 유지하고 오래된 콘텐츠를 방지하는 데 중요합니다.
- 요청 메모이제이션: 함수 호출 결과를 저장하고 후속 동일한 호출에 대해 반환하여 불필요한 계산을 방지하는 특정 유형의 캐싱입니다.
- 시간 기반 재검증(ISR): 캐시된 콘텐츠가 고정된 간격으로 다시 생성되어 신선도와 성능 간의 균형을 보장하는 전략입니다.
- 요청 시 재검증: 외부 이벤트가 데이터 변경을 신호할 때 캐시된 콘텐츠가 명시적으로 다시 생성되어 즉각적인 신선도를 제공하는 전략입니다.
fetch
캐시 제어: Next.js App Router는 네이티브fetch
API를 강력한 캐싱 기능으로 자동 확장하여 개발자가 데이터 요청 내에서 직접 캐싱 동작을 정의할 수 있습니다.
데이터 신선도에 대한 세분화된 제어
Next.js App Router는 fetch
API 내에 캐싱 및 재검증 컨트롤을 전략적으로 배치하여 개발자가 원본 지점에서 데이터 동작을 지정할 수 있도록 합니다.
fetch
캐시 옵션 이해하기
React Server Component(또는 SSR 중에 fetch
호출이 일괄 처리되는 경우 클라이언트 컴포넌트)에서 데이터 요청을 할 때 fetch
API의 두 번째 인수는 중요한 캐싱 속성을 가진 options
객체를 받습니다.
-
cache: 'force-cache'
(정적fetch
호출의 기본값) 이 옵션은 Next.js에 Next.js 데이터 캐시에서 데이터를 검색하도록 항상 시도하라고 지시합니다. 데이터가 있으면 사용됩니다. 그렇지 않으면 요청이 이루어지고 데이터가 캐시됩니다. 이는 진정한 정적 데이터 또는 성능을 위해 일정 수준의 오래된 상태를 허용할 수 있는 infrequently 변경되는 데이터에 이상적입니다.// app/products/[id]/page.tsx async function getProduct(id: string) { const res = await fetch(`https://api.example.com/products/${id}`, { cache: 'force-cache', // 명시적으로 캐시 강제, 종종 기본값이지만 }); if (!res.ok) throw new Error('Failed to fetch product'); return res.json(); } export default async function ProductPage({ params }: { params: { id: string } }) { const product = await getProduct(params.id); return ( <div> <h1>{product.name}</h1> <p>{product.description}</p> </div> ); }
-
cache: 'no-store'
이 옵션은 Next.js에 Next.js 데이터 캐시에 데이터를 캐시하지 않도록 명시적으로 알려줍니다. 각 요청은 항상 원본 서버로 이동합니다. 이는 사용자별 프로필 또는 실시간 센서 판독과 같이 항상 신선해야 하는 매우 동적인 데이터에 필수적입니다.// app/dashboard/page.tsx async function getUserFeed(userId: string) { const res = await fetch(`https://api.example.com/users/${userId}/feed`, { cache: 'no-store', // 항상 신선한 데이터 가져오기 }); if (!res.ok) throw new Error('Failed to fetch user feed'); return res.json(); } export default async function DashboardPage() { // userId는 인증 컨텍스트 또는 세션에서 얻는다고 가정 const userFeed = await getUserFeed('some-user-id'); return ( <div> <h2>Your Latest Activities</h2> <ul> {userFeed.map((activity: any) => ( <li key={activity.id}>{activity.message}</li> ))} </ul> </div> ); }
-
next: { revalidate: number | false }
이 중요한 옵션은 시간 기반 재검증을 활성화하여 데이터 수준에서 점진적 정적 재생성(ISR)을 효과적으로 구현합니다.-
revalidate: number
: 지정된 초(number
) 후에 캐시된 데이터는 오래된 것으로 간주됩니다. 다음 요청은 백그라운드 재검증이 새 데이터를 가져오는 동안 오래된 데이터를 제공합니다. 새 데이터가 가져와지면 후속 요청에 대해 오래된 데이터를 캐시에서 대체합니다.// app/news/page.tsx async function getLatestNews() { const res = await fetch('https://api.example.com/news', { next: { revalidate: 60 }, // 60초마다 재검증 }); if (!res.ok) throw new Error('Failed to fetch news'); return res.json(); } export default async function NewsPage() { const newsItems = await getLatestNews(); return ( <div> <h1>Latest News</h1> <ul> {newsItems.map((item: any) => ( <li key={item.id}>{item.title}</li> ))} </ul> </div> ); }
-
revalidate: false
:POST
요청과 연결되지 않거나 동적으로 렌더링되는 라우트 내부에 있지 않은 명시적cache: 'force-cache'
가 없는fetch
요청의 기본값입니다. 이는 시간이 지남에 따라 데이터가 절대 재검증되지 않음을 의미합니다. 정적일 수 있는 요청의 경우 암묵적으로force-cache
에 의존합니다.
-
요청 시 재검증으로 즉각적인 신선도 확보
시간 기반 재검증은 여러 시나리오에 훌륭하지만, 사용자가 새 게시물을 만들거나 프로필을 업데이트할 때와 같이 즉각적인 데이터 신선도가 요구되는 이벤트가 있습니다. Next.js는 revalidatePath
및 revalidateTag
함수를 사용하여 요청 시 재검증을 위한 강력한 메커니즘을 제공합니다.
revalidatePath(path: string)
이 함수는 특정 경로의 캐시를 무효화합니다. 이후 경로가 요청되면 해당 경로 내의 모든 fetch
요청(명시적으로 no-store
로 설정되지 않은 경우)에 대해 새 데이터가 가져와집니다.
// app/actions.ts (Server Action 또는 API 라우트 핸들러) 'use server'; import { revalidatePath } from 'next/cache'; export async function createPost(formData: FormData) { const title = formData.get('title'); const content = formData.get('content'); // 게시물 생성 API 호출 시뮬레이션 await new Promise(resolve => setTimeout(resolve, 1000)); console.log('Post created:', { title, content }); // 게시물 목록 페이지를 재검증하여 새 게시물을 표시 revalidatePath('/blog'); return { success: true }; }
revalidateTag(tag: string)
이는 세분화된 재검증을 위한 가장 강력한 메커니즘입니다. fetch
요청에 태그를 할당하여 애플리케이션 전체의 특정 데이터 그룹을 무효화할 수 있습니다.
// app/products/[id]/page.tsx async function getProduct(id: string) { const res = await fetch(`https://api.example.com/products/${id}`, { next: { tags: ['products', `product-${id}`] }, // 특정 태그 할당 }); if (!res.ok) throw new Error('Failed to fetch product'); return res.json(); } // app/actions.ts (Server Action 또는 API 라우트 핸들러) 'use server'; import { revalidateTag } from 'next/cache'; export async function updateProduct(id: string, newPrice: number) { // 제품 가격 업데이트 API 호출 시뮬레이션 await new Promise(resolve => setTimeout(resolve, 500)); console.log(`Product ${id} updated with new price: ${newPrice}`); // 모든 제품 관련 캐시 무효화 또는 특정 제품만 revalidateTag('products'); // 'products' 태그가 지정된 모든 것 무효화 // 또는: revalidateTag(`product-${id}`); // 특정 제품만 무효화 return { success: true }; }
이 접근 방식은 매우 유연합니다. updateProduct
가 호출되면 products
태그로 가져온 데이터에 의존하는 모든 페이지 또는 컴포넌트는 다음 요청 시 재검증되어 사용자가 항상 올바른 가격을 볼 수 있도록 보장합니다.
fetch
요청 메모이제이션 함정
동일한 React 렌더링 패스 내(예: 단일 서버 컴포넌트 내 또는 함께 렌더링되는 여러 중첩 서버 컴포넌트 내)의 fetch
요청은 Next.js에 의해 자동으로 메모이제이션된다는 점을 이해하는 것이 중요합니다. 이는 fetch('your-api.com/data')
를 동일한 렌더링 주기 내에서 여러 번 호출하면 cache: 'no-store'
가 지정된 경우에도 Next.js는 네트워크 요청을 한 번만 실행하고 후속 호출에 대해 캐시된 결과를 반환한다는 것을 의미합니다. 특정, 드문 시나리오(예: 본질적으로 데이터를 변경하는 내부 API 호출)에서 이 메모이제이션을 우회하고 새 요청을 강제하려면 다른 HTTP 클라이언트를 사용하거나 메모이제이션 키를 우회하기 위해 URL에 고유 쿼리 매개변수를 추가하는 것을 고려할 수 있습니다. 그러나 대부분의 사용 사례에서 이 메모이제이션은 성능 최적화입니다.
결론: 최고의 성능을 위한 데이터 흐름 오케스트레이션
Next.js App Router에서 캐시 제어 및 데이터 재검증을 마스터하는 것은 현대적이고 성능이 뛰어난 안정적인 웹 애플리케이션을 구축하는 초석입니다. fetch
요청과 함께 cache
및 revalidate
옵션을 신중하게 적용하고, 요청 시 업데이트를 위해 revalidatePath
및 revalidateTag
를 활용함으로써 개발자는 데이터가 캐시되고 새로 고쳐지는 시점과 방법을 정확하게 지정할 수 있으며, 최적의 사용자 경험과 데이터 신선도를 보장합니다. 궁극적으로 이는 사전 렌더링된 속도와 동적 데이터 정확성 간의 강력한 시너지를 가능하게 합니다.