- Published on
Next.js 14 캐시 때문에 ISR 갱신 안 될 때 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트 기반의 Next.js 14(App Router)에서 ISR을 켰는데도 페이지가 안 바뀌는 상황은 생각보다 흔합니다. 흔히 revalidate 값을 올리거나, 배포를 다시 해보거나, 브라우저 캐시를 지우는 식으로 접근하지만, 실제 원인은 캐시 계층이 여러 겹이라서 한 군데만 바꿔도 다른 계층이 그대로 “오래된 응답”을 계속 내보내는 데 있습니다.
이 글은 “ISR이 안 갱신된다”를 증상으로 보고, 어느 계층의 캐시가 막고 있는지를 빠르게 분리하는 디버깅 절차를 제공합니다. 특히 Next.js 14에서 자주 겪는 fetch 캐시, 라우트 캐시, 태그 기반 재검증, CDN 캐시가 섞일 때의 함정을 중심으로 다룹니다.
관련해서 App Router 캐시가 꼬여 보이는 케이스는 아래 글도 함께 보면 맥락이 이어집니다.
먼저 결론: ISR은 “페이지 캐시”만의 문제가 아니다
ISR이 동작하려면 최소한 다음이 일관되어야 합니다.
- 라우트(페이지) 캐시 정책:
export const revalidate = 60같은 라우트 세그먼트 설정 - 데이터 패칭(fetch) 캐시 정책:
fetch의cache,next.revalidate,next.tags - 재검증 트리거: 시간이 지나 자동 재생성되거나,
revalidatePath,revalidateTag로 수동 갱신 - 외부 캐시(CDN, 프록시):
Cache-Control,s-maxage,stale-while-revalidate와 실제 CDN 설정
즉, 페이지에 revalidate를 줬더라도, 내부 fetch가 사실상 “영구 캐시”로 남아 있으면 페이지는 갱신되지 않습니다. 반대로 Next.js는 갱신했는데 CDN이 오래된 HTML을 계속 서빙하면 역시 갱신이 안 된 것처럼 보입니다.
재현 가능한 최소 예제로 현재 캐시 모드를 확인하기
디버깅의 핵심은 “내 라우트가 정적이냐 동적이냐”를 먼저 확정하는 것입니다. 아래처럼 App Router 라우트에서 시간을 출력해 보면, ISR이 정말로 동작하는지 즉시 확인할 수 있습니다.
// app/isr-test/page.tsx
export const revalidate = 10;
export default async function Page() {
const now = new Date().toISOString();
return (
<main>
<h1>ISR test</h1>
<p>now: {now}</p>
</main>
);
}
- 10초가 지나도
now가 안 바뀐다면- 라우트가 실제로는 정적 캐시로 고정됐거나
- CDN이 HTML을 잡고 있거나
- (의외로) 계속 같은 서버 인스턴스가 같은 캐시를 내보내는 구조일 수 있습니다.
이제부터는 원인을 “계층별”로 분해합니다.
1단계: 라우트가 강제로 동적 처리되고 있지 않은지 확인
App Router에서는 특정 API 사용이 라우트를 동적으로 만들어 ISR 기대와 다르게 동작하게 만들 수 있습니다. 대표적으로 다음이 섞이면 라우트가 동적으로 평가됩니다.
cookies()headers()searchParams를 이용한 동적 분기draftMode()
동적 라우트가 되면 “ISR이 안 된다”라기보다, 매 요청마다 렌더링으로 바뀌거나, 반대로 캐시 정책이 예상과 달라져 혼란이 생깁니다.
의도적으로 ISR을 쓰고 싶다면, 다음 같은 선언으로 라우트의 성격을 고정해 디버깅하기 좋습니다.
// app/isr-test/page.tsx
export const dynamic = 'force-static';
export const revalidate = 10;
export default async function Page() {
return <pre>{new Date().toISOString()}</pre>;
}
dynamic = 'force-static'인데도 갱신이 안 되면, 대체로 “외부 캐시” 또는 “fetch 캐시” 쪽을 의심합니다.
반대로 동적이어야 하는데 정적으로 굳어버린 것 같다면 다음을 확인합니다.
export const dynamic = 'force-dynamic';
이 설정은 ISR 문제를 해결하기 위한 최종 해법이라기보다, 캐시 계층을 끊어서 원인 격리에 유용합니다.
2단계: fetch 캐시가 ISR을 가로막는지 확인
Next.js 14에서 가장 흔한 함정은 “페이지에 revalidate를 줬으니 데이터도 10초마다 갱신되겠지”라는 기대입니다. 하지만 실제로는 각 fetch가 별도의 캐시 정책을 가집니다.
패턴 A: 페이지는 ISR인데, 데이터 fetch가 사실상 고정 캐시
아래처럼 아무 옵션 없이 fetch를 쓰면 Next.js가 캐시를 잡는 방식 때문에 데이터가 기대보다 오래 유지될 수 있습니다.
// app/posts/page.tsx
export const revalidate = 60;
export default async function PostsPage() {
const res = await fetch('https://example.com/api/posts');
const posts = await res.json();
return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
이 경우 디버깅용으로 fetch에 명시적으로 옵션을 줘서 “데이터 캐시”를 끊어보세요.
const res = await fetch('https://example.com/api/posts', {
cache: 'no-store',
});
cache: 'no-store'로 바꾸고 나서 페이지가 즉시 갱신되면- ISR이 안 되는 게 아니라, 데이터가 캐시되어 페이지가 같은 결과를 렌더링하던 것입니다.
패턴 B: 라우트 revalidate와 fetch의 next.revalidate 충돌
fetch 레벨에서 재검증 시간을 따로 주면, 라우트와 데이터의 TTL이 달라집니다.
export const revalidate = 60;
const res = await fetch('https://example.com/api/posts', {
next: { revalidate: 3600 },
});
- 페이지는 60초마다 재생성되더라도
- 데이터는 1시간 동안 같은 캐시를 쓰게 되어
- 결과적으로 “페이지는 갱신되는데 내용이 안 바뀌는” 현상이 생깁니다.
디버깅 시에는 라우트와 fetch의 revalidate를 의도적으로 같은 값으로 맞추거나, 한쪽을 no-store로 바꿔 원인 계층을 분리하세요.
3단계: 태그 기반 재검증이 실제로 호출되는지 확인
운영에서 ISR을 “시간 기반”이 아니라 “이벤트 기반”으로 갱신하려면 revalidateTag, revalidatePath를 씁니다. 그런데 호출은 했는데 갱신이 안 되는 경우가 많습니다.
태그로 캐시를 묶는 기본 패턴
// app/posts/page.tsx
export const revalidate = 3600;
export default async function PostsPage() {
const res = await fetch('https://example.com/api/posts', {
next: { tags: ['posts'] },
});
const posts = await res.json();
return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
그리고 서버 액션이나 라우트 핸들러에서 태그를 재검증합니다.
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
export async function refreshPosts() {
revalidateTag('posts');
}
디버깅 체크리스트
revalidateTag가 서버에서만 실행되는지 확인- 클라이언트 컴포넌트에서 직접 호출하려 하면 기대와 다르게 동작합니다.
- 해당
fetch에next: { tags: [...] }가 실제로 붙었는지 확인- 태그가 없으면
revalidateTag는 “무효화할 대상”이 없습니다.
- 태그가 없으면
- 태그 문자열이 정확히 일치하는지 확인
posts와post같은 오타는 흔합니다.
이 지점은 “캐시 무효화”라는 관점에서 접근하면 이해가 쉬운데, 비슷한 디버깅 사고방식은 아래 글도 참고할 만합니다.
4단계: revalidatePath가 기대와 다르게 보이는 이유
revalidatePath('/posts')는 만능처럼 보이지만, 실제로는 다음 제약이 있습니다.
- “해당 경로의 캐시를 무효화”하는 것이지, 외부 데이터 소스나 CDN까지 자동으로 갱신해주지 않습니다.
- 레이아웃, 병렬 라우트, 인터셉트 라우트가 섞이면 “내가 생각한 경로”와 “Next가 캐시한 세그먼트”가 어긋나 갱신이 덜 된 것처럼 보일 수 있습니다.
디버깅 팁은 다음과 같습니다.
- 갱신 대상 페이지에서 사용되는 주요
fetch를 모두 찾아next.tags로 묶고,revalidateTag로 갱신하는 방식이 더 예측 가능합니다. - 혹은 문제를 단순화하기 위해 일시적으로
cache: 'no-store'로 바꿔 “경로 캐시 문제인지 데이터 캐시 문제인지”를 분리하세요.
5단계: CDN 또는 프록시 캐시가 HTML을 잡고 있는지 확인
Vercel이든 자체 배포든, 최종 응답은 종종 CDN을 거칩니다. 여기서 “ISR이 안 된다”는 착시가 생깁니다.
확인 방법
- 브라우저 개발자 도구에서 문서 요청의 응답 헤더를 확인합니다.
- 가능하면
curl -I로도 확인합니다.
curl -I https://your-domain.com/posts
여기서 다음을 봅니다.
Cache-Control값이 과도하게 길지 않은지- CDN이 별도의 캐시 정책을 강제하고 있지 않은지
- 스테이징과 프로덕션에서 헤더가 다른지
특히 s-maxage가 길게 잡혀 있거나, CDN 규칙에서 HTML을 강제 캐시하면 Next.js의 재생성이 이루어져도 사용자는 계속 오래된 HTML을 받습니다.
디버깅을 위해서는 일시적으로 다음을 시도해 원인을 격리할 수 있습니다.
- 특정 경로에 대해 CDN 캐시를 끄거나 TTL을 극단적으로 줄이기
- 캐시 무효화(Purge)를 수행한 뒤 동작 비교
6단계: “배포했는데도 안 바뀜”은 빌드 산출물 고정 문제일 수 있음
ISR은 런타임에서 갱신되지만, 다음과 같은 경우에는 빌드 결과가 사실상 고정되어 보일 수 있습니다.
- 환경변수 변경이 빌드 타임에만 반영되는 코드 구조
generateStaticParams나 정적 최적화로 인해 예상보다 많은 것이 빌드 타임에 결정됨- 데이터 소스가 실제로는 바뀌지 않았는데 바뀌었다고 착각한 경우
이때는 “렌더 시점”을 로깅해보는 게 효과적입니다. 서버 컴포넌트에서 간단히 로그를 찍고, 실제로 재생성이 일어나는지 확인합니다.
export const revalidate = 10;
export default async function Page() {
console.log('render at', new Date().toISOString());
return <pre>{new Date().toISOString()}</pre>;
}
- 로그가 10초마다 찍히지 않으면 라우트 재생성 자체가 안 일어나는 것입니다.
- 로그는 찍히는데 화면이 안 바뀌면, CDN 또는 클라이언트 측 캐시(혹은 데이터 캐시)를 의심합니다.
7단계: 운영 디버깅용 “캐시 분해” 체크리스트
실무에서 시간을 가장 아껴주는 순서대로 정리하면 다음과 같습니다.
A. 즉시 격리(원인 범주 나누기)
- 문제 페이지의 주요
fetch를cache: 'no-store'로 바꿔 본다 - 페이지에
dynamic = 'force-dynamic'를 잠깐 걸어 본다 - CDN 캐시 Purge 또는 해당 경로 캐시 비활성화를 잠깐 적용해 본다
이 3개 중 무엇이 효과가 있는지로 원인 범주가 갈립니다.
B. 원인 범주별로 고치기
- 데이터가 안 바뀐다
fetch의next.revalidate또는cache정책 재설계- 태그 기반으로 묶고
revalidateTag로 무효화
- 페이지가 안 바뀐다
- 라우트 세그먼트
revalidate와dynamic점검 cookies(),headers()같은 동적 요인 제거 또는 의도적으로 분리
- 라우트 세그먼트
- 사용자에게만 안 바뀐다
- CDN 캐시 정책, HTML 캐시 규칙, Purge 자동화 점검
자주 나오는 실전 케이스 3가지
케이스 1: revalidate = 60인데 하루 종일 안 바뀜
- 실제 원인:
fetch가next: { revalidate: 86400 }로 잡혀 있었거나, 기본 캐시가 기대보다 길게 유지 - 해결: 데이터
fetch를 라우트 정책과 맞추거나, 태그로 묶고 이벤트 기반 무효화
케이스 2: 관리자에서 글 수정했는데 방문자는 그대로
- 실제 원인:
revalidatePath는 호출됐지만, CDN이 HTML을 캐시 - 해결: 수정 이벤트 시 CDN Purge 연동 또는 HTML 캐시 정책 조정
케이스 3: 어떤 페이지는 갱신되고 어떤 페이지만 안 됨
- 실제 원인: 레이아웃 단에 캐시된 데이터가 있고, 페이지는 그 레이아웃을 공유
- 해결: 레이아웃에서 사용하는
fetch캐시 정책을 재점검하고, 태그 범위를 분리
마무리: ISR 디버깅은 “캐시 계층”을 하나씩 끊는 게임
Next.js 14에서 ISR이 안 갱신될 때 가장 빠른 접근은 “설정이 맞나”를 보기 전에, 캐시 계층을 하나씩 끊어보며 원인을 격리하는 것입니다.
fetch를no-store로 바꿨을 때 해결되면 데이터 캐시 문제force-dynamic으로 바꿨을 때 해결되면 라우트 캐시 또는 정적 최적화 문제- CDN Purge로만 해결되면 외부 캐시 문제
원인을 범주화한 뒤에야 revalidate, next.tags, revalidateTag, CDN 정책을 “의도한 설계”로 다시 조립하면, ISR은 예측 가능한 방식으로 안정화됩니다.