본문으로 건너뛰기

안동민 개발노트

본문 시작

증분 정적 재생성 (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로 재검증 간격을 설정할 수 있습니다.

이미 성공적으로 생성된 캐시가 있는 경로의 흐름입니다. 시간 경과 자체가 작업을 시작하는 정기 실행기는 아니며, 만료 뒤 요청이 있어야 재생성이 시작됩니다.

만료 뒤 요청이 나누는 응답과 재생성

이미 생성된 캐시가 있고 revalidate 간격이 지난 경로의 요청입니다. 기존 화면 응답과 백그라운드 재생성은 같은 요청을 계기로 진행됩니다. 재생성 성공 뒤에는 새 버전을 보관하고, 실패하면 이전 성공 버전을 유지합니다. 정기 실행이나 최대 신선도 보장은 아닙니다.

만료 뒤 요청이 나누는 응답과 재생성이미 생성된 캐시가 있고 revalidate 간격이 지난 경로의 요청입니다. 기존 화면 응답과 백그라운드 재생성은 같은 요청을 계기로 진행됩니다. 재생성 성공 뒤에는 새 버전을 보관하고, 실패하면 이전 성공 버전을 유지합니다. 정기 실행이나 최대 신선도 보장은 아닙니다.만료 뒤 요청기존 화면사용자에게 응답재생성백그라운드 작업새 버전 보관이후 요청에 사용이전 버전 유지향후 요청에 재시도성공실패
만료 뒤 요청이 나누는 응답과 재생성이미 생성된 캐시가 있고 revalidate 간격이 지난 경로의 요청입니다. 기존 화면 응답과 백그라운드 재생성은 같은 요청을 계기로 진행됩니다. 재생성 성공 뒤에는 새 버전을 보관하고, 실패하면 이전 성공 버전을 유지합니다. 정기 실행이나 최대 신선도 보장은 아닙니다.만료 뒤 요청기존 화면사용자에게 응답재생성백그라운드 작업새 버전 보관이후 요청에 사용이전 버전 유지향후 요청에 재시도성공실패

처음 생성하는 경로에는 재사용할 이전 화면이 없습니다. 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 문구도 모의 로그입니다. 앞 절의 products/[id] 폴더는 이 절의 [productId]로 대체하고, 같은 URL을 나타내는 두 동적 폴더를 함께 두지 않습니다.

src/app/products/[productId]/page.tsx
// 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' }}
        >
          &larr; 홈으로 돌아가기
        </Link>
      </div>
    </div>
  );
}
ISR 테스트 방법

개발 모드의 캐시·HMR 동작은 프로덕션과 다릅니다. 이 절은 일반 next build·next start에서 확인하며, ISR을 지원하지 않는 정적 output: 'export'는 사용하지 않습니다.

빌드 실행: 터미널에서 애플리케이션을 빌드합니다.

npm run build

이때 generateStaticParams가 반환한 상품 ID 1·2·3의 페이지를 미리 생성합니다.

프로덕션 모드에서 실행: 빌드된 정적 파일들을 서빙하기 위해 다음 명령어를 실행합니다.

npm run start

브라우저 확인: http://localhost:3000/products/1과 같은 URL로 접속해 보세요.

  • 캐시 응답: 준비된 화면의 lastUpdated와 가격을 기록합니다. 첫 브라우저 방문 전에 빌드로부터 이미 10초가 지났을 수 있습니다.
  • 만료 뒤 요청: 기존 화면을 받은 요청이 백그라운드 재생성을 촉발할 수 있습니다.
  • 성공 뒤 요청: 새 버전을 비교합니다. 가격은 같은 난수가 다시 나올 수 있고, 표시 시각은 원본 API의 갱신 시각이 아닙니다. 응답값 하나만으로 캐시 상태를 확정하지 않습니다.

이 과정에서 Next.js 서버 콘솔에 ISR 🚀: Fetching product ... from API for revalidation과 같은 로그가 백그라운드에서 찍히는 것을 확인할 수 있습니다.


ISR의 고급 사용 사례 및 고려사항

  • revalidate를 0으로 설정: fetch의 next: { revalidate: 0 }은 해당 응답을 Data Cache에 저장하지 않습니다. 이 장의 모델에서는 캐시하지 않는 데이터 조회가 라우트의 요청 시 렌더에 영향을 줍니다.
  • 태그 기반 재검증: fetch에 next: { 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는 이전 캐시된 버전을 계속 제공합니다. 이후 요청에서 재검증을 다시 시도합니다.

즉시 일관성이 필요한 값은 원본 저장·조회 계약과 무효화 시점을 함께 설계합니다. 요청 시 렌더나 no-store만 선택해도 모든 저장소의 일관성이 보장되는 것은 아닙니다.