SSR/SSG (Server-Side Rendering & Static Site Generation)
서버 사이드 렌더링과 정적 사이트 생성을 통해 초기 로딩 속도와 SEO를 개선하는 패턴입니다.
개요
SSR과 SSG는 다음과 같은 경우에 필요합니다:
- SEO 최적화: 검색 엔진이 콘텐츠를 크롤링하고 인덱싱할 수 있도록
- 초기 로딩 개선: Time to First Byte (TTFB)와 First Contentful Paint (FCP) 단축
- 소셜 미디어 공유: Open Graph 메타 태그를 서버에서 렌더링
- 정적 콘텐츠: 블로그, 문서, 마케팅 페이지 등 변경이 적은 콘텐츠
- 동적 콘텐츠: 사용자별 개인화된 콘텐츠를 서버에서 렌더링
기본 패턴
1. Next.js App Router - Server Components
Next.js 13+ App Router에서 기본적으로 Server Component를 사용합니다.
// app/products/page.tsx (Server Component)
import { Suspense } from "react";
import { ProductList } from "./ProductList";
import { ProductListSkeleton } from "./ProductListSkeleton";
// 서버에서 데이터 fetch
async function getProducts() {
const res = await fetch("https://api.example.com/products", {
// 60초마다 재검증
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error("Failed to fetch products");
}
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">상품 목록</h1>
<Suspense fallback={<ProductListSkeleton />}>
<ProductList products={products} />
</Suspense>
</div>
);
}
// 메타데이터 생성 (SEO)
export async function generateMetadata() {
return {
title: "상품 목록 | Vortex Shop",
description: "최신 상품을 만나보세요",
openGraph: {
title: "상품 목록",
description: "최신 상품을 만나보세요",
images: ["/og-products.jpg"],
},
};
}2. Static Site Generation (SSG)
빌드 시 정적 HTML을 생성합니다.
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
interface Post {
slug: string;
title: string;
content: string;
publishedAt: string;
}
// 모든 포스트 slug 생성 (빌드 시)
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((res) =>
res.json()
);
return posts.map((post: Post) => ({
slug: post.slug,
}));
}
// 각 포스트 데이터 fetch (빌드 시)
async function getPost(slug: string): Promise<Post | null> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
// ISR: 3600초(1시간)마다 재생성
next: { revalidate: 3600 },
});
if (!res.ok) {
return null;
}
return res.json();
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<time className="text-gray-600 mb-8 block">
{new Date(post.publishedAt).toLocaleDateString("ko-KR")}
</time>
<div
className="prose max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
);
}
// 동적 메타데이터
export async function generateMetadata({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
if (!post) {
return {
title: "포스트를 찾을 수 없습니다",
};
}
return {
title: post.title,
description: post.content.substring(0, 160),
openGraph: {
title: post.title,
description: post.content.substring(0, 160),
type: "article",
publishedTime: post.publishedAt,
},
};
}3. Incremental Static Regeneration (ISR)
정적 페이지를 주기적으로 재생성합니다.
// app/dashboard/page.tsx
async function getDashboardData() {
const res = await fetch("https://api.example.com/dashboard", {
// 10초마다 재검증
next: { revalidate: 10 },
});
return res.json();
}
export default async function DashboardPage() {
const data = await getDashboardData();
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">대시보드</h1>
<div className="grid grid-cols-3 gap-4">
<div className="p-6 bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">총 사용자</h2>
<p className="text-3xl font-bold text-blue-600">{data.totalUsers}</p>
</div>
<div className="p-6 bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">총 매출</h2>
<p className="text-3xl font-bold text-green-600">
₩{data.totalRevenue.toLocaleString()}
</p>
</div>
<div className="p-6 bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold mb-2">활성 주문</h2>
<p className="text-3xl font-bold text-orange-600">
{data.activeOrders}
</p>
</div>
</div>
</div>
);
}
// On-Demand Revalidation (API Route에서 호출)
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get("secret");
// 보안 토큰 검증
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ message: "Invalid secret" }, { status: 401 });
}
try {
// 특정 경로 재검증
revalidatePath("/dashboard");
return Response.json({ revalidated: true, now: Date.now() });
} catch (err) {
return Response.json({ message: "Error revalidating" }, { status: 500 });
}
}고급 패턴
1. Streaming SSR with Suspense
React 18의 Streaming을 활용하여 점진적으로 렌더링합니다.
// app/products/page.tsx
import { Suspense } from "react";
// 빠른 컴포넌트
function ProductHeader() {
return (
<header className="mb-8">
<h1 className="text-3xl font-bold">상품 목록</h1>
<p className="text-gray-600">최신 상품을 만나보세요</p>
</header>
);
}
// 느린 컴포넌트 (서버에서 데이터 fetch)
async function FeaturedProducts() {
// 2초 소요되는 API 호출
const products = await fetch("https://api.example.com/featured", {
next: { revalidate: 60 },
}).then((res) => res.json());
return (
<div className="grid grid-cols-4 gap-4">
{products.map((product: any) => (
<div key={product.id} className="p-4 border rounded">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover"
/>
<h3 className="mt-2 font-semibold">{product.name}</h3>
<p className="text-blue-600">₩{product.price.toLocaleString()}</p>
</div>
))}
</div>
);
}
// 더 느린 컴포넌트
async function Recommendations() {
// 3초 소요되는 API 호출
const recommendations = await fetch(
"https://api.example.com/recommendations",
{
next: { revalidate: 300 },
}
).then((res) => res.json());
return (
<div className="mt-8">
<h2 className="text-2xl font-bold mb-4">추천 상품</h2>
<div className="grid grid-cols-3 gap-4">
{recommendations.map((item: any) => (
<div key={item.id} className="p-4 border rounded">
<h3>{item.name}</h3>
</div>
))}
</div>
</div>
);
}
export default function ProductsPage() {
return (
<div className="container mx-auto px-4 py-8">
{/* 즉시 렌더링 */}
<ProductHeader />
{/* Featured Products는 로딩되는 동안 skeleton 표시 */}
<Suspense fallback={<FeaturedProductsSkeleton />}>
<FeaturedProducts />
</Suspense>
{/* Recommendations는 Featured Products와 독립적으로 로딩 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</div>
);
}
function FeaturedProductsSkeleton() {
return (
<div className="grid grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="p-4 border rounded animate-pulse">
<div className="w-full h-48 bg-gray-200" />
<div className="mt-2 h-4 bg-gray-200 rounded" />
<div className="mt-2 h-4 bg-gray-200 rounded w-1/2" />
</div>
))}
</div>
);
}
function RecommendationsSkeleton() {
return (
<div className="mt-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map((i) => (
<div key={i} className="p-4 border rounded">
<div className="h-4 bg-gray-200 rounded" />
</div>
))}
</div>
</div>
);
}2. Partial Prerendering (PPR) - Experimental
정적 콘텐츠와 동적 콘텐츠를 혼합합니다.
// next.config.js
module.exports = {
experimental: {
ppr: true,
},
};
// app/profile/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
// 정적 부분 (빌드 시 생성)
function ProfileLayout({ children }: { children: React.ReactNode }) {
return (
<div className="container mx-auto px-4 py-8">
<nav className="mb-8">
<a href="/profile">프로필</a>
<a href="/settings">설정</a>
</nav>
{children}
</div>
);
}
// 동적 부분 (요청 시 렌더링)
async function UserInfo() {
const cookieStore = cookies();
const userId = cookieStore.get("userId")?.value;
const user = await fetch(`https://api.example.com/users/${userId}`, {
cache: "no-store", // 항상 최신 데이터
}).then((res) => res.json());
return (
<div>
<h2 className="text-2xl font-bold">{user.name}</h2>
<p className="text-gray-600">{user.email}</p>
</div>
);
}
export default function ProfilePage() {
return (
<ProfileLayout>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
</ProfileLayout>
);
}3. Client-Side Data Fetching with Server Components
서버 컴포넌트와 클라이언트 컴포넌트를 조합합니다.
// app/products/[id]/page.tsx (Server Component)
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 },
});
return res.json();
}
// Server Component: 초기 데이터 fetch
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">
{/* 정적 콘텐츠 */}
<h1 className="text-3xl font-bold mb-4">{product.name}</h1>
<img
src={product.image}
alt={product.name}
className="w-full h-96 object-cover mb-4"
/>
<p className="text-gray-700 mb-8">{product.description}</p>
{/* 동적 상호작용 (Client Component) */}
<ProductInteractions productId={params.id} initialLikes={product.likes} />
{/* 실시간 재고 (Client Component) */}
<RealTimeStock productId={params.id} />
</div>
);
}
// components/ProductInteractions.tsx (Client Component)
("use client");
import { useState } from "react";
export function ProductInteractions({
productId,
initialLikes,
}: {
productId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const handleLike = async () => {
if (isLiked) return;
setIsLiked(true);
setLikes(likes + 1);
await fetch(`/api/products/${productId}/like`, { method: "POST" });
};
return (
<div className="flex items-center gap-4">
<button
onClick={handleLike}
disabled={isLiked}
className={`px-6 py-2 rounded-lg ${
isLiked ? "bg-gray-300" : "bg-blue-500 text-white hover:bg-blue-600"
}`}
>
{isLiked ? "좋아요 완료" : "좋아요"} ({likes})
</button>
</div>
);
}
// components/RealTimeStock.tsx (Client Component)
("use client");
import { useEffect, useState } from "react";
export function RealTimeStock({ productId }: { productId: string }) {
const [stock, setStock] = useState<number | null>(null);
useEffect(() => {
// WebSocket으로 실시간 재고 업데이트
const ws = new WebSocket(`wss://api.example.com/stock/${productId}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setStock(data.stock);
};
return () => ws.close();
}, [productId]);
if (stock === null) {
return <div>재고 확인 중...</div>;
}
return (
<div className="mt-4 p-4 bg-gray-100 rounded">
<span className={stock > 0 ? "text-green-600" : "text-red-600"}>
{stock > 0 ? `재고 ${stock}개` : "품절"}
</span>
</div>
);
}4. Edge Runtime SSR
엣지 런타임에서 SSR을 수행하여 지연 시간을 최소화합니다.
// app/api/hello/route.ts
export const runtime = "edge";
export async function GET(request: Request) {
return new Response("Hello from Edge Runtime", {
headers: {
"content-type": "text/plain",
},
});
}
// app/edge-ssr/page.tsx
export const runtime = "edge";
export default async function EdgeSSRPage() {
const data = await fetch("https://api.example.com/data", {
next: { revalidate: 10 },
}).then((res) => res.json());
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-4">Edge SSR Example</h1>
<pre className="bg-gray-100 p-4 rounded">
{JSON.stringify(data, null, 2)}
</pre>
</div>
);
}Best Practices
✅ 권장사항
-
적절한 렌더링 전략 선택:
- 정적 콘텐츠 → SSG
- 자주 변경되는 콘텐츠 → ISR
- 사용자별 콘텐츠 → SSR
- 실시간 데이터 → Client-side fetching
-
메타데이터 최적화: generateMetadata로 SEO 향상
-
Streaming 활용: 느린 컴포넌트는 Suspense로 분리
-
캐싱 전략: next.revalidate로 적절한 캐시 시간 설정
-
에러 처리: error.tsx로 전역 에러 처리
-
로딩 상태: loading.tsx로 일관된 로딩 UI
-
Edge Runtime: 지연 시간이 중요한 API는 Edge에서 실행
⚠️ 피해야 할 것
- 불필요한 SSR: 정적 콘텐츠를 SSR로 렌더링
- 과도한 데이터 fetching: 서버 컴포넌트에서 너무 많은 API 호출
- 클라이언트 상태를 서버에서 접근: cookies, headers는 서버 컴포넌트에서만 사용
- 긴 블로킹 작업: 서버 컴포넌트에서 오래 걸리는 작업 수행
- 캐시 무시: 모든 요청을 cache: ‘no-store’로 설정
- SEO 무시: 중요한 페이지의 메타데이터 누락
렌더링 전략 비교
// 렌더링 전략 선택 가이드
const renderingStrategies = {
SSG: {
use: "블로그, 문서, 마케팅 페이지",
pros: ["최고의 성능", "최고의 SEO", "CDN 캐싱"],
cons: ["빌드 시간 증가", "실시간 데이터 불가"],
example: "next: { revalidate: false }",
},
ISR: {
use: "제품 목록, 뉴스, 대시보드",
pros: ["정적 + 동적", "주기적 업데이트", "CDN 캐싱"],
cons: ["첫 요청 후 업데이트", "stale 데이터 가능"],
example: "next: { revalidate: 60 }",
},
SSR: {
use: "사용자 프로필, 개인화된 콘텐츠",
pros: ["최신 데이터", "사용자별 콘텐츠"],
cons: ["서버 부하", "응답 시간 증가"],
example: "cache: 'no-store'",
},
"Client-side": {
use: "대시보드, 실시간 채팅",
pros: ["실시간 업데이트", "인터랙티브"],
cons: ["SEO 불리", "초기 로딩 느림"],
example: "useEffect + fetch",
},
};제품별 SSR/SSG 전략
Foundation (기본 프레임워크)
// Foundation용 범용 SSR 헬퍼
export async function getServerSideData<T>(
fetcher: () => Promise<T>,
options: { revalidate?: number } = {}
): Promise<T> {
try {
const data = await fetcher();
return data;
} catch (error) {
console.error("Server-side data fetching failed:", error);
throw error;
}
}전체 코드 보기: Foundation SSR Helpers
iCignal (분석 플랫폼)
// iCignal용 실시간 대시보드 (SSR + Client-side)
export default async function AnalyticsDashboard() {
// 초기 데이터는 SSR
const initialData = await fetch("https://api.icignal.com/analytics", {
next: { revalidate: 30 },
}).then((res) => res.json());
return (
<div>
<ServerRenderedCharts data={initialData} />
<RealTimeUpdates />
</div>
);
}전체 코드 보기: iCignal Hybrid Rendering
Cals (예약 시스템)
// Cals용 예약 페이지 (ISR)
export default async function BookingPage({
params,
}: {
params: { id: string };
}) {
const booking = await fetch(`https://api.cals.com/bookings/${params.id}`, {
next: { revalidate: 10 }, // 10초마다 재검증
}).then((res) => res.json());
return <BookingDetails booking={booking} />;
}테스트 및 실행
CodeSandbox에서 실행
로컬 실행
# 1. Next.js 프로젝트 생성
npx create-next-app@latest my-ssr-app --typescript --app
# 2. 개발 서버 실행
cd my-ssr-app
pnpm dev
# 3. 프로덕션 빌드
pnpm build
# 4. 빌드 결과 확인
# ○ (Static) - 정적 페이지 (SSG)
# ƒ (Dynamic) - 서버 렌더링 (SSR)
# ● (SSG) - 자동 정적 생성관련 패턴
- Code Splitting - 코드 분할로 번들 크기 최적화
- Lazy Loading - 리소스 지연 로딩
- Performance Optimization - 전반적인 성능 최적화
- Data Fetching - 효율적인 데이터 로딩
Last updated on