Next.js RSC와 PPR: 서버 부하 관점에서 본 성능 분석

조주영·2025년 7월 6일

Next.js RSC와 PPR: 서버 부하 관점에서 본 성능 분석

들어가며

Next.js App Router의 React Server Components(RSC)는 혁신적인 기술이지만, 서버 부하 측면에서는 양날의 검과 같습니다. 새로운 기능을 제공하는 동시에 서버에 더 많은 작업을 요구하기 때문입니다. 이 글에서는 RSC와 PPR이 서버 부하에 미치는 영향을 심도 있게 분석하고, 실제 프로덕션 환경에서의 최적화 전략을 제시해보겠습니다.


1. RSC의 서버 부하 프로파일

전통적인 SSR vs RSC: 서버 작업량 비교

기존 SSR과 RSC의 서버 작업을 비교해보겠습니다:

전통적인 SSR 워크플로우:
┌─────────────────────────────────────────┐
│ 사용자 요청                              │
│    ↓                                   │
│ React 컴포넌트 실행 (10ms)               │
│    ↓                                   │
│ Virtual DOM → HTML 변환 (5ms)           │
│    ↓                                   │
│ HTML 문자열 전송 (2ms)                   │
│                                        │
│ 총 서버 작업: 17ms                       │
│ 메모리 사용: 중간                        │
└─────────────────────────────────────────┘

RSC 워크플로우:
┌─────────────────────────────────────────┐
│ 사용자 요청                              │
│    ↓                                   │
│ 서버 컴포넌트 실행 + DB 쿼리 (15ms)       │
│    ↓                                   │
│ 복잡한 Fiber Tree 생성 (12ms)           │
│    ↓                                   │
│ RSC Payload 직렬화 (8ms)                │
│    ↓                                   │
│ JSON 데이터 전송 (3ms)                   │
│                                        │
│ 총 서버 작업: 38ms (2.2배 증가)          │
│ 메모리 사용: 높음                        │
└─────────────────────────────────────────┘

RSC가 서버 부하를 증가시키는 요인들

1. 복잡한 Fiber Tree 생성

// 서버에서 실행되는 무거운 컴포넌트
async function ProductCatalog() {
  const categories = await db.categories.findMany()
  const products = await db.products.findMany({ take: 500 })
  const reviews = await db.reviews.findMany()
  
  return (
    <div>
      {categories.map(category => (
        <CategorySection key={category.id}>
          <CategoryHeader category={category} />
          {products
            .filter(p => p.categoryId === category.id)
            .map(product => (
              <ProductCard key={product.id} product={product}>
                <ProductInfo product={product} />
                <ReviewSummary 
                  reviews={reviews.filter(r => r.productId === product.id)} 
                />
                <AddToCartButton productId={product.id} />
              </ProductCard>
            ))}
        </CategorySection>
      ))}
    </div>
  )
}

서버 부하 분석:

  • 메모리: 500개 제품 × 평균 10개 리뷰 = 5,000개 이상의 Fiber Node
  • CPU: 복잡한 중첩 구조의 Tree 생성 및 순회
  • 직렬화: 대용량 JSON Payload (수 MB)

2. 데이터베이스 쿼리 집중화

// 문제: N+1 쿼리 발생
async function UserDashboard() {
  const users = await db.users.findMany({ take: 100 })
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id}>
          <UserProfile userId={user.id} />  {/* DB 쿼리 */}
          <UserPosts userId={user.id} />    {/* DB 쿼리 */}
          <UserStats userId={user.id} />    {/* DB 쿼리 */}
        </UserCard>
      ))}
    </div>
  )
}

// 각 하위 컴포넌트에서 개별 쿼리 실행
async function UserProfile({ userId }) {
  const profile = await db.profiles.findUnique({ where: { userId } })
  return <div>{profile.name}</div>
}

async function UserPosts({ userId }) {
  const posts = await db.posts.findMany({ where: { userId } })
  return <div>{posts.length} posts</div>
}

결과: 100명 사용자 × 3개 쿼리 = 301개 데이터베이스 쿼리!

3. RSC Payload 직렬화 비용

// 대용량 데이터의 직렬화 과정
const largeDataStructure = {
  users: Array(1000).fill().map((_, i) => ({
    id: i,
    name: `User ${i}`,
    posts: Array(10).fill().map((_, j) => ({
      id: j,
      title: `Post ${j}`,
      content: "Lorem ipsum...".repeat(100), // 큰 텍스트
      comments: Array(5).fill().map((_, k) => ({
        id: k,
        text: "Comment...".repeat(20)
      }))
    }))
  }))
}

// JSON.stringify(largeDataStructure) 
// → 수십 MB의 JSON 문자열 생성
// → 높은 CPU 사용량과 메모리 점유

RSC의 서버 부하 절감 요인들

하지만 RSC는 다른 부분에서 서버 부하를 줄이기도 합니다:

1. 클라이언트 번들 크기 감소

// 서버 컴포넌트는 클라이언트로 전송되지 않음
async function ServerOnlyComponent() {
  // 이 코드들은 클라이언트 번들에 포함되지 않음
  const heavyLibrary = await import('heavy-data-processing-lib') // 10MB
  const secretConfig = process.env.SECRET_DATABASE_URL
  const complexCalculation = await performHeavyComputation()
  
  return <DisplayResults data={complexCalculation} />
}

// 결과: 클라이언트 번들 크기 감소 → 초기 로딩 빨라짐

2. 하이드레이션 부하 제거

// 전통적인 SSR: 클라이언트에서 하이드레이션 필요
function TraditionalComponent() {
  const [data, setData] = useState(null)
  
  useEffect(() => {
    // 클라이언트에서 다시 데이터 fetching
    fetchData().then(setData)
  }, [])
  
  return <div>{data ? <DisplayData data={data} /> : 'Loading...'}</div>
}

// RSC: 하이드레이션 없이 바로 렌더링
async function ServerComponent() {
  const data = await fetchData() // 서버에서 한 번만
  return <DisplayData data={data} />
}

2. PPR: 서버 부하의 게임 체인저

현재 App Router의 서버 부하 문제

// 현재: 하나라도 동적이면 전체가 매번 서버에서 렌더링
export default function BlogPost() {
  return (
    <article>
      <Header />           {/* 정적, 하지만 매번 렌더링 */}
      <Navigation />       {/* 정적, 하지만 매번 렌더링 */}
      <BlogContent />      {/* 정적, 하지만 매번 렌더링 */}
      <AuthorInfo />       {/* 정적, 하지만 매번 렌더링 */}
      <RelatedPosts />     {/* 정적, 하지만 매번 렌더링 */}
      <Comments />         {/* 정적, 하지만 매번 렌더링 */}
      <Sidebar />          {/* 정적, 하지만 매번 렌더링 */}
      <Footer />           {/* 정적, 하지만 매번 렌더링 */}
      <CurrentTime />      {/* 🔴 동적! 이것 때문에 전체가 SSR */}
    </article>
  )
}

서버 부하 현황:

요청별 서버 작업:
- Header 렌더링: 5ms
- Navigation 렌더링: 8ms  
- BlogContent 렌더링: 15ms
- AuthorInfo 렌더링: 3ms
- RelatedPosts 렌더링: 12ms
- Comments 렌더링: 7ms
- Sidebar 렌더링: 6ms
- Footer 렌더링: 4ms
- CurrentTime 렌더링: 1ms
─────────────────────────
총 서버 작업: 61ms

1000 RPS (초당 요청) 시:
61ms × 1000 = 61초 분의 CPU 시간 소모!

PPR의 서버 부하 최적화

// PPR 적용: 컴포넌트별 최적화
export default function BlogPost() {
  return (
    <article>
      {/* ✅ 빌드 시 한 번만 생성, CDN에서 서빙 */}
      <Header />
      <Navigation />
      <BlogContent />
      <AuthorInfo />
      <RelatedPosts />
      <Comments />
      <Sidebar />
      <Footer />
      
      {/* 🔄 런타임에만 서버에서 처리 */}
      <Suspense fallback={<TimeLoader />}>
        <CurrentTime />
      </Suspense>
    </article>
  )
}

PPR 적용 후 서버 부하:

빌드 시 (1회만):
- 정적 컴포넌트들 HTML 생성: 60ms × 1회 = 60ms

런타임 (요청별):
- CurrentTime 렌더링: 1ms
- 정적 HTML 서빙: 0ms (CDN)
─────────────────────────
총 서버 작업: 1ms/요청

1000 RPS 시:
1ms × 1000 = 1초 분의 CPU 시간 (61배 감소!)

PPR의 실제 성능 개선 시나리오

시나리오 1: 전자상거래 상품 페이지

// PPR 적용 전
async function ProductPage({ productId }) {
  const product = await db.products.findUnique({ where: { id: productId } })
  const reviews = await db.reviews.findMany({ where: { productId } })
  const recommendations = await getRecommendations(productId)
  
  return (
    <div>
      <ProductHeader />        {/* 매번 렌더링 */}
      <BreadcrumbNav />        {/* 매번 렌더링 */}
      <ProductImages product={product} />  {/* 매번 렌더링 */}
      <ProductInfo product={product} />    {/* 매번 렌더링 */}
      <ReviewList reviews={reviews} />     {/* 매번 렌더링 */}
      <Recommendations items={recommendations} />  {/* 매번 렌더링 */}
      <Footer />               {/* 매번 렌더링 */}
      <LiveChatButton />       {/* 🔴 동적 - 사용자별 상태 */}
    </div>
  )
}

// 서버 부하: 모든 컴포넌트 + DB 쿼리를 매번 실행
// PPR 적용 후
async function ProductPage({ productId }) {
  return (
    <div>
      {/* ✅ 빌드 시 생성 - 모든 상품에 공통 */}
      <ProductHeader />
      <BreadcrumbNav />
      <Footer />
      
      {/* 🔄 상품별로 다름 - 하지만 캐시 가능 */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductContent productId={productId} />
      </Suspense>
      
      {/* 🔄 사용자별로 다름 */}
      <Suspense fallback={<ChatSkeleton />}>
        <LiveChatButton />
      </Suspense>
    </div>
  )
}

// 서버 부하: 동적 부분만 처리 + 적극적 캐싱

시나리오 2: 대시보드 애플리케이션

// 사용자별 대시보드
async function Dashboard({ userId }) {
  return (
    <div className="dashboard">
      {/* ✅ 모든 사용자에게 동일 - 빌드 시 생성 */}
      <DashboardHeader />
      <SidebarNavigation />
      <HelpSection />
      
      {/* 🔄 사용자별 데이터 - 런타임 처리 */}
      <Suspense fallback={<UserSkeleton />}>
        <UserProfile userId={userId} />
      </Suspense>
      
      <Suspense fallback={<AnalyticsSkeleton />}>
        <AnalyticsDashboard userId={userId} />
      </Suspense>
      
      <Suspense fallback={<NotificationSkeleton />}>
        <NotificationCenter userId={userId} />
      </Suspense>
    </div>
  )
}

성능 비교:

PPR 적용 전:
- 전체 대시보드 렌더링: 150ms/요청
- 10,000 활성 사용자: 25분/초 CPU 시간

PPR 적용 후:
- 정적 부분: 0ms (CDN 서빙)
- 동적 부분: 45ms/요청
- 10,000 활성 사용자: 7.5분/초 CPU 시간 (70% 감소)

3. 서버 부하 최적화 전략

데이터 페칭 최적화

1. 배치 쿼리로 N+1 문제 해결

// ❌ N+1 쿼리 문제
async function UserList() {
  const users = await db.users.findMany({ take: 100 })
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id}>
          <UserProfile userId={user.id} />    {/* 100개 쿼리 */}
          <UserStats userId={user.id} />      {/* 100개 쿼리 */}
          <UserBadges userId={user.id} />     {/* 100개 쿼리 */}
        </UserCard>
      ))}
    </div>
  )
}

// ✅ 최적화된 배치 쿼리
async function UserList() {
  const users = await db.users.findMany({ 
    take: 100,
    include: {
      profile: true,
      stats: true,
      badges: true
    }
  })
  
  return (
    <div>
      {users.map(user => (
        <UserCard key={user.id}>
          <UserProfile profile={user.profile} />    {/* 데이터 전달 */}
          <UserStats stats={user.stats} />          {/* 데이터 전달 */}
          <UserBadges badges={user.badges} />       {/* 데이터 전달 */}
        </UserCard>
      ))}
    </div>
  )
}

// 쿼리 개수: 301개 → 1개 (300배 감소)

2. 스마트 캐싱 전략

import { cache } from 'react'

// 요청 레벨 캐싱
const getUser = cache(async (userId) => {
  console.log(`Fetching user ${userId}`) // 한 번만 실행됨
  return await db.users.findUnique({ where: { id: userId } })
})

// 컴포넌트 레벨 캐싱
const getCachedUserProfile = cache(async (userId) => {
  const user = await getUser(userId)
  return <UserProfile user={user} />
})

// 데이터 레벨 캐싱 (Next.js 13+)
async function getPopularPosts() {
  const posts = await fetch('/api/posts/popular', {
    next: { 
      revalidate: 3600, // 1시간 캐시
      tags: ['posts'] 
    }
  })
  return posts.json()
}

3. 스트리밍으로 부하 분산

export default function ComplexPage() {
  return (
    <div>
      {/* 즉시 렌더링되는 가벼운 콘텐츠 */}
      <PageHeader />
      <QuickStats />
      
      {/* 무거운 작업들을 병렬로 스트리밍 */}
      <div className="grid grid-cols-2 gap-4">
        <Suspense fallback={<ChartSkeleton />}>
          <AnalyticsChart />        {/* 무거운 계산 */}
        </Suspense>
        
        <Suspense fallback={<TableSkeleton />}>
          <DataTable />             {/* 대용량 데이터 */}
        </Suspense>
      </div>
      
      <Suspense fallback={<ReportSkeleton />}>
        <MonthlyReport />           {/* 복잡한 리포트 */}
      </Suspense>
    </div>
  )
}

// 결과: 사용자는 즉시 페이지를 보고, 무거운 부분은 점진적으로 로드

메모리 사용량 최적화

1. 대용량 데이터 스트리밍

// ❌ 메모리 과부하
async function LargeDataList() {
  const allData = await db.records.findMany() // 100만 개 레코드 로드
  
  return (
    <div>
      {allData.map(record => (
        <RecordCard key={record.id} record={record} />
      ))}
    </div>
  )
}

// ✅ 페이지네이션 + 가상화
async function OptimizedDataList({ page = 1 }) {
  const data = await db.records.findMany({
    take: 50,
    skip: (page - 1) * 50
  })
  
  return (
    <div>
      {data.map(record => (
        <RecordCard key={record.id} record={record} />
      ))}
      <Pagination currentPage={page} />
    </div>
  )
}

2. 조건부 렌더링으로 부하 감소

async function ConditionalDashboard({ userRole }) {
  return (
    <div>
      <CommonDashboard />
      
      {userRole === 'admin' && (
        <Suspense fallback={<AdminSkeleton />}>
          <AdminPanel />              {/* 관리자에게만 로드 */}
        </Suspense>
      )}
      
      {userRole === 'premium' && (
        <Suspense fallback={<PremiumSkeleton />}>
          <PremiumFeatures />         {/* 프리미엄 사용자에게만 */}
        </Suspense>
      )}
    </div>
  )
}

4. 실제 프로덕션 고려사항

서버 확장성 계획

// 서버 리소스 모니터링 예시
const performanceMetrics = {
  // RSC 렌더링 시간
  averageRenderTime: '45ms',
  p95RenderTime: '120ms',
  
  // 메모리 사용량
  avgMemoryUsage: '2.1GB',
  peakMemoryUsage: '4.2GB',
  
  // 처리량
  requestsPerSecond: 850,
  concurrentUsers: 1200,
  
  // 병목지점
  bottlenecks: [
    'Database query optimization needed',
    'Large RSC payload serialization',
    'Memory pressure during peak hours'
  ]
}

에러 처리 및 Fallback 전략

// 서버 부하로 인한 에러 처리
export default function RobustPage() {
  return (
    <div>
      <StaticHeader />
      
      <ErrorBoundary fallback={<ErrorFallback />}>
        <Suspense fallback={<LoadingSkeleton />}>
          <HeavyServerComponent />
        </Suspense>
      </ErrorBoundary>
      
      {/* 서버 부하가 높을 때 클라이언트로 폴백 */}
      <Suspense fallback={<ClientFallback />}>
        <OptionalServerFeature />
      </Suspense>
    </div>
  )
}

function ClientFallback() {
  return (
    <div>
      <p>서버 부하로 인해 간소화된 버전을 표시합니다.</p>
      <ClientOnlyComponent />
    </div>
  )
}

결론: 서버 부하의 미래

현재 상황 요약

  1. RSC 단독 사용: 서버 부하 증가 (특히 복잡한 애플리케이션)
  2. PPR 도입: 서버 부하 대폭 감소 (정적 부분 사전 처리)
  3. 최적화 기법: 캐싱, 스트리밍, 효율적 쿼리로 부하 최소화

권장 전략

// 단계별 도입 전략
const migrationStrategy = {
  phase1: {
    goal: 'RSC 기본 도입',
    focus: '간단한 서버 컴포넌트부터 시작',
    monitoring: '서버 메트릭 면밀히 관찰'
  },
  
  phase2: {
    goal: '성능 최적화',
    focus: '캐싱, 스트리밍, 데이터 페칭 최적화',
    monitoring: '병목지점 식별 및 해결'
  },
  
  phase3: {
    goal: 'PPR 적용',
    focus: '정적/동적 컴포넌트 분리',
    monitoring: '서버 부하 대폭 감소 확인'
  }
}

미래 전망

PPR이 안정화되면 "서버 부하는 최소화하면서도 RSC의 모든 이점을 활용"할 수 있는 이상적인 환경이 구축될 것으로 예상됩니다. 현재는 과도기이지만, 적절한 최적화 전략을 통해 RSC의 이점을 안전하게 활용할 수 있습니다.

핵심은 점진적 도입과 지속적인 모니터링입니다. 서버 리소스를 면밀히 관찰하면서 단계적으로 RSC를 적용하고, PPR의 안정화를 기다리는 것이 현명한 전략이라 할 수 있겠습니다.

profile
꾸준히 성장하기

0개의 댓글