
Google Search Console에서 "색인 생성 안 됨" 경고가 쏟아졌다. 블로그 포스트 60개 넘게 발행했는데, 검색엔진이 절반을 인식하지 못하고 있었다.
모든 동적 [slug] 페이지에 revalidate = 60을 설정해 두었으니 ISR이 잘 동작하고 있을 거라 생각했다. 그런데 실제로는 전부 fully dynamic이었다. 매 요청마다 서버에서 렌더링하고, 캐시도 없고, 응답 시간도 느렸다. 왜?
ISR이 안 되는 원인 후보는 여러 가지였다. Next.js 16 canary의 ISR 동작 자체가 바뀌었을 수도 있고, 페이지 내부의 특정 API 호출이 dynamic을 강제할 수도 있었다. 하나씩 가설을 세우고 검증했다.
가장 먼저 의심한 건 draftMode()였다. PayloadCMS의 미리보기 기능을 위해 각 상세 페이지에서 호출하고 있었는데, 이 함수는 쿠키를 읽기 때문에 Next.js가 페이지를 dynamic으로 분류한다. 맞았다 — 제거하니 더 이상 강제 dynamic이 아니었다. 하지만 ISR은 여전히 안 됐다.
// Before — draftMode()가 dynamic을 강제
export default async function Page({ params }) {
const { isEnabled } = await draftMode()
// ...
}
// After — draftMode 제거
export default async function Page({ params }) {
const { slug } = await params
// 직접 getPayload 호출
}데이터 조회 함수에 'use cache' 지시어를 쓰고 있었다. Next.js의 데이터 캐시 레이어와 ISR의 페이지 캐시 레이어가 겹치면서 빌드 타임의 빈 결과가 캐시에 고착되는 현상이 발생했다. 직접 getPayload를 호출하는 방식으로 교체했다.
// ISR 페이지에서는 직접 조회
async function getPost(slug: string) {
if (process.env.NEXT_PHASE === 'phase-production-build') return null
const payload = await getPayload({ config })
const result = await payload.find({
collection: 'posts',
where: { slug: { equals: slug }, _status: { equals: 'published' } },
limit: 1,
})
return result.docs[0] ?? null
}draftMode()를 제거하고 'use cache'도 걷어냈는데 여전히 ISR이 안 됐다. 실험 커밋을 만들어서 가설을 하나씩 검증했다.
b718e6f — 가설: generateMetadata가 문제? → 제거해도 변화 없음 ❌
9de6149 — 가설: generateStaticParams가 필요? → 빈 배열 추가 → ISR 작동! ✅빈 배열을 반환하는 generateStaticParams를 추가하자 ISR이 즉시 작동했다. Next.js 16 canary에서 [slug] 동적 라우트는 이 함수가 있어야만 ISR 대상으로 인식된다. revalidate 값만으로는 부족하다.
// 이것만 추가하면 ISR이 활성화된다
export async function generateStaticParams() {
return []
}
export const revalidate = 60이건 Next.js 공식 문서에 명시되어 있지 않다. 문서에는 revalidate만 설정하면 ISR이 동작한다고 되어 있다. 하지만 canary 버전에서는 동작이 다르다. generateStaticParams의 존재 자체가 "이 라우트는 정적 생성 대상"이라는 신호로 작용하는 것 같다. 빈 배열을 반환하더라도 빌드 타임에 생성할 페이지는 없지만, 런타임에 ISR로 생성하겠다는 의도가 전달된다.
이 디버깅을 통해 Next.js 16 canary에서 ISR을 제대로 동작시키기 위한 조건 목록을 정리했다.
revalidate export — 재검증 주기 설정generateStaticParams — [slug] 라우트에 필수 (빈 배열이라도)draftMode() 호출 제거 — 쿠키 접근이 dynamic을 강제'use cache' 함수 호출 제거 — 캐시 레이어 충돌 방지NEXT_PHASE 가드 — 빌드 타임에 DB 없이 빈 결과 반환searchParams 사용 금지 — 있으면 자동으로 fully dynamic이 중 하나라도 위반하면 ISR이 작동하지 않거나 예측 불가능한 캐싱 동작이 발생한다.
이번 디버깅에서 가장 효과적이었던 건 가설을 커밋으로 남기는 방식이었다. 코드를 변경하고 배포해서 동작을 확인한 뒤, 커밋 메시지에 가설과 결과를 기록했다. "generateMetadata 제거 → 변화 없음", "generateStaticParams 추가 → ISR 작동" 같은 형태다.
이렇게 하면 나중에 같은 문제를 만났을 때 git log에서 답을 찾을 수 있다. 블로그에 글을 쓰는 것과 비슷하지만, 커밋 히스토리는 코드와 함께 살아남기 때문에 접근성이 더 좋다. "그때 왜 이렇게 바꿨지?"라는 질문에 커밋 메시지가 바로 답한다.
결과적으로 모든 [slug] 라우트에 빈 generateStaticParams를 추가하고, draftMode()와 'use cache'를 걷어내자 ISR이 정상 작동했다. Google Search Console의 색인 경고도 점차 해소되기 시작했다. canary 버전을 쓴다면 문서를 믿지 말고 직접 검증하자.
괜찮다. 빈 배열을 반환하면 빌드 타임에 정적 생성할 페이지는 없지만, Next.js에게 이 라우트가 ISR 대상이라는 신호를 보낸다. 실제 페이지는 런타임에 요청이 들어올 때 생성되고 revalidate 주기로 캐시된다.
PayloadCMS의 Live Preview 기능을 대신 사용하거나, /api/preview 같은 별도 API 라우트에서 draftMode를 활성화하고 리다이렉트하는 방식으로 분리한다. ISR 페이지 자체에서 draftMode()를 호출하지 않는 게 핵심이다.
'use cache'는 Next.js의 데이터 캐시 레이어에서 동작하고, ISR은 페이지 레벨 캐시에서 동작한다. 두 캐시 레이어가 겹치면 revalidation 타이밍이 꼬여서 빌드 타임의 빈 결과가 캐시에 고착될 수 있다. ISR 페이지에서는 직접 getPayload를 호출하는 게 안전하다.