How to implement Incremental Static Regeneration (ISR)

김동현·2026년 3월 4일

next.js 공식문서 번역

목록 보기
25/79

Incremental Static Regeneration (ISR)을 사용하면 다음과 같은 일들이 가능해집니다:

  • 사이트 전체를 다시 빌드(rebuild)하지 않고도 정적(static) 콘텐츠를 업데이트할 수 있어요.
  • 대부분의 사용자 요청에 대해 이미 미리 렌더링 된(prerendered) 정적 페이지를 제공함으로써 서버의 부하를 크게 줄일 수 있습니다.
  • 페이지에 적절한 cache-control 헤더가 자동으로 추가되도록 보장해 줘요.
  • 콘텐츠 페이지의 양이 엄청나게 많아져도 next build 시간이 끝도 없이 길어지는 것을 방지하고 잘 처리할 수 있게 해줍니다.

💡 강사님의 보충 설명 & 꿀팁
쉽게 말해서 ISR은 "정적 사이트 생성(SSG)의 미친 속도""서버 사이드 렌더링(SSR)의 최신 데이터 유지"라는 두 마리 토끼를 모두 잡는 기술이에요. 처음 빌드할 때 만들어둔 HTML을 사용자에게 빠르게 보여주고, 일정 시간(혹은 특정 이벤트)이 지나면 백그라운드에서 조용히 새로운 데이터를 가져와서 HTML을 업데이트해 놓는 마법 같은 기능이죠!

아주 간단한 최소한의 예시를 먼저 살펴볼게요:

//filename="app/blog/[id]/page.tsx" switcher
interface Post {
  id: string
  title: string
  content: string
}

// Next.js will invalidate the cache when a
// request comes in, at most once every 60 seconds.
export const revalidate = 60

export async function generateStaticParams() {
  const posts: Post[] = await fetch(`https://api.vercel.app/blog/${id}`).then(
    (res) => res.json()
  )
  return posts.map((post) => ({
    id: String(post.id),
  }))
}

export default async function Page({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const post: Post = await fetch(`https://api.vercel.app/blog/${id}`).then(
    (res) => res.json()
  )
  return (
    <main>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </main>
  )
}
//filename="app/blog/[id]/page.jsx" switcher
// Next.js will invalidate the cache when a
// request comes in, at most once every 60 seconds.
export const revalidate = 60

export async function generateStaticParams() {
  const posts = await fetch('[https://api.vercel.app/blog').then((res](https://api.vercel.app/blog').then((res)) =>
    res.json()
  )
  return posts.map((post) => ({
    id: String(post.id),
  }))
}

export default async function Page({ params }) {
  const { id } = await params
  const post = await fetch(`https://api.vercel.app/blog/${id}`).then((res) =>
    res.json()
  )
  return (
    <main>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </main>
  )
}

위의 예시가 어떻게 동작하는지 단계별로 설명해 드릴게요. 이 과정은 면접에서 말로 풀어내야 할 수도 있으니 잘 흐름을 타보세요!

  1. next build를 실행하는 동안, 시스템이 알고 있는 모든 블로그 포스트 페이지가 미리 생성됩니다.
  2. 이 페이지들(예: /blog/1)로 들어오는 모든 요청은 캐시(cache) 처리되어 사용자에게 즉각적으로(순식간에) 제공됩니다.
  3. 설정한 60초라는 시간이 지난 후에 들어오는 첫 번째 다음 요청에 대해서도, 여전히 기존에 캐시 되어 있던 (이제는 유통기한이 지난 상태인) 페이지를 반환합니다.
  4. 이와 동시에, Next.js는 기존 캐시를 무효화(invalidate)하고 백그라운드에서 몰래 새로운 버전의 페이지를 생성하기 시작해요.
  5. 새로운 페이지가 성공적으로 만들어지면, 그 이후의 요청부터는 이 새롭게 업데이트된 페이지를 반환하고, 다음 요청들을 위해 다시 캐시 해 둡니다.
  6. 만약 누군가 /blog/26 같이 빌드 때 만들지 않은 페이지를 요청했는데 데이터가 존재한다면, 그 페이지는 요청이 들어온 순간(on-demand)에 바로 생성됩니다. 이러한 동작 방식은 dynamicParams 설정 값을 다르게 주어 변경할 수 있어요. 하지만, 해당 포스트가 아예 존재하지 않는다면 404 에러 페이지가 반환됩니다.

레퍼런스 (Reference)

라우트 세그먼트 설정 (Route segment config)

함수 (Functions)


예시들 (Examples)

시간 기반 재검증 (Time-based revalidation)

이 방식은 /blog 경로에서 블로그 포스트 목록을 가져와서 보여주는 예시예요. 1시간이 지난 후에 다음 방문자가 접속하더라도, 빠른 응답 속도를 위해 일단은 즉시 캐시된(오래된) 버전의 페이지를 받게 됩니다. 동시에 Next.js는 백그라운드에서 최신 버전의 페이지 재생성을 트리거(시작)하죠. 새 버전이 성공적으로 생성되면 기존의 캐시된 버전을 교체해 버리고, 그 이후의 방문자들은 모두 최신 콘텐츠를 받아보게 됩니다.

//filename="app/blog/page.tsx" switcher
interface Post {
  id: string
  title: string
  content: string
}

export const revalidate = 3600 // invalidate every hour

export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog')
  const posts: Post[] = await data.json()
  return (
    <main>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  )
}
//filename="app/blog/page.js" switcher
export const revalidate = 3600 // invalidate every hour

export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog')
  const posts = await data.json()
  return (
    <main>
      <h1>Blog Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </main>
  )
}

우리는 재검증 시간(revalidate time)을 꽤 높게 설정하는 것을 권장합니다. 예를 들어 1초보다는 1시간처럼 말이죠. 만약 더 정밀하고 즉각적인 업데이트가 필요하다면, 온디맨드(on-demand) 재검증 방식을 고려해 보세요. 만약 완전한 실시간 데이터가 필요한 상황이라면 아예 동적 렌더링(dynamic rendering)으로 전환하는 것을 고민해 봐야 합니다.

💡 강사님의 꿀팁
무턱대고 revalidate = 1 처럼 짧게 주면 백그라운드에서 너무 자주 서버 요청을 날리게 되어 서버 리소스 낭비가 심해져요. 문서나 포스트의 성격에 맞춰 '이 정도면 업데이트가 늦어져도 괜찮다' 싶은 적절한 타협점을 찾는 게 프론트엔드 개발자의 센스랍니다!

revalidatePath를 사용한 온디맨드 재검증 (On-demand revalidation)

더욱 정밀한 재검증 방식이 필요하다면, revalidatePath 함수를 사용해서 원할 때(on-demand) 즉시 캐시된 페이지를 무효화시킬 수 있어요.

예를 들어, 아래의 서버 액션(Server Action)은 새로운 글(post)을 추가한 직후에 호출될 겁니다. 서버 컴포넌트(Server Component)에서 데이터를 가져올 때 fetch를 쓰든, 데이터베이스에 직접 연결하든 상관없이, 이 함수는 해당 라우트 전체에 대한 캐시를 날려버립니다(무효화합니다). 그 라우트로 들어오는 다음 번 요청은 페이지 재생성을 트리거하고 아주 신선한 최신 데이터를 제공하게 되며, 이 데이터는 그다음 요청들을 위해 다시 예쁘게 캐시 된답니다.

참고 (Note): revalidatePath는 캐시 항목을 무효화할 뿐이고, 실제 재생성(regeneration) 작업은 그다음번 첫 요청이 들어올 때 일어납니다. 만약 다음 요청을 기다리지 않고 즉시 캐시 항목을 억지로(eagerly) 재생성하고 싶다면, Pages 라우터의 res.revalidate 메서드를 사용할 수 있어요. 현재 저희는 App Router 환경에서도 이렇게 즉각적인 재생성 기능을 제공하기 위한 새로운 메서드들을 추가하는 작업을 진행 중입니다.

//filename="app/actions.ts" switcher
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost() {
  // Invalidate the cache for the /posts route
  revalidatePath('/posts')
}
//filename="app/actions.js" switcher
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost() {
  // Invalidate the cache for the /posts route
  revalidatePath('/posts')
}

데모 보기소스 코드 살펴보기 도 참고해 보세요.

revalidateTag를 사용한 온디맨드 재검증

대부분의 일반적인 상황에서는 전체 경로(path)를 재검증하는 방식(revalidatePath)을 권장해요. 하지만 좀 더 세밀한(granular) 제어가 필요하다면 revalidateTag 함수를 사용할 수 있습니다. 예를 들어, 개별적인 fetch 호출마다 각각 태그(tag)를 달아줄 수 있어요:

//filename="app/blog/page.tsx" switcher
export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog', {
    next: { tags: ['posts'] },
  })
  const posts = await data.json()
  // ...
}
//filename="app/blog/page.js" switcher
export default async function Page() {
  const data = await fetch('https://api.vercel.app/blog', {
    next: { tags: ['posts'] },
  })
  const posts = await data.json()
  // ...
}

만약 ORM을 사용 중이거나 데이터베이스에 직접 연결하고 계시다면, unstable_cache를 활용할 수도 있습니다:

//filename="app/blog/page.tsx" switcher
import { unstable_cache } from 'next/cache'
import { db, posts } from '@/lib/db'

const getCachedPosts = unstable_cache(
  async () => {
    return await db.select().from(posts)
  },
  ['posts'],
  { revalidate: 3600, tags: ['posts'] }
)

export default async function Page() {
  const posts = getCachedPosts()
  // ...
}
//filename="app/blog/page.js" switcher
import { unstable_cache } from 'next/cache'
import { db, posts } from '@/lib/db'

const getCachedPosts = unstable_cache(
  async () => {
    return await db.select().from(posts)
  },
  ['posts'],
  { revalidate: 3600, tags: ['posts'] }
)

export default async function Page() {
  const posts = getCachedPosts()
  // ...
}

이렇게 태그를 달아두면, 나중에 서버 액션(Server Actions)이나 라우트 핸들러(Route Handler) 안에서 revalidateTag를 아주 유용하게 쓸 수 있어요:

//filename="app/actions.ts" switcher
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost() {
  // Invalidate all data tagged with 'posts'
  revalidateTag('posts')
}
//filename="app/actions.js" switcher
'use server'

import { revalidateTag } from 'next/cache'

export async function createPost() {
  // Invalidate all data tagged with 'posts'
  revalidateTag('posts')
}

💡 강사님의 꿀팁
revalidateTag는 복잡한 서비스에서 정말 빛을 발해요! 예를 들어 화면에 "인기 게시글" 데이터와 "최근 게시글" 데이터가 섞여 있다면, "최근 게시글"이 추가되었을 때 화면 전체를 갱신할 필요 없이 '최근 게시글' 태그가 달린 캐시만 쏙쏙 골라서 업데이트할 수 있거든요. 아주 우아한 캐시 전략이죠!

처리되지 않은 예외 핸들링 (Handling uncaught exceptions)

데이터를 재검증하려고 시도하는 도중에 만약 에러가 발생(throw)한다면 어떻게 될까요? 걱정 마세요. 마지막으로 성공하게 생성되었던 데이터가 캐시에서 계속해서 문제없이 제공됩니다. 그리고 다음번 요청이 들어왔을 때, Next.js가 다시 한번 데이터 재검증을 용감하게 재시도할 거예요. 에러 핸들링에 대해 더 알아보기.

캐시 저장 위치 커스터마이징 (Customizing the cache location)

캐시 된 페이지와 데이터를 오랫동안 보존되는(durable) 저장소에 영구히 보관하고 싶거나, Next.js 애플리케이션의 여러 컨테이너나 인스턴스들 사이에서 캐시를 공유하고 싶다면 Next.js 캐시 저장 위치를 직접 설정할 수도 있습니다. 더 알아보기.


트러블슈팅 (Troubleshooting)

로컬 개발 환경에서 캐시 된 데이터 디버깅하기

만약 fetch API를 사용 중이시라면, 어떤 요청이 캐시 되었고 어떤 요청이 캐시 되지 않았는지 파악하기 위해 로깅(logging) 기능을 추가할 수 있어요. logging 옵션에 대해 더 알아보기.

module.exports = {
  logging: {
    fetches: {
      fullUrl: true,
    },
  },
}

프로덕션(운영) 환경의 동작 올바르게 검증하기

여러분이 만든 페이지들이 실제 프로덕션 환경에서 제대로 캐시 되고 재검증되는지 확인하고 싶으시죠? 로컬에서 next build를 실행한 다음 next start를 실행해서 프로덕션 Next.js 서버를 가동해 보면 테스트할 수 있어요.

이렇게 하면 실제 운영 환경에서 동작하는 것과 동일하게 ISR의 동작을 테스트할 수 있답니다. 더 깊게 디버깅을 하고 싶다면, 여러분의 .env 파일에 다음의 환경 변수를 추가해 보세요:

NEXT_PRIVATE_DEBUG_CACHE=1

이걸 추가하면 Next.js 서버 콘솔에 ISR 캐시 적중(hits)과 실패(misses) 내역이 로그로 찍히게 됩니다. 이 출력 결과를 통해 next build 중에 어떤 페이지들이 생성되었는지, 그리고 사용자의 요청(on-demand)으로 경로에 접근할 때 페이지가 어떻게 업데이트되는지 상세하게 추적해 볼 수 있어요. (포트폴리오 만들 때 이슈 생기면 이 방법으로 꼭 확인해 보세요!)


주의사항 (Caveats)

  • ISR은 기본값인 Node.js 런타임(runtime)을 사용할 때만 지원됩니다.
  • 정적 내보내기(Static Export)를 생성할 때는 ISR 기능이 지원되지 않습니다.
  • 정적으로 렌더링 된 라우트 안에 여러 개의 fetch 요청이 있고, 각 요청이 서로 다른 revalidate 주기(빈도)를 가지고 있다면, 그중에서 가장 짧은 시간이 해당 라우트의 ISR 주기로 사용됩니다. 하지만 이와 별개로 각 fetch의 revalidate 시간 자체는 데이터 캐시(Data Cache)에 의해 여전히 존중받고 유지됩니다.
  • 라우트 안에서 사용된 fetch 요청 중 단 하나라도 revalidate 시간이 0이거나 명시적으로 no-store가 설정되어 있다면, 그 라우트는 전체가 동적으로 렌더링(dynamically rendered) 됩니다.
  • 온디맨드 ISR 요청에는 프록시(Proxy)가 실행되지 않아요. 즉, 프록시에 설정된 경로 재작성(rewrites)이나 로직들이 적용되지 않는다는 뜻입니다. 그러니 무효화(revalidate)를 할 때는 반드시 정확한 원본 경로를 지정해 주셔야 해요. 예를 들어 프록시로 재작성된 /post-1 대신에, 진짜 경로인 /post/1 을 넣어주어야 합니다.

플랫폼 지원 현황 (Platform Support)

배포 옵션 (Deployment Option)지원 여부 (Supported)
Node.js 서버 (Node.js server)예 (Yes)
Docker 컨테이너 (Docker container)예 (Yes)
정적 내보내기 (Static export)아니오 (No)
어댑터 (Adapters)플랫폼에 따라 다름 (Platform-specific)

Next.js를 직접 셀프 호스팅(self-hosting)할 때 ISR을 설정하는 방법 도 참고해 보세요.


버전 역사 (Version history)

버전 (Version)변경 사항 (Changes)
v14.1.0커스텀 cacheHandler가 안정화(stable) 되었습니다.
v13.0.0App Router가 도입되었습니다.
v12.2.0Pages Router: 온디맨드 ISR(On-Demand ISR)이 안정화되었습니다.
v12.0.0Pages Router: 봇 인식 ISR 폴백 (Bot-aware ISR fallback) 기능이 추가되었습니다.
v9.5.0Pages Router: 안정적인 ISR이 처음 도입되었습니다.

모든 문서의 의미론적(semantic) 개요를 보시려면 https://nextjs.org/docs/sitemap.md 를 참고해 주세요.

이용 가능한 모든 문서의 색인(index)을 확인하시려면 https://nextjs.org/docs/llms.txt 를 참고해 주세요.

profile
프론트에_가까운_풀스택_개발자

0개의 댓글