- Published on
Next.js 14 App Router RSC 캐시 꼬임 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, RSC) 기반의 Next.js 14 App Router는 기본값이 “빠르게 캐시하고 재사용”에 가깝습니다. 문제는 이 기본값이 의도와 다르게 작동할 때입니다. 예를 들어:
- 배포 후에도 특정 페이지가 구버전 데이터를 계속 보여줌
- 로그인 사용자 A의 데이터가 사용자 B에게 섞여 보이는 것처럼 보임
router.refresh()를 해도 서버에서 새 데이터를 안 가져옴- 특정 API만 바꿨는데 관련 페이지 여러 곳이 들쭉날쭉하게 갱신됨
이 글은 App Router에서 흔히 말하는 “RSC 캐시 꼬임”을 재현 가능한 원인 단위로 분해하고, 팀에서 재발 방지할 수 있도록 캐시 정책을 코드로 고정하는 방법을 다룹니다.
관련해서 ISR 캐시가 꼬이는 케이스도 결이 비슷합니다. ISR 관점의 증상/대응은 이 글도 함께 참고하면 좋습니다: Next.js ISR 캐시 꼬임으로 404·구버전 뜰 때 해결
RSC 캐시 “꼬임”이란 무엇인가
App Router에서 데이터가 최신이 아닌 이유는 대개 아래 3가지 캐시 레이어 중 하나(또는 조합) 때문입니다.
- Data Cache: 서버에서 실행되는
fetch()의 응답을 Next.js가 캐싱 - Full Route Cache: 페이지(라우트) 단위로 렌더 결과를 캐싱(정적 최적화/ISR 포함)
- Router Cache(클라이언트):
next/navigation라우팅 시 클라이언트가 RSC 페이로드를 재사용
“꼬였다”는 표현은 보통 다음 상황을 의미합니다.
- 원래는 사용자/요청마다 달라야 하는 데이터가 공유 캐시에 들어감
- 원래는 갱신되어야 하는 데이터가 너무 오래 캐시됨
- 갱신 트리거를 걸었는데(예:
revalidateTag) 태그가 연결된 fetch가 아니어서 아무 일도 안 일어남
핵심은 무효화(invalidation) 경로가 코드에 명확히 박혀 있느냐입니다.
가장 흔한 원인 6가지
1) 사용자별 데이터인데 fetch가 기본 캐시로 동작
서버 컴포넌트에서 아래처럼 호출하면, Next.js는 상황에 따라 이를 캐시 대상으로 취급할 수 있습니다.
// app/dashboard/page.tsx
export default async function Page() {
const me = await fetch("https://api.example.com/me", {
headers: {
// 쿠키/토큰 기반으로 사용자 구분한다고 가정
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
}).then((r) => r.json());
return <pre>{JSON.stringify(me, null, 2)}</pre>;
}
여기서 Authorization이 사용자별로 바뀌지 않는 예시라면 괜찮지만, 실제로는 cookies()로 읽은 세션 쿠키에 따라 사용자별로 달라지는 경우가 많습니다. 이때 캐시가 공유되면 유저 데이터가 섞인 것처럼 보일 수 있습니다.
해결은 명확합니다. 사용자별 데이터는 항상 요청 단위로 새로 가져오도록 강제합니다.
import { cookies } from "next/headers";
export default async function Page() {
const cookie = (await cookies()).toString();
const me = await fetch("https://api.example.com/me", {
cache: "no-store",
headers: {
Cookie: cookie,
},
}).then((r) => r.json());
return <pre>{JSON.stringify(me, null, 2)}</pre>;
}
cache: "no-store"는 Data Cache를 끕니다.- 사용자별 응답은 태그 기반 무효화보다
no-store가 더 안전한 경우가 많습니다.
2) revalidateTag를 호출했는데 태그가 실제로 연결되지 않음
태그 무효화는 “태그가 달린 fetch 캐시”에만 먹힙니다. 즉, 아래처럼 next.tags를 붙이지 않으면 revalidateTag("posts")를 호출해도 갱신되지 않습니다.
// 잘못된 예: 태그 없음
await fetch("https://api.example.com/posts");
올바른 연결:
await fetch("https://api.example.com/posts", {
next: {
tags: ["posts"],
revalidate: 60,
},
});
그리고 서버 액션이나 라우트 핸들러에서 무효화:
// app/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(formData: FormData) {
// ... DB 저장
revalidateTag("posts");
}
실무 팁:
- 태그 문자열을 하드코딩하지 말고 상수로 통일하세요.
- “리스트 태그”와 “상세 태그”를 분리하면 무효화 범위를 줄일 수 있습니다.
3) revalidatePath를 남발해서 갱신이 불안정해짐
revalidatePath("/posts")는 직관적이지만, 페이지 구성에 따라 “어떤 fetch가 어떤 경로에 기여하는지”가 복잡해지면 예상과 다르게 동작했다고 느끼기 쉽습니다.
권장 패턴은:
- 데이터 단위는
revalidateTag - 라우트 단위는 정말 필요할 때만
revalidatePath
예:
"use server";
import { revalidateTag } from "next/cache";
export async function updatePost(id: string) {
// ... 업데이트
revalidateTag("posts");
revalidateTag(`post:${id}`);
}
그리고 fetch 쪽에서:
export async function getPost(id: string) {
return fetch(`https://api.example.com/posts/${id}`,
{
next: {
tags: [`post:${id}`],
revalidate: 300,
},
}
).then((r) => r.json());
}
4) dynamic/revalidate 설정이 페이지 기대와 불일치
App Router는 페이지/레이아웃에 설정한 옵션에 따라 정적화 여부가 달라집니다. 특히 아래 조합에서 “왜 안 바뀌지?”가 자주 발생합니다.
- 페이지는 정적 최적화로 굳어 있는데, 내부에서 최신 데이터를 기대
fetch에revalidate를 줬지만, 상위 레벨 설정이 더 강하게 고정
실전에서 자주 쓰는 명시적 설정:
// app/posts/page.tsx
export const revalidate = 60; // 라우트 단위 ISR
// 또는
// export const dynamic = "force-dynamic"; // 항상 동적
export default async function Page() {
const posts = await fetch("https://api.example.com/posts", {
next: { revalidate: 60, tags: ["posts"] },
}).then((r) => r.json());
return <pre>{JSON.stringify(posts, null, 2)}</pre>;
}
정리:
- “항상 최신”이면
dynamic = "force-dynamic"또는fetch에cache: "no-store" - “N초마다 갱신”이면
revalidate와 태그 무효화를 함께 설계
5) router.refresh()를 만능으로 착각
router.refresh()는 클라이언트 Router Cache를 갱신해 서버에서 RSC를 다시 가져오게 하지만, 서버의 Data Cache가 그대로면 같은 캐시 응답을 다시 받는 상황이 가능합니다.
즉:
- 클라이언트만 새로고침:
router.refresh() - 서버 데이터 캐시까지 무효화:
revalidateTag/revalidatePath또는no-store
클라이언트에서 갱신 UX를 만들 때는 보통 다음 조합이 안전합니다.
- 변경 작업은 서버 액션에서 수행
- 서버 액션에서
revalidateTag호출 - 완료 후 클라이언트에서
router.refresh()로 화면 반영
// app/posts/NewPostButton.tsx
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { createPost } from "../actions";
export function NewPostButton() {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => {
startTransition(async () => {
const fd = new FormData();
fd.set("title", "hello");
await createPost(fd);
router.refresh();
});
}}
>
create
</button>
);
}
6) 개발 환경에서의 “캐시처럼 보이는” 착시
개발 모드에서는 HMR, Fast Refresh, React의 렌더링 특성 때문에 “이상하게 이전 값이 남아있다”처럼 보일 때가 있습니다. 하지만 운영에서만 재현되는 진짜 캐시 문제도 많습니다.
운영에서만 재현된다면 다음을 우선 점검하세요.
- 배포 플랫폼이 Vercel인지, 자체 Node 서버인지
- Edge 런타임 사용 여부
- API 응답에 CDN 캐시 헤더가 붙는지
그리고 디버깅 로그를 “서버에서 실제로 fetch가 나갔는지” 기준으로 박아야 합니다.
재현 가능한 디버깅 체크리스트
1) 어떤 캐시 레이어가 문제인지 먼저 분리
fetch에cache: "no-store"를 임시로 걸어보고 문제가 사라지면 Data Cache 쪽입니다.dynamic = "force-dynamic"를 임시로 걸어보고 사라지면 Full Route Cache/정적화 쪽입니다.router.refresh()로만 해결되면 Router Cache 쪽일 가능성이 큽니다.
2) 태그 무효화가 실제로 연결됐는지 확인
revalidateTag("X")호출 위치가 서버에서 실행되는지(서버 액션/라우트 핸들러)- 해당 데이터를 가져오는
fetch에next.tags가 붙었는지 - 태그 문자열이 정확히 일치하는지(대소문자/공백/접두사)
3) 사용자별 데이터는 “공유 캐시 금지”로 원칙화
- 쿠키/세션/Authorization에 따라 달라지는 응답은 기본적으로
no-store - 정말 캐싱하고 싶으면 사용자 키를 포함한 별도 캐시 키 전략이 필요하지만, Next 내장 캐시만으로는 실수하기 쉽습니다.
권장 아키텍처: 태그 기반 캐시 설계 템플릿
실무에서 가장 안정적인 방식은 “데이터 접근 레이어에서 캐시 정책을 고정”하는 것입니다.
1) 데이터 함수에 캐시 정책을 박아두기
// lib/api/posts.ts
const API = process.env.API_BASE_URL;
export async function listPosts() {
return fetch(`${API}/posts`, {
next: {
tags: ["posts"],
revalidate: 60,
},
}).then((r) => r.json());
}
export async function getPost(id: string) {
return fetch(`${API}/posts/${id}`, {
next: {
tags: [`post:${id}`],
revalidate: 300,
},
}).then((r) => r.json());
}
2) 변경 작업에서 관련 태그를 무효화
// app/actions/posts.ts
"use server";
import { revalidateTag } from "next/cache";
export async function deletePost(id: string) {
// ... delete in DB
revalidateTag("posts");
revalidateTag(`post:${id}`);
}
이렇게 하면 “어떤 화면이든 posts 데이터를 쓰는 곳”은 자동으로 갱신됩니다.
운영 장애에서 자주 나오는 패턴: 404/구버전/섞임
404가 간헐적으로 뜨거나 구버전이 남는 문제는 RSC뿐 아니라 ISR에서도 자주 보입니다. 캐시 무효화와 재검증 타이밍이 어긋나면 증상이 비슷하게 나타납니다. 필요하면 위의 ISR 글을 함께 보세요: Next.js ISR 캐시 꼬임으로 404·구버전 뜰 때 해결
화면이 “서버에서 렌더된 값”과 “클라이언트에서 기대한 값”이 달라 보이면 캐시 문제로 오해하기도 합니다. 실제로는 hydration mismatch인 경우가 많으니 체크해볼 만합니다: Next.js Hydration mismatch 원인 7가지와 해결법
결론: RSC 캐시 꼬임을 없애는 3가지 원칙
- 사용자별 데이터는
cache: "no-store"를 기본값으로 - 공유 데이터는
next.tags와revalidateTag로 무효화 경로를 고정 router.refresh()는 클라이언트 캐시 갱신일 뿐, 서버 캐시 무효화가 아님을 팀 규칙으로 문서화
App Router의 캐시는 성능을 크게 끌어올리지만, “기본값”에 기대면 언젠가 한 번은 꼬입니다. 데이터 성격(사용자별/공유/강한 최신성/약한 최신성)을 먼저 분류하고, 그 분류를 fetch 옵션과 무효화 코드로 강제하면 운영에서 재현 불가한 캐시 이슈가 눈에 띄게 줄어듭니다.