Published on

Next.js 14 ISR 캐시가 안 갱신될 때 원인·해결

Authors

서버에 배포했는데도 페이지가 계속 옛날 내용으로 보이거나, revalidate 값을 줬는데도 재생성이 안 되는 경우가 있습니다. Next.js 14의 ISR은 “한 가지 스위치”로 동작하는 기능이 아니라, Route Segment 캐시, Data Cache(fetch 캐시), Full Route Cache, 그리고 배포 환경의 CDN 캐시가 겹쳐서 만들어지는 결과입니다.

그래서 “ISR이 안 된다”는 증상은 보통 아래 중 하나입니다.

  • 페이지(라우트)는 재생성됐는데, 데이터(fetch)가 캐시돼서 내용이 안 바뀜
  • 데이터는 바뀌었는데, 라우트/HTML이 캐시돼서 화면이 안 바뀜
  • 서버에서는 바뀌었는데, CDN이 이전 응답을 계속 서빙함
  • 개발 모드에서는 되는 것 같았는데, 프로덕션에서만 안 됨(혹은 그 반대)

이 글에서는 원인별로 재현 포인트와 해결책을 정리하고, 마지막에 “내 서비스에서 지금 어디가 막혔는지”를 빠르게 좁히는 체크리스트를 제공합니다.

참고로 App Router의 캐시 전반과 TTFB 관점은 아래 글에서도 더 깊게 다룹니다.

1) ISR이 안 갱신되는 가장 흔한 착각: revalidate는 “페이지”가 아니라 “세그먼트” 단위

App Router에서 export const revalidate = 60은 해당 라우트 세그먼트의 정적 생성 결과를 일정 시간 이후 재생성 가능하게 만드는 힌트입니다. 하지만 실제로는 아래 조건에 따라 결과가 달라집니다.

  • 해당 라우트가 정적으로 렌더링 가능한 상태여야 함
  • 내부에서 호출하는 fetch가 어떤 캐시 정책을 쓰는지에 따라 데이터가 고정될 수도 있음
  • cookies(), headers(), searchParams 의존, draftMode() 등으로 인해 라우트가 동적으로 강제되면 ISR 자체가 적용되지 않을 수 있음

즉, revalidate를 넣었는데도 안 바뀐다면 “ISR이 고장”이라기보다 정적/동적 판정이나 fetch 캐시 정책이 기대와 다른 경우가 많습니다.

2) 원인 A: fetch가 영구 캐시되어 데이터가 안 바뀜

증상

  • 페이지는 재생성되는 것 같은데, 내용(리스트/본문)이 계속 동일
  • DB를 바꿨는데도 화면이 그대로

배경

Next.js 14에서 fetch는 기본적으로 캐시될 수 있습니다(환경/호출 형태에 따라 다르게 체감될 수 있음). 특히 정적 렌더링 경로에서 호출되는 fetch는 Data Cache에 들어가면서 사실상 데이터가 고정되는 효과가 나기도 합니다.

해결 1: fetchnext.revalidate를 명시

페이지 수준 revalidate와 별개로, 데이터 소스별로 재검증 주기를 주는 방식이 가장 안전합니다.

// app/posts/page.tsx
export const revalidate = 60

async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  })
  if (!res.ok) throw new Error('failed')
  return res.json()
}

export default async function Page() {
  const posts = await getPosts()
  return (
    <main>
      <pre>{JSON.stringify(posts, null, 2)}</pre>
    </main>
  )
}

next: { revalidate: 60 }을 주면 해당 fetch 결과가 60초마다 재검증 대상이 됩니다. 페이지의 revalidate와 데이터의 revalidate가 섞일 때는 더 짧은 쪽에 맞춰 기대하는 UX가 나오는지 확인하세요.

해결 2: 완전 동기화가 필요하면 cache: 'no-store'

“관리자에서 수정하면 즉시 반영” 같은 요구라면 ISR이 아니라 항상 최신이 맞습니다.

async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    cache: 'no-store',
  })
  if (!res.ok) throw new Error('failed')
  return res.json()
}

주의: no-store는 캐시 이점을 거의 포기하므로, 트래픽이 큰 페이지에 남발하면 비용과 지연이 늘 수 있습니다.

3) 원인 B: 라우트가 동적으로 강제되어 ISR이 “적용되지 않음”

대표 트리거

아래 중 하나라도 있으면 정적 렌더링이 깨지고, 결과적으로 ISR 경로와 달라질 수 있습니다.

  • cookies() 사용
  • headers() 사용
  • draftMode() 사용
  • searchParams를 서버 컴포넌트에서 직접 의존(패턴에 따라)
  • 인증/개인화 로직을 서버 컴포넌트에서 처리

증상

  • revalidate를 줬는데도, 빌드 산출물이 아니라 매 요청 계산처럼 동작
  • 혹은 반대로, 동적으로 만들었다고 생각했는데 정적으로 굳어버림

해결: 의도에 맞게 dynamic을 명시

정적이어야 한다면 동적 트리거를 제거하거나 분리하고, 동적이어야 한다면 명시해서 혼선을 줄입니다.

// 정적 강제(가능한 경우)
export const dynamic = 'force-static'
export const revalidate = 60

// 동적 강제(항상 최신)
// export const dynamic = 'force-dynamic'

force-static을 걸었는데 빌드/런타임에서 에러가 나면, 해당 라우트가 정말로 동적 기능에 의존하고 있다는 뜻입니다. 이때는 페이지를 둘로 쪼개서 “정적 본문 + 클라이언트에서 동적 데이터” 같은 구조로 바꾸는 게 현실적인 해법입니다.

4) 원인 C: 태그 기반 무효화를 안 써서, 원하는 타이밍에 갱신이 안 됨

시간 기반 ISR은 “최대 N초 늦게 반영”을 허용합니다. 하지만 실무에서는 보통 다음이 필요합니다.

  • CMS 저장 버튼 누르면 즉시 반영
  • 특정 글만 갱신(전체 페이지 재생성은 비효율)

이때는 캐시 태그revalidateTag가 가장 깔끔합니다.

데이터 fetch에 태그를 달기

// lib/posts.ts
export async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: [`post:${id}`] },
  })
  if (!res.ok) throw new Error('failed')
  return res.json()
}

웹훅/관리자 액션에서 태그 무효화

// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const { id } = await req.json()

  // 보안: 실제로는 시크릿 검증이 필요
  revalidateTag(`post:${id}`)

  return Response.json({ ok: true })
}

이 방식은 “시간이 지나야만 갱신”되는 문제를 해결하고, 갱신 범위를 최소화합니다.

5) 원인 D: revalidatePath를 호출했는데도 화면이 안 바뀜

revalidatePath라우트 단위 무효화입니다. 하지만 다음 케이스에서는 기대와 다를 수 있습니다.

  • 실제로 stale한 건 라우트가 아니라 Data Cache(fetch)인 경우
  • revalidatePath를 호출했지만, 해당 요청이 실행되는 런타임/환경에서 캐시 스토어에 반영이 안 되는 경우
  • 경로를 잘못 지정했거나, 동적 세그먼트 패턴을 잘못 지정

권장 패턴: 데이터는 revalidateTag, 페이지는 필요 시 revalidatePath

  • 콘텐츠 단위 갱신은 revalidateTag가 더 정확
  • 레이아웃/목록 등 라우트 결과 자체를 날려야 할 때 revalidatePath
import { revalidatePath, revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const { id } = await req.json()

  revalidateTag(`post:${id}`)     // 해당 글 데이터
  revalidatePath('/posts')        // 목록 페이지

  return Response.json({ ok: true })
}

6) 원인 E: CDN 또는 프록시 캐시가 앞에서 막고 있음

프로덕션에서 “서버는 갱신됐는데 사용자에게는 안 보임”이면, Next.js 내부 캐시가 아니라 앞단 캐시일 확률이 큽니다.

체크 포인트

  • Vercel을 쓰면 플랫폼 캐시 정책과 헤더가 관여
  • CloudFront/Fastly/Cloudflare 같은 CDN을 직접 붙였으면, Cache-Control과 캐시 키 설정이 관여
  • 사내 프록시가 HTML까지 캐시하는 경우도 있음

해결 방향

  • HTML 문서에 대해 CDN 캐시를 과도하게 걸지 않기
  • s-maxage, stale-while-revalidate 같은 정책을 의도대로 설정
  • 미스/히트 확인을 위해 응답 헤더에 디버그 헤더를 추가(가능한 범위에서)

Next.js 자체만으로 해결이 안 될 때가 많아서, 인프라 캐시 정책도 함께 봐야 합니다.

7) 원인 F: 개발 모드에서 ISR을 검증하려고 함

next dev는 프로덕션 최적화(정적 생성/캐시)와 다르게 동작합니다. ISR 문제를 재현/검증하려면 다음을 권장합니다.

  • 로컬에서 next buildnext start로 확인
  • 가능하면 실제 배포 환경에서 확인

개발 모드에서 “잘 된다/안 된다”는 결론을 내리면, 프로덕션에서 뒤집히는 경우가 많습니다.

8) 실전 진단 체크리스트(10분 컷)

아래 순서대로 보면 “어느 레이어가 안 바뀌는지”가 빨리 좁혀집니다.

1) 이 라우트는 정적인가, 동적인가

  • cookies()/headers()/draftMode()를 쓰는지 검색
  • 필요하면 dynamic = 'force-static' 또는 dynamic = 'force-dynamic'으로 의도 고정

2) 데이터 fetch의 캐시 정책은 무엇인가

  • fetch(..., { cache: 'no-store' })인지
  • fetch(..., { next: { revalidate: N } })인지
  • 태그를 쓰는지 next: { tags: [...] }

3) 갱신 트리거는 무엇인가

  • 시간 기반이면 revalidate 값이 충분히 작나
  • 즉시 반영이면 revalidateTag 또는 revalidatePath를 제대로 호출하나

4) 앞단 캐시가 있는가

  • CDN이 HTML을 캐시하는지
  • 캐시 무효화가 배포/웹훅과 연결돼 있는지

9) 권장 설계: “목록은 ISR, 상세는 태그 무효화”

콘텐츠 사이트에서 많이 쓰는 균형점은 다음과 같습니다.

  • /posts 목록: export const revalidate = 30 같은 짧은 ISR
  • /posts/[id] 상세: 데이터 fetch에 tags 부여 후, CMS 저장 시 revalidateTag 호출

이렇게 하면 목록은 트래픽을 캐시로 흡수하면서도 최신성이 크게 떨어지지 않고, 상세는 수정 즉시 반영이 가능합니다.

10) 마무리

Next.js 14에서 ISR 캐시가 안 갱신되는 문제는 대개 “ISR이 고장”이 아니라, 정적/동적 판정, fetch 캐시(Data Cache), 태그/패스 무효화 방식, CDN 캐시 중 하나가 기대와 다르게 동작하는 케이스입니다.

특히 App Router에서는 revalidate 하나만 보고 판단하면 오진하기 쉽습니다. 먼저 “무엇이 캐시돼서 안 바뀌는가(라우트 vs 데이터 vs CDN)”를 분리한 뒤, 시간 기반이면 next.revalidate, 즉시 반영이면 revalidateTag 중심으로 설계를 잡는 것이 가장 재현 가능하고 운영 친화적입니다.

캐시가 얽히면 성능도 같이 흔들리기 때문에, 관련해서는 아래 글도 함께 보면 문제를 더 빨리 좁힐 수 있습니다.