React 19가 정식 출시되면서 Server Components(RSC)가 안정화되었습니다. 이 글에서는 기본 개념을 넘어 실전에서 마주치는 문제들과 최적화 기법을 다룹니다.
Server Components 동작 원리
RSC 직렬화 과정
Server Component가 클라이언트에 전달되는 과정:
// Server Component (서버에서 실행)
async function ProductList() {
// 이 코드는 브라우저에 전송되지 않음
const products = await db.query('SELECT * FROM products');
return (
<ul>
{products.map(p => (
<li key={p.id}>{p.name} - {p.price}원</li>
))}
</ul>
);
}
RSC Payload (네트워크로 전송되는 데이터):
{
"type": "ul",
"children": [
{"type": "li", "children": "상품A - 10000원"},
{"type": "li", "children": "상품B - 20000원"}
]
}
핵심: DB 쿼리 코드나 비밀 키는 클라이언트에 전송되지 않습니다.
서버/클라이언트 경계
'use client' 경계 이해하기
app/
├── page.tsx (Server Component)
├── ProductList.tsx (Server Component)
└── AddToCartButton.tsx (Client Component - 'use client')
// page.tsx (Server)
import ProductList from './ProductList';
import AddToCartButton from './AddToCartButton';
export default async function Page() {
return (
<div>
{/* 서버에서 데이터 가져옴 */}
<ProductList />
{/* 클라이언트에서 상호작용 */}
<AddToCartButton />
</div>
);
}
경계에서의 Props 전달
// ✅ 올바른 패턴: 직렬화 가능한 데이터만 전달
// Server Component
async function ProductPage({ id }) {
const product = await getProduct(id);
return (
<ClientComponent
name={product.name} // ✅ string
price={product.price} // ✅ number
createdAt={product.createdAt.toISOString()} // ✅ string으로 변환
/>
);
}
// ❌ 잘못된 패턴: 함수, Date 객체 등은 전달 불가
<ClientComponent
onClick={handleClick} // ❌ 함수
date={new Date()} // ❌ Date 객체
component={<Other />} // ❌ JSX element (일부 경우)
/>
데이터 페칭 패턴
병렬 데이터 페칭
// ❌ 순차 실행 (느림)
async function Dashboard() {
const user = await getUser();
const posts = await getPosts();
const notifications = await getNotifications();
// 총 시간: user + posts + notifications
}
// ✅ 병렬 실행 (빠름)
async function Dashboard() {
const [user, posts, notifications] = await Promise.all([
getUser(),
getPosts(),
getNotifications(),
]);
// 총 시간: max(user, posts, notifications)
}
Suspense와 스트리밍
import { Suspense } from 'react';
export default function Page() {
return (
<div>
{/* 즉시 표시 */}
<Header />
{/* 독립적으로 로딩 */}
<Suspense fallback={<ProductSkeleton />}>
<ProductList />
</Suspense>
<Suspense fallback={<ReviewSkeleton />}>
<Reviews />
</Suspense>
</div>
);
}
중첩 Suspense 전략
// 점진적 로딩
<Suspense fallback={<PageSkeleton />}>
<Layout>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<Main>
<Suspense fallback={<ContentSkeleton />}>
<Content />
</Suspense>
</Main>
</Layout>
</Suspense>
캐싱 전략
React의 cache()
import { cache } from 'react';
// 같은 요청 내에서 중복 호출 방지
const getUser = cache(async (id) => {
const res = await fetch(`/api/users/${id}`);
return res.json();
});
// 여러 컴포넌트에서 호출해도 1번만 실행
async function UserProfile({ id }) {
const user = await getUser(id);
// ...
}
async function UserPosts({ id }) {
const user = await getUser(id); // 캐시된 결과 사용
// ...
}
Next.js의 fetch 캐싱
// 기본: 캐시됨 (빌드 시)
const data = await fetch('https://api.example.com/data');
// 재검증 주기 설정
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // 1시간마다 재검증
});
// 캐시 비활성화 (항상 새로운 데이터)
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
});
수동 캐시 무효화
import { revalidatePath, revalidateTag } from 'next/cache';
// 특정 경로 재검증
async function updateProduct(id, data) {
await db.products.update(id, data);
revalidatePath('/products');
revalidatePath(`/products/${id}`);
}
// 태그 기반 재검증
const data = await fetch(url, {
next: { tags: ['products'] }
});
// 태그로 무효화
revalidateTag('products');
흔한 실수와 해결
1. 클라이언트 컴포넌트에서 async
// ❌ Client Component는 async 불가
'use client';
export default async function Button() { // Error!
const data = await fetchData();
}
// ✅ useEffect 또는 상위 Server Component에서 처리
'use client';
export default function Button({ data }) {
// data는 Server Component에서 props로 전달
}
2. 서버 전용 코드 누출
// ❌ 클라이언트에 서버 코드 노출 위험
import { db } from './db'; // 클라이언트 번들에 포함될 수 있음
// ✅ server-only 패키지 사용
import 'server-only';
import { db } from './db';
export async function getProducts() {
return db.query('...');
}
npm install server-only
3. Context 사용
// ❌ Server Component에서 Context 사용 불가
// 'use client'가 없으면 useContext 사용 불가
// ✅ Provider는 Client Component로 분리
// providers.tsx
'use client';
export function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
);
}
// layout.tsx (Server Component)
export default function Layout({ children }) {
return <Providers>{children}</Providers>;
}
4. 컴포넌트 Import 혼동
// ❌ Server Component 내부에서 client import 후 사용
// 경고: 전체가 Client Component가 됨
// ✅ 명확한 분리
// page.tsx (Server)
import ClientButton from './ClientButton'; // 'use client' 파일
export default async function Page() {
const data = await fetchData();
return <ClientButton data={data} />;
}
성능 최적화
컴포넌트 청킹
import dynamic from 'next/dynamic';
// 필요할 때만 로드
const HeavyChart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // 클라이언트에서만 렌더링
});
부분 프리렌더링 (PPR)
// next.config.js
module.exports = {
experimental: {
ppr: true,
},
};
// page.tsx
export default function Page() {
return (
<div>
{/* 정적 부분: 빌드 시 생성 */}
<Header />
<StaticContent />
{/* 동적 부분: 요청 시 스트리밍 */}
<Suspense fallback={<Skeleton />}>
<DynamicContent />
</Suspense>
</div>
);
}
마이그레이션 체크리스트
기존 React 앱을 RSC로 전환할 때:
- 데이터 페칭 로직을 Server Component로 이동
-
'use client'가 필요한 컴포넌트 식별 (이벤트, 훅 사용) - Context Provider를 최상위 Client Component로 분리
- server-only 패키지로 서버 코드 보호
- Suspense 경계 설정
- fetch 캐싱 전략 수립
- 번들 크기 확인 및 최적화
React 19의 Server Components는 단순히 새로운 기능이 아니라 React 아키텍처의 패러다임 전환입니다. 서버와 클라이언트의 경계를 이해하고 적절히 활용하면, 더 빠르고 안전한 애플리케이션을 만들 수 있습니다.