Next.js SSG 완벽 가이드 2026 — App Router 정적 사이트 생성 실전
Next.js App Router에서 SSG를 제대로 활용하는 방법. generateStaticParams, revalidate, 동적 라우트 정적 생성, Core Web Vitals 최적화까지 실전 코드로 정리했습니다.
SSG가 중요한 이유
Next.js에서 SSG(Static Site Generation)는 성능과 SEO를 동시에 잡는 가장 효율적인 렌더링 방식입니다. 빌드 시점에 HTML을 미리 생성해 CDN에 배포하면, 사용자 요청마다 서버 연산 없이 즉시 응답합니다.
App Router가 도입된 이후 렌더링 방식 선택이 더 세분화됐습니다. 기본 동작과 제어 방법을 정확히 이해해야 의도한 대로 정적/동적 렌더링을 나눌 수 있습니다.
App Router의 렌더링 모드 이해
기본값은 정적 렌더링
App Router에서 외부 데이터 없이 렌더링되는 컴포넌트는 자동으로 정적 생성됩니다.
// app/about/page.tsx — 빌드 시 정적 생성됨 (추가 설정 불필요)
export default function AboutPage() {
return <h1>About</h1>;
}
동적 렌더링으로 전환되는 조건
다음 중 하나라도 해당되면 SSR(요청마다 렌더링)로 전환됩니다:
// 1. dynamic = 'force-dynamic' 명시
export const dynamic = 'force-dynamic';
// 2. cookies(), headers() 사용
import { cookies } from 'next/headers';
// 3. searchParams 사용 (Page 컴포넌트)
export default function Page({ searchParams }) { ... }
// 4. cache: 'no-store' fetch 사용
const data = await fetch(url, { cache: 'no-store' });
렌더링 모드 비교
| 모드 | 언제 생성 | 캐시 | 사용 케이스 |
|---|---|---|---|
| Static (SSG) | 빌드 시 | CDN 영구 캐시 | 블로그, 문서, 마케팅 페이지 |
| ISR | 빌드 후 주기적 재생성 | CDN + revalidate | 뉴스, 상품 목록 |
| Dynamic (SSR) | 요청마다 | 캐시 없음 | 로그인 페이지, 실시간 데이터 |
generateStaticParams — 동적 라우트 정적 생성
기본 사용법
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts(); // DB나 파일시스템에서 포스트 목록 가져오기
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
중첩 동적 라우트
// app/[category]/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({
category: post.category,
slug: post.slug,
}));
}
generateStaticParams 없을 때 동작
generateStaticParams를 정의하지 않으면 기본적으로 요청 시 동적 렌더링됩니다. 이를 바꾸려면:
// 빌드 시 알 수 없는 경로는 404 처리
export const dynamicParams = false;
// 또는 런타임에 동적 생성 허용 (기본값)
export const dynamicParams = true;
ISR — 정적 페이지 자동 갱신
완전 정적(내용이 절대 안 바뀜)과 완전 동적(매 요청마다 새 데이터) 사이에 **ISR(Incremental Static Regeneration)**이 있습니다.
route segment config로 revalidate 설정
// app/news/page.tsx
export const revalidate = 3600; // 1시간마다 재생성
export default async function NewsPage() {
const news = await fetch('https://api.example.com/news', {
next: { revalidate: 3600 }, // 또는 fetch 레벨에서 설정
}).then(res => res.json());
return <NewsList items={news} />;
}
On-demand Revalidation
특정 이벤트(CMS 업데이트, 새 글 발행 등)에 즉시 재생성:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const { path, secret } = await request.json();
// 보안: secret 토큰 검증
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidatePath(path); // 특정 경로 재생성
// 또는 revalidateTag('posts'); // 태그 기반 재생성
return Response.json({ revalidated: true });
}
MDX 블로그 SSG 실전 구현
개발자 블로그에서 가장 많이 쓰는 패턴입니다.
파일시스템 기반 MDX 블로그
// lib/posts.ts
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
const postsDir = path.join(process.cwd(), 'content');
export function getAllSlugs() {
return fs.readdirSync(postsDir)
.filter(file => file.endsWith('.mdx'))
.map(file => file.replace('.mdx', ''));
}
export function getPost(slug: string) {
const fullPath = path.join(postsDir, `${slug}.mdx`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const { data, content } = matter(fileContents);
return { frontmatter: data, content };
}
// app/blog/[slug]/page.tsx
import { getAllSlugs, getPost } from '@/lib/posts';
import { MDXRemote } from 'next-mdx-remote/rsc';
export async function generateStaticParams() {
const slugs = getAllSlugs();
return slugs.map(slug => ({ slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { frontmatter } = getPost(slug);
return {
title: frontmatter.title,
description: frontmatter.description,
};
}
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const { frontmatter, content } = getPost(slug);
return (
<article>
<h1>{frontmatter.title}</h1>
<MDXRemote source={content} />
</article>
);
}
generateMetadata로 SEO 최적화
SSG 페이지에서 SEO 메타데이터를 동적으로 생성합니다.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
type: 'article',
publishedTime: post.date,
images: [
{
url: post.ogImage ?? '/default-og.png',
width: 1200,
height: 630,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
},
};
}
Core Web Vitals 최적화
SSG 자체가 LCP에 유리하지만, 추가 최적화로 점수를 더 높일 수 있습니다.
이미지 최적화
import Image from 'next/image';
// SSG 페이지의 히어로 이미지
export default function BlogPost() {
return (
<Image
src="/hero.jpg"
alt="히어로 이미지"
width={1200}
height={630}
priority // LCP 이미지는 priority 필수
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
);
}
폰트 최적화
// app/layout.tsx
import { Noto_Sans_KR } from 'next/font/google';
const notoSansKR = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '700'],
display: 'swap', // FOUT 방지, CLS 개선
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={notoSansKR.className}>
<body>{children}</body>
</html>
);
}
Core Web Vitals 체크리스트
| 지표 | 목표값 | 개선 방법 |
|---|---|---|
| LCP (최대 콘텐츠 렌더링) | 2.5초 이하 | priority 이미지, CDN 배포 |
| FID/INP (상호작용 응답) | 100ms 이하 | 무거운 JS 지연 로딩 |
| CLS (레이아웃 이동) | 0.1 이하 | 이미지 width/height 명시, 폰트 display:swap |
| TTFB (서버 응답 시간) | 800ms 이하 | CDN 엣지 배포, revalidate 활용 |
SSG에 적합한 사이트 유형
| 사이트 유형 | SSG 적합 여부 | 이유 |
|---|---|---|
| 개발자 블로그 | 매우 적합 | 내용이 자주 안 바뀜, SEO 중요 |
| 문서 사이트 | 매우 적합 | 정적 내용, 빠른 탐색 필요 |
| 마케팅 랜딩 | 적합 | 변경 빈도 낮음 |
| 뉴스/매거진 | ISR 적합 | 콘텐츠 업데이트 주기적 발생 |
| 전자상거래 | 부분 SSG | 상품 목록은 ISR, 장바구니는 클라이언트 |
| 대시보드 | 부적합 | 실시간 데이터 필요 |
자주 묻는 질문 (FAQ)
Q. App Router에서 SSG와 SSR은 어떻게 구분하나요?
외부 데이터 없이 렌더링되거나 cache: 'force-cache' fetch를 사용하면 정적 렌더링(SSG)입니다. cookies(), headers(), cache: 'no-store' fetch, dynamic = 'force-dynamic' 중 하나라도 사용하면 동적 렌더링(SSR)으로 전환됩니다.
Q. generateStaticParams를 꼭 써야 하나요?
동적 라우트([slug])에서 빌드 시 정적 생성을 원한다면 필수입니다. 없으면 해당 경로는 요청 시 렌더링됩니다. 블로그처럼 경로가 미리 정해진 경우라면 항상 써야 합니다.
Q. revalidate와 ISR이란 무엇인가요?
ISR(Incremental Static Regeneration)은 정적 페이지를 일정 시간마다 자동 재생성하는 기능입니다. export const revalidate = 3600으로 1시간마다 갱신할 수 있습니다. 완전 정적과 완전 동적 사이의 중간점으로, 콘텐츠가 주기적으로 업데이트되는 사이트에 적합합니다.
핵심 정리
| 항목 | 요점 |
|---|---|
| 기본 렌더링 | App Router는 기본적으로 정적 렌더링 |
| 동적 라우트 | generateStaticParams로 빌드 시 사전 생성 |
| 자동 갱신 | export const revalidate = N (초 단위) |
| 즉시 갱신 | revalidatePath / revalidateTag 호출 |
| SEO | generateMetadata로 페이지별 메타태그 동적 생성 |
| 이미지 | next/image + priority로 LCP 최적화 |
SSG는 설정이 간단하면서도 성능·SEO 효과가 큽니다. 블로그나 문서 사이트라면 특별한 이유 없이 ISR이나 SSR로 전환할 필요가 없습니다.
관련 글: Next.js MDX 블로그 만들기 · Vercel 무료 배포 가이드
관련 글
React vs Next.js 선택 가이드 2026 — 프로젝트에 맞는 프레임워크 고르기
React와 Next.js의 차이점, 장단점, 선택 기준을 2026년 기준으로 비교합니다. SPA vs SSR/SSG, 라우팅, 성능, SEO, 배포 환경까지 실제 프로젝트 상황별로 어떤 것을 선택해야 할지 정리했습니다.
Next.js + MDX로 개발자 블로그 만들기 2026 — 완전 실전 가이드
Next.js App Router와 MDX를 활용해 개발자 블로그를 처음부터 만드는 방법을 단계별로 설명합니다. 정적 생성, SEO 메타데이터, 코드 하이라이팅, RSS 피드, JSON-LD Schema까지 완전 정리했습니다.
Tailwind CSS v4 마이그레이션 완전 가이드 2026
Tailwind CSS v3에서 v4로 마이그레이션하는 방법을 단계별로 정리했습니다. 설정 파일 변경, 테마 마이그레이션, 플러그인 호환성, 자주 발생하는 오류 해결까지 포함합니다.