증분 정적 재생성 (ISR)
revalidate 유효기간과 요청 기반 재검증으로 정적 응답 속도와 콘텐츠 신선도를 조정합니다.
앞에서 정적 사이트 생성(SSG)은 성능과 SEO에서 장점이 크지만 빌드할 때 내용이 고정된다는 한계가 있고, 서버 사이드 렌더링(SSR)은 요청마다 서버에서 렌더링하며 원본을 다시 조회하도록 구성할 수 있지만 그만큼 서버 작업과 응답 시간이 늘어날 수 있다는 점을 확인했습니다. 증분 정적 재생성(Incremental Static Regeneration, ISR)은 SSG와 SSR의 일부 특성을 결합한 렌더링 전략입니다. ISR은 배포한 뒤 캐시의 유효 기간이 지난 다음의 요청을 계기로 정적 페이지를 백그라운드에서 다시 만들어, 빠른 캐시 응답과 허용할 수 있는 데이터의 신선도를 조정하게 해 줍니다.
ISR이란 무엇인가요?
증분 정적 재생성(ISR)은 Next.js 애플리케이션을 빌드한 뒤에도 정적 페이지를 다시 만들 수 있게 하는 기능입니다. 이 글은 cacheComponents를 켜지 않은 일반 Next.js 16 App Router를 기준으로 하며, fetch의 next.revalidate나 라우트의 export const revalidate로 재검증 간격을 정합니다.
만료 뒤 요청이 나누는 응답과 재생성
만료된 캐시 요청은 기존 응답을 먼저 제공하면서 백그라운드 갱신을 시작하고, 성공 뒤 다음 요청이 새 값을 봅니다. 실패 때 이전 캐시를 유지한다는 분기도 추적할 수 있어 주기적 실행 오해를 줄입니다.
NEXT · CH6-4
만료 뒤 요청이 응답과 재생성을 시작합니다
이미 생성된 캐시가 있는 경로에서 revalidate 간격이 지난 뒤 요청이 도착한 경우입니다. 기존 화면을 응답하면서 백그라운드에서 새 버전을 준비합니다.
본문은 revalidate=10과 고정 상품 목록·지연으로 재생성을 설명합니다. 10초마다 자동 실행하거나 최대 10초의 신선도를 보장하지 않습니다. 성공하면 새 결과를 쓰고, 실패하면 이전 성공 결과를 유지하며 이후 요청에서 재시도합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 이미 생성된 캐시가 있고 revalidate 간격이 지난 경로의 요청입니다. 기존 화면 응답과 백그라운드 재생성은 같은 요청을 계기로 진행됩니다. 재생성 성공 뒤에는 새 버전을 보관하고, 실패하면 이전 성공 버전을 유지합니다. 정기 실행이나 최대 신선도 보장은 아닙니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 만료된 기존 cache 이미 성공한 cache가 있고 revalidate 간격이 지났습니다. | cache: 이전 성공 버전 · 간격: 만료 · 정기 실행: 아님 | 새 요청을 기다리는 만료 상태가 됩니다. |
| 요청이 갱신 촉발 요청에 이전 화면을 응답하면서 백그라운드 재생성을 진행합니다. | 현재 응답: 이전 화면 · 재생성: 백그라운드 | 응답과 갱신이 같은 요청을 계기로 분리되어 진행됩니다. |
| 성공 또는 실패 보관 성공은 새 버전을 저장하고 실패는 이전 성공 버전을 유지합니다. | 성공: 새 cache · 실패: 이전 cache 유지 | 재생성 결과가 다음 요청의 자료를 정합니다. |
| 다음 요청 관찰 다음 요청은 보관된 성공 버전을 사용합니다. | 성공 이후: 새 버전 · 실패 이후: 이전 버전 · 최대 신선도: 보장 아님 | 다음 응답에서 갱신 결과를 관찰합니다. |
만료 뒤 요청이 나누는 응답과 재생성
만료된 캐시 요청은 기존 응답을 먼저 제공하면서 백그라운드 갱신을 시작하고, 성공 뒤 다음 요청이 새 값을 봅니다. 실패 때 이전 캐시를 유지한다는 분기도 추적할 수 있어 주기적 실행 오해를 줄입니다.
NEXT · CH6-4
만료 뒤 요청이 응답과 재생성을 시작합니다
이미 생성된 캐시가 있는 경로에서 revalidate 간격이 지난 뒤 요청이 도착한 경우입니다. 기존 화면을 응답하면서 백그라운드에서 새 버전을 준비합니다.
본문은 revalidate=10과 고정 상품 목록·지연으로 재생성을 설명합니다. 10초마다 자동 실행하거나 최대 10초의 신선도를 보장하지 않습니다. 성공하면 새 결과를 쓰고, 실패하면 이전 성공 결과를 유지하며 이후 요청에서 재시도합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 이미 생성된 캐시가 있고 revalidate 간격이 지난 경로의 요청입니다. 기존 화면 응답과 백그라운드 재생성은 같은 요청을 계기로 진행됩니다. 재생성 성공 뒤에는 새 버전을 보관하고, 실패하면 이전 성공 버전을 유지합니다. 정기 실행이나 최대 신선도 보장은 아닙니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 만료된 기존 cache 이미 성공한 cache가 있고 revalidate 간격이 지났습니다. | cache: 이전 성공 버전 · 간격: 만료 · 정기 실행: 아님 | 새 요청을 기다리는 만료 상태가 됩니다. |
| 요청이 갱신 촉발 요청에 이전 화면을 응답하면서 백그라운드 재생성을 진행합니다. | 현재 응답: 이전 화면 · 재생성: 백그라운드 | 응답과 갱신이 같은 요청을 계기로 분리되어 진행됩니다. |
| 성공 또는 실패 보관 성공은 새 버전을 저장하고 실패는 이전 성공 버전을 유지합니다. | 성공: 새 cache · 실패: 이전 cache 유지 | 재생성 결과가 다음 요청의 자료를 정합니다. |
| 다음 요청 관찰 다음 요청은 보관된 성공 버전을 사용합니다. | 성공 이후: 새 버전 · 실패 이후: 이전 버전 · 최대 신선도: 보장 아님 | 다음 응답에서 갱신 결과를 관찰합니다. |
위 그림은 이미 성공적으로 만든 캐시가 있는 경로에서, revalidate 간격이 지난 뒤 요청이 들어왔을 때 응답과 재생성이 어떻게 나뉘는지 보여 줍니다. 그림의 요약처럼 만료된 뒤의 요청이 응답과 재생성을 함께 시작하며, 기존 화면을 사용자에게 먼저 응답하는 동시에 같은 요청이 백그라운드 재생성을 일으킵니다. 재생성에 성공하면 새 버전을 보관해 이후의 요청에 쓰고, 실패하면 이전의 성공 버전을 유지한 채 다음 요청에서 다시 시도합니다. 그림의 단계도 만료된 기존 캐시, 요청이 일으키는 갱신, 성공이나 실패의 보관, 다음 요청에서의 확인 순서입니다.
그림의 캡션이 밝히듯 이것은 정기적으로 실행되는 작업이 아니며 최대 신선도를 보장하지도 않습니다. 시간이 지나는 것 자체가 작업을 시작하지 않고 만료 뒤의 요청이 있어야 재생성이 시작되므로, 본문의 revalidate = 10은 10초마다 자동으로 실행되거나 데이터가 최대 10초 안에 최신이 된다는 뜻이 아닙니다. 처음 만드는 경로에는 재사용할 이전 화면이 없으며, generateStaticParams에 없는 경로를 만들지는 dynamicParams 정책을 따릅니다.
ISR은 SSG처럼 처음 로딩이 빠르면서도, 데이터가 바뀔 때 직접 다시 빌드하거나 배포하지 않아도 새 데이터를 반영할 수 있어 성능과 신선도의 균형을 맞춥니다. 모든 페이지를 빌드할 때 만들 필요 없이 가장 중요한 페이지만 SSG하고 나머지는 ISR로 처리해 빌드 시간을 줄일 수 있고, 쓸 수 있는 기존 캐시가 있으면 재생성하는 동안에도 이전 결과를 제공합니다. 다만 캐시가 없는 첫 요청까지 같은 응답 시간을 보장하지는 않으며, 대규모 웹사이트에서 많은 페이지를 관리할 때 효율적입니다.
App Router에서 ISR 구현하기
다음 예제는 실제 API 대신 모의 상품 데이터를 만들고, 라우트의 export const revalidate = 10을 씁니다. 구현은 generateStaticParams로 SSG 방식으로 빌드할 경로를 정하고, page.tsx에 export const revalidate = N을 지정하는 두 단계입니다. 실제 API를 읽는 예제라면 개별 fetch에 next: { revalidate: N }을 지정할 수도 있으며, N은 재검증할 수 있는 간격일 뿐 데이터가 그 시간 안에 반드시 최신이 된다는 뜻은 아닙니다.
이 예제는 1.5초의 지연 뒤에 가격을 난수로 조정하고 생성 시각을 붙이며, fetch나 재고 조회는 없고 콘솔의 from API 문구도 모의 로그입니다. 6장 2절의 products/[id] 폴더는 이 글의 [productId]로 바꾸며, 같은 URL을 나타내는 두 동적 폴더를 함께 두지 않습니다.
// src/app/products/[productId]/page.tsx (새로 생성할 페이지)
import Link from 'next/link';
export const revalidate = 10;
interface Product {
id: string;
name: string;
price: number;
lastUpdated: string; // 서버가 모의 페이지 데이터를 만든 시각
}
// 1. 특정 상품 데이터를 가져오는 함수
async function getProduct(productId: string): Promise<Product> {
console.log(`ISR 🚀: Fetching product ${productId} from API for revalidation`);
// 실제 API 대신 더미 데이터와 지연 시간을 시뮬레이션합니다.
// 실제 상황에서는 DB나 외부 API에서 데이터를 가져옵니다.
await new Promise(resolve => setTimeout(resolve, 1500)); // 1.5초 지연
const productsData = [
{ id: '1', name: '스마트워치 X', price: 299000 },
{ id: '2', name: '무선 이어폰 Pro', price: 199000 },
{ id: '3', name: '노트북 울트라', price: 1500000 },
];
const product = productsData.find(p => p.id === productId);
if (!product) {
// 상품이 없을 경우 notFound() 함수를 사용하여 404 페이지를 렌더링할 수 있습니다.
// import { notFound } from 'next/navigation';
// notFound();
throw new Error(`Product with ID ${productId} not found`);
}
return {
...product,
price: product.price + Math.floor(Math.random() * 20000 - 10000), // 가격 변동 시뮬레이션
lastUpdated: new Date().toLocaleTimeString('ko-KR', { hour12: false }),
};
}
// 2. generateStaticParams 함수: 빌드 시점에 어떤 상품 페이지들을 미리 생성할지 지정합니다.
export async function generateStaticParams() {
console.log('generateStaticParams 🔥: Preparing product IDs for initial SSG');
const productIds = [
{ productId: '1' },
{ productId: '2' },
{ productId: '3' },
]; // 폴더의 [productId] 이름과 반환 객체 키가 같아야 합니다.
return productIds;
}
// 3. 페이지 컴포넌트: params를 받아 데이터를 렌더링합니다.
export default async function ProductDetailPage({ params }: { params: Promise<{ productId: string }> }) {
const { productId } = await params;
const product = await getProduct(productId);
return (
<div style={{ padding: '25px', maxWidth: '700px', margin: '20px auto', border: '2px solid #28a745', borderRadius: '12px', boxShadow: '0 6px 12px rgba(40,167,69,0.1)' }}>
<h1 style={{ color: '#28a745', textAlign: 'center', marginBottom: '25px' }}>{product.name} (ISR)</h1>
<div style={{ fontSize: '1.3em', lineHeight: '1.8' }}>
<p><strong>상품 ID:</strong> {product.id}</p>
<p><strong>가격:</strong> <span style={{ color: '#dc3545', fontWeight: 'bold' }}>{product.price.toLocaleString()}원</span></p>
<p><strong>페이지 데이터 생성:</strong> {product.lastUpdated}</p>
</div>
<p style={{ marginTop: '30px', textAlign: 'center', color: '#666', fontSize: '0.95em' }}>
이 페이지는 빌드 시점에 미리 생성됩니다. 캐시가 <strong>10초</strong> 이상 지난 뒤 들어온 요청이 재검증을 촉발하면 기존 응답을 먼저 제공하고 백그라운드에서 새 버전을 준비합니다.
</p>
<div style={{ marginTop: '30px', paddingTop: '15px', borderTop: '1px dashed #eee', textAlign: 'center' }}>
<Link
href="/"
style={{ color: '#007bff', textDecoration: 'none', fontWeight: 'bold' }}
>
← 홈으로 돌아가기
</Link>
</div>
</div>
);
}개발 모드의 캐시와 HMR 동작은 프로덕션과 다르므로, 이 글은 일반적인 next build와 next start에서 확인하며 ISR을 지원하지 않는 정적 output: 'export'는 쓰지 않습니다. 먼저 터미널에서 애플리케이션을 빌드합니다.
npm run build이때 generateStaticParams가 돌려준 상품 ID 1, 2, 3의 페이지가 미리 만들어집니다. 다음 명령으로 프로덕션 모드로 실행합니다.
npm run starthttp://localhost:3000/products/1 같은 URL에 들어가, 준비된 화면의 lastUpdated와 가격을 기록합니다. 첫 방문 전에 빌드한 때부터 이미 10초가 지났을 수 있습니다. 만료된 뒤의 요청은 기존 화면을 받으면서 백그라운드 재생성을 일으킬 수 있고, 재생성이 성공한 뒤의 요청에서 새 버전을 비교합니다. 가격은 같은 난수가 다시 나올 수 있고 표시된 시각은 원본 API의 갱신 시각이 아니므로, 응답 값 하나만으로 캐시의 상태를 확정하지 않습니다. 이 과정에서 Next.js 서버 콘솔에 ISR 🚀: Fetching product ... from API for revalidation 같은 로그가 백그라운드에서 찍히는 것을 볼 수 있습니다.
ISR의 고급 사용 사례와 고려할 점
fetch의 next: { revalidate: 0 }은 그 응답을 Data Cache에 저장하지 않으며, 이 장의 모델에서는 캐시하지 않는 데이터 조회가 라우트를 요청할 때 렌더링하게 만듭니다.
태그를 기준으로 재검증할 수도 있습니다. fetch에 next: { tags: [...] }를 지정한 뒤 revalidateTag(tag, 'max')를 부르면 그 데이터를 오래된 상태로 표시하며, 다음 방문자는 기존 응답을 먼저 받고 백그라운드에서 새 응답을 가져오는 stale-while-revalidate 방식으로 갱신됩니다. Next.js 16에서 인자 하나만 넘기는 revalidateTag(tag)는 사용 중단되었으므로 새 코드에서는 쓰지 않습니다. 8장에서 다룰 서버 액션(Server Action)이 데이터를 쓴 직후 같은 사용자가 최신 값을 반드시 읽어야 한다면 updateTag(tag)로 태그를 곧바로 만료하며, 이 API는 서버 액션에서만 씁니다(Next.js 16 업그레이드 안내). 특정 경로의 캐시를 직접 무효화하고 다시 만들 때는 revalidatePath를 씁니다.
ISR의 캐시는 서버와 CDN에서 응답을 재사용하는 기능이므로, 네트워크가 끊긴 브라우저에서도 페이지를 열려면 Service Worker와 별도의 오프라인 캐시 전략이 필요합니다. ISR 과정에서 데이터 요청이 실패하면 Next.js는 이전에 캐시한 버전을 계속 제공하고 이후의 요청에서 재검증을 다시 시도합니다. 곧바로 일관되어야 하는 값이라면 원본의 저장·조회 방식과 무효화 시점을 함께 설계하며, 요청할 때 렌더링하거나 no-store를 고르는 것만으로 모든 저장소의 일관성이 보장되지는 않습니다.
더 깊이: 직접 운영하는 서버에서 ISR 캐시가 놓이는 곳
Next.js의 직접 호스팅 안내에 따르면 ISR로 만든 페이지와 Data Cache는 같은 Next.js 서버 캐시를 쓰며, 이 캐시는 기본적으로 각 서버 인스턴스의 메모리(기본 50MB)와 로컬 디스크에 저장됩니다. 디스크가 유지되는 next start 인스턴스 하나라면 따로 설정할 것이 없습니다. 하지만 Kubernetes처럼 여러 파드(pod)를 띄우면 파드마다 캐시 사본을 따로 가지므로, 같은 URL이라도 어느 파드가 응답했느냐에 따라 재검증 시점이 다른 결과가 보일 수 있습니다. 디스크가 오래 유지되지 않는 서버리스 환경에서는 캐시가 인스턴스마다 짧게 살다 사라집니다.
이를 맞추려면 next.config의 cacheHandler로 Redis나 S3처럼 모든 인스턴스가 접근하는 저장소를 쓰는 캐시 처리기를 지정하고, cacheMaxMemorySize: 0으로 인스턴스 안의 메모리 캐시를 끕니다. 기본 구성에서 한 인스턴스가 부른 revalidateTag는 그 인스턴스의 캐시만 무효화하므로, 태그의 상태도 공유 저장소에서 맞춰야 합니다. 참고로 revalidatePath는 그 페이지에 붙는 특별한 기본 태그로 revalidateTag를 부르는 편의 기능입니다.
캐시 앞에 CDN을 둔다면 응답 헤더도 함께 봅니다. ISR 페이지는 s-maxage에 재검증 간격을 담고 stale-while-revalidate를 붙인 Cache-Control을 보내고, 동적으로 렌더링한 페이지는 사용자별 데이터가 캐시되지 않도록 private, no-cache, no-store, max-age=0, must-revalidate를 보냅니다. s-maxage와 stale-while-revalidate가 CDN 같은 공유 캐시에서 어떻게 해석되는지는 네트워크 교재의 CDN에서 다룹니다. CDN이 이 지시어를 따르지 않으면 의도한 재검증 주기와 다른 응답이 사용자에게 갈 수 있습니다.
정리
- ISR은 빌드 뒤에도 정적 페이지를 다시 만드는 기능으로,
fetch의next.revalidate나 라우트의export const revalidate로 간격을 정합니다. - 재생성은 시간이 지나 저절로 시작되지 않고, 간격이 지난 뒤의 요청이 기존 화면을 받으면서 백그라운드 재생성을 일으킵니다.
- 재생성이 성공하면 이후의 요청에 새 버전을 쓰고, 실패하면 이전의 성공 버전을 유지한 채 다음 요청에서 다시 시도합니다.
revalidateTag(tag, 'max')는 태그의 데이터를 오래된 상태로 표시하고,updateTag(tag)는 서버 액션에서 곧바로 만료하며,revalidatePath는 경로 단위로 무효화합니다.- ISR은 개발 모드가 아니라
next build와next start로 확인하며, 정적 내보내기에서는 지원되지 않습니다.
연습 문제
1. 만료와 실패를 나누어 풀어 보기
성공한 정적 결과 A가 시각 0초에 준비되었고 재검증 간격은 30초입니다. 45초까지 새 요청이 전혀 없었고, 45초의 첫 요청이 시작한 재생성은 오류를 던집니다. 이후 50초의 요청에서 다시 시도해 51초에 새 결과 B가 준비되었다고 가정합니다. 31초에 작업이 저절로 시작되었는지, 45초의 응답과 52초의 새 응답은 무엇인지 설명해 보세요. 이전의 성공 결과가 없는 첫 생성의 실패와도 비교해 보세요.
해설 보기
31초에는 요청이 없으므로 재생성이 시작되지 않습니다. 이미 A가 있는 45초의 요청은 오래된 A를 먼저 받고 재생성을 일으키며, 실패가 오류로 전달되면 A를 유지합니다. 다시 시도한 재생성이 성공해 B가 준비된 뒤인 52초의 새 요청은 B를 쓸 수 있습니다. 첫 생성부터 실패했다면 재사용할 A 자체가 없습니다.
이 문제의 초 단위 시각은 주어진 순서의 조건일 뿐, 실제 재생성이 언제나 1초 안에 끝난다는 측정값이 아닙니다. 실패를 빈 목록처럼 정상적으로 돌려주면 성공 결과로 저장될 수 있어, 이 오류 경로와 달라집니다.
2. 관리자가 상품을 고친 직후
관리자가 서버 액션으로 상품 가격을 고친 직후, 그 관리자가 상품 페이지를 열었을 때 반드시 새 가격이 보여야 합니다. revalidateTag(tag, 'max')와 updateTag(tag) 가운데 무엇을 써야 할까요?
해설 보기
updateTag(tag)를 씁니다. revalidateTag(tag, 'max')는 데이터를 오래된 상태로 표시할 뿐 다음 방문자는 기존 응답을 먼저 받을 수 있어, 방금 고친 관리자에게도 이전 가격이 잠깐 보일 수 있습니다. updateTag는 서버 액션 안에서 태그를 곧바로 만료하고 같은 요청에서 새로 가져오므로, 쓴 사람이 바로 자신의 변경을 읽을 수 있습니다.
조금 늦게 보여도 되는 블로그 글이나 상품 카탈로그처럼 일반 방문자의 화면이라면 revalidateTag(tag, 'max')가 알맞습니다.
3. 개발 서버에서 ISR 확인하기
npm run dev로 상품 페이지를 열어 10초가 지나기 전에 새로고침했더니 가격과 시각이 바로 바뀌었습니다. ISR이 동작하지 않는 걸까요?
해설 보기
개발 모드는 요청마다 페이지를 다시 렌더링하는 등 캐시와 HMR의 동작이 프로덕션과 다르므로, 개발 서버의 화면으로는 ISR의 동작을 판단할 수 없습니다. npm run build로 빌드한 뒤 npm run start로 실행해, 만료 전의 요청과 만료 뒤의 요청, 재생성이 성공한 뒤의 요청을 나누어 비교해야 합니다.
다음 글 클라이언트 컴포넌트에서 데이터 페칭에서는 브라우저에서 사용자 상호작용에 맞춰 데이터를 가져오는 방법을 알아봅니다.