- Published on
Next.js RSC로 리렌더 폭증·상태관리 꼬임 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 컴포넌트(React Server Components, 이하 RSC)를 Next.js App Router에 본격 적용하면, 클라이언트 번들이 줄고 데이터 패칭이 단순해지는 이점이 큽니다. 그런데 실무에서는 도입 직후 리렌더가 과도하게 발생하거나, 상태가 초기화·꼬임(필터가 리셋, 모달이 닫힘, 입력값이 날아감, 스크롤 위치 튐) 같은 문제가 자주 터집니다.
이 글은 “RSC 자체가 느리다”가 아니라, Server-Client 경계가 흐려졌을 때 생기는 구조적 문제를 진단하고, 재발을 막는 설계 패턴을 정리합니다.
문제 1: 리렌더 폭증의 1차 원인, Server 컴포넌트 재실행
RSC에서 가장 흔한 오해는 “클라이언트에서 상태가 바뀌면 해당 부분만 다시 그려질 것”이라는 기대입니다. 하지만 App Router에서 페이지 트리 상단에 Server 컴포넌트가 많을수록, 다음 상황에서 서버 렌더링이 쉽게 재실행됩니다.
searchParams변경(필터, 정렬, 페이지네이션)router.refresh()호출- Server Action 성공 후 자동 재검증(revalidation)
fetch캐시 정책이no-store로 되어 매번 재요청
즉, 사용자가 클라이언트에서 작은 상태를 바꿨다고 생각해도, 실제로는 서버가 다시 렌더링하며 트리 전체가 교체될 수 있습니다. 이때 Client Component는 “리렌더”가 아니라 언마운트 후 재마운트에 가까운 경험을 하게 되고, 로컬 상태가 초기화됩니다.
체크리스트: “리렌더”인지 “재마운트”인지 구분
Client Component에 아래 로그를 넣어보면 빠르게 판단할 수 있습니다.
'use client'
import { useEffect } from 'react'
export function DebugMount({ name }: { name: string }) {
useEffect(() => {
console.log(`[mount] ${name}`)
return () => console.log(`[unmount] ${name}`)
}, [name])
return null
}
필터를 바꿀 때마다 unmount가 찍히면, 단순 리렌더 최적화가 아니라 경계 재설계가 필요합니다.
문제 2: 상태관리 꼬임의 핵심은 “서버가 소유권을 다시 가져감”
RSC에서는 데이터의 소유권이 기본적으로 서버에 있습니다. 그런데 UI 상태까지 서버가 소유하려고 하면 다음 문제가 생깁니다.
- URL 기반 상태(
searchParams)와 로컬 상태가 서로 덮어씀 - Server Action 이후 서버가 최신 데이터로 트리를 교체하면서, 클라이언트 입력 중이던 값이 초기화
- Suspense 경계가 상위에 있어 로딩 중 fallback으로 교체되며 UI 상태가 리셋
정리하면, 서버는 “데이터 상태”를, 클라이언트는 “인터랙션 상태”를 소유하도록 분리해야 합니다.
해결 전략 1: Server-Client 경계를 “안정적인 섬”으로 만들기
가장 효과적인 패턴은 페이지 상단은 Server로 두되, 사용자가 자주 만지는 영역을 Client 섬(island) 으로 고정하는 것입니다.
나쁜 예: 리스트와 필터가 모두 Server에 걸려 있음
- 필터 변경
searchParams업데이트 - 페이지 전체 Server 재실행
- 리스트 영역뿐 아니라 필터 UI도 교체
- 입력값, 포커스, 펼침 상태가 초기화
좋은 예: 데이터만 Server, UI는 Client
- Server는 초기 데이터와 총 개수, 초기 필터값만 내려줌
- Client는 필터 UI와 리스트 렌더를 담당
- 필터 변경은 클라이언트 상태로 즉시 반영
- 필요 시에만 API 호출 또는
router.replace로 URL 동기화
예시 구조입니다.
// app/products/page.tsx (Server Component)
import { ProductsClient } from './products-client'
export default async function Page({ searchParams }: { searchParams: Record<string, string | string[] | undefined> }) {
const initialQuery = {
q: typeof searchParams.q === 'string' ? searchParams.q : '',
sort: typeof searchParams.sort === 'string' ? searchParams.sort : 'popular',
}
const initialData = await getProducts(initialQuery) // 서버에서 1회
return (
<ProductsClient
initialQuery={initialQuery}
initialData={initialData}
/>
)
}
// app/products/products-client.tsx (Client Component)
'use client'
import { useMemo, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
type Query = { q: string; sort: string }
export function ProductsClient({ initialQuery, initialData }: { initialQuery: Query; initialData: any }) {
const router = useRouter()
const [query, setQuery] = useState<Query>(initialQuery)
const [data, setData] = useState(initialData)
const [isPending, startTransition] = useTransition()
const stableParams = useMemo(() => new URLSearchParams(query as any).toString(), [query])
const syncUrl = () => {
startTransition(() => {
router.replace(`?${stableParams}`)
})
}
return (
<div>
<div>
<input
value={query.q}
onChange={(e) => setQuery((p) => ({ ...p, q: e.target.value }))}
onBlur={syncUrl}
placeholder="검색"
/>
<select value={query.sort} onChange={(e) => { setQuery((p) => ({ ...p, sort: e.target.value })); }}>
<option value="popular">인기순</option>
<option value="new">최신순</option>
</select>
<button onClick={syncUrl} disabled={isPending}>적용</button>
</div>
{/* data는 필요 시 클라이언트에서 재조회하거나, URL 변경에 맞춰 서버 네비게이션을 유도 */}
<pre>{JSON.stringify(data, null, 2)}</pre>
</div>
)
}
포인트는 onChange마다 URL을 바꾸지 않고, onBlur나 버튼 클릭처럼 의도된 타이밍에만 URL을 동기화해 서버 재실행을 줄이는 것입니다.
해결 전략 2: key로 Client 섬을 죽이지 말기
의외로 자주 보는 실수는 Client Component에 key={searchParams.xxx} 같은 값을 걸어버리는 것입니다. 이렇게 하면 해당 값이 바뀔 때마다 React는 컴포넌트를 새 인스턴스로 교체합니다.
// 안 좋은 예: 검색어가 바뀔 때마다 언마운트
<ProductsClient key={q} initialQuery={...} />
key는 “리셋이 필요할 때”만 쓰고, 대부분의 경우는 내부 상태를 업데이트하거나 store로 유지하세요.
해결 전략 3: fetch 캐시 정책을 의도적으로 선택하기
RSC에서 데이터 패칭이 불안정하게 느껴질 때, 실제 원인은 fetch 캐시 설정이 뒤섞인 경우가 많습니다.
- 항상 최신이 필요하면
cache: 'no-store' - 일정 시간 캐시해도 되면
next: { revalidate: 60 } - 태그 기반 무효화가 필요하면
next: { tags: ['products'] }
// lib/api.ts
export async function getProducts(query: { q: string; sort: string }) {
const url = new URL('https://api.example.com/products')
url.searchParams.set('q', query.q)
url.searchParams.set('sort', query.sort)
const res = await fetch(url, {
next: { revalidate: 30, tags: ['products'] },
})
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
}
캐시 전략이 불분명하면, 개발 중에는 “왜 이 화면이 또 서버에서 돈 거지?” 같은 현상이 반복됩니다. 특히 no-store를 남발하면 작은 UI 변화도 서버 부하와 TTFB 변동으로 이어질 수 있습니다.
해결 전략 4: Server Action 이후 상태 리셋을 막는 패턴
Server Action은 편하지만, 성공 후 revalidatePath 또는 revalidateTag로 인해 RSC 트리가 갱신되면서 Client 섬이 흔들릴 수 있습니다. 이때는 다음 중 하나를 선택합니다.
- 폼 입력/임시 상태는 Client에서 유지하고, Action은 최소 데이터만 서버로 보냄
- 성공 후에도 UI를 유지하려면, 상위에서 트리를 갈아끼우지 않도록 경계를 더 아래로 내림
- 낙관적 업데이트가 필요하면
useOptimistic또는 클라이언트 캐시 라이브러리(SWR, React Query)를 사용
간단한 예시입니다.
'use client'
import { useOptimistic, useTransition } from 'react'
import { createComment } from './actions'
export function CommentBox({ postId, initialComments }: { postId: string; initialComments: any[] }) {
const [isPending, startTransition] = useTransition()
const [optimisticComments, addOptimistic] = useOptimistic(
initialComments,
(state, newItem: any) => [newItem, ...state]
)
const onSubmit = (text: string) => {
const temp = { id: `temp-${Date.now()}`, text }
addOptimistic(temp)
startTransition(async () => {
await createComment({ postId, text })
// 필요 시 여기서만 refresh하거나, 태그 무효화로 서버 데이터 갱신
})
}
return (
<div>
<button onClick={() => onSubmit('hello')} disabled={isPending}>댓글 작성</button>
<ul>
{optimisticComments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
</div>
)
}
핵심은 “서버 갱신이 오더라도 사용자 인터랙션은 끊기지 않게” 클라이언트에서 완충층을 만드는 것입니다.
해결 전략 5: URL 상태와 로컬 상태의 단일 진실 공급원(SSOT) 정하기
필터/정렬/페이지 같은 상태는 두 방식 중 하나로 통일하는 게 안전합니다.
- URL이 SSOT: 새로고침, 공유 링크가 중요할 때. 대신 입력 중 상태는 디바운스/커밋 시점에만 URL 반영
- 로컬 상태가 SSOT: 복잡한 UI(다단 필터, 드래그, 임시 선택)가 중요할 때. 필요 시에만 URL로 스냅샷 저장
혼합하면 “뒤로가기 했더니 UI는 그대로인데 데이터만 바뀜” 같은 불일치가 생깁니다.
해결 전략 6: 성능 진단은 “렌더링”이 아니라 “폭증 트리거”부터
RSC에서 체감 성능이 흔들릴 때는 렌더 횟수보다 “무엇이 서버 재실행을 트리거했는지”를 먼저 찾는 편이 빠릅니다.
searchParams변경이 잦은가router.refresh()가 습관적으로 들어가 있나- Server Action 성공 후 무효화 범위가 너무 넓나(
revalidatePath('/')같은) fetch에no-store가 섞여 있나
이 관점은 인프라 문제에서도 유사합니다. 예를 들어 QPS가 갑자기 튈 때 “서버가 느려졌다”보다 “핫키가 생겼다”를 먼저 의심하듯이, RSC도 “렌더가 느리다”보다 “갱신 트리거가 과하다”를 먼저 봐야 합니다. 핫키로 인한 트래픽 폭증과 분산 전략은 Redis 핫키로 QPS 폭증? 키 분산·LFU 튜닝 글의 접근법이 진단 관점에서 참고가 됩니다.
또한 사용자 체감이 “화면이 튄다”로 나타나면, 리렌더 자체보다 레이아웃 변동이 원인인 경우가 많습니다. 이때는 LCP와 Layout Shift를 함께 보세요. Chrome LCP 느림? Layout Shift 원인 6가지도 같이 읽으면 프론트 성능 디버깅의 우선순위를 잡는 데 도움이 됩니다.
실전 권장 아키텍처: RSC는 데이터 파이프, Client는 상호작용 파이프
정리하면, RSC를 잘 쓰는 팀은 대체로 이렇게 나눕니다.
- Server Component
- 인증/권한 체크
- 데이터 패칭과 캐시 정책 결정
- 초기 HTML과 초기 데이터 전달
- Client Component
- 입력, 포커스, 모달, 토스트, 드래그 같은 인터랙션
- URL 동기화는 커밋 시점에만
- 낙관적 업데이트 또는 클라이언트 캐시로 UX 유지
이 경계를 지키면 “리렌더 폭증”은 대부분 “서버 재실행 폭증”으로 재정의되고, 원인도 searchParams 남발, 무효화 범위 과다, 캐시 정책 혼재처럼 제어 가능한 요소로 바뀝니다.
마무리: 증상별 빠른 처방전
- 입력값이 자꾸 초기화된다
- Client 섬이 언마운트되는지 확인하고, 상위 Server 갱신 트리거를 줄이거나 경계를 아래로 내리기
- 필터 바꿀 때마다 화면이 크게 깜빡인다
searchParams업데이트 빈도를 줄이고,startTransition으로 URL 동기화
- Server Action 후 UI가 꼬인다
- 무효화 범위를 좁히고, 낙관적 업데이트로 완충층 만들기
- 왜 자꾸 서버가 다시 도는지 모르겠다
fetch캐시 정책 통일,no-store남발 제거,router.refresh()재검토
RSC는 “상태관리 라이브러리를 대체”하는 기술이 아니라, 서버 중심 데이터 흐름을 단순화하는 기술입니다. 데이터는 서버가, 인터랙션은 클라이언트가 책임지는 경계를 만들면 리렌더 폭증과 상태 꼬임은 대부분 구조적으로 사라집니다.