- Published on
Next.js RSC에서 리렌더링 줄이는 상태관리 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Next.js App Router + RSC(React Server Components)를 쓰다 보면 상태관리가 “쉬워진 것 같다가도” 갑자기 어려워집니다. 이유는 간단합니다. 기존 SPA에서 익숙했던 전역 상태(예: Redux/Zustand)로 모든 UI를 밀어 넣으면, RSC가 제공하는 서버 렌더링/스트리밍/캐싱 이점을 스스로 깎아 먹기 쉽기 때문입니다.
RSC의 핵심은 서버에서 가능한 한 많이 렌더링하고, 클라이언트는 상호작용이 필요한 작은 섬(island) 으로 제한하는 것입니다. 이 글에서는 “리렌더링을 줄이는” 관점에서, RSC 환경에 맞는 상태 배치 패턴을 정리합니다.
또한 캐시/리페치가 꼬여서 “왜 갱신이 안 되지?” 같은 문제를 겪는다면, 서버 캐싱 관점은 Next.js 14 ISR 캐시가 안 갱신될 때 원인·해결 글도 함께 참고하면 좋습니다.
RSC에서 리렌더링이 늘어나는 전형적인 이유
App Router에서 리렌더링(정확히는 클라이언트 컴포넌트의 재렌더)이나 UI 갱신 비용이 커지는 패턴은 대체로 다음 중 하나입니다.
- 클라이언트 전역 스토어가 너무 넓은 범위를 감싼다
- 페이지 루트에
use client를 붙이거나, 레이아웃에서 전역 Provider로 전체 트리를 클라이언트로 만들어버리는 경우
- 페이지 루트에
- 서버 데이터와 클라이언트 상태가 섞여서 “무효화 범위”가 커진다
- 서버에서 가져온 리스트를 클라이언트 스토어에 통째로 넣고, 작은 UI 변경에도 리스트 전체가 다시 그려짐
- URL로 표현 가능한 상태를 로컬 상태로만 관리한다
- 필터/정렬/페이지네이션이
useState에만 있으면, 서버 컴포넌트 재요청과 동기화가 어렵고, 컴포넌트 트리 갱신이 과도해짐
- 필터/정렬/페이지네이션이
정리하면, RSC에서는 상태를 “한 군데 모으는 것”보다 경계를 잘라서 영향을 최소화 하는 게 더 중요합니다.
패턴 1) 서버 상태는 서버에 두고, 클라이언트는 최소 섬으로
가장 강력한 최적화는 단순합니다.
- 데이터 패칭/조합/권한 체크/포맷팅: 서버 컴포넌트에서
- 클릭, 입력, 토글: 아주 작은 클라이언트 컴포넌트에서
예를 들어 상품 목록 페이지에서, 목록 자체는 서버에서 렌더링하고 “장바구니 담기 버튼”만 클라이언트로 둡니다.
// app/products/page.tsx (Server Component)
import AddToCartButton from './AddToCartButton'
async function getProducts() {
const res = await fetch('https://example.com/api/products', {
cache: 'no-store',
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<div>
<h1>Products</h1>
<ul>
{products.map((p: any) => (
<li key={p.id}>
<div>{p.name}</div>
<div>{p.price}</div>
<AddToCartButton productId={p.id} />
</li>
))}
</ul>
</div>
)
}
// app/products/AddToCartButton.tsx (Client Component)
'use client'
import { useTransition } from 'react'
export default function AddToCartButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await fetch('/api/cart', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ productId }),
})
})
}}
>
{isPending ? 'Adding...' : 'Add to cart'}
</button>
)
}
이 구조의 장점은 “버튼 상태 변화”가 목록 전체를 흔들지 않는다는 점입니다. 목록은 서버에서 이미 렌더된 결과이고, 버튼만 클라이언트에서 재렌더됩니다.
패턴 2) URL(Search Params)을 상태의 1급 저장소로 사용
필터/정렬/탭/페이지네이션 같은 상태는 대부분 URL로 표현 가능합니다. 이걸 useState로만 관리하면 다음 문제가 생깁니다.
- 새로고침 시 상태가 날아감
- 링크 공유가 안 됨
- 서버 컴포넌트가 해당 상태를 모름(= 서버에서 최적의 데이터 패칭이 어려움)
App Router에서는 searchParams를 서버 컴포넌트에서 받아 그대로 데이터 패칭에 사용하고, 클라이언트에서는 라우터로 URL만 갱신하는 패턴이 깔끔합니다.
// app/products/page.tsx (Server Component)
import ProductsFilter from './ProductsFilter'
export default async function ProductsPage({
searchParams,
}: {
searchParams: { q?: string; sort?: string; page?: string }
}) {
const q = searchParams.q ?? ''
const sort = searchParams.sort ?? 'popular'
const page = Number(searchParams.page ?? '1')
const res = await fetch(
`https://example.com/api/products?q=${encodeURIComponent(q)}&sort=${sort}&page=${page}`,
{ cache: 'no-store' }
)
const data = await res.json()
return (
<div>
<ProductsFilter />
<ul>
{data.items.map((p: any) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
</div>
)
}
// app/products/ProductsFilter.tsx (Client Component)
'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useMemo, useState } from 'react'
export default function ProductsFilter() {
const router = useRouter()
const pathname = usePathname()
const sp = useSearchParams()
const initialQ = useMemo(() => sp.get('q') ?? '', [sp])
const [q, setQ] = useState(initialQ)
function apply() {
const next = new URLSearchParams(sp.toString())
if (q) next.set('q', q)
else next.delete('q')
next.set('page', '1')
router.push(`${pathname}?${next.toString()}`)
}
return (
<div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search" />
<button onClick={apply}>Apply</button>
</div>
)
}
이렇게 하면 “필터 변경”은 URL 변경으로 귀결되고, 서버 컴포넌트는 해당 URL을 기준으로 필요한 부분만 다시 렌더/패치합니다. 클라이언트 전역 스토어로 목록을 들고 있을 때보다, 리렌더링 범위가 훨씬 예측 가능해집니다.
패턴 3) 전역 스토어는 ‘UI 상태’만, 데이터는 서버/캐시로
Zustand/Redux 같은 전역 스토어가 나쁜 게 아닙니다. 문제는 무엇을 넣느냐입니다.
권장 기준:
- 전역 스토어에 넣기 좋은 것
- 모달 열림 여부, 토스트 큐, 사이드바 접힘, 테마, 로컬에서만 의미 있는 임시 선택 상태
- 전역 스토어에 넣기 위험한 것
- 서버에서 가져온 대형 리스트/상세 데이터(= 변경 빈도와 구독 범위가 커서 리렌더 폭발)
Zustand를 쓰더라도 “구독 셀렉터”를 강제하는 식으로 리렌더링을 줄일 수 있습니다.
// app/store/ui.ts
import { create } from 'zustand'
type UIState = {
isCartOpen: boolean
openCart: () => void
closeCart: () => void
}
export const useUIStore = create<UIState>((set) => ({
isCartOpen: false,
openCart: () => set({ isCartOpen: true }),
closeCart: () => set({ isCartOpen: false }),
}))
// app/components/CartToggle.tsx
'use client'
import { useUIStore } from '../store/ui'
export default function CartToggle() {
const isCartOpen = useUIStore((s) => s.isCartOpen)
const openCart = useUIStore((s) => s.openCart)
const closeCart = useUIStore((s) => s.closeCart)
return (
<button onClick={isCartOpen ? closeCart : openCart}>
{isCartOpen ? 'Close cart' : 'Open cart'}
</button>
)
}
포인트는 useUIStore()를 통째로 구독하지 않고, 필요한 조각만 선택해서 구독하는 겁니다. 그래야 다른 UI 상태가 바뀌어도 이 컴포넌트는 리렌더되지 않습니다.
패턴 4) Server Action + revalidatePath로 “서버를 진실”로 두기
RSC에서 상태 동기화를 어렵게 만드는 대표 원인은 “낙관적 업데이트 + 전역 스토어 + 서버 캐시”가 뒤엉키는 상황입니다. 이럴 때는 서버를 진실(source of truth)로 두고, 변경은 Server Action으로 처리한 뒤 해당 경로만 재검증하는 패턴이 단순하고 강합니다.
// app/products/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function toggleFavorite(productId: string) {
await fetch('https://example.com/api/favorites/toggle', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ productId }),
})
revalidatePath('/products')
}
// app/products/FavoriteButton.tsx
'use client'
import { useTransition } from 'react'
import { toggleFavorite } from './actions'
export default function FavoriteButton({ productId }: { productId: string }) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => startTransition(async () => toggleFavorite(productId))}
>
{isPending ? 'Saving...' : 'Favorite'}
</button>
)
}
이 패턴은 “클라이언트에서 대형 데이터를 들고 갱신”하는 대신, 서버에서 재렌더/재패치를 통해 UI를 맞춥니다. 결과적으로 클라이언트 리렌더링은 버튼 수준으로 제한되고, 데이터 정합성도 좋아집니다.
서버 캐시/ISR/재검증이 얽히면 증상이 미묘하게 나타날 수 있으니, 캐시가 기대대로 갱신되지 않을 때는 앞서 언급한 Next.js 14 ISR 캐시가 안 갱신될 때 원인·해결도 같이 보면 문제 분리가 빨라집니다.
패턴 5) “Provider를 레이아웃 최상단에 두지 말기”
RSC에서 흔한 실수는 app/layout.tsx에서 전역 Provider를 감싸며 레이아웃 전체를 클라이언트로 만드는 것입니다. 예를 들어 아래처럼 하면, 사실상 RSC의 이점을 크게 잃습니다.
// app/layout.tsx
// 안티패턴 예시: 레이아웃 전체를 클라이언트로 만드는 경우
'use client'
import { SomeProvider } from './SomeProvider'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<SomeProvider>{children}</SomeProvider>
</body>
</html>
)
}
대신 Provider는 “정말 필요한 구간”에만 두고, 가능한 한 아래로 내립니다.
// app/products/layout.tsx (Server Component)
import ProductsClientProviders from './ProductsClientProviders'
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
return (
<section>
<h2>Products</h2>
<ProductsClientProviders>{children}</ProductsClientProviders>
</section>
)
}
// app/products/ProductsClientProviders.tsx
'use client'
export default function ProductsClientProviders({ children }: { children: React.ReactNode }) {
return children
}
Provider를 “하위 라우트 세그먼트 수준”으로 제한하면, 다른 페이지/레이아웃까지 연쇄적으로 클라이언트 트리가 되는 것을 막고 리렌더 범위를 줄일 수 있습니다.
패턴 6) 비싼 클라이언트 계산은 useMemo가 아니라 “서버로”
리렌더링 비용은 단순히 횟수만이 아니라 “리렌더 시 수행되는 일”에 의해 커집니다. 클라이언트에서 정렬/그룹핑/필터링 같은 계산을 크게 하고 있으면, 상태 변경 한 번에 긴 작업이 발생해 INP가 나빠질 수 있습니다.
이런 경우는 다음 중 하나로 옮기는 게 좋습니다.
- 서버에서 계산 후 내려주기(RSC에서 자연스러움)
- 혹은 API에서 정렬/필터링 결과를 내려주기
클라이언트 성능 지표(INP) 관점에서 긴 작업을 추적하는 방법은 Chrome INP 급락 원인 찾기 - Long Task 추적도 도움이 됩니다.
체크리스트: 리렌더링 줄이는 RSC 상태관리 기준
- URL로 표현 가능한 상태(필터/정렬/페이지)는
searchParams로 승격했는가 - 서버 데이터는 서버 컴포넌트에서 패칭하고, 클라이언트에는 상호작용만 남겼는가
- 전역 스토어는 UI 상태 중심이며, 구독은 셀렉터로 쪼갰는가
- Provider가 앱 최상단에서 전체 트리를 클라이언트로 만들고 있지 않은가
- 변경은 Server Action으로 처리하고 필요한 경로만
revalidatePath로 무효화하는가 - 클라이언트에서 큰 계산/정렬을 매 렌더마다 수행하지 않는가
마무리
RSC 시대의 상태관리는 “전역 스토어로 일원화”가 아니라, 서버/클라이언트의 책임을 분리하고 상태의 위치를 재배치하는 작업에 가깝습니다. 서버가 잘하는 일(데이터 조합, 캐시, 권한, 스트리밍)은 서버에 두고, 클라이언트는 작은 상호작용 섬으로 제한하면 리렌더링 비용이 눈에 띄게 줄어듭니다.
다음 단계로는, 현재 프로젝트에서
use client가 붙은 파일이 어디까지 전파되는지- Provider가 어느 레벨에 있는지
- 전역 스토어에 “서버 데이터”가 들어가 있는지
부터 점검해보면, 큰 리팩터링 없이도 성능이 개선되는 경우가 많습니다.