Published on

Next.js RSC·Server Actions로 렌더링 폭주 잡기

Authors

렌더링 폭주(render storm)는 대개 “상태 변경 1번”이 “컴포넌트 트리 전체 재렌더링 + 연쇄 데이터 재요청 + 중복 네트워크”로 증폭될 때 발생합니다. Next.js App Router 환경에서는 이를 RSC(React Server Components)Server Actions 로 구조적으로 끊어낼 수 있습니다. 핵심은 간단합니다.

  • 상태와 데이터의 경계를 서버로 옮기고
  • 클라이언트는 상호작용(UI 상태)만 최소화하며
  • 서버에서 캐시·중복 제거·동시성 제어를 수행하는 것

이 글에서는 “왜 폭주가 생기는지”를 먼저 해부하고, RSC/Server Actions로 옮겨가며 어떤 지점에서 렌더링이 줄어드는지, 운영에서 다시 폭주하지 않게 만드는 체크리스트까지 정리합니다.

렌더링 폭주의 전형적인 패턴

App Router 이전(또는 Pages Router/CSR 중심)에서 흔한 구조는 다음과 같습니다.

  1. 클라이언트 컴포넌트가 useEffect 로 데이터 페치
  2. 입력/필터/정렬 상태가 바뀔 때마다 setState
  3. useEffect 의 deps가 바뀌어 재요청
  4. 자식 컴포넌트까지 연쇄 재렌더
  5. 로딩 스피너/스켈레톤 토글까지 합쳐져 렌더 횟수 폭증

특히 테이블/리스트/대시보드에서 “검색어 타이핑” “필터 클릭” “페이지 이동” 같은 이벤트가 많으면, 프레임 드랍과 함께 백엔드에도 부하가 전파됩니다.

이 문제를 해결하는 관점은 렌더링을 줄이는 최적화가 아니라, 렌더링이 폭주할 수 없는 구조로 바꾸는 것입니다.

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 상태를 바꾸어 서버 렌더 경계에서 처리됩니다.
  • useDeferredValueuseTransition 을 조합하면 “타이핑 즉시 반응”과 “데이터 갱신은 천천히”를 동시에 만족시켜 렌더링 압력을 낮춥니다.

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로 옮겼는데도 “서버 렌더가 너무 많이 돈다”는 문제가 생길 수 있습니다. 이때는 대개 캐시 전략이 불명확합니다.

  • 모든 fetchcache: '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로 옮기는 순서

  1. 읽기 화면부터 RSC로 전환
    • 리스트/상세의 데이터 페치를 서버로 이동
    • 클라이언트 컴포넌트는 입력/버튼 등 인터랙션만 남김
  2. URL 상태로 검색/필터를 승격
    • searchParams 기반으로 서버 렌더 경계에서 데이터 변경
  3. 쓰기 작업을 Server Actions로 전환
    • API Route 왕복 제거
    • revalidateTag 또는 revalidatePath 로 일관성 확보
  4. 캐시 전략 확정
    • 자주 바뀌는 데이터는 태그 무효화
    • 거의 안 바뀌는 데이터는 TTL 캐시
  5. 스트리밍/Suspense로 느린 블록 격리

팀 단위로 이 작업을 진행할 때는 브랜치 충돌/리베이스 비용도 커지기 쉬운데, 장기 마이그레이션에서 충돌 비용을 줄이는 방법으로 Git rebase 충돌 최소화 - rerere 설정과 운영 팁 같은 운영 팁도 도움이 됩니다.

결론: “최적화”가 아니라 “경계 설계”가 답이다

Next.js에서 렌더링 폭주를 잡는 가장 확실한 방법은, 컴포넌트 단위의 미세 최적화보다

  • 데이터는 RSC로
  • 쓰기는 Server Actions로
  • URL을 상태의 1급 시민으로
  • 캐시/무효화를 데이터 단위로

라는 경계 설계를 먼저 세우는 것입니다. 이렇게 구조를 바꾸면, 타이핑/클릭 같은 잦은 상호작용이 있어도 클라이언트 렌더와 서버 렌더가 서로 증폭하지 않고, 성능과 운영 안정성이 함께 좋아집니다.