- Published on
Next.js 캐시 안 먹힘? ISR·revalidate 오작동 7분 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 비용은 오르는데 페이지는 매번 새로 렌더링되고, 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' })를 어딘가에서 사용fetch에Authorization,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 build후next start로 확인 - 배포 환경에서도 동일하게 재현되는지 확인
로컬에서 프로덕션 모드 확인:
npm run build
npm run start
5) CDN/프록시가 Cache-Control을 덮어써서 ISR이 꼬인다
ISR은 Next.js 내부 캐시만이 아니라, 앞단의 CDN이 응답을 오래 잡아두면 “갱신이 안 되는 것처럼” 보일 수 있습니다.
확인할 헤더
cache-controlagex-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호출 불가 - 태그 문자열 불일치(예:
productvsproducts)는 매우 흔한 실수
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라면 getStaticProps의 revalidate를 다시 점검
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번에서 끝납니다.
next dev가 아니라next build+next start로 재현- 문제 페이지에서
cookies()/headers()/dynamic = 'force-dynamic'사용 여부 검색 - 모든
fetch에서cache: 'no-store'또는 인증 헤더를 넣는지 검색 export const revalidate또는fetch next.revalidate중 하나를 명시했는지 확인curl -I로cache-control,age확인해서 CDN이 잡아먹는지 확인- 온디맨드 갱신을 쓴다면
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분 안에 원인이 잡힙니다.