Skip to Content
PatternsSSR/SSG (Server-Side Rendering & Static Site Generation)

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

✅ 권장사항

  1. 적절한 렌더링 전략 선택:

    • 정적 콘텐츠 → SSG
    • 자주 변경되는 콘텐츠 → ISR
    • 사용자별 콘텐츠 → SSR
    • 실시간 데이터 → Client-side fetching
  2. 메타데이터 최적화: generateMetadata로 SEO 향상

  3. Streaming 활용: 느린 컴포넌트는 Suspense로 분리

  4. 캐싱 전략: next.revalidate로 적절한 캐시 시간 설정

  5. 에러 처리: error.tsx로 전역 에러 처리

  6. 로딩 상태: loading.tsx로 일관된 로딩 UI

  7. Edge Runtime: 지연 시간이 중요한 API는 Edge에서 실행

⚠️ 피해야 할 것

  1. 불필요한 SSR: 정적 콘텐츠를 SSR로 렌더링
  2. 과도한 데이터 fetching: 서버 컴포넌트에서 너무 많은 API 호출
  3. 클라이언트 상태를 서버에서 접근: cookies, headers는 서버 컴포넌트에서만 사용
  4. 긴 블로킹 작업: 서버 컴포넌트에서 오래 걸리는 작업 수행
  5. 캐시 무시: 모든 요청을 cache: ‘no-store’로 설정
  6. 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} />; }

전체 코드 보기: Cals ISR Booking


테스트 및 실행

CodeSandbox에서 실행

SSR/SSG 예제 실행하기 

로컬 실행

# 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) - 자동 정적 생성

관련 패턴

Last updated on