Published on

Next.js RSC에서 use client로 번들 폭증 잡기

Authors

서버 컴포넌트(React Server Components, RSC)를 쓰면 기본적으로 클라이언트로 내려가는 자바스크립트가 줄어들어야 합니다. 그런데 실제 프로젝트에서는 오히려 번들이 폭증하는 경우가 자주 생깁니다. 원인은 대부분 단순합니다. 서버에서 렌더링해도 되는 UI까지 클라이언트 컴포넌트로 승격되면서, 그 아래 트리 전체가 클라이언트 번들에 묶여 내려가 버리기 때문입니다.

이 글에서는 Next.js App Router 기준으로, use client를 “많이 붙이는 기술”이 아니라 정확한 경계를 만드는 도구로 사용해 번들 폭증을 해결하는 방법을 다룹니다.

왜 번들이 폭증하나: RSC와 use client의 전파 규칙

App Router에서 컴포넌트는 기본이 서버 컴포넌트입니다. 서버 컴포넌트는 브라우저로 JS를 보내지 않고, 서버에서 렌더된 결과(및 RSC payload)만 전달합니다.

하지만 파일 최상단에 use client가 있으면 해당 파일은 클라이언트 컴포넌트가 되고, 중요한 규칙이 생깁니다.

  • 클라이언트 컴포넌트가 import하는 컴포넌트는 전부 클라이언트 번들 후보가 됩니다.
  • 즉, 상단에서 use client가 “한 번” 붙으면, 그 아래로 import 체인이 길어질수록 번들이 커질 수 있습니다.

흔한 실수는 “페이지에서 인터랙션이 조금 있으니 페이지 전체를 클라이언트로 만들자”입니다. 이 순간부터 데이터 패칭/마크업/정적 섹션까지 전부 클라이언트로 내려가고, UI 라이브러리/차트/에디터 같은 무거운 의존성이 한 덩어리로 묶입니다.

증상 체크리스트: 이런 상황이면 use client 경계가 잘못됐다

  • 페이지 상단에 use client가 있고, 실제로는 버튼/모달/탭 정도만 인터랙션이 필요하다
  • react-hook-form, zod, chart.js, monaco-editor, framer-motion 같은 라이브러리가 “페이지 진입 시” 함께 내려온다
  • 서버에서 처리해도 되는 데이터 변환/포맷팅 로직이 클라이언트 번들에 포함된다
  • next build 후 번들 분석에서 특정 route의 First Load JS가 과도하게 크다

원칙 1: use client는 “잎(leaf)”에만 붙인다

가장 강력한 규칙은 간단합니다.

  • 페이지/레이아웃은 가능한 서버 컴포넌트로 유지
  • 클라이언트 상태가 필요한 작은 컴포넌트만 클라이언트로 분리

안 좋은 예: 페이지 전체를 클라이언트로

// app/products/page.tsx
'use client'

import { useState } from 'react'
import ProductTable from './ProductTable'
import Filters from './Filters'
import HeavyChart from './HeavyChart'

export default function ProductsPage() {
  const [q, setQ] = useState('')

  return (
    <div>
      <h1>Products</h1>
      <Filters q={q} onChange={setQ} />
      <ProductTable query={q} />
      <HeavyChart query={q} />
    </div>
  )
}

이 구조는 ProductTable, HeavyChart까지 클라이언트 번들로 끌고 갈 가능성이 큽니다. 특히 HeavyChart가 차트 라이브러리를 import하면 초기 로드가 바로 무거워집니다.

좋은 예: 서버 페이지 + 클라이언트 섬(island)

// app/products/page.tsx (Server Component)
import ProductTable from './ProductTable'
import FiltersClient from './FiltersClient'
import HeavyChartClient from './HeavyChartClient'

export default async function ProductsPage() {
  const initialQuery = ''
  const data = await fetch('https://example.com/api/products', { cache: 'no-store' }).then(r => r.json())

  return (
    <div>
      <h1>Products</h1>
      <FiltersClient initialQuery={initialQuery} />
      <ProductTable data={data} />
      <HeavyChartClient />
    </div>
  )
}
// app/products/FiltersClient.tsx
'use client'

import { useState } from 'react'

export default function FiltersClient({ initialQuery }: { initialQuery: string }) {
  const [q, setQ] = useState(initialQuery)

  return (
    <div>
      <label>
        Search
        <input value={q} onChange={(e) => setQ(e.target.value)} />
      </label>
    </div>
  )
}

핵심은 “상태가 필요한 부분만” 클라이언트로 만들고, 표 렌더링/데이터 패칭은 서버에서 처리하는 겁니다.

원칙 2: 무거운 의존성은 경계 밖으로 새지 않게 ‘격리’한다

번들 폭증의 직접적인 트리거는 보통 무거운 라이브러리입니다. 그런데 문제는 라이브러리 자체보다도 import 위치입니다.

  • 서버 컴포넌트에서 무거운 라이브러리를 import하면 서버 번들에만 포함될 수 있음
  • 클라이언트 컴포넌트에서 import하면 클라이언트 번들로 포함될 수 있음

따라서 “차트/에디터/애니메이션” 같은 것은 다음 중 하나로 통제합니다.

  1. 정말 필요한 페이지에서만 클라이언트 dynamic import
  2. 클라이언트 컴포넌트라도 더 작은 단위로 쪼개서 import 범위를 최소화

dynamic import로 초기 번들에서 분리

// app/products/HeavyChartClient.tsx
'use client'

import dynamic from 'next/dynamic'

const HeavyChart = dynamic(() => import('./HeavyChart'), {
  ssr: false,
  loading: () => <div>Loading chart...</div>,
})

export default function HeavyChartClient() {
  return <HeavyChart />
}
// app/products/HeavyChart.tsx
'use client'

import { useMemo } from 'react'
import Chart from 'chart.js/auto'

export default function HeavyChart() {
  const config = useMemo(() => ({ /* ... */ }), [])
  return <canvas data-config={JSON.stringify(config)} />
}

이 패턴은 “차트가 화면에 꼭 필요하지 않거나, 아래쪽에 위치하거나, 탭에서만 열리는” UI에서 특히 효과적입니다.

원칙 3: 서버에서 가능한 계산은 서버로 올린다

클라이언트 컴포넌트가 많아지면 번들 크기뿐 아니라 실행 비용도 커집니다. 특히 리스트 페이지에서 아래가 흔합니다.

  • 날짜/통화 포맷팅
  • 정렬/그룹핑
  • 마크다운 렌더링
  • 권한/플래그 기반 분기

이 중 상당수는 서버에서 처리해 HTML로 내려도 됩니다.

예: 포맷팅을 서버에서 처리

// app/orders/OrderRow.tsx (Server Component)
export function OrderRow({ order }: { order: { id: string; total: number } }) {
  const totalText = new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(order.total)

  return (
    <tr>
      <td>{order.id}</td>
      <td>{totalText}</td>
    </tr>
  )
}

여기서 실수는 use client가 붙은 테이블 컴포넌트 안에서 포맷팅을 하는 것입니다. 그러면 Intl 자체는 문제 없더라도, 다른 의존성까지 함께 딸려 내려가며 전체가 클라이언트가 됩니다.

원칙 4: “클라이언트에서 서버 컴포넌트를 import”하려 하지 않는다

개념적으로도, 구현적으로도 안전한 방향은 다음입니다.

  • 서버 컴포넌트가 클라이언트 컴포넌트를 자식으로 렌더링한다
  • 클라이언트 컴포넌트는 서버 컴포넌트를 import하지 않는다

대신 서버 컴포넌트를 children이나 props로 “전달”받는 방식이 가능합니다.

슬롯 패턴으로 경계 유지

// app/settings/page.tsx (Server Component)
import SettingsTabsClient from './SettingsTabsClient'
import ProfilePanel from './panels/ProfilePanel'
import BillingPanel from './panels/BillingPanel'

export default function SettingsPage() {
  return (
    <SettingsTabsClient
      profile={<ProfilePanel />}
      billing={<BillingPanel />}
    />
  )
}
// app/settings/SettingsTabsClient.tsx
'use client'

import { useState } from 'react'

export default function SettingsTabsClient({
  profile,
  billing,
}: {
  profile: React.ReactNode
  billing: React.ReactNode
}) {
  const [tab, setTab] = useState<'profile' | 'billing'>('profile')

  return (
    <div>
      <nav>
        <button onClick={() => setTab('profile')}>Profile</button>
        <button onClick={() => setTab('billing')}>Billing</button>
      </nav>
      <section>{tab === 'profile' ? profile : billing}</section>
    </div>
  )
}

이렇게 하면 탭 UI만 클라이언트로 유지하면서, 패널 내용은 서버 컴포넌트로 둘 수 있습니다.

원칙 5: Server Actions로 폼 제출을 클라이언트 JS 없이 끝내기

폼 때문에 페이지를 통째로 클라이언트로 만드는 경우가 많습니다. Next.js에서는 Server Actions로 상당 부분을 서버로 되돌릴 수 있습니다.

// app/profile/actions.ts
'use server'

export async function updateProfile(formData: FormData) {
  const name = String(formData.get('name') || '')
  // DB 업데이트 등
  return { ok: true }
}
// app/profile/page.tsx (Server Component)
import { updateProfile } from './actions'

export default function ProfilePage() {
  return (
    <form action={updateProfile}>
      <label>
        Name
        <input name="name" />
      </label>
      <button type="submit">Save</button>
    </form>
  )
}

이 패턴이 항상 정답은 아니지만, “간단한 제출과 리다이렉트/리밸리데이션” 정도라면 클라이언트 상태/라이브러리 없이도 충분히 동작해 번들을 크게 줄일 수 있습니다.

실전 디버깅: 어디서 번들이 새는지 찾는 방법

1) route별 First Load JS 확인

  • next build 후 출력되는 route 테이블에서 특정 페이지의 JS가 유난히 큰지 확인합니다.

2) 번들 분석기 사용

npm i -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({
  experimental: {
    // 프로젝트 상황에 맞게
  },
})
ANALYZE=true npm run build

큰 덩어리로 들어온 라이브러리를 찾은 다음, 해당 라이브러리를 import하는 컴포넌트가 use client 트리 아래에 있는지 역추적합니다.

3) “상단 use client” 파일부터 의심

경험적으로 번들 폭증의 80%는 다음 중 하나입니다.

  • app/.../page.tsxuse client가 붙어 있음
  • layout.tsxuse client가 붙어 전체 앱이 클라이언트가 됨
  • 공용 components/index.ts 배럴 파일이 무심코 클라이언트 컴포넌트를 re-export하면서 경계를 흐림

특히 배럴 export는 “어디서 무엇이 import되는지”를 흐리게 만들어, 실수로 클라이언트 컴포넌트를 상위에서 잡아당기기 쉽습니다.

팀 운영 팁: use client를 규칙으로 관리하기

  • use client 파일은 디렉터리로 격리한다. 예: components/client/*
  • 페이지/레이아웃에는 원칙적으로 use client 금지(정말 필요할 때만 예외)
  • 무거운 라이브러리는 반드시 “클라이언트 전용 래퍼”에서만 import
  • PR 리뷰 체크리스트에 “use client 추가로 인해 import 체인이 확장되는가”를 포함

CI에서 캐시가 잘못되면 번들 분석 결과 추적이 어려워질 수 있습니다. 빌드/분석이 자꾸 흔들린다면 캐시 함정을 먼저 정리해 두는 것도 도움이 됩니다: GitHub Actions 캐시가 안 먹을 때 터지는 7가지 함정

정리: 번들 폭증을 막는 use client 체크리스트

  • use client는 페이지가 아니라 “상태가 필요한 최소 컴포넌트”에만 붙인다
  • 클라이언트 컴포넌트가 import하는 것들은 전부 클라이언트로 전파될 수 있음을 전제한다
  • 무거운 의존성은 dynamic import로 지연 로드하거나, 더 작은 클라이언트 섬으로 격리한다
  • 서버에서 가능한 계산/포맷팅/렌더링은 서버로 올린다
  • 폼은 Server Actions로 대체해 클라이언트 JS 의존을 줄인다

RSC의 성능 이점은 “자동으로” 오지 않습니다. use client 경계를 어떻게 설계하느냐가 곧 번들 크기, 초기 로드 성능, 그리고 유지보수성으로 직결됩니다. 위 원칙대로 트리를 다시 쪼개면, 대개는 큰 리팩터링 없이도 First Load JS를 눈에 띄게 줄일 수 있습니다.