이 글은 누구를 위한 것인가
- 새 프로젝트에서 어떤 렌더링 전략을 선택해야 할지 모르는 팀
- Next.js App Router의 fetch 캐싱과 ISR을 이해하려는 개발자
- 페이지별로 다른 렌더링 전략을 혼용하고 싶은 팀
들어가며
"SSR이 빠르다"는 말은 절반만 맞다. SSG가 SSR보다 훨씬 빠르고, 올바른 ISR 설정은 정적 사이트의 성능과 동적 사이트의 최신성을 동시에 달성한다. 페이지 유형별로 전략을 다르게 적용하는 것이 핵심이다.
이 글은 bluefoxdev.kr의 웹 렌더링 전략 가이드 를 참고하여 작성했습니다.
1. 렌더링 전략 비교
[4가지 렌더링 전략]
SPA/CSR (Client Side Rendering):
HTML: 빈 껍데기, JS로 채움
최초 로딩: 느림 (JS 다운로드 → 실행 → 렌더링)
이후 탐색: 빠름 (클라이언트 라우팅)
SEO: 나쁨 (크롤러가 JS 실행 못함)
데이터: 항상 최신 (API 호출)
호스팅: 단순 (정적 파일 서버)
적합: 대시보드, 관리자 도구, 인증 후 앱
SSR (Server Side Rendering):
HTML: 요청마다 서버에서 생성
최초 로딩: 빠름 (완성된 HTML)
이후 탐색: 빠름 (Hydration 후)
SEO: 좋음 (완성된 HTML)
데이터: 항상 최신 (매 요청 DB 쿼리)
비용: 높음 (서버 컴퓨팅)
적합: 개인화 페이지, 실시간 데이터, 검색
SSG (Static Site Generation):
HTML: 빌드 시 미리 생성
최초 로딩: 매우 빠름 (CDN에서 서빙)
SEO: 최고
데이터: 빌드 시점 데이터 (오래될 수 있음)
비용: 매우 낮음 (CDN)
재배포: 데이터 변경 시 전체 빌드
적합: 마케팅 페이지, 블로그, 문서
ISR (Incremental Static Regeneration):
HTML: 처음엔 정적, 주기적으로 재생성
최초 로딩: 매우 빠름 (CDN 캐시)
SEO: 좋음
데이터: N초마다 최신화
비용: 낮음 (서빙은 CDN, 재생성만 서버)
적합: 상품 목록, 뉴스, 블로그 (적당한 최신성)
[Next.js App Router: 하이브리드]
같은 앱에서 페이지별 전략 혼용 가능
기본: 정적 생성 (SSG)
'use client': 클라이언트 전용
force-dynamic: SSR로 강제
revalidate: ISR
2. Next.js 렌더링 전략 구현
// 1. SSG - 빌드 시 정적 생성 (기본값)
// app/about/page.tsx
export default function AboutPage() {
return <div>회사 소개 (빌드 시 생성, 변경 없으면 무한 캐시)</div>;
}
// generateStaticParams로 동적 라우트 SSG
// app/products/[id]/page.tsx
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { id: true } });
return products.map(p => ({ id: p.id }));
}
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } });
return <div>{product?.name}</div>;
}
// 2. ISR - 주기적 재생성
// app/products/page.tsx
// 방법 1: route segment config
export const revalidate = 60; // 60초마다 재생성
export default async function ProductsPage() {
// 캐시됨, 60초마다 백그라운드 재검증
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
}).then(r => r.json());
return <ProductList products={products} />;
}
// 방법 2: fetch 수준 제어 (더 세밀함)
export default async function ProductsPage() {
// 캐시 전략을 fetch별로 다르게
const [products, categories] = await Promise.all([
fetch('/api/products', { next: { revalidate: 60 } }).then(r => r.json()), // 1분
fetch('/api/categories', { next: { revalidate: 3600 } }).then(r => r.json()), // 1시간
]);
return <div>...</div>;
}
// On-demand ISR - 특정 이벤트에 즉시 재검증
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const secret = request.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: '권한 없음' }, { status: 401 });
}
const { path, tag } = await request.json();
if (tag) revalidateTag(tag);
if (path) revalidatePath(path);
return Response.json({ revalidated: true });
}
// 상품 업데이트 시 웹훅으로 On-demand ISR 트리거
// fetch('https://mysite.com/api/revalidate', {
// method: 'POST',
// headers: { 'x-revalidate-secret': 'secret' },
// body: JSON.stringify({ tag: 'products', path: '/products' })
// })
// 3. SSR - 매 요청마다 서버 생성
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // SSR 강제
// 또는
export const revalidate = 0; // 캐시 비활성화
export default async function DashboardPage() {
// 요청마다 실행 (캐시 없음)
const session = await auth();
if (!session) redirect('/login');
const userStats = await db.user.findUnique({
where: { id: session.user.id },
include: { orders: { orderBy: { createdAt: 'desc' }, take: 5 } },
});
return <Dashboard stats={userStats} />;
}
// 4. Edge Runtime SSR - 빠른 SSR
// app/api/personalize/route.ts
export const runtime = 'edge'; // Edge Runtime
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get('userId');
// Edge에서 실행: 글로벌 분산, 낮은 레이턴시
// 주의: Node.js API 사용 불가 (Web API만)
const recommendations = await getRecommendations(userId);
return Response.json(recommendations, {
headers: {
'Cache-Control': 'private, max-age=300', // 개인화: CDN 캐시 불가
},
});
}
async function getRecommendations(userId: string | null) { return []; }
function ProductList({ products }: { products: any[] }) { return null; }
function Dashboard({ stats }: { stats: any }) { return null; }
[페이지 유형별 전략 선택]
홈/랜딩 페이지:
SSG + ISR (revalidate: 3600)
이유: 자주 변경 안 됨, CDN 속도 최대화
상품 목록:
SSG + ISR (revalidate: 60-300)
재고 변경 시 On-demand ISR
상품 상세:
SSG (generateStaticParams) + ISR
가격 변경 시 On-demand ISR
검색 결과:
SSR 또는 CSR
이유: URL 파라미터 기반, 캐시 어려움
사용자 대시보드:
SSR (dynamic = 'force-dynamic')
이유: 개인화, 실시간 데이터
관리자 페이지:
CSR (Suspense + 클라이언트 패칭)
이유: SEO 불필요, 인증 후에만 접근
마무리
렌더링 전략의 선택 기준: "데이터가 얼마나 자주 바뀌나"와 "SEO가 필요한가"의 교차점이다. 대부분의 상용 앱은 SSG(마케팅/문서) + ISR(상품/블로그) + SSR(개인화) + CSR(대시보드)를 혼용한다. Next.js App Router는 페이지별, fetch 호출별로 전략을 다르게 적용할 수 있어 이 최적 조합을 달성하기 가장 쉬운 프레임워크다.