증분 정적 재생성 (ISR)
revalidate 유효기간과 요청 기반 재검증으로 정적 응답 속도와 콘텐츠 신선도를 조정합니다.
이전 절에서 우리는 정적 사이트 생성(SSG)이 뛰어난 성능과 SEO 이점을 제공하지만, 빌드 시점에 콘텐츠가 고정된다는 한계가 있음을 확인했습니다.
또한 서버 사이드 렌더링(SSR)은 요청마다 서버에서 렌더링하며 원본을 다시 조회하도록 구성할 수 있지만, 그만큼 서버 작업과 응답 시간이 늘어날 수 있습니다.
증분 정적 재생성(Incremental Static Regeneration, ISR)은 SSG와 SSR의 일부 특성을 결합한 렌더링 전략입니다.
ISR은 배포 후 캐시 유효기간이 지난 다음 요청을 계기로 정적 페이지를 백그라운드에서 재생성하여, 빠른 캐시 응답과 허용 가능한 데이터 신선도를 조정하게 해줍니다.
재생성 타임라인을 그리기 전에 이 경로가 정적 응답을 공유해도 되는지 판단한다. 세 질문을 통과한 뒤 갱신 신호를 고르면 된다.
- 사용자 간 같은 응답인가?
SHARE 사용자 간 같은 응답인가? 공개 게시글·상품 설명처럼 캐시를 공유할 수 있어야 한다.
- 짧은 지연을 허용하는가?
STALE 짧은 지연을 허용하는가? 갱신 직전 버전을 잠시 제공해도 정확성 문제가 없어야 한다.
- 갱신 기준을 정할 수 있는가?
SIGNAL 갱신 기준을 정할 수 있는가? 시간 간격 또는 콘텐츠 변경 이벤트가 있어야 한다.
- 주기형
revalidate = 3600 변경 시점이 느슨하면 긴 주기로 갱신한다.
- 이벤트형
revalidateTag(tag, 'max') 경로는 revalidatePath, Server Action의 즉시 만료는 updateTag를 사용한다.
- 동적 렌더링
no-store · request 질문 하나라도 NO이거나 실시간 값이면 동적 경로로 둔다.
ISR이란 무엇인가요?
증분 정적 재생성(ISR)은 Next.js 애플리케이션이 빌드된 후에도 정적 페이지를 "재생성"할 수 있도록 하는 기능입니다.
이는 fetch 함수의 next.revalidate 옵션을 사용하여 구현됩니다.
초기 빌드: npm run build 시점에 generateStaticParams와 함께 정의된 페이지들은 SSG 방식으로 미리 생성됩니다.
첫 요청: 사용자가 ISR이 적용된 페이지에 처음 접속하면, 캐시된(미리 생성된) 페이지가 즉시 제공됩니다.
이때 Next.js는 revalidate 옵션으로 설정된 시간(예: 60초)을 확인합니다.
revalidate 시간 경과 후 요청: 설정된 revalidate 시간이 경과한 후, 다음 사용자 요청이 들어오면 Next.js는 다음과 같이 동작합니다.
- 사용자에게는 즉시 오래된(stale) 캐시된 페이지를 제공합니다.
- 동시에 백그라운드에서 새로운 데이터를 가져와 페이지를 재렌더링하고 캐시를 업데이트합니다.
다음 요청: 캐시가 업데이트된 후 들어오는 모든 후속 요청에는 새로 생성된 페이지가 제공됩니다.
- 성능과 신선도의 균형: SSG처럼 빠른 초기 로딩을 제공하면서도, 데이터 변경 시 수동으로 재빌드/재배포할 필요 없이 자동으로 최신 데이터를 반영할 수 있습니다.
- 빌드 시간 단축: 모든 페이지를 빌드 시점에 생성할 필요 없이, 가장 중요한 페이지만 SSG하고 나머지는 ISR로 처리하여 빌드 시간을 단축할 수 있습니다.
- 즉각적인 사용자 경험: 사용자는 항상 캐시된 콘텐츠를 즉시 받으므로 빈 화면을 보지 않습니다.
- 확장성: 대규모 웹사이트에서 수많은 페이지를 관리할 때 효율적입니다.
App Router에서 ISR 구현하기
Next.js App Router에서 ISR을 구현하는 가장 기본적인 방법은 서버 컴포넌트 내의 fetch 함수에 next.revalidate 옵션을 추가하는 것입니다.
generateStaticParams를 사용하여 페이지를 SSG 방식으로 빌드할 경로를 지정합니다.
해당 page.tsx 파일 내에서 데이터를 페칭하는 fetch 호출에 next: { revalidate: N } 옵션을 추가합니다.
여기서 N은 초 단위로 캐시가 유효한 시간을 의미합니다.
빌드 시점에 미리 생성하되, 배포 후 캐시 유효기간이 지난 뒤 요청이 들어오면 가격이나 재고를 재검증하는 상품 상세 페이지를 ISR로 구현해 봅시다.
// 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>
);
}ISR은 개발 모드(npm run dev)에서는 SSR처럼 동작하므로, 프로덕션 환경에서 테스트해야 그 효과를 명확히 볼 수 있습니다.
빌드 실행: 터미널에서 애플리케이션을 빌드합니다.
npm run build이때 generateStaticParams가 실행되어 모든 상품 페이지가 미리 생성됩니다.
프로덕션 모드에서 실행: 빌드된 정적 파일들을 서빙하기 위해 다음 명령어를 실행합니다.
npm run start브라우저 확인: http://localhost:3000/products/1과 같은 URL로 접속해 보세요.
- 처음 접속: 페이지가 즉시 로드되고 현재
lastUpdated시간이 표시됩니다. (이것은 빌드 시 생성된 초기 HTML입니다.) - 10초 이내 새로고침: 페이지를 새로고침해도
lastUpdated시간과 가격은 변경되지 않습니다. 여전히 캐시된 페이지가 즉시 제공됩니다. - 10초 경과 후 새로고침:
lastUpdated시간이 10초 이상 지난 후에 다시 페이지를 새로고침하면, 이전 캐시된 페이지가 즉시 표시되지만, 동시에 백그라운드에서 새로운 데이터로 페이지를 재생성합니다. - 한 번 더 새로고침: 백그라운드 재생성이 완료된 후 한 번 더 새로고침하면, 이제 새로운
lastUpdated시간과 변경된 가격이 표시되는 것을 볼 수 있습니다.
이 과정에서 Next.js 서버 콘솔에 ISR 🚀: Fetching product ... from API for revalidation과 같은 로그가 백그라운드에서 찍히는 것을 확인할 수 있습니다.
ISR의 고급 사용 사례 및 고려사항
만료 뒤에도 이전 응답을 먼저 주고 새 버전은 백그라운드에서 만든다.
- HITFresh → Fresh
유효 시간 안에는 기존 캐시를 즉시 반환
- EXPIREFresh → Stale
첫 만료 요청도 오래된 HTML을 먼저 받음
- BUILDStale → Regenerating
새 데이터를 읽어 다음 버전을 뒤에서 생성
- COMMITRegenerating → Fresh
성공 시 교체, 실패 시 안정된 이전 캐시 유지
revalidate를 0으로 설정:next: { revalidate: 0 }은 캐시를 사용하지 않고 매 요청마다 SSR처럼 동작하도록 강제합니다. (이는cache: 'no-store'와 유사하게 작동합니다.)- 태그 기반 재검증:
fetch에tags를 지정한 뒤revalidateTag(tag, 'max')를 호출하면 해당 데이터를 오래된 상태로 표시합니다. 다음 방문자는 기존 응답을 먼저 받고 백그라운드에서 새 응답을 가져오는 SWR 방식으로 갱신됩니다. - 즉시 만료 (
updateTag): Server Action이 데이터를 쓴 직후 같은 사용자가 최신 값을 반드시 읽어야 한다면updateTag(tag)로 태그를 즉시 만료합니다. 이 API는 Server Action에서만 사용합니다.
Next.js 16에서 인자 하나만 받는 revalidateTag(tag) 호출은 deprecated이므로 새 코드에서는 사용하지 않습니다.
- 경로 기반 재검증 (
revalidatePath): 특정 경로에 대한 캐시를 수동으로 무효화하고 재생성할 수도 있습니다. - 오프라인과의 구분: ISR 캐시는 서버와 CDN에서 응답을 재사용하는 기능입니다. 네트워크가 끊긴 브라우저에서도 페이지를 열려면 Service Worker와 별도의 오프라인 캐시 전략이 필요합니다.
- 에러 처리: ISR 과정에서 데이터 페칭에 실패하면, Next.js는 이전 캐시된 버전을 계속 제공합니다. 오류가 해결된 후 다음 재검증 주기에서 다시 시도합니다.
아래 다이어그램은 ISR 캐시를 갱신할 때 시간 기반, 태그 기반, 경로 기반, 캐시 비활성화 전략을 어떻게 고르는지 정리한 것입니다.
시간 경과와 업무 변경 이벤트 중 어느 쪽이 최신성 요구를 더 잘 설명하는지 판단한다.
- 주기가 명확revalidate seconds
뉴스 목록처럼 일정 시간 뒤 background 재생성
- stale 허용Tag SWR
revalidateTag(tag, 'max') 로 기존 값을 먼저 주고 백그라운드 갱신
- 즉시 최신즉시 tag 만료
Server Action에서 updateTag(tag) 로 최신 값 확인
- URL 단위revalidatePath
특정 page·layout 결과를 다음 방문에 다시 계산
ISR은 낡은 페이지를 즉시 제공하고 백그라운드에서 새 페이지를 준비하는 방식이므로, 사용자 응답성과 데이터 신선도를 서로 다른 단계로 나누어 이해하는 것이 중요합니다.
사용자 응답 경로와 재생성 경로를 분리하면 정적 속도와 콘텐츠 최신성을 함께 얻는다.
- request캐시 상태 확인
요청한 path의 HTML과 revalidate 시점을 읽음
- fresh현재 HTML 응답
유효하면 캐시 결과를 즉시 반환
- stale이전 HTML도 응답
만료돼도 사용자를 재생성 작업에 기다리게 하지 않음
- background새 버전 생성
데이터를 다시 읽어 다음 요청용 HTML 준비
- commit성공 시 교체
실패하면 안정된 이전 버전을 계속 보존
ISR은 빌드 시 생성된 페이지를 일정 조건에서 재검증해 응답 속도와 데이터 갱신 요구를 조정하는 전략입니다.
이를 통해 빠른 캐시 응답을 유지하면서 업무가 허용하는 신선도 범위 안에서 데이터를 갱신할 수 있습니다.
즉시 일관성이 필요한 값에는 ISR 대신 요청 시 조회나 명시적 무효화 전략을 선택합니다.
증분 정적 재생성 (ISR) 적용 전에는 서버/클라이언트 경계, 캐싱 조건, 배포 영향을 함께 확인해야 합니다.
몇 초 뒤 바뀌어도 되는지, 저장 직후 어떤 화면이 함께 달라져야 하는지부터 정한다.
- 시간 경과revalidate
허용한 stale 시간이 지난 다음 요청에서 갱신
- 같은 데이터Tag SWR
revalidateTag(tag, 'max') 로 목록·상세를 함께 갱신
- 특정 URLpath
영향받은 경로만 명시적으로 다시 계산
- 즉시 일치즉시 tag 만료
Server Action에서 updateTag(tag) 로 최신 값을 다시 읽음
마지막으로 ISR의 revalidate 기준을 정적 성능과 데이터 최신성 사이의 운영 선택으로 정리합니다.
변경 빈도보다 오래된 데이터가 사용자와 업무에 미치는 영향을 먼저 합의한다.
- 분 단위 허용Long interval
문서·소개처럼 변화가 느린 공개 콘텐츠
- 짧은 지연 허용Short interval
뉴스·상품처럼 주기적 최신화가 필요한 화면
- 발행 직후On-demand
CMS publish·관리자 변경 사건과 갱신 연결
- 즉시 일치SSR / no-store
오래된 값이 안전하지 않은 결제·권한 데이터