정적 데이터 생성 (SSG)
빌드 시 HTML을 만드는 SSG를 구현하고 generateStaticParams와 ISR을 결합해 정적 상세 경로를 생성합니다.
Next.js는 높은 성능을 위해 여러 렌더링 전략을 제공하며, 그 대표가 정적 사이트 생성(Static Site Generation, SSG)입니다. SSG는 빌드할 때 페이지를 HTML로 미리 만들어 두고, 요청이 오면 서버 렌더링 없이 곧바로 보내는 방식입니다. 이 방식은 로딩 속도를 크게 높이고, CDN으로 전 세계 사용자에게 빠르게 내용을 제공할 수 있게 해 줍니다. 이 글에서는 Next.js App Router에서 SSG를 구현하는 방법과 장점, 그리고 어떤 상황에서 SSG를 골라야 하는지 자세히 알아봅니다.
실습은 6장 1절의 json-server 0.17.4와 http://localhost:4000을 쓰며, Next.js 16의 cacheComponents는 켜지 않습니다. 포트는 4000번을 목 API, 4100번을 별도의 백엔드나 실시간 서버로 나누어 두면 여러 실습을 함께 할 때 충돌을 줄일 수 있습니다.
SSG란 무엇인가요?
정적 사이트 생성(SSG)은 처음 화면의 HTML과 RSC 결과를 빌드 시점(Build Time)에 미리 만드는 방식입니다. 그 뒤의 클라이언트 상호작용이나 데이터 갱신까지 빌드 시점에 고정하는 것은 아닙니다. 만들어 둔 결과를 Next.js 서버나 CDN에서 재사용하므로, 캐시된 경로에 요청이 올 때마다 원본 데이터를 읽고 화면을 다시 계산하는 비용이 줄어듭니다.
SSG에서는 미리 만들어 둔 HTML을 돌려주므로 요청 시점의 데이터 요청이나 서버 렌더링 비용이 없어 응답이 빠릅니다. 검색 엔진 크롤러가 미리 만든 HTML을 쉽게 읽고 색인할 수 있어 검색 엔진 최적화에 유리하고, 서버의 부하가 거의 없어 트래픽이 많아도 서버 비용을 줄일 수 있습니다. 빌드할 때 만든 결과이므로 런타임 오류의 가능성도 적지만, 가용성은 CDN과 서버, 배포의 구성에 달려 있습니다.
그래서 SSG는 블로그 게시물이나 문서, 재고가 잘 바뀌지 않는 상품 목록, 포트폴리오 사이트처럼 내용이 자주 바뀌지 않는 경우, 사용자마다 다른 내용이 필요 없이 모든 사용자에게 같은 페이지를 보여 줄 때, 검색 엔진 노출이 중요한 경우에 씁니다. SSG를 고를 때는 미리 만들 수 있는지와 얼마나 자주 바뀌는지를 함께 판단해야 합니다.
| 시점 | 하는 일 |
|---|---|
| 빌드 | 미리 만들 경로를 정하고 처음 화면의 HTML·RSC 결과를 만듭니다. |
| 배포 | 결과와 정적 자산을 서버·CDN이 제공할 수 있게 둡니다. |
| 요청 | 준비된 경로의 결과를 재사용하며, 목록 밖의 경로와 갱신은 별도의 정책을 따릅니다. |
App Router에서 SSG 구현하기
동적 세그먼트가 없는 경로도 정적으로 사전 렌더링할 수 있지만, 이 실습은 동적 라우트([slug], [id] 등)에서 generateStaticParams로 미리 만들 매개변수의 목록을 정합니다. 이 함수만 더한다고 요청 시점 API를 쓰는 라우트까지 정적으로 바뀌지는 않습니다. generateStaticParams는 프로덕션 빌드에서 페이지를 만들기 전에 실행되어, 그 동적 라우트에서 어떤 매개변수 값을 미리 만들지 Next.js에 알려 줍니다. 개발 서버에서는 그 라우트로 이동할 때 불리므로, 프로덕션 빌드에서만 실행되는 함수로 오해하면 안 됩니다.
구현은 세 단계입니다. SSG할 페이지의 동적 라우트 폴더(예: blog/[slug])를 만들고, 그 안의 page.tsx에서 params prop으로 동적인 값을 받아 데이터를 가져와 UI를 렌더링하며, page.tsx(또는 layout.tsx) 안에 미리 만들 경로의 매개변수 객체 배열을 돌려주는 async 함수 generateStaticParams를 정의합니다.
5장 1절에서 만든 src/app/posts/[id]/page.tsx 예제로, 게시물 상세 페이지들을 빌드할 때 미리 만들도록 설정해 봅시다.
import Link from 'next/link';
interface PostDetailPageProps {
params: Promise<{
id: string; // URL에서 추출될 게시물 ID
}>;
}
interface Post {
id: number;
title: string;
body: string;
}
// 1. 특정 게시물 데이터를 가져오는 함수 (서버 컴포넌트 내부에서 사용)
async function getPost(id: string): Promise<Post> {
const res = await fetch(`http://localhost:4000/posts/${id}`);
if (!res.ok) {
// 실제 서비스에서는 적절한 에러 처리 또는 notFound() 호출
throw new Error(`Failed to fetch post with ID: ${id}`);
}
return res.json();
}
// 2. generateStaticParams 함수 정의: 미리 생성할 경로들을 결정합니다.
// 이 함수는 'app' 폴더의 동적 라우트에서 페이지, 레이아웃, Route Handler와 함께 쓸 수 있습니다.
export async function generateStaticParams() {
// 프로덕션 빌드 전 또는 개발 중 이 라우트로 이동할 때 호출됩니다.
console.log('generateStaticParams 🚀: fetching all posts IDs for SSG');
const res = await fetch('http://localhost:4000/posts?_limit=20');
const payload = await res.json();
const posts: Post[] = payload;
// 모든 게시물의 ID를 추출하여 { id: string } 형태의 객체 배열로 반환합니다.
// Next.js는 이 배열의 각 객체에 대해 해당 페이지를 미리 생성합니다.
return posts.map((post) => ({
id: post.id.toString(), // params의 값은 항상 문자열이어야 합니다.
}));
}
// 3. 페이지 컴포넌트: params를 받아 데이터를 렌더링합니다.
export default async function PostDetailPage({ params }: PostDetailPageProps) {
const { id } = await params;
const post = await getPost(id); // getStaticProps의 context.params와 유사하게 작동
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto', border: '1px solid #eee', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
<h1 style={{ color: '#333', marginBottom: '15px' }}>{post.title}</h1>
<p style={{ lineHeight: '1.6', color: '#555' }}>{post.body}</p>
<div style={{ marginTop: '30px', paddingTop: '15px', borderTop: '1px dashed #eee' }}>
<Link href="/posts" style={{ color: '#0070f3', textDecoration: 'none' }}>
← 목록으로 돌아가기
</Link>
</div>
</div>
);
}반환 목록 밖의 매개변수는 dynamicParams 정책을 따릅니다. 경로 목록을 미리 정하는 일과 API의 데이터를 저장하는 일은 다르며, getPost는 고른 id의 본문을 가져오고 generateStaticParams는 미리 만들 경로의 문자열 id를 돌려줍니다. 목록의 길이가 페이지에 보일 게시물의 수나 DB의 총 개수를 대신하지 않으며, getPost는 HTTP 실패를 일반 오류로 던질 뿐 없는 게시물을 notFound()로 처리하는 구현은 없습니다.
SSG를 시험하려면 먼저 터미널에서 다음 명령으로 애플리케이션을 빌드합니다.
npm run build
# 또는
yarn build빌드하는 동안 generateStaticParams가 실행되어 console.log 메시지가 터미널에 나옵니다. 일반적인 next build의 사전 렌더링 결과와 캐시 정보는 .next 빌드 산출물에 들어가며, output: 'export'로 정적 내보내기를 골랐을 때만 독립된 정적 파일이 기본 out 폴더에 만들어집니다. 아래 next start 실습은 일반 빌드를 기준으로 하며, 정적 내보내기는 별도의 파일 서버로 제공하고 ISR을 지원하지 않습니다. 다음 명령으로 프로덕션 모드로 실행합니다.
npm run start
# 또는
yarn starthttp://localhost:3000/posts/1이나 /posts/3 같은 URL에 들어가 보세요. 로컬에서는 next start 서버가 빌드할 때 준비한 사전 렌더링 결과로 응답하므로 요청마다 페이지 전체를 새로 렌더링하지 않습니다. CDN을 제공하는 플랫폼에 배포하면 이 정적 응답을 엣지 캐시에서 보낼 수도 있지만, localhost 실행 자체에는 CDN이 끼어들지 않습니다.
generateStaticParams의 작동 방식
generateStaticParams는 프로덕션 빌드에서 그에 대응하는 페이지나 레이아웃을 만들기 전에 실행되고, 개발 서버에서는 그 라우트로 이동할 때 불립니다. ISR로 페이지를 다시 검증할 때는 다시 불리지 않으므로, 배포한 뒤 새 경로를 허용할지는 dynamicParams와 반환 목록을 함께 보고 정해야 합니다. 함수 안에서도 fetch 같은 비동기 데이터 요청을 쓸 수 있으며, 그 결과는 미리 만들 경로의 목록을 정하는 데 쓰입니다.
함수는 { paramName: value } 모양의 객체 배열을 돌려줘야 하며, paramName은 동적 라우트 폴더의 대괄호 안 이름([id]의 id)과 정확히 같아야 하고, 세그먼트 하나의 value는 string입니다. Catch-all 세그먼트([...slug])라면 slug: ['path', 'to', 'document']처럼 문자열 배열을 돌려줍니다.
export async function generateStaticParams() {
return [{ slug: ['a', 'b'] }, { slug: ['c'] }];
}generateStaticParams가 돌려주는 경로가 많을수록 빌드 시간이 길어집니다. 그래서 실제로 필요한 페이지만 SSG하거나, 중요한 페이지 위주로 SSG하고 나머지는 CSR이나 SSR로 처리하는 전략을 생각할 수 있습니다(Next.js 문서의 generateStaticParams).
더 깊이: 빌드할 경로를 나누는 세 가지 방법
반환하는 목록의 크기로 빌드 시점과 첫 방문 시점의 작업을 나눌 수 있습니다. 모든 경로를 돌려주면 빌드할 때 전부 만들고, 일부만 돌려주면 그 경로만 빌드할 때 만들고 나머지는 dynamicParams의 기본값 true에서 처음 방문될 때 만들어 이후 요청에 다시 씁니다. 빈 배열을 돌려주면 빌드할 때는 아무 경로도 만들지 않고, 모든 경로를 처음 방문될 때 정적으로 만듭니다. 이때 빈 배열이라도 반드시 배열을 돌려줘야 하며, 배열을 돌려주지 않으면 그 라우트는 요청마다 렌더링되는 동적 라우트가 됩니다. 이 교재에서 켜지 않은 Cache Components를 쓰는 프로젝트라면 사정이 달라서, 빈 배열이 빌드 오류가 되므로 적어도 하나의 값을 돌려줘야 합니다.
동적 세그먼트가 여러 개인 경로는 두 방향으로 만들 수 있습니다. products/[category]/[product]/page.tsx 하나가 두 값을 함께 돌려주는 아래에서 위로의 방법과, products/[category]/layout.tsx가 category 목록을 먼저 돌려주고 페이지의 함수가 부모가 만든 값마다 한 번씩 실행되는 위에서 아래로의 방법입니다. 위에서 아래로 만들 때 자식 함수는 인자의 params로 부모의 값을 받는데, 이 params는 페이지의 params와 달리 Promise가 아니라 바로 읽을 수 있는 객체입니다. 어느 방법이든 이 함수들과 레이아웃, 페이지가 같은 데이터를 fetch하면 그 요청은 자동으로 메모이제이션되어 빌드 중에 같은 요청을 반복하지 않습니다.
빌드할 때 읽은 상품 가격 남겨 두기
앞 글 서버 컴포넌트에서 데이터 페칭에서 준비한 상품을 SSG 페이지로 보여 줍니다. 아래 파일에는 요청 정보를 읽는 API나 재검증 설정이 없으므로, 이 조건에서는 기본 fetch가 빌드할 때 원본을 읽고, 만들어진 HTML·RSC 라우트 결과가 이후의 요청에 재사용됩니다. 데이터 응답을 force-cache로 저장하는 것과는 구분합니다.
interface Product {
id: number;
name: string;
price: number;
}
async function getProduct(): Promise<Product> {
const res = await fetch('http://localhost:4000/products/1');
if (!res.ok) {
throw new Error(`상품 조회 실패: ${res.status}`);
}
return res.json();
}
export default async function SsgProductPage() {
const product = await getProduct();
return (
<main>
<h1>SSG 상품 가격</h1>
<p>{product.name} (상품 {product.id})</p>
<p>가격: {product.price.toLocaleString('ko-KR')}원</p>
</main>
);
}원본 가격이 299000일 때 빌드에 성공했다면, 그 빌드의 SSG 페이지에는 “299,000원”이 남습니다. 원본만 349000으로 바꾸고 새로 요청해도 같은 빌드의 정적 결과는 바뀌지 않으며, 새로 빌드에 성공해야 새 원본을 읽은 결과를 만들 수 있습니다. 목 API가 꺼졌거나 상품 조회에서 오류가 던져지면 이 페이지의 빌드도 실패할 수 있으므로, 성공한 빌드가 없는 상태를 “이전 가격을 계속 보여 주는 SSG”로 해석하지 않습니다. Product는 TypeScript의 타입일 뿐 API 응답의 필드와 자료형을 실행 중에 검사하는 코드가 아닙니다.
이 파일만 만든 뒤 곧바로 비교를 시작하지 말고, 서버 사이드 렌더링 (SSR)의 SSR·ISR 파일과 확인 순서까지 준비합니다. 세 라우트가 같은 원본을 읽어야 만들어지는 시점의 차이를 볼 수 있습니다.
SSG와 ISR
generateStaticParams로 SSG한 페이지는 기본적으로 빌드 시점에 고정됩니다. 하지만 fetch의 next.revalidate 옵션을 쓰면, 캐시가 정한 시간보다 오래된 뒤에 들어온 요청을 계기로 백그라운드에서 재검증해 새 데이터를 반영할 수 있으며, 이것이 ISR(Incremental Static Regeneration)입니다. SSG와 ISR은 모두 정적 응답의 장점을 활용하지만, 데이터가 얼마나 자주 바뀌는지에 따라 고르는 기준이 달라집니다.
아래는 재검증 설정에 집중한 일부 코드입니다. Product 타입과 일부 오류 처리, 페이지가 돌려주는 JSX가 생략되어 있으므로 그대로 실행할 수 있는 완성 파일은 아닙니다.
// ... (다른 코드 생략) ...
async function getProduct(id: string): Promise<Product> {
const res = await fetch(`http://localhost:4000/products/${id}`, {
// 이 페이지는 SSG로 미리 생성되지만, 캐시가 60초 이상 지난 뒤
// 새 요청이 들어오면 백그라운드 재검증을 시작할 수 있습니다.
next: { revalidate: 60 },
});
if (!res.ok) { /* ... */ }
return res.json();
}
export async function generateStaticParams() {
// 빌드 시점에 생성할 모든 상품 ID를 반환
// ... (모든 상품 ID를 가져오는 로직) ...
return [{ id: '1' }]; // 6장 1절 db.json의 상품 ID(숫자 1)를 문자열로
}
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
// ... (상품 정보 렌더링) ...
}위 예시에서 generateStaticParams는 프로덕션 빌드에서 미리 만들 경로를 정합니다. fetch의 next: { revalidate: 60 }은 캐시가 60초 이상 지난 뒤 새 요청이 들어오면 재검증할 수 있게 하지만, 이 과정에서 generateStaticParams를 다시 부르지는 않습니다.
정리
- SSG는 빌드할 때 처음 화면의 HTML과 RSC 결과를 만들어 두고 이후의 요청에 재사용하는 방식으로, 내용이 잘 바뀌지 않고 모든 사용자에게 같은 페이지에 알맞습니다.
- 동적 라우트에서는
generateStaticParams가 미리 만들 매개변수의 목록을 돌려주며, 값은 문자열(Catch-all은 문자열 배열)입니다. generateStaticParams는 프로덕션 빌드 전에 실행되고 개발 서버에서는 이동할 때 불리며, ISR의 재검증 때는 다시 불리지 않습니다.- 목록 밖 경로의 허용 여부는
dynamicParams로 정하며, 기본값에서는 요청 때 처리하고false에서는 404입니다. - SSG 결과는 같은 빌드에서 고정되므로, 원본이 바뀌어도 새로 빌드하거나 ISR로 재검증해야 반영됩니다.
연습 문제
1. 미리 만든 목록과 요청 경로
목 API에는 게시물 1, 2, 10이 있지만 미리 만들 목록은 [{ id: '1' }, { id: '2' }]라고 가정합니다. cookies나 headers 같은 요청 시점 API를 쓰지 않는 이 실습 모델에서, 빌드 전에 무엇을 알고 무엇을 /posts/10 요청 때 판단해야 할까요? 기본 dynamicParams와 false를 구분해 답해 보세요. 아래 코드는 false를 고를 때 더하는 선언이며 원래 페이지의 필수 줄은 아닙니다.
export const dynamicParams = false;해설 보기
빌드는 목록에 있는 1과 2의 페이지를 미리 준비합니다. 기본 정책에서는 10이 목록에 없다는 이유만으로 404가 정해지지 않으며, 요청 때 허용된 경로의 데이터를 읽을 수 있습니다. 반면 false라면 목록 밖의 10은 원본에 있어도 404가 됩니다.
목록에 든 경로라도 API가 실패하면 getPost는 일반 오류를 던지므로, “목록에 있다”와 “성공한 페이지가 있다”도 구분해야 합니다. 새 경로의 캐시 수명은 라우트와 데이터의 설정을 함께 확인하며, 이 가정만으로 모든 요청의 캐시 결과를 단정하지 않습니다.
2. 원본만 바뀐 SSG 페이지
render-compare/ssg 페이지를 가격 299000으로 빌드해 next start로 실행한 뒤, db.json의 가격을 349000으로 바꾸고 페이지를 새로고침했습니다. 화면의 가격은 무엇일까요? 349,000원을 보려면 어떻게 해야 할까요?
해설 보기
여전히 “299,000원”입니다. 이 페이지는 재검증 설정이 없는 SSG라서 빌드할 때 읽은 결과가 그대로 재사용되며, 원본이 바뀐 것은 같은 빌드의 결과에 반영되지 않습니다. 349,000원을 보려면 목 API가 실행 중인 상태에서 다시 npm run build로 빌드하고 npm run start로 실행합니다. 시간이 지나면 저절로 바뀌게 하려면 다음 글들의 ISR처럼 재검증 설정을 더해야 합니다.
3. 빌드 시간이 너무 길 때
상품이 10만 개인 쇼핑몰에서 generateStaticParams가 모든 상품의 ID를 돌려주게 했더니 빌드가 몇 시간씩 걸립니다. 어떤 방법을 생각할 수 있을까요?
해설 보기
조회가 많은 인기 상품처럼 중요한 일부만 generateStaticParams로 돌려주고, 나머지는 dynamicParams의 기본값(true)에 맡겨 첫 요청이 왔을 때 만들게 할 수 있습니다. 데이터가 바뀌는 주기에 맞춰 revalidate를 더하면 만든 결과도 일정 시간마다 갱신됩니다. 사용자마다 달라지는 페이지라면 SSG 대신 SSR이나 클라이언트 측 요청을 검토합니다.
다음 글 서버 사이드 렌더링 (SSR)에서는 요청할 때마다 서버에서 페이지를 렌더링하는 SSR을 알아보고, 같은 상품으로 SSG·SSR·ISR의 차이를 비교합니다.