2026-03-12
Next.js ISR + on-demand revalidation 실전기 — 캐시가 안 풀린다
들어가며
블로그 글을 수정하고 저장했다. 배포됐다. 근데 글이 안 바뀌어 있다. 새로고침을 해도, 시크릿 모드로 열어봐도, 여전히 수정 전 내용이 보인다. 10분이 지나도. 30분이 지나도.
"캐시 때문이겠지"라고 생각하면서도, 직접 겪으면 꽤 당황스럽다. 글을 수정했는데 독자에게 수정 전 내용이 보이고 있다는 건, 블로그 운영 입장에서 꽤 심각한 문제다.
이 글에서는 Next.js의 ISR(Incremental Static Regeneration) 캐시가 어떻게 동작하는지, 왜 글 수정 후에도 바로 반영이 안 됐는지, 그리고 어떻게 해결했는지를 실제 경험 기반으로 정리한다.
ISR이 뭔데
ISR은 Next.js가 제공하는 정적 페이지 재생성 전략이다. 페이지를 빌드 시점에 생성하되, 일정 시간이 지나면 백그라운드에서 다시 생성한다. 설정은 간단하다.
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // 1시간마다 재생성이렇게 하면 한 번 생성된 페이지는 1시간 동안 캐시된 버전을 서빙하고, 1시간이 지난 후 누군가 접근하면 백그라운드에서 새로 생성한다. SSR의 실시간성과 SSG의 성능을 절충한 전략이다.
문제: 1시간을 기다려야 한다고?
블로그 글을 수정하고 나면 당연히 바로 확인하고 싶다. 근데 ISR은 revalidate 시간이 지나야 새 버전을 만든다. 즉, revalidate = 3600이면 최악의 경우 1시간 동안 수정 전 내용이 보일 수 있다.
글 하나 오타 고쳤는데 1시간을 기다린다? 말이 안 된다.
해결: on-demand revalidation
Next.js는 이 문제를 위해 revalidatePath라는 API를 제공한다. 특정 경로의 캐시를 즉시 무효화하는 기능이다.
import { revalidatePath } from 'next/cache';
// 글 수정 API에서
revalidatePath('/blog');
revalidatePath(`/blog/${slug}`);이걸 글 CRUD API의 응답 직전에 호출하면, 수정 즉시 캐시가 날아가고 다음 요청 시 최신 내용으로 페이지가 생성된다.
적용한 위치
캐시 무효화가 필요한 곳은 생각보다 많다.
- 글 생성(POST) — 블로그 목록에 새 글이 보여야 한다
- 글 수정(PATCH) — 수정된 내용이 반영되어야 한다
- 글 삭제(DELETE) — 삭제된 글이 목록에서 사라져야 한다
- 노트 CRUD — 노트도 동일하게 적용
모든 CRUD 엔드포인트에 revalidatePath를 추가했다.
두 번째 문제: 임베딩이 안 생긴다
캐시 문제를 해결하고 나니, 이번에는 임베딩이 생성되지 않는 문제가 터졌다. 로컬에서는 잘 되는데, Vercel에 배포하면 임베딩이 안 만들어진다.
원인은 서버리스 함수의 생명주기에 있었다.
setTimeout은 서버리스에서 죽는다
기존 코드는 이렇게 생겼었다.
// 기존: 2초 후 임베딩 생성 (debounce 의도)
setTimeout(async () => {
await createPostEmbeddings(postId, ...);
}, 2000);로컬에서는 Node.js 프로세스가 계속 살아있으니까 2초 후에 콜백이 실행된다. 하지만 Vercel 서버리스에서는 응답을 보내면 함수가 즉시 종료될 수 있다. 2초를 기다리기 전에 함수가 죽는 거다.
해결: after() API
Next.js 15부터 제공되는 after() API가 정확히 이 문제를 해결한다. 응답을 먼저 보내고, 서버리스 함수를 살려둔 채 백그라운드 작업을 실행할 수 있다.
import { after } from 'next/server';
// 글 저장 API
export async function POST(req: NextRequest) {
// ... 글 저장 로직
after(async () => {
await createPostEmbeddings(postId, title, description, tags, content);
});
revalidatePath('/blog');
return NextResponse.json({ slug });
}after()에 전달된 콜백은 응답이 전송된 후에 실행되지만, 함수는 콜백이 완료될 때까지 살아있다. setTimeout 같은 핵이 아니라 공식 API다.
fire-and-forget도 위험하다
비슷한 패턴으로, Promise를 await 없이 날리는 코드도 있었다.
// 기존: await 없이 실행
cleanupDeletedImages(oldContent, newContent);
// 함수가 끝나면 이 Promise도 죽을 수 있음이것도 after()로 감싸서 해결했다. 서버리스 환경에서 백그라운드 작업은 무조건 after()를 써야 한다.
적용 결과
모든 CRUD API에 revalidatePath와 after()를 적용한 후:
- 글 수정 즉시 최신 내용 반영 — 새로고침하면 바로 보인다
- 임베딩 생성 정상 동작 — Vercel 배포 후에도 벡터 검색이 작동한다
- 이미지 정리도 안정적 — 삭제된 이미지의 Cloudinary cleanup이 확실하게 실행된다
정리
| 문제 | 원인 | 해결 |
|---|---|---|
| 글 수정 후 반영 안 됨 | ISR 캐시가 revalidate 시간까지 유지 | revalidatePath로 즉시 무효화 |
| 임베딩 생성 안 됨 | setTimeout 콜백이 서버리스 종료 시 실행 안 됨 | after() API로 백그라운드 실행 보장 |
| 이미지 정리 누락 | fire-and-forget Promise가 함수 종료 시 죽음 | after()로 감싸서 실행 보장 |
서버리스 환경에서 "응답 보낸 후에 뭔가 하고 싶다"면 after()가 정답이다. setTimeout이나 setImmediate, 혹은 await 없는 Promise 실행은 전부 위험하다. 함수가 언제 죽을지 모르기 때문이다.
그리고 ISR을 쓸 때는 revalidatePath로 on-demand invalidation을 반드시 세팅해두자. 안 그러면 "글 고쳤는데 왜 안 바뀌지?"를 한 시간 동안 고민하게 된다.
관련 글
벡터 유사도 기반Neon DB + Vercel 무료 플랜으로 사이드 프로젝트 운영하기
Vercel Hobby + Neon 무료 PostgreSQL로 블로그를 운영하면서 겪은 cold start, 크론 제한, 함수 타임아웃 등의 삽질 기록.
82% 일치Next.js after() — 응답 먼저, 무거운 작업은 나중에
Next.js 15의 after() API로 응답 속도를 희생하지 않으면서 백그라운드 작업을 처리하는 방법.
78% 일치개인 블로그에 단축 URL 만들기 — /p/[id]로 한글 URL 문제 해결
한글 slug가 URL 인코딩되면 엄청 길어지는 문제를 자체 단축 URL과 301 리다이렉트로 해결한 과정.