Published on

Next.js 14 서버 컴포넌트와 클라 상태 분리 튜닝

Authors

Next.js 14(App Router)의 성능 튜닝에서 가장 큰 레버는 서버 컴포넌트(RSC)와 클라이언트 상태를 얼마나 깔끔하게 분리하느냐입니다. 같은 화면이라도 어떤 로직을 서버로 올리고, 어떤 상호작용만 클라이언트로 남기느냐에 따라 JS 번들 크기, 초기 렌더링 시간, LCP, TTI가 크게 달라집니다.

이 글은 “서버에서 가능한 한 많이 렌더링하되, 클라이언트는 상태와 이벤트 처리만 담당”하는 관점에서 Next.js 14 성능 튜닝을 정리합니다.

문제의 본질: 상태가 섞이면 번들이 커진다

App Router에서 기본은 서버 컴포넌트입니다. 그런데 아래 중 하나가 섞이는 순간 해당 컴포넌트는 클라이언트로 전환되거나(혹은 클라이언트 하위 트리에 포함되면서) 번들이 커집니다.

  • useState, useEffect, useReducer 같은 훅
  • 브라우저 전용 API 접근(window, document, localStorage)
  • 이벤트 핸들러(onClick 등)
  • 클라이언트 상태 라이브러리(예: Zustand, Redux)

핵심은 간단합니다.

  • 데이터 패칭/가공/HTML 생성은 서버 컴포넌트에서
  • 상호작용/로컬 상태/낙관적 UI만 클라이언트 컴포넌트에서

이 경계를 잘못 잡으면 “서버에서 렌더링 가능한 정적 UI까지” 전부 클라이언트 번들로 떨어져 LCP가 악화됩니다. LCP 관점의 병목을 추적하는 방법은 이 글도 함께 보면 좋습니다: Chrome LCP 느림? Render‑Blocking 리소스 추적법

튜닝 목표 체크리스트

성능 튜닝을 할 때는 아래 목표를 동시에 만족시키는 방향으로 설계합니다.

  1. 클라이언트 번들 최소화: 클라이언트 컴포넌트는 “상호작용이 필요한 최소 단위”로 쪼갠다.
  2. 서버 캐시 최대 활용: fetch 캐시, revalidate, 태그 기반 무효화로 서버 렌더 비용을 줄인다.
  3. 스트리밍으로 체감 속도 개선: 느린 데이터는 Suspense로 분리해 먼저 그릴 수 있는 것부터 그린다.
  4. 서버 액션으로 왕복 단순화: API 라우트 남발 대신 서버 액션으로 폼/뮤테이션 경로를 단순화한다.

패턴 1: “페이지는 서버, 위젯만 클라이언트”로 쪼개기

가장 흔한 실수는 페이지 상단에서 use client를 선언해버리는 것입니다. 그러면 페이지 전체가 클라이언트 번들에 포함됩니다.

나쁜 예: 페이지 전체 클라이언트화

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

import { useEffect, useState } from 'react'

export default function ProductsPage() {
  const [items, setItems] = useState<any[]>([])

  useEffect(() => {
    fetch('/api/products').then(r => r.json()).then(setItems)
  }, [])

  return (
    <main>
      <h1>Products</h1>
      {items.map(p => (
        <div key={p.id}>{p.name}</div>
      ))}
    </main>
  )
}

이 코드는 다음을 동시에 망칩니다.

  • 초기 HTML에 상품 목록이 없어서 LCP가 늦어짐
  • 클라이언트에서 데이터 패칭 + 렌더링 비용 발생
  • 페이지 전체가 JS 의존

좋은 예: 목록은 서버에서, 필터/정렬만 클라이언트에서

// app/products/page.tsx (Server Component)
import ProductsList from './products-list'
import ProductsToolbar from './products-toolbar'

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string; sort?: string }>
}) {
  const { q, sort } = await searchParams

  return (
    <main>
      <h1>Products</h1>
      <ProductsToolbar initialQ={q ?? ''} initialSort={sort ?? 'popular'} />
      <ProductsList q={q ?? ''} sort={sort ?? 'popular'} />
    </main>
  )
}
// app/products/products-list.tsx (Server Component)
export default async function ProductsList({ q, sort }: { q: string; sort: string }) {
  const res = await fetch(
    `${process.env.API_BASE_URL}/products?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`,
    {
      // 캐시 전략은 아래에서 자세히 다룸
      next: { revalidate: 60 },
    }
  )

  if (!res.ok) throw new Error('Failed to fetch products')
  const items = (await res.json()) as Array<{ id: string; name: string }>

  return (
    <section>
      {items.map(p => (
        <article key={p.id}>{p.name}</article>
      ))}
    </section>
  )
}
// app/products/products-toolbar.tsx (Client Component)
'use client'

import { useRouter, useSearchParams } from 'next/navigation'
import { useTransition, useState } from 'react'

export default function ProductsToolbar({
  initialQ,
  initialSort,
}: {
  initialQ: string
  initialSort: string
}) {
  const router = useRouter()
  const sp = useSearchParams()
  const [isPending, startTransition] = useTransition()
  const [q, setQ] = useState(initialQ)
  const [sort, setSort] = useState(initialSort)

  const apply = () => {
    const params = new URLSearchParams(sp.toString())
    params.set('q', q)
    params.set('sort', sort)

    startTransition(() => {
      router.replace(`?${params.toString()}`)
    })
  }

  return (
    <div>
      <input value={q} onChange={e => setQ(e.target.value)} placeholder="Search" />
      <select value={sort} onChange={e => setSort(e.target.value)}>
        <option value="popular">Popular</option>
        <option value="price_asc">Price asc</option>
      </select>
      <button onClick={apply} disabled={isPending}>
        Apply
      </button>
    </div>
  )
}

이 구조의 장점은 명확합니다.

  • 목록은 서버에서 렌더되어 초기 HTML에 포함
  • 클라이언트는 툴바만 하이드레이션(번들 감소)
  • URL 기반 상태(searchParams)로 서버 렌더와 동기화

패턴 2: RSC 경계는 “데이터 단위”로 자른다

클라이언트 컴포넌트는 서버 컴포넌트를 import 할 수 없습니다. 따라서 경계를 자를 때는 보통 아래 규칙이 안전합니다.

  • 서버: 데이터 패칭, 권한 체크, 포맷팅, 리스트 렌더
  • 클라이언트: 클릭/입력/토글/드래그 같은 상호작용, 로컬 UI 상태

경계를 자르는 기준을 “컴포넌트 크기”로 잡으면 실패하기 쉽고, “데이터 의존성”으로 잡으면 튜닝이 쉬워집니다.

예를 들어 상품 카드에서 “좋아요 토글”만 클라이언트로 남기고, 카드 자체는 서버에서 그리게 할 수 있습니다.

// app/products/product-card.tsx (Server Component)
import LikeButton from './like-button'

export default function ProductCard({
  product,
}: {
  product: { id: string; name: string; liked: boolean }
}) {
  return (
    <article>
      <h3>{product.name}</h3>
      <LikeButton productId={product.id} initialLiked={product.liked} />
    </article>
  )
}
// app/products/like-button.tsx (Client Component)
'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from './like-actions'

export default function LikeButton({
  productId,
  initialLiked,
}: {
  productId: string
  initialLiked: boolean
}) {
  const [isPending, startTransition] = useTransition()
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(initialLiked)

  const onToggle = () => {
    startTransition(async () => {
      setOptimisticLiked(v => !v)
      await toggleLike({ productId })
    })
  }

  return (
    <button onClick={onToggle} disabled={isPending}>
      {optimisticLiked ? 'Liked' : 'Like'}
    </button>
  )
}
// app/products/like-actions.ts (Server Action)
'use server'

import { revalidateTag } from 'next/cache'

export async function toggleLike({ productId }: { productId: string }) {
  // DB 업데이트 등
  // await db.likes.toggle({ productId, userId })

  // 관련 RSC 캐시 무효화
  revalidateTag(`product:${productId}`)
}

여기서 포인트는 “좋아요”는 상호작용이니 클라이언트에 두되, 데이터 일관성은 서버 액션과 revalidateTag로 맞추는 것입니다.

패턴 3: 캐시 전략을 명시해 서버 렌더 비용 줄이기

서버 컴포넌트를 잘 써도 매 요청마다 DB/외부 API를 두드리면 TTFB가 흔들립니다. Next.js의 데이터 캐시를 적극적으로 써야 합니다.

fetch 캐시 기본기

  • cache: 'no-store': 매번 새로 가져옴(대시보드, 개인화 강함)
  • next: { revalidate: N }: N초 동안 캐시 후 재검증(목록/상세에 흔함)
  • next: { tags: [...] }: 태그 기반 무효화(서버 액션과 궁합 좋음)
// app/products/products-list.tsx
export default async function ProductsList({ q, sort }: { q: string; sort: string }) {
  const res = await fetch(`${process.env.API_BASE_URL}/products?q=${q}&sort=${sort}`, {
    next: {
      revalidate: 120,
      tags: ['products'],
    },
  })

  const items = await res.json()
  return <pre>{JSON.stringify(items, null, 2)}</pre>
}

태그 무효화로 “수정 즉시 반영” 만들기

// app/products/admin-actions.ts
'use server'

import { revalidateTag } from 'next/cache'

export async function updateProductName({ productId, name }: { productId: string; name: string }) {
  // await db.products.update(...)
  revalidateTag('products')
  revalidateTag(`product:${productId}`)
}

이 방식은 “짧은 revalidate로 버티기”보다 비용이 낮고, 변경 반영도 즉시라 운영 난이도가 내려갑니다.

패턴 4: 스트리밍과 Suspense로 느린 조각 격리

페이지에 느린 위젯(추천, 리뷰, 연관 상품)이 섞여 있으면 전체가 늦어집니다. 서버 컴포넌트는 스트리밍이 가능하므로 느린 부분을 Suspense로 감싸 분리하세요.

// app/products/[id]/page.tsx (Server Component)
import { Suspense } from 'react'
import ProductMain from './product-main'
import ProductRecommendations from './product-recommendations'

export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params

  return (
    <main>
      <ProductMain id={id} />

      <Suspense fallback={<div>Loading recommendations...</div>}>
        <ProductRecommendations id={id} />
      </Suspense>
    </main>
  )
}

이렇게 하면 메인 콘텐츠의 LCP 후보가 추천 영역에 발목 잡히는 상황을 줄일 수 있습니다.

패턴 5: 클라이언트 상태는 “전역”보다 “국소”로

클라이언트 상태 라이브러리를 도입하면 편하지만, 전역 스토어가 커질수록 다음 문제가 생깁니다.

  • 구독 범위가 넓어 리렌더가 늘어남
  • 직렬화/초기화 비용이 커짐
  • 서버 컴포넌트의 장점(무JS 초기 렌더)을 잠식

권장 순서는 아래처럼 잡는 것이 안전합니다.

  1. URL 상태(searchParams)로 서버 렌더와 동기화
  2. 컴포넌트 로컬 상태(useState)로 UI 토글/입력 처리
  3. 정말 공유가 필요할 때만 Zustand 같은 경량 스토어를 클라이언트 섬 내부에 제한적으로 사용

예: 장바구니 아이콘 같은 “헤더 전역 UI”는 클라이언트 스토어가 필요할 수 있지만, 상품 목록 필터까지 전역으로 올리는 건 대개 손해입니다.

패턴 6: 서버 액션으로 API 라우트 왕복을 줄이되, 경계는 지킨다

서버 액션은 폼 제출/뮤테이션을 단순화합니다. 다만 “서버 액션을 호출하려고 상위 컴포넌트를 클라이언트로 바꾸는” 실수가 많습니다.

가장 안전한 형태는 다음입니다.

  • 서버 컴포넌트에서 form action={serverAction}을 사용
  • 입력 UI만 클라이언트로 만들고, 제출은 서버 액션으로
// app/profile/page.tsx (Server Component)
import { updateProfile } from './profile-actions'

export default async function ProfilePage() {
  return (
    <form action={updateProfile}>
      <label>
        Name
        <input name="name" />
      </label>
      <button type="submit">Save</button>
    </form>
  )
}
// app/profile/profile-actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function updateProfile(formData: FormData) {
  const name = String(formData.get('name') ?? '')
  // await db.user.update(...)
  revalidatePath('/profile')
}

이 패턴은 클라이언트 JS 의존을 늘리지 않으면서도 UX를 유지할 수 있습니다.

실전 디버깅 포인트: “왜 이게 클라이언트 번들에 들어갔지?”

분리를 했는데도 번들이 커진다면 보통 원인은 아래 중 하나입니다.

  • 서버 컴포넌트가 클라이언트 컴포넌트를 상위에서 감싸는 구조(경계가 너무 위)
  • 클라이언트 컴포넌트에 불필요한 UI/리스트 렌더가 포함됨
  • 날짜/통화 포맷 같은 유틸이 클라이언트로 끌려옴(서버에서 처리 가능)
  • 외부 라이브러리(차트, 에디터)가 생각보다 무거움

빠른 점검 방법

  • 클라이언트 컴포넌트 파일에서 “정말 필요한 코드만 남기기”
  • 무거운 라이브러리는 동적 로딩 + 클라이언트 섬에 격리
// app/analytics/chart-widget.tsx
'use client'

import dynamic from 'next/dynamic'

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

export default function ChartWidget() {
  return <HeavyChart />
}

이때도 중요한 건 “차트가 필요한 영역만” 클라이언트화하는 것입니다.

렌더링 최적화까지 같이 잡기: CSS로 페인트 비용 낮추기

RSC/상태 분리로 JS를 줄였는데도 스크롤/페인트가 무겁다면, 이제는 렌더링 파이프라인 최적화 영역입니다. 리스트/카드가 많은 화면에서는 content-visibility 같은 속성이 체감 성능에 크게 기여합니다.

관련해서는 이 글을 같이 참고하면 연결이 잘 됩니다: CSS contain·content-visibility로 렌더링 최적화

권장 아키텍처 요약

  • 페이지/레이아웃은 기본적으로 서버 컴포넌트 유지
  • 데이터 패칭은 서버에서, 상호작용만 클라이언트 컴포넌트로 최소화
  • 상태는 URL 기반으로 먼저 해결하고, 전역 스토어는 최후에 도입
  • 느린 조각은 Suspense로 격리해 스트리밍 이점을 살림
  • 캐시는 revalidatetags로 “싸고 빠르게” 운영

마무리

Next.js 14에서 성능 튜닝은 결국 클라이언트로 내려가는 JS를 줄이고, 서버에서 만들어낼 수 있는 HTML을 최대한 활용하는 게임입니다. 서버 컴포넌트와 클라이언트 상태를 분리하는 순간, 번들 크기뿐 아니라 LCP/TTFB/체감 반응성이 함께 좋아집니다.

다음 단계로는 실제 사용자 환경에서 LCP 병목을 추적해 “어떤 리소스가 렌더를 막는지” 확인하는 것이 좋습니다. 위에서 언급한 Chrome LCP 느림? Render‑Blocking 리소스 추적법과 함께 보면, RSC 분리 튜닝의 효과를 수치로 검증하는 데 도움이 됩니다.