- Published on
React 19 RSC 캐시 꼬임? 상태·렌더링 분리 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(RSC) 기반 앱을 운영하다 보면, 어느 순간부터 “분명 서버에서 최신 데이터를 읽었는데 화면이 옛날 값으로 보인다”, “뒤로가기 하면 데이터가 롤백된 것처럼 보인다”, “서버 액션 후에 일부만 갱신되고 나머지는 그대로다” 같은 현상을 겪습니다. 흔히 이를 RSC 캐시가 꼬였다고 부르지만, 실제로는 캐시 자체의 버그라기보다 상태와 렌더링 경계가 섞인 설계 때문에 발생하는 경우가 많습니다.
React 19(그리고 이를 적극 활용하는 Next.js App Router)에서 핵심은 간단합니다.
- 서버는 렌더링과 데이터 스냅샷을 책임진다.
- 클라이언트는 상호작용(상태)과 UI 전환을 책임진다.
이 글에서는 “캐시 꼬임처럼 보이는 증상”을 재현 가능한 케이스로 분해하고, 상태·렌더링 분리로 해결하는 실전 패턴을 정리합니다.
관련해서 RSC/하이드레이션 이슈의 전형적인 패턴은 아래 글도 함께 참고하면 좋습니다.
“캐시 꼬임”이란 무엇을 말하나
현장에서 말하는 캐시 꼬임은 보통 아래 중 하나입니다.
- 서버에서 fetch 결과가 갱신되지 않음
- 동일 요청인데도 이전 응답이 재사용됨
- 서버 액션 이후 UI가 부분적으로만 갱신
- 리스트는 갱신되는데 카운터/요약은 그대로
- 클라이언트 상태가 서버 렌더 결과를 덮어씀
- 서버는 최신인데, 클라이언트가 가진 이전 상태가 우선 적용됨
- 라우팅 전환/뒤로가기에서 오래된 RSC payload가 재사용
- “뒤로가기 했더니 예전 화면” 같은 체감
중요한 포인트는, 이 현상들이 “단일 캐시” 문제로만 설명되지 않는다는 점입니다. RSC 환경에는 최소한 다음 레이어가 겹칩니다.
- 데이터 캐시:
fetch의 캐시(Next.js의 확장 포함) - RSC 결과 캐시: 서버 컴포넌트 트리의 결과(payload) 재사용
- 라우터 캐시: App Router가 페이지/세그먼트 전환 시 들고 있는 결과
- 브라우저 캐시: HTTP 캐시
- 클라이언트 상태: React state, store, SWR/React Query 캐시
따라서 해결도 “캐시 끄기”가 아니라, 어떤 레이어가 무엇을 책임지는지 분리하는 방향이 가장 안전합니다.
문제의 근본: 상태와 렌더링을 같은 곳에서 해결하려는 욕심
RSC를 쓰면서 가장 흔한 실수는 아래처럼 “서버에서 읽은 데이터”를 “클라이언트에서 다시 상태로 관리”하려는 패턴입니다.
- 서버 컴포넌트에서 DB를 읽고 props로 내려줌
- 클라이언트 컴포넌트에서 그 props를
useState로 초기화 - 이후 서버 액션/재검증으로 서버 데이터가 바뀌어도, 클라이언트 state는 그대로
이때 사용자는 “서버는 최신인데 UI가 안 바뀐다”라고 느끼고 캐시가 꼬였다고 말합니다. 하지만 실제로는 클라이언트 상태가 서버 렌더를 덮어쓴 것입니다.
원칙 1: 서버는 스냅샷을 만든다, 클라이언트는 상호작용만 한다
서버 컴포넌트는 “현재 시점의 스냅샷”을 만드는 데 최적화되어 있습니다. 반면 클라이언트는 “사용자 상호작용 상태”를 들고 있는 데 최적화되어 있습니다.
아래는 나쁜 예시(의도치 않은 스냅샷 고정)입니다.
// app/products/page.tsx (Server Component)
import ProductsClient from './ProductsClient'
export default async function Page() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
return <ProductsClient initialProducts={products} />
}
// app/products/ProductsClient.tsx (Client Component)
'use client'
import { useState } from 'react'
export default function ProductsClient({ initialProducts }: { initialProducts: any[] }) {
const [products, setProducts] = useState(initialProducts)
// 서버에서 products가 바뀌어도 여기 state는 자동 갱신되지 않음
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
이 구조는 “처음 렌더”는 잘 되지만, 이후에 서버 데이터가 바뀌는 순간부터 동기화 문제가 시작됩니다.
개선: 렌더링은 서버에서 끝내고, 클라이언트는 상호작용만
// app/products/page.tsx (Server Component)
export default async function Page() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store',
})
const products = await res.json()
return (
<ul>
{products.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
- 서버에서 그린 리스트는 서버 재렌더 시점에만 바뀝니다.
- “클라이언트가 들고 있던 오래된 state”가 서버 결과를 덮어쓰는 일이 사라집니다.
상호작용이 필요하다면 “제품 목록 자체”를 state로 잡는 대신, 정말 상호작용에 필요한 최소 상태만 클라이언트에 둡니다(예: 선택된 제품 id, 필터 입력값 등).
원칙 2: 서버 액션 이후에는 재검증을 ‘명시적으로’ 설계한다
서버 액션으로 데이터를 변경한 뒤 UI가 갱신되지 않는 경우가 많습니다. 이때도 “캐시 꼬임”처럼 보이지만, 실제로는 어떤 경로를 재검증할지 정의하지 않았거나, 재검증 단위가 너무 작거나, 클라이언트가 갱신 트리거를 안 걸었거나 중 하나입니다.
다음은 Next.js에서 흔히 쓰는 패턴입니다.
// app/products/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = String(formData.get('name') || '')
await fetch('https://api.example.com/products', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
})
// 목록 페이지를 다시 그리도록 명시
revalidatePath('/products')
}
// app/products/NewProductForm.tsx
'use client'
import { useTransition } from 'react'
import { createProduct } from './actions'
export default function NewProductForm() {
const [pending, startTransition] = useTransition()
return (
<form
action={(fd) => {
startTransition(async () => {
await createProduct(fd)
})
}}
>
<input name="name" placeholder="product name" />
<button type="submit" disabled={pending}>Create</button>
</form>
)
}
여기서 핵심은 두 가지입니다.
revalidatePath또는revalidateTag로 서버 스냅샷을 다시 만들 범위를 지정- 클라이언트는 그저 액션을 호출하고 전환을 관리
만약 revalidatePath('/products')를 안 걸면, 서버는 “이미 만든 스냅샷”을 계속 재사용할 수 있고 사용자는 이를 캐시 꼬임으로 인식합니다.
원칙 3: 데이터 캐시 키를 “상태”로 쓰지 않는다
RSC에서 fetch는 종종 캐시 키로 동작합니다. 문제는 개발자가 무심코 “요청 URL”을 상태처럼 쓰기 시작할 때 발생합니다.
예를 들어 다음과 같은 상황입니다.
/products?sort=popular을 보고 있다가- 클라이언트에서 sort 상태를 바꾸며
- 서버 컴포넌트가 다시 렌더되길 기대
이때 sort는 “클라이언트 state”가 아니라 “서버 렌더 입력”이어야 합니다. 즉, URL(searchParams)로 승격시키거나, 서버 액션을 통해 서버 렌더 트리를 갱신해야 합니다.
// app/products/page.tsx
export default async function Page({
searchParams,
}: {
searchParams: Promise<{ sort?: string }>
}) {
const sp = await searchParams
const sort = sp.sort ?? 'popular'
const res = await fetch(`https://api.example.com/products?sort=${sort}`, {
next: { revalidate: 60 },
})
const products = await res.json()
return (
<div>
<h1>Products</h1>
<ul>
{products.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
)
}
정리하면:
searchParams는 서버 렌더의 입력값- 입력값이 바뀌면 서버는 다른 스냅샷을 만들 수 있음
- 클라이언트 state로 sort를 들고 있으면 “서버 스냅샷이 안 바뀌는 것처럼” 보일 수 있음
원칙 4: 클라이언트 store는 “서버 진실”을 복제하지 않는다
Redux, Zustand, Jotai, SWR, React Query 등을 쓰는 경우, 서버에서 내려준 데이터를 store에 넣어 “전역 상태”로 만들고 싶어집니다. 하지만 RSC에서는 이게 곧 동기화 지옥으로 이어집니다.
권장 패턴은 다음 중 하나입니다.
- 서버 데이터를 서버에서만 렌더링한다
- 클라이언트에서 서버 데이터를 써야 한다면, 클라이언트 전용 데이터 패칭 계층(SWR/React Query) 로 일원화하고, 서버는 초기 HTML만 제공한다
- 혼합이 필요하면, 서버는
initialData를 제공하되 “진실의 원천(source of truth)”을 클라이언트 쿼리로 둔다
예시: React Query로 클라이언트가 진실을 갖고, 서버는 초기값만 전달
// app/products/page.tsx (Server)
import ProductsClient from './ProductsClient'
export default async function Page() {
const res = await fetch('https://api.example.com/products', { cache: 'no-store' })
const initial = await res.json()
return <ProductsClient initial={initial} />
}
// app/products/ProductsClient.tsx (Client)
'use client'
import { useQuery } from '@tanstack/react-query'
async function fetchProducts() {
const res = await fetch('/api/products')
return res.json()
}
export default function ProductsClient({ initial }: { initial: any[] }) {
const { data } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
initialData: initial,
})
return (
<ul>
{data.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
이 경우 “갱신”은 React Query의 invalidate로 해결하고, RSC의 재검증과 섞지 않는 게 포인트입니다. 즉, 한 화면에서 ‘갱신의 주체’를 하나로 만듭니다.
원칙 5: no-store는 만능이 아니다 (성능과 일관성의 트레이드오프)
cache: 'no-store'는 “항상 최신”을 보장해주는 것처럼 보이지만, 다음 비용이 있습니다.
- 서버 부하 증가
- TTFB 증가
- 동일 요청의 중복 호출
- 스트리밍/부분 렌더링에서도 체감 성능이 떨어질 수 있음
따라서 “항상 최신이어야 하는 데이터”에만 제한적으로 적용하고, 나머지는 revalidate 또는 tag 기반 재검증을 사용해 일관성과 성능을 함께 가져가는 편이 좋습니다.
LCP 관점에서 RSC 캐시 전략이 성능에 미치는 영향은 아래 글의 최적화 파트가 도움이 됩니다.
체크리스트: “캐시 꼬임”을 설계로 없애는 질문 7개
디버깅할 때는 아래 질문으로 원인을 빠르게 좁힐 수 있습니다.
- 이 데이터의 진실의 원천은 서버 렌더인가, 클라이언트 쿼리인가?
- 서버 액션 후에 어떤 경로를
revalidatePath해야 하는가? - 재검증 단위는 path가 맞나, tag가 맞나?
- 클라이언트에서 서버 데이터를
useState로 고정하고 있지 않은가? - URL(searchParams)을 바꿔야 할 상태를 로컬 state로만 처리하고 있지 않은가?
no-store를 남발해서 성능을 망치고 있지 않은가?- 하이드레이션 에러나 경계 혼합(서버/클라 컴포넌트 혼용)로 인해 “보이는 값”이 왜곡된 건 아닌가?
결론: 캐시 문제처럼 보이면, 먼저 경계를 의심하라
React 19 + RSC 환경에서 캐시가 꼬였다는 체감은 상당 부분이 상태·렌더링 분리 실패에서 시작됩니다.
- 서버는 “스냅샷 렌더링”에 집중
- 클라이언트는 “상호작용 상태”에 집중
- 갱신은
revalidatePath/revalidateTag등으로 명시 - 한 화면에서 “갱신의 주체”를 하나로 통일
이 원칙만 지켜도, 캐시를 무작정 끄지 않고도 일관성과 성능을 동시에 얻을 수 있습니다.