- Published on
Next.js RSC·Server Actions로 렌더링 폭주 잡기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
렌더링 폭주(render storm)는 대개 “상태 변경 1번”이 “컴포넌트 트리 전체 재렌더링 + 연쇄 데이터 재요청 + 중복 네트워크”로 증폭될 때 발생합니다. Next.js App Router 환경에서는 이를 RSC(React Server Components) 와 Server Actions 로 구조적으로 끊어낼 수 있습니다. 핵심은 간단합니다.
- 상태와 데이터의 경계를 서버로 옮기고
- 클라이언트는 상호작용(UI 상태)만 최소화하며
- 서버에서 캐시·중복 제거·동시성 제어를 수행하는 것
이 글에서는 “왜 폭주가 생기는지”를 먼저 해부하고, RSC/Server Actions로 옮겨가며 어떤 지점에서 렌더링이 줄어드는지, 운영에서 다시 폭주하지 않게 만드는 체크리스트까지 정리합니다.
렌더링 폭주의 전형적인 패턴
App Router 이전(또는 Pages Router/CSR 중심)에서 흔한 구조는 다음과 같습니다.
- 클라이언트 컴포넌트가
useEffect로 데이터 페치 - 입력/필터/정렬 상태가 바뀔 때마다
setState useEffect의 deps가 바뀌어 재요청- 자식 컴포넌트까지 연쇄 재렌더
- 로딩 스피너/스켈레톤 토글까지 합쳐져 렌더 횟수 폭증
특히 테이블/리스트/대시보드에서 “검색어 타이핑” “필터 클릭” “페이지 이동” 같은 이벤트가 많으면, 프레임 드랍과 함께 백엔드에도 부하가 전파됩니다.
이 문제를 해결하는 관점은 렌더링을 줄이는 최적화가 아니라, 렌더링이 폭주할 수 없는 구조로 바꾸는 것입니다.
RSC가 폭주를 줄이는 이유: 데이터 경계를 서버로 이동
RSC의 가장 큰 효과는 “데이터 페치가 클라이언트 상태 변화에 끌려다니지 않게” 만드는 것입니다.
- 서버 컴포넌트는 기본적으로 서버에서만 실행되고
- 클라이언트 번들에 포함되지 않으며
- 클라이언트의 잦은 상태 변화로 인해 자동으로 재렌더되지 않습니다
즉, 리스트 데이터/집계 데이터/권한 체크 같은 무거운 로직을 서버 컴포넌트로 밀어 넣으면, UI 상호작용이 많아도 “데이터 레이어”가 쉽게 흔들리지 않습니다.
안티패턴: 클라이언트에서 데이터 페치 + 광범위한 상태
// app/users/page.tsx
'use client'
import { useEffect, useState } from 'react'
export default function UsersPage() {
const [q, setQ] = useState('')
const [users, setUsers] = useState<any[]>([])
useEffect(() => {
const ac = new AbortController()
fetch(`/api/users?q=${encodeURIComponent(q)}`, { signal: ac.signal })
.then((r) => r.json())
.then(setUsers)
return () => ac.abort()
}, [q])
return (
<div>
<input value={q} onChange={(e) => setQ(e.target.value)} />
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
</div>
)
}
이 코드는 “타이핑”이 곧 “네트워크 요청 + 상태 업데이트 + 전체 렌더”로 직결됩니다. AbortController 로 취소해도, 이미 발생한 렌더와 요청 시도 자체는 줄지 않습니다.
개선: 서버 컴포넌트에서 데이터 페치, 클라이언트는 입력만
// app/users/page.tsx (Server Component)
import UsersSearchBox from './users-search-box'
export default async function UsersPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>
}) {
const { q = '' } = await searchParams
const res = await fetch(`${process.env.API_BASE_URL}/users?q=${encodeURIComponent(q)}`, {
// 필요에 따라 캐시 정책 선택
cache: 'no-store',
})
const users = await res.json()
return (
<div>
<UsersSearchBox initialQuery={q} />
<ul>
{users.map((u: any) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
</div>
)
}
// app/users/users-search-box.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useDeferredValue, useMemo, useState, useTransition } from 'react'
export default function UsersSearchBox({ initialQuery }: { initialQuery: string }) {
const router = useRouter()
const [q, setQ] = useState(initialQuery)
const deferredQ = useDeferredValue(q)
const [isPending, startTransition] = useTransition()
// 타이핑은 로컬 상태로만 처리하고, 라우팅 갱신은 transition으로 낮은 우선순위로
useMemo(() => {
startTransition(() => {
const sp = new URLSearchParams()
if (deferredQ) sp.set('q', deferredQ)
router.replace(`/users?${sp.toString()}`)
})
}, [deferredQ, router])
return (
<div>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search users" />
{isPending ? <span>Loading…</span> : null}
</div>
)
}
여기서 중요한 변화는 다음입니다.
- 데이터는 서버에서 가져오므로 클라이언트 렌더링 트리의 흔들림이 줄어듭니다.
- 입력 타이핑은 로컬 상태에만 머물고, 실제 데이터 갱신은
router.replace로 URL 상태를 바꾸어 서버 렌더 경계에서 처리됩니다. useDeferredValue와useTransition을 조합하면 “타이핑 즉시 반응”과 “데이터 갱신은 천천히”를 동시에 만족시켜 렌더링 압력을 낮춥니다.
Server Actions로 폭주 줄이기: API 라우트 왕복과 클라이언트 상태를 제거
렌더링 폭주는 데이터 페치뿐 아니라 “쓰기 작업”에서도 자주 터집니다.
- 폼 제출 후
POST /api/...호출 - 성공하면 다시
GET /api/...호출 - 로컬 상태를 업데이트하고
- 여러 컴포넌트가 그 상태를 구독하며 재렌더
Server Actions는 이 흐름을 단순화합니다.
- 클라이언트는 “액션 호출”만 하고
- 서버에서 DB 업데이트 후
- 필요한 경로를
revalidatePath또는revalidateTag로 무효화 - 다음 RSC 렌더에서 최신 데이터가 반영
예시: 댓글 작성에서 렌더링 폭주 줄이기
// app/posts/[id]/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function addComment(postId: string, formData: FormData) {
const body = String(formData.get('body') || '').trim()
if (!body) return
// DB insert (예: prisma)
// await prisma.comment.create({ data: { postId, body } })
// 해당 페이지를 다시 렌더하도록 캐시 무효화
revalidatePath(`/posts/${postId}`)
}
// app/posts/[id]/comment-form.tsx
'use client'
import { useRef, useTransition } from 'react'
import { addComment } from './actions'
export default function CommentForm({ postId }: { postId: string }) {
const formRef = useRef<HTMLFormElement | null>(null)
const [isPending, startTransition] = useTransition()
return (
<form
ref={formRef}
action={(fd) => {
startTransition(async () => {
await addComment(postId, fd)
formRef.current?.reset()
})
}}
>
<textarea name="body" required />
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Add comment'}
</button>
</form>
)
}
이 접근의 장점:
- 클라이언트가 “댓글 리스트 상태”를 들고 있을 필요가 줄어듭니다.
- 성공 후 재조회 로직을 직접 작성하지 않아도 됩니다.
- 서버 캐시 무효화를 기준으로 데이터 일관성을 맞추므로, 컴포넌트 간 상태 동기화로 인한 재렌더 폭발을 피하기 쉽습니다.
캐시/무효화 전략이 없으면 RSC도 폭주한다
RSC로 옮겼는데도 “서버 렌더가 너무 많이 돈다”는 문제가 생길 수 있습니다. 이때는 대개 캐시 전략이 불명확합니다.
- 모든
fetch를cache: 'no-store'로 두면, 작은 상호작용에도 서버가 매번 풀 렌더 - 반대로 무조건 캐시하면, 쓰기 후 최신 데이터가 안 보이는 문제
실무 추천: revalidateTag 기반으로 데이터 단위 무효화
// lib/data.ts
import { unstable_cache } from 'next/cache'
export const getPost = unstable_cache(
async (postId: string) => {
const res = await fetch(`${process.env.API_BASE_URL}/posts/${postId}`, {
// fetch 자체는 캐시 가능
next: { tags: [`post:${postId}`] },
})
return res.json()
},
['getPost'],
{ revalidate: 300 }
)
// app/posts/[id]/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePost(postId: string, formData: FormData) {
const title = String(formData.get('title') || '')
// DB update...
revalidateTag(`post:${postId}`)
}
이렇게 하면 “페이지 전체”가 아니라 “데이터 단위”로 무효화할 수 있어, 불필요한 서버 렌더 범위를 줄이는 데 유리합니다.
스트리밍과 Suspense 로 체감 폭주를 분리한다
렌더링 폭주는 실제로는 두 가지 문제를 섞어 부릅니다.
- CPU/네트워크 관점의 “진짜 폭주”(서버/클라이언트가 과도하게 일함)
- 사용자 체감 관점의 “느리다”(첫 화면이 늦게 뜸)
RSC는 스트리밍을 지원하므로, 느린 블록을 Suspense 로 분리해 사용자 체감 병목을 줄일 수 있습니다.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import Summary from './summary'
import SlowChart from './slow-chart'
export default function DashboardPage() {
return (
<div>
<Summary />
<Suspense fallback={<div>Loading chart…</div>}>
<SlowChart />
</Suspense>
</div>
)
}
이 방식은 “폭주 자체”를 없애기보다는, 느린 부분을 격리해 전체 UI가 함께 흔들리는 것을 방지합니다. 특히 대시보드처럼 위젯이 많은 화면에서 효과가 큽니다.
동시성 제어: 중복 요청을 줄여 서버 렌더 폭주를 막기
검색/필터처럼 요청이 잦은 기능은 서버에서도 중복이 발생합니다. 같은 쿼리로 동일한 렌더가 동시에 여러 번 들어오면, 백엔드와 DB가 함께 흔들릴 수 있습니다.
실무에서는 다음을 같이 씁니다.
- 요청 단위 디바운스(클라이언트에서
useDeferredValue또는 디바운스) - 서버 캐시(태그/TTL)
- 백엔드 쿼리 최적화(인덱스, 페이지네이션)
운영에서 “어떤 백그라운드 작업이 CPU를 태우며 폭주하는지”를 잡는 접근은 DB에서도 유사합니다. 예를 들어 자동 정리 작업이 과도하게 돌아 CPU를 100%로 만드는 케이스처럼, 원인을 분해하고 트리거를 줄이는 방식이 중요합니다. 참고로 비슷한 관점의 운영 글로 PostgreSQL RDS autovacuum 폭주로 CPU 100% 해결도 함께 보면 진단 프레임을 가져가기 좋습니다.
어디까지를 클라이언트로 남길 것인가: “UI 상태만” 남기는 규칙
RSC/Server Actions로 옮길 때 가장 흔한 실수는
- “기존 클라이언트 상태를 그대로 유지한 채”
- 서버 액션만 호출해서
- 결과를 다시 클라이언트 상태로 반영
하는 것입니다. 그러면 상태 동기화 비용이 남아 렌더링 폭주가 줄지 않습니다.
권장 규칙:
- 클라이언트 상태: 입력값, 모달 열림/닫힘, 탭 선택 등 순수 UI 상태
- 서버 상태: 리스트 데이터, 합계/집계, 권한, 가격 계산 등 일관성이 중요한 상태
- URL 상태: 검색어, 필터, 페이지 번호처럼 공유/북마크 가능한 상태
즉, “데이터는 서버, UI는 클라이언트, 공유 가능한 상태는 URL”로 나누면 폭주가 구조적으로 줄어듭니다.
디버깅 체크리스트: 폭주가 남아있을 때 보는 지점
1) 클라이언트 리렌더가 과도한가
- React DevTools Profiler로 특정 입력 이벤트에 렌더가 몇 번 발생하는지 확인
- 큰 리스트가 클라이언트 컴포넌트로 남아 있는지 확인
2) 서버 렌더가 과도한가
cache: 'no-store'남발 여부- 태그/경로 무효화가 너무 넓게 걸려 있는지(
revalidatePath('/')같은 형태) - 동일 쿼리 요청이 짧은 시간에 반복되는지(로그에서 확인)
3) Server Actions가 “쓰기-읽기 폭주”를 만들고 있나
- 액션 성공 후 클라이언트에서 별도
fetch로 재조회하고 있지 않은지 - 액션 내부에서 불필요하게 많은 경로를 무효화하지 않는지
4) 스트리밍 분리가 되어 있나
- 느린 컴포넌트가 상단 레이아웃/페이지 루트에 붙어 전체 TTFB를 늘리고 있지 않은지
Suspense경계가 적절한지
운영에서 로그가 폭주하며 디스크를 가득 채우는 문제처럼, “폭주를 유발하는 트리거를 제거하고, 경계를 쪼개고, 샘플링/레벨링을 적용”하는 방식이 효과적입니다. 비슷한 접근으로 리눅스 journald 로그 폭주로 디스크 꽉 찰 때 해결도 함께 참고할 만합니다.
마이그레이션 가이드: 안전하게 RSC/Actions로 옮기는 순서
- 읽기 화면부터 RSC로 전환
- 리스트/상세의 데이터 페치를 서버로 이동
- 클라이언트 컴포넌트는 입력/버튼 등 인터랙션만 남김
- URL 상태로 검색/필터를 승격
searchParams기반으로 서버 렌더 경계에서 데이터 변경
- 쓰기 작업을 Server Actions로 전환
- API Route 왕복 제거
revalidateTag또는revalidatePath로 일관성 확보
- 캐시 전략 확정
- 자주 바뀌는 데이터는 태그 무효화
- 거의 안 바뀌는 데이터는 TTL 캐시
- 스트리밍/Suspense로 느린 블록 격리
팀 단위로 이 작업을 진행할 때는 브랜치 충돌/리베이스 비용도 커지기 쉬운데, 장기 마이그레이션에서 충돌 비용을 줄이는 방법으로 Git rebase 충돌 최소화 - rerere 설정과 운영 팁 같은 운영 팁도 도움이 됩니다.
결론: “최적화”가 아니라 “경계 설계”가 답이다
Next.js에서 렌더링 폭주를 잡는 가장 확실한 방법은, 컴포넌트 단위의 미세 최적화보다
- 데이터는 RSC로
- 쓰기는 Server Actions로
- URL을 상태의 1급 시민으로
- 캐시/무효화를 데이터 단위로
라는 경계 설계를 먼저 세우는 것입니다. 이렇게 구조를 바꾸면, 타이핑/클릭 같은 잦은 상호작용이 있어도 클라이언트 렌더와 서버 렌더가 서로 증폭하지 않고, 성능과 운영 안정성이 함께 좋아집니다.