Next.js ISR과 generateStaticParams
로딩 화면 파일 하나의 위치를 옮겨 push 했는데 Vercel 빌드가 실패했습니다. 페이지의 동작을 바꾸는 코드는 건드리지 않은 커밋이었습니다. 빌드 로그의 오류 줄은 이랬습니다.
[blog] listPosts failed: Query read timeout
Error occurred prerendering page "/category".
Export encountered an error on /category/page: /category, exiting the build.코드는 멀쩡했습니다. 빌드가 데이터베이스에 접속했고, 그 조회가 시간 초과로 실패했습니다. 같은 커밋으로 다시 배포하자 58초 만에 성공했습니다. 이 블로그의 빌드가 데이터베이스를 타는 이유는 페이지를 미리 만들어 두는 방식, 즉 정적 렌더링과 ISR 설정에 있습니다.
revalidate 를 선언하면 캐시된다는 생각
페이지 파일에 한 줄을 더하면 그 페이지가 캐시된다고 흔히 이해합니다.
export const revalidate = 300;「이 페이지를 300초 동안 캐시하고, 지나면 새로 만든다」는 뜻으로 읽힙니다. 그렇다면 모든 페이지에 이 줄만 넣으면 되고, 그 페이지가 언제 처음 만들어지는지는 신경 쓸 필요가 없어 보입니다.
이 이해로는 두 가지가 설명되지 않습니다. revalidate 를 선언했는데도 요청마다 새로 렌더링되는 동적 경로가 있다는 것, 그리고 반대로 코드와 상관없는 빌드가 데이터베이스 때문에 실패한다는 것입니다. 페이지가 언제 만들어지는지를 정하는 설정이 따로 있습니다.
이 글은 Cache Components 를 켜지 않은 이전 캐싱 모델 기준입니다. Next.js 16 문서는 두 모델의 가이드를 나눠 두고 있습니다.
페이지가 만들어지는 세 시점
Next.js 의 페이지는 세 시점 중 하나에 만들어집니다.
| 시점 | 이름 | 결과 |
|---|---|---|
| 빌드할 때 | 정적 렌더링(프리렌더) | 빌드 결과물에 HTML 이 들어 있다 |
| 첫 요청 때 만들고 보관 | ISR | 처음 방문한 사람이 만들고, 다음 사람부터 보관본을 받는다 |
| 요청마다 | 동적 렌더링 | 매번 새로 만든다 |
revalidate = 300 은 이 중 보관본의 수명을 정합니다. 보관본을 언제 처음 만드는지는 정하지 않습니다.
동적 세그먼트가 없는 경로, 예를 들어 전체 글 목록 / 이나 카테고리 목록 /category 는 빌드할 때 프리렌더됩니다. 페이지가 데이터베이스를 읽는다면 빌드가 데이터베이스를 읽습니다. 처음의 빌드 실패가 이것이었습니다. /category 를 프리렌더하려고 카테고리 목록을 조회하다 시간이 초과됐습니다. 이 블로그의 루트 페이지 주석에는 이 사실이 미리 적혀 있었습니다. 빌드 시점에 데이터베이스가 응답하지 않으면 빌드가 실패하지만, 직전 배포는 그대로 살아 있으므로 안전한 실패라는 내용입니다.
동적 경로의 ISR 에는 generateStaticParams 가 필요하다
/category/[name] 같은 동적 세그먼트는 사정이 다릅니다. 빌드할 때 어떤 name 이 있는지 모르니 미리 만들 수 없습니다. 여기에 revalidate 만 선언하면 ISR 이 될 것 같지만, generateStaticParams 문서에는 조건이 적혀 있습니다.
You must return an empty array from generateStaticParams or utilize export const dynamic = 'force-static' in order to revalidate (ISR) paths at runtime.
실행 중에 경로를 ISR 로 만들려면 generateStaticParams 가 빈 배열을 돌려주거나 dynamic = 'force-static' 을 써야 합니다. 이 블로그의 동적 경로들은 그래서 이렇게 되어 있습니다.
export const revalidate = 300;
// 빌드 때는 아무 카테고리도 렌더하지 않는다(DB 를 타지 않는다).
// 방문한 카테고리만 그때 만들어 캐시한다.
export async function generateStaticParams() {
return [];
}빈 배열은 「빌드 때는 하나도 만들지 말고, 요청이 오면 그때 만들어 보관하라」는 뜻입니다. 빌드가 데이터베이스를 타지 않으면서도 방문한 경로는 보관됩니다. 문서는 한 가지를 더 적습니다. ISR 로 다시 만들 때는 generateStaticParams 가 다시 호출되지 않습니다.
반대로 자주 방문하는 경로를 빌드 때 미리 만들어 두고 싶다면 그 목록을 돌려주면 됩니다. 대신 그만큼 빌드가 데이터베이스를 더 많이 읽고, 빌드 실패의 가능성도 그 조회에 묶입니다.
레이아웃과 페이지의 revalidate 중 가장 짧은 값
revalidate 는 페이지에만 쓰는 것이 아니라 레이아웃에도 쓸 수 있습니다. 둘이 다르면 어떻게 될까요. 이전 모델의 캐싱 가이드는 규칙을 이렇게 적습니다.
The lowest revalidate across each layout and page of a single route will determine the revalidation frequency of the entire route.
한 경로를 이루는 레이아웃과 페이지 중 가장 짧은 값이 경로 전체의 주기가 됩니다. 레이아웃에 revalidate = 60 을 두면, 페이지에 3600 을 써도 60초마다 다시 만들어집니다. 레이아웃에 설정을 넣을 때는 그 아래 모든 페이지에 영향을 준다는 점을 기억해야 합니다.
같은 가이드에는 이 값이 정적으로 분석 가능해야 한다는 주의도 있습니다. revalidate = 600 은 되지만 revalidate = 60 * 10 은 안 됩니다. 빌드가 코드를 실행하지 않고 값을 읽기 때문입니다.
개발 환경에서는 캐시가 보이지 않는다
ISR 설정을 로컬에서 확인하려다 헷갈리는 이유도 문서에 있습니다.
In Development, Pages are always rendered on-demand and are never cached.
next dev 에서는 모든 페이지가 요청마다 렌더링되고 캐시되지 않습니다. 코드를 고치면 바로 보이게 하려는 것입니다. 그래서 revalidate 가 제대로 걸렸는지, 빌드가 어떤 페이지를 프리렌더하는지는 개발 서버로는 알 수 없습니다. next build 의 출력에서 경로마다 정적인지 동적인지 표시되는 것을 보거나, 배포한 뒤 응답 헤더로 확인해야 합니다.
경로마다 정할 두 가지
이제 경로 하나를 만들 때 두 가지를 따로 정하면 된다고 봅니다.
| 질문 | 설정 |
|---|---|
| 이 경로를 언제 처음 만드는가 (빌드 / 첫 요청 / 매번) | 동적 세그먼트 여부, generateStaticParams 의 반환값, dynamic |
| 만든 것을 얼마나 오래 쓰는가 | revalidate, 태그 무효화 |
그리고 빌드 때 만드는 경로가 데이터베이스를 읽는다면, 빌드가 데이터베이스의 가용성에 묶인다는 사실을 받아들이거나 그 경로를 첫 요청 때 만들도록 옮깁니다. 이 블로그는 동적 세그먼트가 없는 목록 페이지는 빌드 때 만들고, 수가 늘어나는 동적 경로는 모두 첫 요청 때 만드는 쪽을 택했습니다. 빌드 실패는 직전 배포를 남기므로 다시 배포하면 되는 문제였습니다.
여기까지가 확실한 부분
ISR 에 빈 generateStaticParams 나 force-static 이 필요하다는 점, ISR 재생성 때 generateStaticParams 가 다시 호출되지 않는다는 점, 가장 짧은 revalidate 가 경로 전체를 정한다는 점, 값이 정적으로 분석 가능해야 한다는 점, 개발 환경에서는 캐시되지 않는다는 점은 Next.js 16 문서의 이전 캐싱 모델 기준입니다. 빌드 로그와 재배포 결과는 2026년 9월 18일 이 블로그의 Vercel 배포에서 확인한 내용입니다. Cache Components 를 켠 프로젝트는 use cache 와 cacheLife 로 같은 문제를 다르게 다룹니다.
함께 읽기
- Next.js 병렬 라우트와 인터셉트 라우트: 모달 패턴과 default.js사진 목록에서 사진을 누르면 모달로 크게 보여 주고, 같은 주소를 새 탭에서 열면 전체 페이지로 보여 주는 화면이 있습니다. 주소는 /photo/123 하나인데 보이는 모양이 둘입니다.
- Next.js 데이터 가져오기 순서: 순차와 병렬, 그리고 Suspense 위치서버 컴포넌트에서 데이터를 읽는 코드는 이렇게 생깁니다.
- Next.js 다국어 경로 설계: 쿠키와 하위 경로공개하지 않고 내부에서만 여는 제품 카탈로그 데모에 언어 전환을 붙였습니다. 한국어, 영어, 중국어, 일본어, 독일어, 프랑스어 여섯 개입니다. 고른 언어는 쿠키에 저장하고, 쿠키가 없으면 브라우저의 Accept-Language 헤더를 봅니다. 주소는 나누지 않았습니다. /products/<기종> 하나로 여섯 언어를 모…
- Next.js 라우트 핸들러와 서버 함수 구분앱 라우터에서 서버로 무언가를 보내는 방법이 둘입니다. app/api/.../route.ts 에 라우트 핸들러를 만들거나, "use server" 를 붙인 서버 함수를 부르는 것입니다. 폼 하나를 붙일 때마다 어느 쪽을 쓸지 정해야 하는데, 「둘 다 서버에서 도니 아무거나」로 두면 나중에 갈립니다.
- Next.js 이미지 최적화: next/image와 빌드 때 굽기제품 카탈로그 데모를 만들면서 이미지 처리 방식을 정해야 했습니다. 화면에 나가는 사진은 제품 사진 몇 장과 로고뿐이고, 모두 저장소에 들어 있는 파일입니다. 요청마다 달라질 것이 없습니다.