Published on

Next.js 캐시 안 먹힘? ISR·revalidate 오작동 7분 해결

Authors

서버 비용은 오르는데 페이지는 매번 새로 렌더링되고, revalidate를 줬는데도 갱신이 안 되거나 반대로 너무 자주 갱신되는 경우가 있습니다. Next.js 캐시는 “내가 생각한 계층”이 아니라 Next.js 런타임의 캐시 규칙 + fetch 캐시 + CDN/프록시 캐시 + 배포 환경이 합쳐져 결정되기 때문에, 한 군데만 삐끗해도 ISR이 “안 먹히는 것처럼” 보입니다.

이 글은 App Router 기준을 중심으로, ISR·revalidate 오작동을 7분 내로 좁히는 체크리스트와 함께, 바로 복붙 가능한 코드로 정리합니다.

0) 먼저 확인: 지금 쓰는 라우터가 App Router인가?

  • App Router: app/ 디렉터리, page.tsx, layout.tsx 기반
  • Pages Router: pages/ 디렉터리, getStaticProps 기반

캐시 옵션과 동작이 다릅니다. 두 방식을 섞어 이해하면 “분명 설정했는데 왜…”가 발생합니다.

1) 가장 흔한 1순위: fetch가 기본적으로 “정적 캐시”가 아니다

App Router에서 페이지를 정적으로 만들고 싶어도, 내부에서 호출한 fetch동적 요인을 만들면 전체가 동적으로 승격됩니다.

문제 패턴

  • fetch(url, { cache: 'no-store' })를 어딘가에서 사용
  • fetchAuthorization, Cookie 같은 헤더를 넣어 사용자별 요청으로 변질
  • headers() / cookies() / searchParams 의존으로 동적 렌더링 유발

해결 패턴: ISR을 의도한다면 next.revalidate를 명시

// app/posts/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  })

  const posts = await res.json()
  return (
    <main>
      <h1>Posts</h1>
      <pre>{JSON.stringify(posts, null, 2)}</pre>
    </main>
  )
}
  • next: { revalidate: 60 }데이터 캐시에 대한 ISR 힌트입니다.
  • 같은 URL이라도 헤더/쿠키가 다르면 캐시 키가 달라져 “캐시가 안 먹는 것처럼” 보일 수 있습니다.

2) 페이지 단위 설정을 놓쳤다: export const revalidate vs fetch revalidate

App Router에는 페이지/세그먼트 레벨revalidate도 있습니다.

// app/products/page.tsx
export const revalidate = 300

export default async function Page() {
  const res = await fetch('https://api.example.com/products')
  return <pre>{await res.text()}</pre>
}

핵심:

  • export const revalidate = 300는 해당 라우트 세그먼트의 기본 재검증 정책
  • fetch(..., { next: { revalidate } })해당 fetch의 정책

실무 팁:

  • “페이지 전체가 같은 주기로 갱신”이면 페이지 레벨 revalidate
  • “일부 데이터만 다른 주기”면 fetch 레벨 next.revalidate

3) dynamic = 'force-dynamic' 또는 동적 API 사용으로 ISR이 무력화됨

아래 중 하나라도 있으면 정적/ISR이 깨질 가능성이 큽니다.

  • export const dynamic = 'force-dynamic'
  • export const fetchCache = 'force-no-store'
  • cookies() / headers() 사용
  • draftMode() 사용

예시: 헤더를 읽는 순간 동적 렌더링이 됩니다.

import { headers } from 'next/headers'

export default async function Page() {
  const h = headers()
  const ua = h.get('user-agent')
  return <div>{ua}</div>
}

해결:

  • 정말 필요한 경우만 headers()를 쓰고, 캐시가 필요한 페이지에서는 제거
  • 사용자별 내용이 필요하면 해당 부분만 클라이언트 컴포넌트로 분리하거나, 정적 페이지 + API로 분리

4) 개발 모드에서는 캐시가 “안 먹는 것처럼” 보일 수 있다

next dev는 생산 환경과 캐시/재검증 동작이 다를 수 있습니다.

체크:

  • 문제 재현은 반드시 next buildnext start로 확인
  • 배포 환경에서도 동일하게 재현되는지 확인

로컬에서 프로덕션 모드 확인:

npm run build
npm run start

5) CDN/프록시가 Cache-Control을 덮어써서 ISR이 꼬인다

ISR은 Next.js 내부 캐시만이 아니라, 앞단의 CDN이 응답을 오래 잡아두면 “갱신이 안 되는 것처럼” 보일 수 있습니다.

확인할 헤더

  • cache-control
  • age
  • x-vercel-cache(Vercel)
  • x-nextjs-cache(환경에 따라)

curl로 확인:

curl -I https://your-domain.com/products

대응 전략:

  • HTML 문서는 CDN이 과도하게 캐시하지 않도록 정책을 점검
  • API 응답에 s-maxage 등을 쓰는 경우 의도와 일치하는지 확인

프록시/리다이렉트가 끼어 캐시가 꼬일 때는, 인프라 단의 302/헤더 변조도 함께 의심해야 합니다. 비슷한 유형의 “앞단 설정 때문에 앱이 이상해 보이는” 케이스는 Nginx 뒤 OAuth 콜백 302 무한리다이렉트 원인 글의 점검 방식이 그대로 도움이 됩니다.

6) 태그 기반 재검증을 안 했거나, 잘못된 곳에서 호출했다

App Router에서는 온디맨드 갱신을 revalidateTag, revalidatePath로 할 수 있습니다. 그런데 호출 위치가 틀리면 “호출했는데도 반영이 안 됨”이 발생합니다.

올바른 패턴: 서버 액션 또는 라우트 핸들러에서 호출

// app/actions/revalidate-products.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function revalidateProducts() {
  revalidateTag('products')
}

그리고 fetch에 태그를 연결:

// app/products/page.tsx
export default async function Page() {
  const res = await fetch('https://api.example.com/products', {
    next: { tags: ['products'], revalidate: 3600 },
  })

  return <pre>{await res.text()}</pre>
}

주의:

  • 클라이언트 컴포넌트에서 직접 revalidateTag 호출 불가
  • 태그 문자열 불일치(예: product vs products)는 매우 흔한 실수

7) unstable_noStore를 써놓고 ISR을 기대했다

서버 컴포넌트에서 아래를 호출하면 해당 범위는 캐시가 꺼집니다.

import { unstable_noStore as noStore } from 'next/cache'

export default async function Page() {
  noStore()
  const res = await fetch('https://api.example.com/posts')
  return <pre>{await res.text()}</pre>
}

해결:

  • 정말 “항상 최신”이 필요한 페이지에서만 사용
  • ISR이 목적이면 제거하고 revalidate로 제어

8) Pages Router라면 getStaticPropsrevalidate를 다시 점검

Pages Router에서 ISR은 getStaticProps 반환값의 revalidate가 핵심입니다.

// pages/news.tsx
export async function getStaticProps() {
  const res = await fetch('https://api.example.com/news')
  const news = await res.json()

  return {
    props: { news },
    revalidate: 60,
  }
}

export default function NewsPage({ news }: any) {
  return <pre>{JSON.stringify(news, null, 2)}</pre>
}

체크리스트:

  • getServerSideProps를 쓰고 있지 않은가(SSR이면 ISR 아님)
  • 프리뷰 모드가 켜져 있지 않은가

9) 7분 디버그 루틴: 원인 좁히기 순서

아래 순서대로 보면 대부분 1~2번에서 끝납니다.

  1. next dev가 아니라 next build + next start로 재현
  2. 문제 페이지에서 cookies() / headers() / dynamic = 'force-dynamic' 사용 여부 검색
  3. 모든 fetch에서 cache: 'no-store' 또는 인증 헤더를 넣는지 검색
  4. export const revalidate 또는 fetch next.revalidate 중 하나를 명시했는지 확인
  5. curl -Icache-control, age 확인해서 CDN이 잡아먹는지 확인
  6. 온디맨드 갱신을 쓴다면 tags 문자열 일치 및 호출 위치(서버) 확인

인프라 레벨에서 장애처럼 보이는 “캐시 미스 폭증”은 결국 서버 리소스 문제로 이어지기도 합니다. 서비스가 재시작 루프를 타거나 부하가 튀는 상황이라면 systemd 서비스가 자꾸 재시작될 때 7단계 진단처럼 증상 기반으로 단계적으로 좁히는 접근이 효과적입니다.

10) 운영에서 자주 쓰는 권장 조합(레시피)

레시피 A: 목록 페이지는 60초 ISR, 상세는 온디맨드

  • 목록: revalidate: 60
  • 상세: 태그 기반으로 상품 수정 시 바로 갱신
// app/items/page.tsx
export const revalidate = 60

export default async function Page() {
  const res = await fetch('https://api.example.com/items', {
    next: { tags: ['items'] },
  })
  return <pre>{await res.text()}</pre>
}
// app/api/admin/item-updated/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  // 인증/검증 로직은 생략
  revalidateTag('items')
  revalidateTag('item-detail')
  return Response.json({ ok: true })
}

상세 fetch에 태그 부여:

// app/items/[id]/page.tsx
export default async function Page({ params }: { params: { id: string } }) {
  const res = await fetch(`https://api.example.com/items/${params.id}`, {
    next: { tags: ['item-detail'], revalidate: 3600 },
  })
  return <pre>{await res.text()}</pre>
}

레시피 B: 사용자별 페이지는 과감히 SSR로 분리

  • 사용자별 대시보드: 캐시 기대하지 말고 no-store
  • 공용 콘텐츠: ISR

이렇게 분리하면 “대시보드 때문에 전체가 동적이 되는” 사고를 줄일 수 있습니다.

마무리: 캐시는 “설정”이 아니라 “합성 결과”다

Next.js에서 ISR·revalidate가 오작동하는 듯 보일 때는 대부분

  • 동적 렌더링 트리거가 숨어 있거나
  • fetch 캐시 정책이 의도와 다르거나
  • CDN/프록시가 헤더를 덮어쓴 경우입니다.

위 체크리스트대로 라우터 확인 → 동적 트리거 제거 → revalidate 명시 → 헤더 확인 순으로 보면, 체감상 80%는 7분 안에 원인이 잡힙니다.