React 19의 핵심 기능인 Server Components와 Client Components의 차이점을 이해하고, 이커머스 애플리케이션에서 효과적으로 활용하는 방법을 학습합니다.
React 19와 Next.js 16에서 Server Components는 웹 개발의 패러다임을 바꾸는 혁신입니다. 컴포넌트가 서버에서만 실행되어 클라이언트로 JavaScript를 전송하지 않습니다. 이를 통해 번들 크기를 대폭 줄이고 초기 로딩 속도를 개선할 수 있습니다.
// 기존 방식: 클라이언트에 moment.js 전체 번들 전송 (300KB+)
import moment from 'moment';
export default function ProductDate({ date }) {
return <span>{moment(date).format('YYYY-MM-DD')}</span>;
}
// Server Component: 서버에서만 실행, 클라이언트에 0KB
// 결과 HTML만 전송: <span>2026-01-31</span>JavaScript 번들 크기 감소로 빠른 로딩
API 키, DB 쿼리가 클라이언트에 노출되지 않음
서버에서 직접 DB 접근, 워터폴 제거
서버 측 캐싱으로 효율적인 데이터 관리
| 페이지 | Server Component 활용 | 이점 |
|---|---|---|
| 상품 목록 | DB에서 직접 조회 | SEO + 빠른 로딩 |
| 상품 상세 | 상품 정보 렌더링 | 검색 엔진 노출 |
| 카테고리 | 정적 데이터 표시 | 번들 크기 0 |
Server Component 제약
핵심 개념
Next.js App Router에서 모든 컴포넌트는 기본적으로 Server Component입니다. 'use client' 지시어를 명시적으로 추가해야만 Client Component가 됩니다. 이 기본값 덕분에 자연스럽게 성능 최적화가 이루어집니다.
Client Components는 브라우저에서 실행되어 사용자 상호작용을 처리합니다. 'use client' 지시어로 선언합니다.
🖱️ 이벤트 핸들러
onClick, onChange, onSubmit 등
🔄 상태 관리
useState, useReducer
⚡ 생명주기
useEffect, useLayoutEffect
🌐 브라우저 API
localStorage, window, navigator
'use client'; // 파일 최상단에 선언
import { useState } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
});
setIsLoading(false);
};
return (
<button onClick={handleClick} disabled={isLoading}>
{isLoading ? '추가 중...' : '장바구니 담기'}
</button>
);
}주의사항
| 기능 | Server | Client |
|---|---|---|
| 데이터 페칭 | ✅ | ✅ |
| 직접 DB 접근 | ✅ | ❌ |
| useState/useEffect | ❌ | ✅ |
| 이벤트 핸들러 | ❌ | ✅ |
| 브라우저 API | ❌ | ✅ |
| 번들 크기 영향 | 없음 | 증가 |
설계 원칙
기본적으로 Server Component를 사용하고, 상호작용이 필요한 부분만 Client Component로 분리하세요.
Server Component와 Client Component를 효과적으로 조합하는 패턴을 학습합니다. 핵심은 Server Component를 최대한 활용하고, 상호작용이 필요한 부분만 Client Component로 분리하는 것입니다.
// app/products/[id]/page.tsx (Server Component)
import { ProductInfo } from './ProductInfo';
import { AddToCartButton } from './AddToCartButton';
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`);
return res.json();
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id);
return (
<div>
{/* Server Component: 정적 정보 표시 */}
<ProductInfo product={product} />
{/* Client Component: 상호작용 처리 */}
<AddToCartButton productId={product.id} />
</div>
);
}// 상호작용 부분만 Client Component로 분리
<ProductPage> {/* Server */}
<ProductImage /> {/* Server */}
<ProductDetails /> {/* Server */}
<AddToCartButton /> {/* Client */}
</ProductPage>// 전체를 Client Component로 만들면 번들 크기 증가
'use client';
<ProductPage> {/* 전체가 Client */}
<ProductImage />
<ProductDetails />
<AddToCartButton />
</ProductPage>// Server → Client로 전달 가능한 props
// ✅ 직렬화 가능한 데이터만 전달 가능
// 가능: 문자열, 숫자, 배열, 객체
<ClientComponent
name="상품명"
price={10000}
tags={['신상품', '할인']}
/>
// 불가능: 함수, Date 객체, Map, Set 등
<ClientComponent
onClick={() => {}} // ❌ 함수 전달 불가
date={new Date()} // ❌ Date 객체 전달 불가
/>설계 팁
Server Components에서는 async/await를 직접 사용하여 데이터를 가져올 수 있습니다. 별도의 useEffect나 상태 관리 없이 깔끔하게 데이터를 로드할 수 있습니다.
// app/products/page.tsx
// Server Component는 async 함수로 선언 가능
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'force-cache', // 기본값: 캐시 사용
});
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}// 병렬로 여러 데이터 동시 요청
async function getProduct(id: string) {
const res = await fetch(`/api/products/${id}`);
return res.json();
}
async function getReviews(productId: string) {
const res = await fetch(`/api/products/${productId}/reviews`);
return res.json();
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
// Promise.all로 병렬 요청 - 워터폴 방지
const [product, reviews] = await Promise.all([
getProduct(params.id),
getReviews(params.id),
]);
return (
<div>
<h1>{product.name}</h1>
<p>리뷰 {reviews.length}개</p>
</div>
);
}// 캐시 사용 (기본값)
fetch(url, { cache: 'force-cache' });
// 캐시 사용 안 함 (항상 새로운 데이터)
fetch(url, { cache: 'no-store' });
// 시간 기반 재검증 (ISR)
fetch(url, { next: { revalidate: 3600 } }); // 1시간마다
// 태그 기반 재검증
fetch(url, { next: { tags: ['products'] } });이커머스 적용
Streaming을 사용하면 페이지의 일부를 먼저 보여주고, 나머지는 준비되는 대로 점진적으로 렌더링할 수 있습니다. 사용자는 전체 페이지가 로드될 때까지 기다리지 않아도 됩니다.
import { Suspense } from 'react';
// 느린 컴포넌트 (데이터 페칭에 시간 소요)
async function ProductReviews({ productId }: { productId: string }) {
const reviews = await fetch(`/api/products/${productId}/reviews`);
return <ReviewList reviews={reviews} />;
}
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* 즉시 렌더링 */}
<ProductInfo productId={params.id} />
{/* 리뷰는 로딩 중일 때 스켈레톤 표시 */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
</div>
);
}// app/products/loading.tsx
// 해당 라우트 전체의 로딩 상태를 자동으로 처리
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/3 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-2/3" />
</div>
);
}
// Next.js가 자동으로 Suspense로 감싸줌
// <Suspense fallback={<Loading />}>
// <ProductsPage />
// </Suspense>⚡ TTFB 개선
첫 바이트까지의 시간 단축 - 빠른 콘텐츠 표시
🎯 점진적 렌더링
중요한 콘텐츠 먼저, 부가 정보는 나중에
🔄 병렬 처리
느린 데이터가 전체 페이지를 블로킹하지 않음
이커머스 활용
Server Component와 Client Component를 조합하여 실제 상품 상세 페이지를 구현합니다. 각 컴포넌트의 역할을 명확히 분리하는 것이 핵심입니다.
// app/products/[id]/page.tsx (Server Component)
import { Suspense } from 'react';
import { ProductImage } from './ProductImage';
import { ProductInfo } from './ProductInfo';
import { AddToCartButton } from './AddToCartButton';
import { ProductReviews } from './ProductReviews';
async function getProduct(id: string) {
const res = await fetch(`https://api.myshop.com/products/${id}`, {
next: { tags: [`product-${id}`] }
});
return res.json();
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id);
return (
<div className="container mx-auto px-4 py-8">
<div className="grid md:grid-cols-2 gap-8">
{/* Server: 이미지 (최적화) */}
<ProductImage src={product.image} alt={product.name} />
<div>
{/* Server: 상품 정보 */}
<ProductInfo product={product} />
{/* Client: 장바구니 버튼 (상호작용) */}
<AddToCartButton
productId={product.id}
price={product.price}
/>
</div>
</div>
{/* Streaming: 리뷰 (느린 데이터) */}
<Suspense fallback={<div>리뷰 로딩 중...</div>}>
<ProductReviews productId={params.id} />
</Suspense>
</div>
);
}// app/products/[id]/AddToCartButton.tsx
'use client';
import { useState } from 'react';
interface Props {
productId: string;
price: number;
}
export function AddToCartButton({ productId, price }: Props) {
const [quantity, setQuantity] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const handleAddToCart = async () => {
setIsLoading(true);
try {
await fetch('/api/cart', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, quantity }),
});
alert('장바구니에 추가되었습니다!');
} finally {
setIsLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<button
onClick={() => setQuantity(q => Math.max(1, q - 1))}
className="px-3 py-1 border rounded"
>
-
</button>
<span>{quantity}</span>
<button
onClick={() => setQuantity(q => q + 1)}
className="px-3 py-1 border rounded"
>
+
</button>
</div>
<p className="text-xl font-bold">
{(price * quantity).toLocaleString()}원
</p>
<button
onClick={handleAddToCart}
disabled={isLoading}
className="w-full py-3 bg-blue-600 text-white rounded-lg"
>
{isLoading ? '추가 중...' : '장바구니 담기'}
</button>
</div>
);
}핵심 포인트