Published on

Next.js ISR 캐시 꼬임으로 404·구버전 뜰 때 해결

Authors

서버리스/엣지 환경에서 Next.js ISR(Incremental Static Regeneration)을 쓰다 보면, 특정 경로가 갑자기 404로 굳어버리거나, 배포 후에도 구버전 HTML이 계속 노출되는 현상을 종종 만납니다. 개발 환경에서는 재현이 잘 안 되는데 운영에서만 터지고, 새 글을 발행했는데도 방문자에겐 예전 페이지가 보이거나(혹은 404), 어떤 사용자만 정상이고 어떤 사용자만 깨지는 식으로 나타나면 대부분 캐시 계층이 2~3겹으로 얽혀 있습니다.

이 글은 “ISR 자체 캐시 + 데이터 캐시 + CDN 캐시”가 섞이면서 생기는 대표적인 꼬임 패턴을 증상 → 진단 포인트 → 해결책 순서로 정리합니다.

ISR에서 실제로 캐시되는 것들

ISR을 이해할 때 중요한 건 “페이지가 한 번 빌드되면 끝”이 아니라, 아래 캐시들이 서로 다른 수명/키로 동작한다는 점입니다.

  1. HTML/RSC 결과(페이지 결과물): revalidate 주기 또는 on-demand revalidate로 갱신
  2. fetch() 데이터 캐시(App Router): next: { revalidate }, cache: 'force-cache' 등에 의해 별도 캐시
  3. CDN/프록시 캐시: Cache-Control, s-maxage, stale-while-revalidate에 의해 엣지에서 재사용
  4. 브라우저 캐시: 일반적으로 HTML은 짧지만, 설정에 따라 예상보다 오래 남을 수 있음

이 중 하나라도 “오래된 키”로 남거나 “404 응답이 캐시”되면, ISR이 정상이어도 사용자 입장에선 계속 깨져 보입니다.

대표 증상 1) 특정 경로가 404로 고착(캐시된 404)

흔한 원인

  • 동적 라우트에서 존재하지 않는 slug를 404로 반환했는데, 그 404가 ISR/CDN에 의해 캐시됨
  • App Router에서 notFound()가 호출된 결과가 재검증 없이 캐시
  • generateStaticParams()가 일부만 생성하고, 나머지 경로는 런타임에서 생성돼야 하는데 설정이 맞지 않음

진단 체크

  • 응답 헤더에서 CDN 캐시 여부 확인
    • x-vercel-cache: HIT/MISS/STALE(Vercel 계열) 또는 cf-cache-status(Cloudflare)
  • Next.js 로그에서 재생성 시도가 있었는지 확인
  • 동일 URL을 쿼리스트링 없이 반복 호출했을 때 404가 계속 HIT이면 “404 캐시” 가능성이 큼

해결책 A) 동적 라우트 설정을 명확히(특히 App Router)

App Router(Next.js 13+)에서 동적 세그먼트는 아래 조합이 꼬이면 404가 굳을 수 있습니다.

  • generateStaticParams()를 쓰면서
  • dynamicParams 기본값(버전에 따라 동작 차이)
  • 데이터 소스가 늦게 반영되는 경우

권장 패턴(예시):

// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'

export const revalidate = 60 // ISR: 60초마다 재검증
export const dynamicParams = true // generateStaticParams에 없는 slug도 런타임 생성 허용

export async function generateStaticParams() {
  // 초기 프리렌더링 대상만 제한적으로 생성
  const slugs = await fetch('https://api.example.com/posts/slugs?top=50', {
    next: { revalidate: 300 },
  }).then(r => r.json())

  return slugs.map((slug: string) => ({ slug }))
}

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 },
  })
  if (res.status === 404) return null
  if (!res.ok) throw new Error('upstream error')
  return res.json()
}

export default async function Page({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug)
  if (!post) notFound()
  return <article>{post.title}</article>
}

핵심은 **“없는 slug는 404”**가 맞더라도, 그 상태가 영구 캐시로 굳지 않게 revalidate/데이터 캐시 정책을 명확히 두는 것입니다.

해결책 B) 404를 캐시하지 않도록 CDN/헤더를 점검

프록시가 404를 캐시하면 ISR이 재생성해도 사용자에겐 계속 404가 보입니다.

  • 가능하면 404 응답에 Cache-Control: no-store 또는 매우 짧은 s-maxage를 적용
  • 플랫폼별 설정에서 “404 캐시” 옵션이 있는지 확인

App Router에서는 라우트 핸들러를 활용해 캐시 정책을 더 강하게 제어할 수도 있습니다(예: API에서 404는 no-store).

대표 증상 2) 배포했는데 구버전 HTML이 계속 노출

흔한 원인

  • CDN이 HTML을 오래 들고 있음(특히 s-maxage, stale-while-revalidate 조합)
  • on-demand revalidate를 호출했는데, 실제로는 다른 경로/태그를 무효화하고 있음
  • fetch() 데이터 캐시가 오래 남아 페이지 재생성이 되어도 데이터가 구버전

진단 체크: “페이지 캐시” vs “데이터 캐시” 분리

  1. HTML이 구버전인지 확인: 페이지에 buildId/commitSha 같은 마커를 잠시 넣어 확인
  2. 데이터가 구버전인지 확인: API 응답에 updatedAt 등을 찍고 실제 값 비교

둘 중 무엇이 오래됐는지에 따라 해결이 달라집니다.

On-demand Revalidate: 경로 기반 vs 태그 기반

Next.js 최신(App Router)에서는 태그 기반 무효화가 운영에서 더 안전한 경우가 많습니다.

  • revalidatePath('/posts/foo'): 특정 경로 HTML을 무효화
  • revalidateTag('posts'): 해당 태그를 사용한 fetch() 데이터 캐시를 무효화

예시) 글 발행 시 태그 기반으로 데이터/페이지 동시 갱신

// app/api/revalidate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function POST(req: NextRequest) {
  const { secret, slug } = await req.json()
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false, message: 'unauthorized' }, { status: 401 })
  }

  // 1) 목록/상세에서 공유하는 데이터 캐시를 태그로 무효화
  revalidateTag('posts')

  // 2) 상세 페이지 경로 무효화
  revalidatePath(`/posts/${slug}`)

  // 3) 목록 페이지도 무효화
  revalidatePath('/posts')

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

그리고 데이터를 가져오는 쪽에서 태그를 붙입니다.

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

이렇게 하면 “페이지는 갱신됐는데 데이터는 구버전” 같은 반쪽짜리 갱신을 줄일 수 있습니다.

캐시 꼬임을 만드는 자주 있는 실수 6가지

1) 페이지 revalidate만 믿고, fetch()는 기본 캐시로 방치

App Router에서 fetch()는 기본적으로 캐시될 수 있습니다. 페이지 레벨의 revalidate와 데이터 캐시의 revalidate는 별개로 생각해야 합니다.

  • 빠르게 최신이 필요하면 cache: 'no-store'
  • ISR 유지하면서 주기 갱신이면 next: { revalidate: N }

2) revalidatePath를 호출했지만 실제 URL과 불일치

  • 트레일링 슬래시(/posts/a/ vs /posts/a)
  • locale prefix(/ko/posts/a)
  • basePath 적용

운영 URL과 정확히 일치하는 path를 무효화해야 합니다.

3) 목록 페이지는 갱신했는데, 상세 페이지는 그대로

CMS에서 글 수정 시 보통 다음이 함께 바뀝니다.

  • /posts 목록
  • /posts/[slug] 상세
  • / 홈 섹션

무효화 범위를 누락하면 “목록은 최신, 상세는 구버전”이 됩니다.

4) CDN이 stale-while-revalidate로 구버전을 오래 제공

stale-while-revalidate는 체감 성능엔 좋지만, 운영 사고 시 “왜 계속 구버전이냐”로 이어집니다.

  • 사고 대응 모드: 일시적으로 stale-while-revalidate를 줄이거나 제거
  • 배포 직후 강제 갱신이 필요하면 on-demand revalidate + CDN purge 전략 고려

5) 404/500 같은 에러 응답이 캐시됨

특히 업스트림 API가 일시 장애로 500을 내면, 그 결과가 캐시되어 “장애가 끝났는데도 계속 장애”처럼 보일 수 있습니다.

  • 에러 응답은 no-store
  • 업스트림 장애 시 fallback(이전 정상 데이터) 전략 검토

6) 멀티 리전/멀티 인스턴스에서 무효화 이벤트가 전파되지 않음

플랫폼에 따라 revalidate 이벤트가 리전에 늦게 반영되거나, 외부 캐시 계층이 별도로 존재할 수 있습니다.

  • revalidate 호출 결과를 로깅(요청 ID, slug, 시간)
  • 실제로 갱신된 시점을 헤더/마커로 검증

운영에서 바로 쓰는 “진단용 헤더/마커” 넣기

캐시 문제는 눈에 보이지 않아서, 임시로라도 아래를 넣으면 원인 분리가 빨라집니다.

  • HTML에 generatedAt, commitSha
  • API 응답에 dataVersion, updatedAt
  • 서버 로그에 cacheStatus(가능하면)

예: 페이지에 생성 시각을 심기(임시)

export default async function Page() {
  const now = new Date().toISOString()
  return (
    <>
      <small data-generated-at={now}>generatedAt: {now}</small>
      {/* ... */}
    </>
  )
}

이걸로 “정말 재생성이 안 되는지(HTML 고정)” vs “재생성은 되는데 데이터가 고정인지”를 빠르게 가릅니다.

체크리스트: 404·구버전 ISR 꼬임을 끝내는 순서

  1. 문제 URL을 단일화: 슬래시/locale/basePath 포함한 ‘정확한 URL’ 확정
  2. 헤더 확인: CDN HIT/STALE 여부로 캐시 계층 추정
  3. HTML vs 데이터 분리: build 마커/updatedAt으로 어디가 구버전인지 확인
  4. 동적 라우트 설정 점검: generateStaticParams, dynamicParams, revalidate 정합성
  5. on-demand revalidate 도입/정비: revalidatePath + revalidateTag 조합
  6. 404/에러 캐시 차단: 에러 응답 no-store, CDN의 404 캐시 정책 확인
  7. 배포 파이프라인에서 무효화 자동화: 배포 후 주요 경로 purge/revalidate

배포 자동화 관점에서 캐시/무효화가 꼬이면 CI/CD가 원인을 숨기는 경우도 많습니다. 캐시 계층을 정리하는 과정에서 GitHub Actions 캐시/파이프라인 동작도 함께 점검하면 운영 안정성이 올라갑니다: GitHub Actions 캐시가 안 먹을 때 키·경로·권한

또한 “캐시된 키가 안 맞아서 계속 실패” 유형은 다른 영역에서도 동일하게 나타납니다. 예를 들어 JWK를 오래 캐시해 kid 불일치가 지속되는 문제와 구조가 유사합니다: JWT 검증 실패 - kid 불일치와 JWK 캐시 갱신법

결론: ISR 문제의 80%는 ‘무효화 단위’와 ‘캐시 계층’ 불일치

Next.js ISR 자체는 비교적 단순하지만, 실제 운영에서는

  • 페이지 결과물 캐시
  • fetch() 데이터 캐시
  • CDN 캐시 가 서로 다른 규칙으로 움직입니다. 404나 구버전이 “꼬여서” 지속될 때는 (1) 무엇이 캐시됐는지, (2) 어떤 키로 캐시됐는지, (3) 무효화가 그 키를 정확히 때리는지를 순서대로 확인해야 합니다.

가장 재현이 어렵고 비용이 큰 장애는 ‘에러(404/500)가 캐시되어 정상화 이후에도 계속 장애처럼 보이는’ 케이스입니다. 이 글의 체크리스트대로 404 캐시 차단과 on-demand revalidate 체계를 잡아두면, ISR을 성능 최적화가 아니라 운영 안정성 도구로 쓸 수 있게 됩니다.