- Published on
Next.js App Router RSC 캐시·revalidate로 튐 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, RSC) 기반의 Next.js App Router는 기본 동작이 **"서버에서 렌더링한 결과를 캐시하고 재사용"**하는 쪽에 가깝습니다. 문제는 이 캐시 규칙이 fetch 옵션, revalidate, dynamic/force-static, no-store, cookies()/headers() 같은 동적 신호에 의해 쉽게 바뀐다는 점입니다.
그 결과로 흔히 다음 같은 “렌더링 튐”을 겪습니다.
- 같은 URL인데도 새로고침할 때마다 리스트 순서/카운트가 바뀜
- 처음엔 오래된 데이터가 보였다가 잠깐 뒤 최신 데이터로 바뀜
- 페이지 이동 시에는 정상인데, 뒤로가기/앞으로가기에서 순간적으로 다른 상태가 표시됨
- 로딩 스켈레톤이 불필요하게 깜빡이거나,
Suspense경계가 예상치 못하게 재진입
이 글에서는 RSC 캐시의 경계와 revalidate의 의미를 정확히 잡고, 렌더링 튐을 재현·진단·해결하는 실전 패턴을 정리합니다.
렌더링 튐의 정체: “캐시된 RSC + 재검증(또는 클라이언트 갱신)”의 충돌
App Router에서 화면은 크게 두 층으로 나뉩니다.
- RSC 페이로드(서버 컴포넌트 트리): 서버에서 생성되어 스트리밍되며, Next가 캐시할 수 있음
- 클라이언트 컴포넌트 상태: 브라우저에서 유지되며, 라우팅/상태 변화에 따라 재사용됨
여기서 튐이 발생하는 대표적인 조합은 다음입니다.
- 서버는
revalidate로 이전 결과를 캐시해서 즉시 내려줌 - 동시에(혹은 직후) 다른 경로로 최신 데이터 갱신이 일어나 UI가 바뀜
- 예: 클라이언트에서 SWR/React Query가 다시
fetch해서 최신으로 덮어씀 - 예:
router.refresh()가 트리거되어 RSC가 재요청됨 - 예: ISR 재검증 타이밍에 걸려 새 HTML/RSC로 교체됨
- 예: 클라이언트에서 SWR/React Query가 다시
즉, “오래된 데이터가 잠깐 보였다가 최신으로 바뀌는 현상”은 버그라기보다 캐시 정책이 섞였다는 신호인 경우가 많습니다.
먼저 잡아야 할 개념: revalidate는 “캐시를 안 한다”가 아니다
많이 헷갈리는 포인트가 revalidate입니다.
revalidate: 60은 “60초 동안 캐시를 재사용한다”에 가깝습니다.- 60초가 지나면 다음 요청에서 재검증이 발생할 수 있고, 그 순간 응답이 교체되며 화면이 바뀔 수 있습니다.
또한 App Router에서는 다음 두 레벨의 캐시가 얽힙니다.
- Data Cache:
fetch응답 캐시(기본적으로 캐시될 수 있음) - Full Route Cache: 라우트 전체 결과 캐시(정적화될 때)
여기에 cookies()/headers()를 읽는 순간 라우트가 동적으로 바뀌며(정적 캐시 불가) 정책이 달라집니다.
렌더링 튐을 만드는 대표적인 안티패턴 5가지
1) 서버는 캐시, 클라이언트는 즉시 재요청(SWR/React Query 기본 설정)
서버 컴포넌트에서 이미 데이터를 렌더링했는데, 클라이언트 컴포넌트에서 같은 API를 다시 호출하면 초기 화면과 곧바로 달라질 수 있습니다.
- 서버:
revalidate로 캐시된 결과를 내려줌 - 클라이언트:
revalidateOnFocus/refetchOnMount등으로 즉시 최신 데이터로 갱신
해결은 “서버에서 만든 초기 데이터를 클라이언트 캐시에 주입”하거나, “클라이언트 자동 재검증을 끄기”입니다.
2) fetch 옵션이 서로 다른 곳에서 혼재
한 라우트에서 어떤 요청은 cache: 'no-store', 다른 요청은 기본 캐시, 또 다른 요청은 next: { revalidate: 30 }이면 같은 화면 트리 안에서 데이터 신선도가 제각각이 됩니다.
그 결과 특정 영역만 뒤늦게 바뀌며 튐처럼 보입니다.
3) cookies()/headers()를 “필요 없는 곳”에서 읽음
서버 컴포넌트에서 cookies()를 읽으면 해당 렌더는 사용자별로 달라질 수 있어 정적 캐시가 깨집니다. 그런데 실제로는 사용자별 변화가 필요 없는 페이지에서도 이를 읽어버리면, 캐시 경계가 흔들려 예측 불가한 갱신이 생깁니다.
4) router.refresh()를 습관적으로 호출
클라이언트에서 router.refresh()는 RSC 트리를 다시 가져옵니다. 이때 서버 캐시가 갱신되었거나 재검증 타이밍이면 화면이 바뀝니다. “데이터 동기화”를 위해 무분별하게 호출하면 튐이 잦아집니다.
5) loading.tsx/Suspense 경계를 너무 촘촘히
스트리밍과 Suspense는 좋은 기능이지만, 경계가 너무 많고 각 경계가 서로 다른 캐시 정책의 데이터를 읽으면 “부분 로딩 → 부분 교체”가 자주 발생합니다.
해결 전략 1: 페이지의 “정답”을 먼저 정하라 (정적/반정적/완전 동적)
렌더링 튐을 없애려면 먼저 페이지를 다음 중 하나로 명확히 분류해야 합니다.
- 완전 정적(Static): 빌드 시 고정, 사용자별 차이 없음
- 반정적(ISR):
revalidate주기로 갱신, “최대 N초까지 오래된 데이터 허용” - 완전 동적(Dynamic): 요청마다 최신, 사용자별/권한별로 다름
여기서 “튐”은 대개 반정적(ISR)인데 사용자는 최신처럼 느끼길 원함 또는 완전 동적인데 일부만 캐시됨에서 발생합니다.
해결 전략 2: 서버 데이터 페칭을 한 곳에 모으고, 캐시 정책을 단일화
서버 컴포넌트에서 데이터를 가져오는 함수를 만들고, 모든 호출이 같은 정책을 쓰게 강제하세요.
// lib/api.ts
export async function fetchProducts() {
const res = await fetch('https://example.com/api/products', {
next: { revalidate: 60 },
})
if (!res.ok) throw new Error('Failed to fetch products')
return res.json() as Promise<Array<{ id: string; name: string }>>
}
// app/products/page.tsx
import { fetchProducts } from '@/lib/api'
export default async function ProductsPage() {
const products = await fetchProducts()
return (
<main>
<h1>Products</h1>
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</main>
)
}
이렇게 “서버에서만 데이터 소스가 결정”되면 최소한 RSC 트리 내부의 신선도 불일치가 줄어듭니다.
해결 전략 3: “정말 최신”이 필요하면 no-store로 고정하고 튐을 없애라
사용자가 보는 데이터가 결제/재고/권한처럼 민감해서 “최신이 아니면 안 되는” 경우, ISR은 근본적으로 튐을 만들 수 있습니다(오래된 캐시가 잠깐 보일 수 있음).
이때는 과감히 cache: 'no-store'로 고정해 일관성을 얻는 편이 낫습니다.
export async function fetchInventory() {
const res = await fetch('https://example.com/api/inventory', {
cache: 'no-store',
})
if (!res.ok) throw new Error('Failed to fetch inventory')
return res.json()
}
추가로 라우트 단에서 동적 렌더링을 명시할 수도 있습니다.
// app/inventory/page.tsx
export const dynamic = 'force-dynamic'
import { fetchInventory } from '@/lib/api'
export default async function InventoryPage() {
const data = await fetchInventory()
return <pre>{JSON.stringify(data, null, 2)}</pre>
}
이 조합은 성능 비용이 있지만, “캐시된 값이 먼저 보였다가 바뀌는” 류의 튐을 구조적으로 차단합니다.
해결 전략 4: ISR을 쓰되 “클라이언트 재검증”을 끄거나 초기값을 주입
ISR을 유지하면서 튐을 줄이려면, 클라이언트에서 같은 데이터를 다시 갱신하지 않도록 해야 합니다.
SWR 예시: 서버 데이터로 fallback 주입
// app/products/page.tsx (Server Component)
import { fetchProducts } from '@/lib/api'
import ProductsClient from './products-client'
export default async function ProductsPage() {
const products = await fetchProducts()
return <ProductsClient initialProducts={products} />
}
// app/products/products-client.tsx (Client Component)
'use client'
import useSWR from 'swr'
type Product = { id: string; name: string }
const fetcher = (url: string) => fetch(url).then((r) => r.json())
export default function ProductsClient({ initialProducts }: { initialProducts: Product[] }) {
const { data } = useSWR<Product[]>('/api/products', fetcher, {
fallbackData: initialProducts,
revalidateOnFocus: false,
revalidateOnMount: false,
})
return (
<ul>
{data?.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
핵심은 “서버가 보여준 첫 화면”을 클라이언트가 즉시 뒤집지 않게 만드는 것입니다.
해결 전략 5: 태그 기반 재검증으로 “원하는 순간에만” 갱신
revalidate: 60처럼 시간 기반은 예측이 어렵습니다. 반면 태그 기반 재검증은 데이터 변경 이벤트(예: 관리자 수정, 결제 완료) 이후에만 캐시를 무효화할 수 있어 튐을 크게 줄입니다.
// lib/api.ts
export async function fetchProducts() {
const res = await fetch('https://example.com/api/products', {
next: { tags: ['products'] },
})
if (!res.ok) throw new Error('Failed')
return res.json()
}
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProductAction() {
// ...DB update...
revalidateTag('products')
}
이 방식은 “그냥 시간이 지나서 바뀌는 화면”을 줄이고, “변경이 있었을 때만 갱신”으로 사용자 경험을 안정화합니다.
캐시 무효화는 트래픽이 몰릴 때 스탬피드(동시 재생성) 문제로 이어질 수 있으니, 백엔드 캐시 전략도 함께 점검하는 것이 좋습니다. 트래픽 급증 시 캐시 갱신 충돌을 다루는 관점은 MySQL+Redis 캐시 스탬피드 방지 5가지도 같이 참고하면 도움이 됩니다.
해결 전략 6: loading.tsx는 “진짜 느린 경계”에만 두고, 스트리밍을 과신하지 않기
loading.tsx가 많으면 많을수록, 사용자는 “깜빡임”을 더 자주 체감합니다. 특히 캐시 정책이 서로 다른 데이터가 섞인 트리에서 스트리밍 경계가 쪼개지면, 부분 교체가 빈번해집니다.
실전 팁:
- 페이지 최상단
loading.tsx는 최소화 - 리스트/상세 등 핵심 영역은 서버에서 한 번에 렌더링
- 정말 느린 서브영역(추천 상품, 로그, 광고 등)만
Suspense로 분리
UI 튐이 실제로는 프론트 성능 문제(롱태스크, 레이아웃 스래싱)에서 증폭되는 경우도 있습니다. 체감 성능 분석 관점은 Chrome INP 폭증 원인 찾기 - Long Task 분해도 함께 보면 원인 분리가 빨라집니다.
진단 체크리스트: “왜 이 라우트가 캐시되거나 안 되지?”
아래 질문에 답하면 대부분 원인이 드러납니다.
- 이 페이지는 사용자별로 달라져야 하나?
- 그렇다면
cookies()/headers()사용이 합리적이고,force-dynamic또는no-store가 자연스럽습니다.
- 그렇다면
- 데이터는 “최대 N초 오래되어도 되는가?”
- 된다면 ISR(
revalidate)이 적합하지만, 클라이언트 재검증은 꺼야 튐이 줄어듭니다.
- 된다면 ISR(
- 서버와 클라이언트가 같은 API를 중복 호출하고 있나?
- 중복 호출은 튐의 1순위입니다. 서버 결과를 초기값으로 주입하거나, 한쪽을 제거하세요.
fetch옵션이 파일마다 제각각인가?- 정책을 공통 함수로 모으고, 한 화면에서는 한 종류의 신선도 규칙을 유지하세요.
- 시간 기반
revalidate때문에 “원치 않는 순간”에 바뀌는가?- 태그 기반
revalidateTag로 전환을 고려하세요.
- 태그 기반
결론: 튐을 없애는 핵심은 “캐시 정책의 일관성”
Next.js App Router의 RSC 캐시는 강력하지만, 조금만 섞이면 사용자는 즉시 “화면이 바뀐다/깜빡인다”로 인지합니다. 해결의 핵심은 아래 3줄로 요약됩니다.
- 페이지 성격을 정하고(정적/ISR/동적) 그에 맞는 정책을 고정
- 서버/클라이언트 데이터 소스를 중복시키지 말고 초기값 주입으로 일관성 확보
- 시간 기반
revalidate를 남용하지 말고, 가능하면 태그 기반 무효화로 “원하는 순간에만” 갱신
이 원칙대로 정리하면 렌더링 튐은 대부분 사라지고, 캐시로 얻는 성능 이점도 안정적으로 유지할 수 있습니다.