- Published on
Next.js RSC 캐시 꼬임, revalidateTag로 푸는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(RSC) 중심의 Next.js App Router를 운영하다 보면, “분명 DB는 업데이트됐는데 화면은 예전 값”, “어떤 유저만 최신/구버전이 섞여 보임”, “배포 후 특정 페이지가 랜덤하게 stale” 같은 캐시 꼬임 현상을 자주 만납니다. 원인은 대개 캐시 레이어가 한 겹이 아니라는 점에 있습니다. RSC 렌더 결과, fetch 캐시, 라우트/세그먼트 캐시, CDN 캐시가 서로 다른 키와 수명으로 동작하면서, 무효화 전략이 빈틈을 만들면 ‘부분적으로만’ 갱신됩니다.
이 글에서는 Next.js에서 흔히 발생하는 RSC 캐시 꼬임 패턴을 재현해 보고, revalidateTag로 “데이터 단위” 캐시 무효화를 설계하는 실전 방법을 정리합니다.
RSC 캐시가 꼬이는 전형적인 시나리오
1) fetch 캐시와 RSC 렌더 캐시의 동시 존재
App Router에서 서버 컴포넌트가 호출하는 fetch는 기본적으로 캐시가 걸릴 수 있습니다. 또한 페이지/레이아웃 단위로 RSC 렌더 결과가 캐시되기도 합니다. 이때 개발자가 의도한 것은 “데이터만 갱신되면 화면도 갱신”인데, 실제로는 다음처럼 분리됩니다.
- 데이터
fetch는 캐시되어 최신이 아님 - 혹은 데이터는 최신인데 RSC 렌더 결과가 캐시되어 화면이 바뀌지 않음
- 둘 다 캐시되어 더 오래 stale
특히 fetch에 next: { revalidate: ... }를 섞거나, 어떤 곳은 no-store, 어떤 곳은 기본값을 쓰면 페이지 내부에서 데이터 freshness가 달라져서 “섞여 보이는” 문제가 생깁니다.
2) 갱신 트리거가 사용자 액션인데, 캐시 무효화가 없음
예를 들어 게시글 수정 API를 만들었는데, 수정 후 상세 페이지가 계속 예전 데이터를 보여주는 상황입니다. 클라이언트에서 router.refresh()를 호출해도, 서버가 같은 캐시를 다시 내주면 바뀌지 않습니다. 즉 “리프레시”는 재요청일 뿐, “무효화”가 아닙니다.
3) 경로 기반 무효화(revalidatePath)만으로는 부족
revalidatePath는 특정 경로의 캐시를 날리지만, 같은 데이터를 참조하는 다른 경로(예: 목록, 사이드바, 추천 위젯, RSS 등)까지 커버하기 어렵습니다. 결국 경로를 열거하다가 누락이 생기고, 그 누락이 “캐시 꼬임”으로 나타납니다.
해결의 핵심: “데이터 단위”로 태그를 설계하고 revalidateTag로 무효화
revalidateTag는 Next.js 캐시를 태그 기반으로 무효화하는 도구입니다. 경로가 아니라 “이 데이터에 붙은 태그”를 날리는 방식이라, 동일 데이터를 쓰는 여러 화면을 한 번에 정리할 수 있습니다.
핵심은 두 가지입니다.
- 데이터를 가져오는
fetch에tags를 일관되게 붙인다 - 데이터가 바뀌는 지점(서버 액션/API)에서 해당 태그를
revalidateTag로 무효화한다
예제: 게시글 상세/목록 캐시 꼬임을 revalidateTag로 해결
아래는 흔한 구조입니다.
/posts목록 페이지: 게시글 리스트를fetch로 가져옴/posts/[id]상세 페이지: 단일 게시글을fetch로 가져옴- 수정/삭제는 서버 액션 또는 API Route에서 처리
1) 데이터 fetch 함수에 태그 부여
lib/posts.ts 같은 곳에 데이터 접근 함수를 모으고, 여기서 태그 규칙을 강제하는 것이 좋습니다.
// lib/posts.ts
export type Post = {
id: string
title: string
content: string
updatedAt: string
}
const API_BASE = process.env.API_BASE_URL
export async function fetchPost(id: string): Promise<Post> {
const res = await fetch(`${API_BASE}/posts/${id}`, {
// 핵심: 이 데이터는 post:{id} 태그를 가진다
next: { tags: [`post:${id}`] },
})
if (!res.ok) throw new Error("Failed to fetch post")
return res.json()
}
export async function fetchPostList(): Promise<Post[]> {
const res = await fetch(`${API_BASE}/posts`, {
// 목록은 posts 태그
next: { tags: ["posts"] },
})
if (!res.ok) throw new Error("Failed to fetch posts")
return res.json()
}
여기서 중요한 점은 “태그 네이밍 규칙”입니다.
- 컬렉션:
posts - 단건:
post:{id}
이 규칙만 지켜도, 무효화가 매우 단순해집니다.
2) RSC 페이지에서 동일 함수만 사용
// app/posts/[id]/page.tsx
import { fetchPost } from "@/lib/posts"
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await fetchPost(params.id)
return (
<main>
<h1>{post.title}</h1>
<article>{post.content}</article>
<p>updated: {post.updatedAt}</p>
</main>
)
}
// app/posts/page.tsx
import { fetchPostList } from "@/lib/posts"
export default async function PostsPage() {
const posts = await fetchPostList()
return (
<main>
<h1>Posts</h1>
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</main>
)
}
페이지에서 fetch 옵션을 제각각 쓰기 시작하면 태그가 누락되기 쉽습니다. 데이터 계층에서 통일하는 편이 안전합니다.
3) 수정 로직에서 revalidateTag 호출
서버 액션을 예로 들면 다음과 같습니다.
// app/posts/[id]/actions.ts
"use server"
import { revalidateTag } from "next/cache"
export async function updatePostAction(id: string, formData: FormData) {
const title = String(formData.get("title") || "")
const content = String(formData.get("content") || "")
const res = await fetch(`${process.env.API_BASE_URL}/posts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
})
if (!res.ok) throw new Error("Failed to update")
// 핵심: 단건 + 목록을 함께 무효화
revalidateTag(`post:${id}`)
revalidateTag("posts")
return { ok: true }
}
이렇게 하면 다음이 한 번에 해결됩니다.
- 상세 페이지의 단건 캐시 무효화
- 목록 페이지의 리스트 캐시 무효화
- 동일 데이터를 참조하는 다른 컴포넌트가 있다면, 같은 태그를 붙여둔 한 모두 갱신
revalidateTag 설계 팁: 태그를 “도메인 이벤트”처럼 다루기
태그를 단순히 화면 단위가 아니라, 변경 이벤트 단위로 잡으면 운영이 편해집니다.
- 게시글이 바뀌면:
post:{id} - 게시글 컬렉션이 바뀌면(추가/삭제/정렬 기준 변경):
posts - 댓글이 바뀌면:
comments:post:{id}또는post-comments:{id} - 유저 프로필이 바뀌면:
user:{id}
이 방식은 이벤트스토밍에서 말하는 “무엇이 바뀌었는가” 관점과도 잘 맞습니다. 캐시 무효화는 결국 변경 전파 문제이기 때문입니다. 참고로 도메인 경계/이벤트 관점이 익숙하지 않다면 이벤트스토밍 후 바운디드 컨텍스트 쪼개기 실전도 같이 보면 태그 설계에 도움이 됩니다.
흔한 함정과 디버깅 체크리스트
1) no-store와 태그를 섞으면 태그 무효화가 의미 없어질 수 있음
cache: "no-store"는 애초에 캐시를 저장하지 않으므로 revalidateTag로 날릴 대상이 없습니다. “무조건 최신”이 필요한 구간만 no-store를 쓰고, 나머지는 태그 기반 캐시로 정리하는 편이 보통 더 낫습니다.
2) 태그를 붙였는데도 안 풀릴 때: 실제로 그 fetch가 실행되는지 확인
- 컴포넌트가 정적 렌더로 굳어버렸는지
- 해당 데이터가 다른 레이어(예: 외부 CDN)에서 캐시되는지
- 동일 URL을 다른 옵션으로
fetch해서 캐시 키가 분리되는지
Next.js의 캐시는 “요청 URL + 옵션” 조합에 영향을 받습니다. 같은 엔드포인트라도 어떤 곳은 헤더가 다르거나, 어떤 곳은 next 옵션이 다르면 캐시가 갈라질 수 있습니다.
3) 목록/상세 외에 “파생 데이터”를 놓치기 쉽다
예를 들어 사이드바에 “최근 수정된 글” 위젯이 있고, 이 위젯이 fetch("/posts?sort=updated")를 호출한다면 posts 태그를 붙이지 않으면 계속 stale로 남습니다. 즉, “posts를 참조하는 모든 쿼리”가 posts 태그를 공유하도록 규칙화해야 합니다.
4) 서버 액션 호출 후 클라이언트 화면 갱신
revalidateTag는 서버 캐시를 무효화할 뿐, 현재 브라우저에 떠 있는 화면을 자동으로 바꾸진 않습니다. 보통은 다음 중 하나를 함께 씁니다.
- 서버 액션 이후
redirect(...) - 클라이언트에서
router.refresh()
단, 다시 강조하면 router.refresh()는 “무효화된 캐시를 다시 가져오게” 만드는 트리거이고, 무효화 자체는 revalidateTag가 담당합니다.
revalidatePath vs revalidateTag 언제 무엇을 쓰나
revalidatePath: 특정 경로만 확실히 갱신하면 되는 경우(단일 페이지, 경로가 소수)revalidateTag: 같은 데이터가 여러 경로/컴포넌트에서 재사용되는 경우(대부분의 서비스)
실무에서는 둘을 섞기도 합니다. 예를 들어 “이 경로는 무조건 재생성”이 필요하면 revalidatePath를 추가로 호출하되, 기본 전략은 태그로 가져가는 편이 누락이 적습니다.
운영 관점: 캐시 꼬임을 줄이는 팀 규칙
- 데이터 접근 함수는 한 곳에 모으고 태그를 강제한다
- 태그 네이밍 규칙을 문서화한다(컬렉션/단건/관계)
- 쓰기 작업(POST/PUT/DELETE)은 반드시 어떤 태그를 무효화하는지 코드리뷰 체크리스트에 포함한다
- 파생 쿼리도 같은 태그를 공유하도록 한다
이런 규칙은 CI/CD나 권한 문제처럼 “가끔만 터지는데 터지면 크게 아픈” 운영 이슈를 줄이는 것과 결이 같습니다. 배포 파이프라인에서 자주 겪는 권한/인증 문제는 GitHub Actions OIDC 403·권한거부 원인 7가지처럼 체크리스트화가 효과적이듯, 캐시 무효화도 규칙과 점검 포인트가 있으면 재발이 크게 줄어듭니다.
마무리
RSC 시대의 캐시 문제는 “캐시를 끄면 해결”로 끝내기엔 비용이 큽니다. 성능을 유지하면서도 일관성을 잡으려면, 경로 중심이 아니라 데이터 중심으로 무효화를 설계해야 합니다. revalidateTag는 그 목적에 가장 잘 맞는 도구입니다.
- 데이터
fetch에 태그를 일관되게 붙이고 - 데이터 변경 시점에 해당 태그를 무효화하며
- 목록/단건/파생 쿼리까지 태그 규칙으로 묶으면
“꼬이는” 현상이 대부분 사라지고, 새 기능이 추가돼도 무효화 누락이 줄어듭니다.