Next.js 14에서 16까지 캐싱 전략이 어떻게 변화했는지 살펴봅니다. 네 가지 캐싱 레이어, fetch() 기본값 변경, 재검증 전략을 다룹니다.
4장까지 라우팅 시스템을 다루었습니다. 이번 장에서는 Next.js App Router의 핵심인 데이터 페칭과 캐싱 전략을 살펴봅니다. 특히 Next.js 14에서 15, 그리고 16으로 넘어오면서 캐싱 기본값이 어떻게 변화했는지에 집중합니다.
웹 애플리케이션의 성능은 데이터를 얼마나 효율적으로 가져오고 재사용하느냐에 크게 좌우됩니다. Next.js는 프레임워크 수준에서 다층 캐싱 시스템을 제공하여, 개발자가 세밀하게 캐싱 전략을 제어할 수 있도록 합니다.
하지만 Next.js 14에서 15로 넘어오면서 캐싱 기본값이 근본적으로 변경되었습니다. 이 변화를 이해하지 못하면 예상치 못한 동작에 당황하거나, 성능 최적화 기회를 놓치게 됩니다.
Next.js App Router는 네 가지 독립적인 캐싱 레이어를 가지고 있습니다.
같은 렌더링 과정에서 동일한 fetch() 호출이 여러 번 발생하면, 실제로는 한 번만 실행하고 결과를 재사용합니다. 이것은 React의 기능이며, 하나의 서버 렌더링 패스 동안에만 유지됩니다.
// Layout에서 호출
async function Layout({ children }: { children: React.ReactNode }) {
const user = await fetch('/api/user'); // 실제 fetch 실행
return <div>{children}</div>;
}
// Page에서도 동일한 호출
async function Page() {
const user = await fetch('/api/user'); // 메모이제이션 - 캐시에서 반환
return <div>{user.name}</div>;
}Request Memoization은 GET 메서드의 fetch() 요청에만 적용됩니다. POST, DELETE 등의 요청은 메모이제이션되지 않습니다. 또한 React의 cache() 함수를 사용하면 fetch()가 아닌 함수도 메모이제이션할 수 있습니다.
fetch() 응답을 서버 측에서 지속적으로 캐싱합니다. Request Memoization이 하나의 렌더링 패스에서만 유지되는 반면, Data Cache는 여러 요청과 배포에 걸쳐 유지됩니다.
// 캐시됨 (Next.js 14 기본값)
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache',
});
// 캐시 안 됨 (Next.js 15 기본값)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
});
// 시간 기반 재검증
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // 1시간
});빌드 시점에 정적으로 렌더링된 라우트의 HTML과 RSC Payload를 캐시합니다. 동적 라우트(쿠키, 헤더 접근 등)에는 적용되지 않습니다.
클라이언트 측에서 방문한 라우트의 RSC Payload를 메모리에 캐시합니다. 뒤로가기/앞으로가기 탐색이 즉시 이루어지며, prefetch된 라우트도 빠르게 탐색할 수 있습니다.
Next.js 15에서 가장 큰 변화는 캐싱의 기본값이 바뀐 것입니다.
| 영역 | Next.js 14 | Next.js 15 |
|---|---|---|
fetch() 기본값 | force-cache (캐시됨) | no-store (캐시 안 됨) |
| GET Route Handlers | 캐시됨 | 캐시 안 됨 |
| Client Router Cache (Pages) | staleTime 30초 | staleTime 0 |
| Client Router Cache (Layouts) | staleTime 5분 | staleTime 5분 (유지) |
Next.js 14에서는 fetch()가 기본적으로 캐시되었습니다. 이 설계 의도는 "성능 최적화를 기본으로 제공"하는 것이었지만, 실제로는 많은 개발자들이 데이터가 갱신되지 않는 문제로 혼란을 겪었습니다.
// Next.js 14에서 이 코드는 첫 번째 요청의 결과를 계속 캐시
async function Dashboard() {
const stats = await fetch('https://api.example.com/stats');
// 사용자가 새로고침해도 같은 데이터가 표시됨
return <StatsDisplay data={await stats.json()} />;
}Next.js 15에서는 fetch()의 기본값이 no-store로 변경되어, 명시적으로 캐싱을 선택해야 합니다.
// 캐싱이 필요하면 명시적으로 선언
async function Dashboard() {
const stats = await fetch('https://api.example.com/stats', {
cache: 'force-cache', // 명시적 캐싱
next: { revalidate: 60 }, // 또는 시간 기반 재검증
});
return <StatsDisplay data={await stats.json()} />;
}Next.js 15의 철학은 "캐싱은 옵트인(opt-in)"입니다. 성능 최적화가 필요한 곳에 개발자가 직접 캐싱을 적용하는 것이 기본이며, 이는 예측 가능한 동작을 보장합니다.
Next.js 14에서는 GET Route Handlers가 기본적으로 캐시되었습니다.
// Next.js 14: 이 응답이 자동으로 캐시됨
export async function GET() {
const data = await db.query('SELECT * FROM items');
return Response.json(data);
}
// Next.js 15: 캐시되지 않음. 캐싱하려면 명시적 설정 필요
export const dynamic = 'force-static';
export async function GET() {
const data = await db.query('SELECT * FROM items');
return Response.json(data);
}Next.js 14에서는 페이지의 Router Cache staleTime이 30초였습니다. 즉, 한 번 방문한 페이지를 30초 이내에 다시 방문하면 캐시된 버전이 표시되었습니다. Next.js 15에서는 이 값이 0으로 변경되어, 페이지 탐색 시 항상 최신 데이터를 가져옵니다.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
staleTimes: {
dynamic: 30, // 동적 페이지의 staleTime (기본 0)
static: 180, // 정적(prefetch) 페이지의 staleTime (기본 300)
},
},
};
export default nextConfig;레이아웃의 staleTime은 Next.js 15에서도 5분으로 유지됩니다. 레이아웃은 탐색 시 리렌더링되지 않는 것이 App Router의 핵심 설계이기 때문입니다.
캐시된 데이터를 최신 상태로 유지하는 세 가지 재검증 전략이 있습니다.
일정 시간이 지나면 캐시를 자동으로 무효화합니다.
// fetch 단위
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // 1시간마다 재검증
});
// 페이지/레이아웃 단위
export const revalidate = 3600;
async function Page() {
const data = await fetch('https://api.example.com/posts');
return <PostList posts={await data.json()} />;
}시간 기반 재검증은 stale-while-revalidate(SWR) 패턴을 따릅니다. 재검증 시간이 지난 후 첫 번째 요청은 여전히 캐시된 (stale) 데이터를 반환하면서 백그라운드에서 데이터를 다시 가져옵니다. 데이터가 갱신되면 이후 요청부터 새 데이터가 반환됩니다.
데이터에 태그를 부여하고, 태그 단위로 캐시를 무효화합니다.
// 데이터 fetch 시 태그 부여
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
const post = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts', `post-${id}`] },
});'use server';
import { revalidateTag } from 'next/cache';
export async function createPost(formData: FormData) {
await db.posts.create({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
revalidateTag('posts'); // 'posts' 태그가 붙은 모든 캐시 무효화
}특정 경로의 캐시를 무효화합니다.
'use server';
import { revalidatePath } from 'next/cache';
export async function updateProfile(formData: FormData) {
await db.users.update({
name: formData.get('name') as string,
});
revalidatePath('/profile'); // 특정 경로
revalidatePath('/blog', 'layout'); // 레이아웃 단위로 하위 전체
revalidatePath('/blog', 'page'); // 페이지만
}revalidatePath('/')를 호출하면 애플리케이션의 모든 라우트 캐시가 무효화됩니다. 강력하지만 그만큼 광범위하므로, 가능하면 태그 기반 재검증을 사용하는 것이 더 정밀합니다.
| 전략 | 적합한 상황 | 장점 | 단점 |
|---|---|---|---|
| 시간 기반 | 주기적으로 변하는 데이터 | 설정이 간단 | 불필요한 갱신 발생 가능 |
| 태그 기반 | 데이터 변경 시점을 아는 경우 | 가장 정밀한 제어 | 태그 설계가 필요 |
| 경로 기반 | 특정 페이지 갱신 | 직관적 | 범위가 넓을 수 있음 |
fetch() 외의 데이터 소스(ORM, 데이터베이스 직접 호출 등)에는 Request Memoization이 자동으로 적용되지 않습니다. 이 경우 React의 cache() 함수를 사용하여 같은 렌더링 패스에서의 중복 호출을 방지할 수 있습니다.
import { cache } from 'react';
import { db } from '@/lib/db';
// cache()로 감싸면 같은 렌더링 패스에서 중복 호출 방지
export const getUser = cache(async (id: string) => {
return await db.users.findUnique({ where: { id } });
});
export const getPosts = cache(async () => {
return await db.posts.findMany({ orderBy: { createdAt: 'desc' } });
});// Layout
async function Layout({ children }: { children: React.ReactNode }) {
const user = await getUser('user-1'); // DB 쿼리 실행
return <Nav user={user}>{children}</Nav>;
}
// Page
async function Page() {
const user = await getUser('user-1'); // cache 히트 - DB 쿼리 생략
return <Profile user={user} />;
}cache()는 하나의 서버 렌더링 패스에서만 유효합니다. 여러 요청에 걸친 캐싱이 필요하면 Next.js 15의 unstable_cache 또는 Next.js 16의 "use cache" 디렉티브를 사용해야 합니다.
Next.js 15에서 도입된 unstable_cache는 ORM 쿼리, 직접 데이터베이스 호출 등 fetch()가 아닌 비동기 함수의 결과를 Data Cache에 저장합니다.
import { unstable_cache } from 'next/cache';
import { db } from '@/lib/db';
const getCachedPosts = unstable_cache(
async () => {
return await db.posts.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
},
['posts-list'], // 캐시 키
{
revalidate: 3600, // 1시간마다 재검증
tags: ['posts'], // 태그 기반 무효화 가능
}
);
async function PostList() {
const posts = await getCachedPosts();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}unstable_cache라는 이름의 "unstable" 접두사는 API가 변경될 수 있음을 의미합니다. 실제로 Next.js 16에서는 "use cache" 디렉티브가 이를 대체하는 더 우아한 방법으로 도입되었습니다. 다음 장에서 자세히 다룹니다.
전자상거래 상품 페이지를 예로 들어, 각 데이터에 적합한 캐싱 전략을 설계해 보겠습니다.
import { cache } from 'react';
import { revalidateTag } from 'next/cache';
// 상품 기본 정보: 자주 변하지 않으므로 시간 기반 캐싱
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
revalidate: 3600,
tags: [`product-${id}`],
},
});
return res.json();
}
// 재고 정보: 실시간성 중요, 캐싱 안 함
async function getStock(id: string) {
const res = await fetch(`https://api.example.com/products/${id}/stock`, {
cache: 'no-store',
});
return res.json();
}
// 리뷰: 태그 기반 재검증 (리뷰 작성 시 무효화)
async function getReviews(productId: string) {
const res = await fetch(
`https://api.example.com/products/${productId}/reviews`,
{
next: {
tags: [`reviews-${productId}`],
revalidate: 300, // 5분 간격으로도 재검증
},
}
);
return res.json();
}
// 사용자별 추천: 요청마다 다르므로 캐싱 안 함
async function getRecommendations(userId: string) {
const res = await fetch(
`https://api.example.com/recommendations/${userId}`,
{ cache: 'no-store' }
);
return res.json();
}이처럼 데이터의 특성에 맞게 캐싱 전략을 혼합하여 사용하는 것이 실전에서의 핵심입니다.
기존 Next.js 14 프로젝트를 15로 업그레이드할 때 캐싱 관련 주의사항을 정리합니다.
기존에 캐싱에 의존하던 fetch() 호출에 명시적으로 cache: 'force-cache'를 추가합니다.
페이지 단위로 캐싱 동작을 제어할 수 있습니다.
// 이 페이지의 모든 fetch를 강제로 캐시
export const fetchCache = 'default-cache';
// 또는 정적 생성 강제
export const dynamic = 'force-static';한 번에 모든 것을 바꾸기보다, 페이지별로 캐싱 전략을 점검하고 필요한 곳에만 명시적 캐싱을 추가하는 것을 권장합니다.
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
staleTimes: {
dynamic: 30, // 14의 동작 유지
static: 300,
},
},
};
export default nextConfig;fetch() 기본값이 force-cache에서 no-store로, GET Route Handlers가 캐시에서 비캐시로, Router Cache staleTime이 30초에서 0으로 변경되었습니다.revalidate), 태그 기반(revalidateTag), 경로 기반(revalidatePath)의 세 가지가 있으며, 데이터 특성에 맞게 조합하여 사용합니다.cache() 함수로 fetch가 아닌 데이터 소스의 요청 중복을 제거하고, unstable_cache로 Data Cache에 저장할 수 있습니다.다음 장에서는 Next.js 16에서 도입된 Cache Components와 "use cache" 디렉티브를 다룹니다. unstable_cache를 대체하는 더 선언적이고 강력한 캐싱 방법을 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
Next.js 16의 Cache Components 시스템을 다룹니다. 'use cache' 디렉티브, cacheLife 프로필, cacheTag 무효화, 세 가지 캐시 변형을 살펴봅니다.
Next.js의 인터셉팅 라우트로 모달, 사진 갤러리, 미리보기 패턴을 구현합니다. 병렬 라우트와의 조합, 딥 링킹, 공유 가능한 URL을 다룹니다.
Next.js의 스트리밍 SSR 동작 원리를 살펴봅니다. loading.tsx, Suspense 경계, 스켈레톤 설계, 프로그레시브 렌더링 전략과 성능 지표 영향을 다룹니다.