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 은 안 됩니다. 빌드가 코드를 실행하지 않고 값을 읽기 때문입니다.
선언이 없어도 경로의 주기를 정하는 데이터 캐시의 수명
같은 규칙은 선언이 없는 쪽에서도 작동합니다. 본진 사이트(duolabs.co.kr)의 홈 페이지 파일에는 revalidate 선언이 한 줄도 없습니다. 그런데 빌드 결과의 prerender-manifest.json 을 열어 보니 /ko, /en, /ja 의 initialRevalidateSeconds 가 모두 21600, 6시간이었습니다.
홈이 읽는 견적 가격, FAQ, 영상 목록은 unstable_cache(..., { revalidate: 21600 }) 로 감싸져 있었고, 그 수명이 페이지로 올라와 경로 전체의 주기가 된 것입니다. 앞 절의 규칙에서 레이아웃과 페이지만 표를 가진 것이 아니었습니다. 데이터 캐시도 한 표를 갖습니다.
그래서 어느 경로가 ISR 인지는 페이지 파일의 선언만 봐서는 알 수 없습니다. manifest 의 initialRevalidateSeconds 를 읽는 편이 정확합니다. false 면 빌드 때 한 번 만들고 끝나는 경로이고, 숫자가 있으면 그 초마다 다시 만들어지는 경로입니다. 같은 manifest 에서 /en/services 는 false, /en/pricing 은 21600 이었습니다. 어느 쪽인지는 빌드가 끝난 뒤에야 확인됩니다.
개발 환경에서는 캐시가 보이지 않는다
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 로 같은 문제를 다르게 다룹니다. 홈의 21600 이 unstable_cache 의 수명 때문이라는 설명은 manifest 값과 코드를 맞춰 본 것이고, 문서에서 그 전파 규칙을 직접 확인한 것은 아닙니다.
우리 서비스에 적용할 계획이 있으신가요?
홈페이지·웹서비스 개발 살펴보기 →함께 읽기
- Next.js 16 revalidateTag와 브라우저 캐시: 사이드바 숫자가 어긋난 문제한 번에 글을 일곱 편 발행한 날, 블로그 왼쪽의 카테고리 메뉴가 이상하게 보였습니다. 카테고리를 오가며 스크린샷 두 장을 찍었습니다. 첫 장은 전체 글 수가 377, 「데이터베이스 설계」가 14편이었고, 몇 초 뒤에 다른 카테고리를 눌러 찍은 둘째 장은 전체 370, 「데이터베이스 설계」 7편이었습니다. 메뉴가 글 수…
- Next.js 레이아웃과 라우트 그룹: loading.tsx 적용 범위Next.js 앱 라우터에서 한 폴더에는 이런 파일들이 함께 놓입니다.
- Next.js 16 Proxy(구 Middleware)와 인증Next.js 16 으로 올리면 middleware.ts 에 경고가 붙습니다. 파일 규칙 이름이 proxy 로 바뀌었다는 안내입니다. Proxy 문서의 첫 문장은 기능이 그대로라고 적습니다.
- Next.js 오류 처리 파일: error.tsx, global-error, not-foundNext.js 앱 라우터에는 오류를 다루는 파일이 셋 있습니다. error.tsx, global-error.tsx, not-found.tsx 입니다. 이름만 보면 「오류 화면, 전체 오류 화면, 404 화면」으로 나뉘는 것 같은데, 실제로는 각자 감싸는 범위와 잡는 대상이 다릅니다. 하나로 다 될 것 같아서 error.…
- Next.js 사이트맵과 메타데이터 라우트 캐싱글을 발행하면 블로그의 RSS 에는 몇 초 만에 새 글이 들어오는데, 사이트맵에는 며칠째 들어오지 않았습니다. 응답 헤더의 age 값만 계속 올라갔습니다. 발행할 때 캐시 태그를 무효화하고 있었고, 그 태그는 글 목록과 RSS 가 함께 쓰는 태그였습니다. RSS 는 갱신되는데 사이트맵만 멈춰 있었습니다.