[Next.js] 정적 페이지 생성(SSG) 제대로 활용하기
- Developer/Web Frontend
- 2025. 3. 31.
웹 개발자라면 누구나 겪는 문제다. 사용자가 많아질수록 서버는 부하를 견디지 못하고, 결국 성능은 떨어진다. 특히 내용이 자주 바뀌지 않는 페이지까지 매번 서버에서 새로 생성한다면 그건 명백한 자원 낭비다. 최근 React 기반 프로젝트 몇 개를 Next.js로 마이그레이션하면서 정적 생성(Static Site Generation, SSG)을 적용해봤는데, 성능 개선 효과가 생각보다 훨씬 컸다. 이번 글에서는 삽질하면서 배운 Next.js의 SSG 구현 방법과 실제 적용 시 주의점을 공유하려 한다.


목차
정적 생성(SSG)의 원리와 이점
SSG(Static Site Generation)라는 용어가 어렵게 들릴 수 있지만, 개념은 단순하다. 빌드 시점에 HTML을 미리 생성해두고, 사용자 요청이 들어올 때마다 이미 만들어진 HTML을 그대로 제공하는 방식이다. 반대 개념인 SSR(Server-Side Rendering)은 요청마다 서버에서 페이지를 새로 만들어 제공한다.
솔직히 말하자면, 예전에는 정적 사이트라고 하면 뭔가 기능이 제한적이고 촌스러운 웹사이트를 떠올렸다. 하지만 Next.js, Gatsby 같은 현대적인 프레임워크의 등장으로 정적 사이트의 개념이 완전히 바뀌었다. 지금의 SSG는 React 같은 최신 프론트엔드 기술을 모두 활용하면서도 정적 배포의 이점을 누릴 수 있다.
SSG의 명확한 장점들
SSG가 이론적으로는 좋아 보이지만 실제로 어떤 이점이 있을까? 지난 프로젝트에서 SSR에서 SSG로 전환한 후 확실히 체감한 이점들이다..
- 압도적인 속도 향상: TTFB(Time to First Byte)가 평균 300ms에서 70ms로 줄었다.
- 서버 부하 감소: 트래픽이 10배 증가해도 서버 리소스 사용량은 거의 변화가 없었다.
- CDN 활용 최적화: Vercel, Cloudflare 같은 CDN에 캐싱되어 전 세계 어디서든 빠른 접속 속도 보장.
- 비용 절감: 서버리스 구조로 전환한 후 인프라 비용이 약 60% 감소했다.
- 보안 강화: 정적 파일만 제공하므로 서버 사이드 취약점 공격 표면이 크게 감소한다.
Next.js에서 SSG 구현하기
Next.js의 가장 큰 장점 중 하나는 특별한 설정 없이도 SSG를 쉽게 적용할 수 있다는 점이다. 사실 Next.js는 기본적으로 모든 페이지를 정적으로 생성하려고 시도한다. 따라서 대부분의 경우 특별한 작업 없이도 SSG가 적용된다.
하지만 SSG를 제대로 활용하려면 몇 가지 핵심 API를 이해해야 한다. Next.js에서 사용하는 SSG 관련 메소드들과 각각의 사용 상황을 비교해보자.
Next.js API | 용도 | 사용 상황 |
---|---|---|
getStaticProps |
빌드 시점에 데이터 가져오기 | 정적 페이지에 외부 데이터가 필요할 때 |
getStaticPaths |
동적 라우트의 경로 지정 | 동적 라우팅([id].js 같은)을 정적으로 생성할 때 |
next.config.js |
Next.js 설정 | 전역 설정, 빌드 옵션 조정 필요 시 |
revalidate |
증분 정적 재생성(ISR) | 정적 페이지를 주기적으로 업데이트할 때 |
가장 기본적인 SSG 페이지 구현은 아래와 같다. 아무런 특별한 데이터 없이 정적 HTML만 필요한 경우는 일반 React 컴포넌트처럼 작성하면 된다.
// pages/about.js
function About() {
return (
<div>
<h1>About Us</h1>
<p>This is a static page that will be generated at build time.</p>
</div>
)
}
export default About
빌드 타임 데이터 페칭 방법
정적 페이지에 동적 데이터를 넣어야 하는 경우가 많다. 이럴 때 사용하는 것이 getStaticProps
다. 이 함수는 빌드 시점에 실행되어 필요한 데이터를 가져온 후, 그 결과를 페이지 컴포넌트에 props로 전달한다.
빌드 타임 데이터 페칭을 구현하는 방법에는 여러 가지가 있다. 가장 많이 사용되는 방법들을 정리해봤다
-
API 호출로 데이터 가져오기
외부 API에서 데이터를 가져와 정적 페이지에 주입하는 방법. 가장 일반적인 사용 사례다.
// pages/posts.js export async function getStaticProps() { // 외부 API 호출 const res = await fetch('https://api.example.com/posts') const posts = await res.json() // props 형태로 컴포넌트에 데이터 전달 return { props: { posts, }, } } function Posts({ posts }) { return ( <div> <h1>Blog Posts</h1> <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ) }
-
데이터베이스에서 직접 가져오기
빌드 시점에 데이터베이스에 직접 접근하여 정보를 가져오는 방법이다. 보안에 주의해야 한다.
-
파일 시스템 활용하기
마크다운 파일과 같은 로컬 파일에서 데이터를 가져오는 방법. 블로그나 문서 사이트에 적합하다.
-
CMS 활용하기
Contentful, Sanity, Strapi 같은 콘텐츠 관리 시스템에서 데이터를 가져오는 방법.
Q: getStaticProps에서 API 키와 같은 민감한 정보를 어떻게 다뤄야 하나요?
A: getStaticProps는 서버 사이드에서만 실행되고 클라이언트 번들에 포함되지 않기 때문에, 환경 변수를 직접 사용해도 안전합니다. 다만, process.env.API_KEY와 같이 사용하고, NEXT_PUBLIC_ 접두사 없이 .env.local 파일에 정의하세요. 이렇게 하면 클라이언트 측 JavaScript 번들에 노출되지 않습니다.
점진적 정적 재생성(ISR) 활용하기
정적 생성의 가장 큰 단점은 뭘까? 바로 콘텐츠 업데이트다. 전통적인 SSG는 빌드 시점에만 페이지를 생성하기 때문에, 콘텐츠가 변경될 때마다 전체 사이트를 재빌드해야 했다. 이는 대규모 사이트에서는 큰 문제가 된다.
Next.js의 ISR(Incremental Static Regeneration)은 이 문제를 해결한다. 특정 시간 간격으로 백그라운드에서 페이지를 재생성할 수 있게 해준다. 이를 통해 정적 생성의 성능 이점을 유지하면서도 데이터를 최신 상태로 유지할 수 있다.
ISR 구현하기
ISR을 구현하는 방법은 매우 간단하다. getStaticProps
함수에 revalidate
속성을 추가하기만 하면 된다. 이 값은 페이지가 재생성될 수 있는 최소 시간(초)을 나타낸다.
// pages/products/[id].js
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/products/${params.id}`)
const product = await res.json()
return {
props: {
product,
},
// 60초마다 페이지 재생성 가능
revalidate: 60,
}
}
export async function getStaticPaths() {
const res = await fetch('https://api.example.com/products')
const products = await res.json()
const paths = products.map((product) => ({
params: { id: product.id.toString() },
}))
return { paths, fallback: 'blocking' }
}
function ProductPage({ product }) {
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<p>${product.price}</p>
</div>
)
}
위 코드에서 revalidate: 60
은 페이지가 최소 60초마다 백그라운드에서 재생성될 수 있음을 의미한다. 하지만 여기서 중요한 점은, 페이지가 정확히 60초마다 재생성되는 것이 아니라는 점이다. 페이지 요청이 들어올 때만 Next.js가 재생성 여부를 판단한다.
동적 라우트에서 SSG 구현하기
블로그 게시물, 제품 상세 페이지 같은 동적 라우트에서 SSG를 활용하려면 getStaticPaths
와 getStaticProps
를 함께 사용해야 한다. getStaticPaths
는 빌드 시점에 어떤 경로들을 생성할지 지정하고, getStaticProps
는 각 경로에 필요한 데이터를 가져온다.
getStaticPaths
에서 가장 중요한 속성은 fallback
이다. 이 값에 따라 빌드 시 생성되지 않은 경로에 대한 처리 방식이 결정된다. 각 fallback 값별 차이점을 비교해보자.
fallback 값 | 동작 방식 | 적합한 상황 |
---|---|---|
false |
빌드 시 생성되지 않은 경로는 404 반환 | 경로 수가 적고 고정되어 있는 경우 |
true |
빌드 시 생성되지 않은 경로도 요청 시 생성, 로딩 UI 표시 | 대규모 동적 콘텐츠, 로딩 상태 처리 필요 시 |
'blocking' |
빌드 시 생성되지 않은 경로도 요청 시 생성, SSR처럼 완료될 때까지 대기 | SEO 중요한 페이지, 로딩 상태 없이 완성된 HTML 필요 시 |
개인적으로 대부분의 상황에서 fallback: 'blocking'
을 선호한다. 이는 사용자 경험과 SEO 측면에서 가장 안전한 옵션이다. fallback: true
는 페이지 로딩 상태를 커스텀하게 처리해야 하므로 구현이 더 복잡해질 수 있다.
SSG 최적화 전략과 성능 측정
SSG를 구현했다고 해서 자동으로 최고의 성능이 보장되는 것은 아니다. 다양한 최적화 전략을 통해 더 나은 성능을 얻을 수 있다. 실제 프로젝트에서 효과를 본 SSG 최적화 전략들을 소개한다.
- 선택적 페이지 생성: 모든 페이지를 한번에 생성하지 말고, 중요한 페이지만 빌드 시 생성하고 나머지는 ISR로 처리하는 전략
- 데이터 오버페칭 줄이기: 필요한 데이터만 정확히 가져와 빌드 시간과 페이지 크기 최적화
- 이미지 최적화: Next.js의 Image 컴포넌트를 활용해 이미지 최적화 및 지연 로딩 적용
- 증분 빌드 활용: Vercel이나 Netlify 같은 플랫폼의 증분 빌드 기능 활용하여 빌드 시간 단축
- 클라이언트 사이드 데이터 페칭 조합: 정적 콘텐츠는 SSG로, 사용자별 데이터는 클라이언트에서 페칭하는 하이브리드 접근법
- 코드 분할: dynamic import를 사용하여 필요한 코드만 로드하도록 최적화
성능 최적화 작업을 했다면 반드시 측정을 통해 개선 효과를 확인해야 한다. SSG 성능을 측정하기 위한 주요 지표들은 다음과 같다,
- Time to First Byte (TTFB): 요청 시작부터 첫 바이트 수신까지 걸린 시간
- First Contentful Paint (FCP): 첫 콘텐츠가 화면에 표시되는데 걸린 시간
- Largest Contentful Paint (LCP): 가장 큰 콘텐츠 요소가 화면에 표시되는데 걸린 시간
- Cumulative Layout Shift (CLS): 페이지 로딩 중 레이아웃 변화량
- Total Blocking Time (TBT): 사용자 입력에 대한 응답이 지연되는 총 시간
Q: 수만 개의 동적 페이지를 가진 대규모 사이트에서 빌드 시간이 너무 오래 걸립니다. 어떻게 해결할 수 있을까요?
A: 모든 페이지를 빌드 시점에 생성하지 말고, 트래픽이 많은 핵심 페이지만 미리 생성하세요. getStaticPaths에서 paths 배열에 포함시킬 페이지 수를 제한하고 fallback: 'blocking'으로 설정하면, 나머지 페이지는 첫 요청 시 자동으로 생성됩니다. 이렇게 하면 빌드 시간을 크게 단축할 수 있습니다. 실제 프로젝트에서는 Google Analytics 같은 분석 도구를 활용해 가장 많이 방문하는 상위 100~1000개 페이지만 빌드 시점에 생성하는 전략이 효과적입니다.
Q: SSG와 ISR에서 유저별 개인화된 콘텐츠는 어떻게 처리해야 하나요?
A: SSG/ISR은 모든 사용자에게 동일한 HTML을 제공하므로, 개인화된 콘텐츠는 클라이언트 사이드에서 처리해야 합니다. React의 useEffect 훅이나 SWR/React Query 같은 라이브러리를 사용해 컴포넌트가 마운트된 후 개인화 데이터를 가져오세요. 기본 페이지 구조는 정적 생성으로 빠르게 로드하고, 사용자별 맞춤 콘텐츠는 자바스크립트로 동적 삽입하는 하이브리드 접근법이 가장 효과적입니다.
실전 Next.js SSG 구현 예제
지금까지 설명한 내용을 바탕으로 실제 활용 가능한 Next.js SSG 블로그 시스템 코드 예제를 만들어봤다. 이 예제는 블로그 글 목록 페이지와 상세 페이지를 정적 생성하면서도 주기적으로 콘텐츠를 업데이트하는 ISR 기능을 포함한다.
// lib/api.js - 데이터 페칭 함수
export async function getAllPosts() {
// 실제 프로젝트에서는 이 부분을 CMS, 데이터베이스 등에서 데이터를 가져오는 코드로 변경
const res = await fetch('https://api.example.com/posts')
const posts = await res.json()
return posts
}
export async function getPostBySlug(slug) {
const res = await fetch(`https://api.example.com/posts/${slug}`)
const post = await res.json()
return post
}
// pages/blog/index.js - 블로그 목록 페이지
import Link from 'next/link'
import { getAllPosts } from '../../lib/api'
export default function Blog({ posts }) {
return (
<div className="container">
<h1>블로그</h1>
<div className="posts-grid">
{posts.map(post => (
<article key={post.slug} className="post-card">
<Link href={`/blog/${post.slug}`}>
<a>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<div className="post-meta">
<time>{new Date(post.date).toLocaleDateString()}</time>
<span>{post.readingTime} min read</span>
</div>
</a>
</Link>
</article>
))}
</div>
</div>
)
}
export async function getStaticProps() {
const posts = await getAllPosts()
return {
props: {
posts,
},
// 10분마다 페이지 재생성 시도
revalidate: 600,
}
}
// pages/blog/[slug].js - 블로그 상세 페이지
import { getAllPosts, getPostBySlug } from '../../lib/api'
import markdownToHtml from '../../lib/markdownToHtml'
export default function Post({ post }) {
return (
<div className="container">
<article>
<h1>{post.title}</h1>
<div className="post-meta">
<time>{new Date(post.date).toLocaleDateString()}</time>
<span>{post.readingTime} min read</span>
</div>
<div
className="post-content"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</div>
)
}
export async function getStaticPaths() {
const posts = await getAllPosts()
// 모든 포스트 페이지를 빌드 시 생성하는 대신
// 최근 10개의 포스트만 미리 생성
const paths = posts
.slice(0, 10)
.map(post => ({
params: { slug: post.slug },
}))
return {
paths,
// 나머지는 요청 시 생성
fallback: 'blocking',
}
}
export async function getStaticProps({ params }) {
const post = await getPostBySlug(params.slug)
const content = await markdownToHtml(post.content || '')
return {
props: {
post: {
...post,
content,
},
},
// 1시간마다 재생성 시도
revalidate: 3600,
}
}
ISR과 온디맨드 재검증 조합하기
Next.js 12.2부터는 ISR에 온디맨드 재검증(On-demand Revalidation) 기능이 추가되었다. 이를 통해 특정 이벤트가 발생했을 때(예: CMS에서 콘텐츠 업데이트) 해당 페이지만 즉시 재생성할 수 있다. 다음은 온디맨드 재검증을 위한 API 라우트 구현 예제다.
// pages/api/revalidate.js
export default async function handler(req, res) {
// 요청 검증 (실제 환경에서는 더 강력한 인증 필요)
if (req.query.secret !== process.env.REVALIDATION_TOKEN) {
return res.status(401).json({ message: '유효하지 않은 토큰' })
}
try {
// path 쿼리 파라미터로 전달된 경로 재검증
const path = req.query.path
if (!path) {
return res.status(400).json({ message: '경로가 지정되지 않았습니다' })
}
// 해당 경로 재검증 실행
await res.revalidate(path)
return res.json({
revalidated: true,
message: `경로 ${path} 재검증 완료`
})
} catch (err) {
// 에러가 발생하면 429 상태코드 반환
return res.status(500).json({
message: `재검증 실패: ${err.message}`
})
}
}
이 API 라우트를 호출하여 특정 페이지를 즉시 재생성할 수 있다.
// 블로그 상세 페이지 재생성
fetch('/api/revalidate?path=/blog/post-1&secret=your-token')
// 블로그 목록 페이지 재생성
fetch('/api/revalidate?path=/blog&secret=your-token')
이 방법을 사용하면 CMS나 백엔드 시스템에서 콘텐츠가 업데이트될 때마다 웹훅을 통해 해당 API를 호출하여 관련 페이지만 즉시 업데이트할 수 있다. 이는 주기적인 ISR과 함께 사용하면 더욱 효과적이다.
Next.js 13 App Router에서의 SSG
최신 Next.js 13에서는 App Router를 통해 정적 생성이 더욱 간단해졌다. 이제 더 이상 getStaticProps
나 getStaticPaths
를 사용할 필요가 없으며, 컴포넌트 내에서 직접 데이터를 가져올 수 있다.
// app/blog/page.js - 블로그 목록 페이지 (App Router)
import Link from 'next/link'
import { getAllPosts } from '@/lib/api'
// 이 페이지는 기본적으로 정적으로 생성됨
export default async function BlogPage() {
// 서버 컴포넌트에서 직접 데이터 가져오기
const posts = await getAllPosts()
return (
<div className="container">
<h1>블로그</h1>
<div className="posts-grid">
{posts.map(post => (
<article key={post.slug} className="post-card">
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<div className="post-meta">
<time>{new Date(post.date).toLocaleDateString()}</time>
<span>{post.readingTime} min read</span>
</div>
</Link>
</article>
))}
</div>
</div>
)
}
// ISR 설정
export const revalidate = 600 // 10분
// app/blog/[slug]/page.js - 블로그 상세 페이지 (App Router)
import { getAllPosts, getPostBySlug } from '@/lib/api'
import markdownToHtml from '@/lib/markdownToHtml'
export default async function PostPage({ params }) {
const post = await getPostBySlug(params.slug)
const content = await markdownToHtml(post.content || '')
return (
<div className="container">
<article>
<h1>{post.title}</h1>
<div className="post-meta">
<time>{new Date(post.date).toLocaleDateString()}</time>
<span>{post.readingTime} min read</span>
</div>
<div
className="post-content"
dangerouslySetInnerHTML={{ __html: content }}
/>
</article>
</div>
)
}
// 정적 경로 생성
export async function generateStaticParams() {
const posts = await getAllPosts()
// 최근 10개 포스트만 미리 생성
return posts.slice(0, 10).map(post => ({
slug: post.slug,
}))
}
// ISR 설정
export const revalidate = 3600 // 1시간
App Router에서는 export const revalidate = 시간(초)
로 간단하게 ISR을 설정할 수 있고, generateStaticParams
함수로 정적 경로를 정의할 수 있다. 이전보다 더 직관적이고 간결한 문법으로 SSG 및 ISR을 구현할 수 있게 되었다.
Next.js의 정적 생성(SSG)은 프론트엔드 개발자라면 꼭 익혀둬야 할 핵심 기술이다. 기존의 서버 사이드 렌더링 방식이 가지는 성능 한계와 인프라 부담을 획기적으로 개선할 수 있기 때문이다. 매번 똑같은 HTML을 생성하는 비효율적인 SSR 방식에서 벗어나 빌드 타임에 미리, 혹은 첫 요청 시 한 번만 페이지를 생성하는 SSG/ISR 방식으로 전환하면 빠른 응답 속도와 서버 부하 감소라는 두 마리 토끼를 모두 잡을 수 있다.
물론 SSG가 만능은 아니다. 사용자별로 다른 콘텐츠를 보여줘야 하는 페이지나, 실시간 데이터가 중요한 대시보드 같은 페이지에는 적합하지 않다. 그러나 블로그, 제품 상세 페이지, 마케팅 페이지, 문서 사이트 등 대다수의 웹사이트에서는 SSG가 최적의 선택일 가능성이 높다. 특히 Next.js의 ISR을 활용하면 정적 사이트의 한계였던 콘텐츠 업데이트 문제도 효과적으로 해결할 수 있다.
'Developer > Web Frontend' 카테고리의 다른 글
[Next.js] 동적 라우팅 (0) | 2025.04.02 |
---|---|
[Next.js] Next.js 서버 사이드 렌더링(SSR) (0) | 2025.04.01 |
[Next.js] pages 폴더 구조와 라우팅 (0) | 2025.03.31 |
[Next.js] 설치부터 첫 프로젝트 시작까지. (0) | 2025.03.13 |
[Next.js] Next.js란 무엇인가? React와의 차이점 (0) | 2025.03.11 |