Next.js App Router의 React Server Components(RSC)는 혁신적인 기술이지만, 서버 부하 측면에서는 양날의 검과 같습니다. 새로운 기능을 제공하는 동시에 서버에 더 많은 작업을 요구하기 때문입니다. 이 글에서는 RSC와 PPR이 서버 부하에 미치는 영향을 심도 있게 분석하고, 실제 프로덕션 환경에서의 최적화 전략을 제시해보겠습니다.
기존 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배 증가) │
│ 메모리 사용: 높음 │
└─────────────────────────────────────────┘
// 서버에서 실행되는 무거운 컴포넌트
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>
)
}
서버 부하 분석:
// 문제: 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개 데이터베이스 쿼리!
// 대용량 데이터의 직렬화 과정
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는 다른 부분에서 서버 부하를 줄이기도 합니다:
// 서버 컴포넌트는 클라이언트로 전송되지 않음
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} />
}
// 결과: 클라이언트 번들 크기 감소 → 초기 로딩 빨라짐
// 전통적인 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} />
}
// 현재: 하나라도 동적이면 전체가 매번 서버에서 렌더링
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 적용: 컴포넌트별 최적화
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 적용 전
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>
)
}
// 서버 부하: 동적 부분만 처리 + 적극적 캐싱
// 사용자별 대시보드
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% 감소)
// ❌ 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배 감소)
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()
}
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>
)
}
// 결과: 사용자는 즉시 페이지를 보고, 무거운 부분은 점진적으로 로드
// ❌ 메모리 과부하
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>
)
}
async function ConditionalDashboard({ userRole }) {
return (
<div>
<CommonDashboard />
{userRole === 'admin' && (
<Suspense fallback={<AdminSkeleton />}>
<AdminPanel /> {/* 관리자에게만 로드 */}
</Suspense>
)}
{userRole === 'premium' && (
<Suspense fallback={<PremiumSkeleton />}>
<PremiumFeatures /> {/* 프리미엄 사용자에게만 */}
</Suspense>
)}
</div>
)
}
// 서버 리소스 모니터링 예시
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'
]
}
// 서버 부하로 인한 에러 처리
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>
)
}
// 단계별 도입 전략
const migrationStrategy = {
phase1: {
goal: 'RSC 기본 도입',
focus: '간단한 서버 컴포넌트부터 시작',
monitoring: '서버 메트릭 면밀히 관찰'
},
phase2: {
goal: '성능 최적화',
focus: '캐싱, 스트리밍, 데이터 페칭 최적화',
monitoring: '병목지점 식별 및 해결'
},
phase3: {
goal: 'PPR 적용',
focus: '정적/동적 컴포넌트 분리',
monitoring: '서버 부하 대폭 감소 확인'
}
}
PPR이 안정화되면 "서버 부하는 최소화하면서도 RSC의 모든 이점을 활용"할 수 있는 이상적인 환경이 구축될 것으로 예상됩니다. 현재는 과도기이지만, 적절한 최적화 전략을 통해 RSC의 이점을 안전하게 활용할 수 있습니다.
핵심은 점진적 도입과 지속적인 모니터링입니다. 서버 리소스를 면밀히 관찰하면서 단계적으로 RSC를 적용하고, PPR의 안정화를 기다리는 것이 현명한 전략이라 할 수 있겠습니다.