본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
6장 : 데이터 페칭

증분 정적 재생성 (ISR)

revalidate 유효기간과 요청 기반 재검증으로 정적 응답 속도와 콘텐츠 신선도를 조정합니다.

이전 절에서 우리는 정적 사이트 생성(SSG)이 뛰어난 성능과 SEO 이점을 제공하지만, 빌드 시점에 콘텐츠가 고정된다는 한계가 있음을 확인했습니다.

또한 서버 사이드 렌더링(SSR)은 요청마다 서버에서 렌더링하며 원본을 다시 조회하도록 구성할 수 있지만, 그만큼 서버 작업과 응답 시간이 늘어날 수 있습니다.

증분 정적 재생성(Incremental Static Regeneration, ISR)은 SSG와 SSR의 일부 특성을 결합한 렌더링 전략입니다.

ISR은 배포 후 캐시 유효기간이 지난 다음 요청을 계기로 정적 페이지를 백그라운드에서 재생성하여, 빠른 캐시 응답과 허용 가능한 데이터 신선도를 조정하게 해줍니다.


ISR이란 무엇인가요?

증분 정적 재생성(ISR)은 Next.js 애플리케이션이 빌드된 후에도 정적 페이지를 "재생성"할 수 있도록 하는 기능입니다.

이는 fetch 함수의 next.revalidate 옵션을 사용하여 구현됩니다.

ISR의 작동 방식

초기 빌드: npm run build 시점에 generateStaticParams와 함께 정의된 페이지들은 SSG 방식으로 미리 생성됩니다.

첫 요청: 사용자가 ISR이 적용된 페이지에 처음 접속하면, 캐시된(미리 생성된) 페이지가 즉시 제공됩니다.

이때 Next.js는 revalidate 옵션으로 설정된 시간(예: 60초)을 확인합니다.

revalidate 시간 경과 후 요청: 설정된 revalidate 시간이 경과한 후, 다음 사용자 요청이 들어오면 Next.js는 다음과 같이 동작합니다.

  • 사용자에게는 즉시 오래된(stale) 캐시된 페이지를 제공합니다.
  • 동시에 백그라운드에서 새로운 데이터를 가져와 페이지를 재렌더링하고 캐시를 업데이트합니다.

다음 요청: 캐시가 업데이트된 후 들어오는 모든 후속 요청에는 새로 생성된 페이지가 제공됩니다.

ISR의 주요 이점
  • 성능과 신선도의 균형: 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
// 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 테스트 방법

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의 고급 사용 사례 및 고려사항

  • revalidate를 0으로 설정: next: { revalidate: 0 }은 캐시를 사용하지 않고 매 요청마다 SSR처럼 동작하도록 강제합니다. (이는 cache: 'no-store'와 유사하게 작동합니다.)
  • 태그 기반 재검증: fetchtags를 지정한 뒤 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 캐시를 갱신할 때 시간 기반, 태그 기반, 경로 기반, 캐시 비활성화 전략을 어떻게 고르는지 정리한 것입니다.

ISR은 낡은 페이지를 즉시 제공하고 백그라운드에서 새 페이지를 준비하는 방식이므로, 사용자 응답성과 데이터 신선도를 서로 다른 단계로 나누어 이해하는 것이 중요합니다.

ISR은 빌드 시 생성된 페이지를 일정 조건에서 재검증해 응답 속도와 데이터 갱신 요구를 조정하는 전략입니다.

이를 통해 빠른 캐시 응답을 유지하면서 업무가 허용하는 신선도 범위 안에서 데이터를 갱신할 수 있습니다.

즉시 일관성이 필요한 값에는 ISR 대신 요청 시 조회나 명시적 무효화 전략을 선택합니다.

증분 정적 재생성 (ISR) 적용 전에는 서버/클라이언트 경계, 캐싱 조건, 배포 영향을 함께 확인해야 합니다.

마지막으로 ISR의 revalidate 기준을 정적 성능과 데이터 최신성 사이의 운영 선택으로 정리합니다.