Next.js를 제대로 다루기 전에,
Next.js의 렌더링에 대해 정리해보려고 한다.
블로그를 작성한다고 생각하고, 아래와 같은 폴더구조가 있다고 가정하고 설명해볼 것이다.
├── src/
│ ├── pages/ # Pages Router
│ │ ├── index.tsx
│ │ ├── about.tsx
│ │ └── posts/
│ │ ├── [id].tsx
│ │ └── index.tsx
│ │
│ ├── app/ # App Router
│ │ ├── page.tsx
│ │ ├── about/
│ │ │ └── page.tsx
│ │ └── posts/
│ │ ├── [id]/
│ │ │ └── page.tsx
│ │ └── page.tsx
│ │
│ └── components/ # 공통 컴포넌트
SSG(Static Site Generation, 정적 사이트 생성)
정적 사이트 생성(SSG)은 빌드 시점에 HTML을 생성하는 렌더링 방식으로,
모든 사용자에게 동일한 내용을 보여줄 때 적합하며, 한 번 생성된 HTML을 재사용하므로 매우 빠른 페이지 로딩이 가능하다.
장점
- 빠른 페이지 로딩(CDN 캐싱 가능)
- 서버 부하 감소
- SEO 최적화
- 보안성 향상(서버 노출 최소화)
단점
- 빌드 시간 길어짐
- 데이터 업데이트 시 재빌드 필요
- 동적 콘텐츠에 부적합
Pages Router
getStaticPaths 옵션 설명
- fallback: false: 빌드 시 생성되지 않은 경로는 404 페이지 반환
- fallback: true: 없는 경로 접근 시 먼저 폴백 페이지를 보여주고, 백그라운드에서 페이지 생성
- fallback: 'blocking': SSR처럼 페이지가 생성될 때까지 대기
// pages/posts/[id].tsx
export async function getStaticPaths() {
// 빌드할 경로들을 지정
return {
paths: [
{ params: { id: '1' } },
{ params: { id: '2' } }
],
fallback: false // 또는 true, 'blocking'
}
}
export async function getStaticProps({ params }) {
// 빌드 시점에 실행되어 정적 페이지 생성
const post = await fetchPost(params.id)
return {
props: { post }
}
}
// 생성된 props를 사용하는 컴포넌트
function PostPage({ post }) {
return <div>{post.title}</div>
}
App Router
App Router의 SSG 특징
- React 서버 컴포넌트를 기본으로 사용
- 데이터 페칭이 자동으로 캐시되어 SSG로 동작
- generateStaticParams를 통해 빌드 시 생성할 경로 지정
- 별도의 props 전달 없이 컴포넌트 내에서 직접 데이터 사용
// 자동으로 SSG가 적용되는 경우:
// 1. 동적 API 사용하지 않음 (cookies, headers 등)
// 2. 데이터가 캐시됨
// app/posts/[id]/page.tsx
async function PostPage({ params }) {
const post = await fetchPost(params.id)
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}
// 동적 라우트 생성을 위한 설정
export async function generateStaticParams() {
// 실제 데이터를 가져와서 정적 경로 생성
const posts = await fetchPosts() // 모든 포스트 데이터 조회
// 조회된 포스트들의 ID로 정적 경로 생성
return posts.map((post) => ({
id: post.id.toString()
}))
// 또는 특정 조건의 포스트만 정적 생성
return posts
.filter(post => post.isPublished) // 공개된 포스트만
.map((post) => ({
id: post.id.toString()
}))
}
// 선택적: 동적 메타데이터 생성
export async function generateMetadata({ params }) {
const post = await fetchPost(params.id)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.thumbnail]
}
}
}
SSR(Server-Side Rendering, 서버 사이드 렌더링)
서버 사이드 렌더링(SSR)은 각 요청마다 서버에서 페이지를 동적으로 생성하는 렌더링 방식으로,
실시간 데이터나 사용자별 맞춤 콘텐츠가 필요한 경우에 적합하다.
특징
데이터 접근
- 요청별 컨텍스트 사용 가능 (cookies, headers 등)
- 실시간 데이터 조회 가능
- 사용자별 맞춤 콘텐츠 제공 가능
캐싱
- cache: 'no-store' 옵션으로 항상 최신 데이터 사용
- 응답 헤더로 캐시 전략 설정 가능
- 동적 라우트 파라미터와 검색 파라미터 활용
장점
- 항상 최신 데이터(index.html을 클라이언트의 매 요청시마다 생성)
- 실시간 데이터 반영
- 사용자별 맞춤 콘텐츠 제공
- SEO 최적화
- 완성된 HTML이 검색 엔진에 제공됨
- 메타 태그 동적 생성 가능
- 보안
- 민감한 데이터를 클라이언트에 노출하지 않음
- 서버에서 인증/인가 처리
단점
- 성능
- 매 요청마다 페이지 생성
- TTFB(Time To First Byte) 증가
- 서버 자원 소비 증가
- 비용
- 높은 서버 연산 비용
- 스케일링 필요성 증가
- 복잡성
- 상태 관리 복잡도 증가
- 서버-클라이언트 코드 분리 필요
최적화 전략
1. 선택적 캐싱
// 부분적 캐싱 적용
const [
cachedData,
realtimeData
] = await Promise.all([
fetchStaticContent({ cache: 'force-cache' }),
fetchDynamicContent({ cache: 'no-store' })
])
2. 스트리밍과 Suspense 활용
import { Suspense } from 'react'
function PostPage() {
return (
<>
<PostContent />
<Suspense fallback={<Loading />}>
<DynamicComments />
</Suspense>
</>
)
}
3. Edge Runtime 활용
export const runtime = 'edge'
Pages Router
// pages/posts/[id].tsx
export async function getServerSideProps({ req, res, params, query }) {
// 요청 시점의 컨텍스트 활용 가능
const { id } = params
const { filter } = query
const userToken = req.cookies.token
// 사용자별 맞춤 데이터 조회
const post = await fetchPost(id)
const userComments = await fetchUserComments(userToken)
// 응답 헤더 설정 가능
res.setHeader(
'Cache-Control',
'public, s-maxage=10, stale-while-revalidate=59'
)
return {
props: {
post,
userComments,
lastUpdated: new Date().toISOString()
}
}
}
function PostPage({ post, userComments, lastUpdated }) {
return (
<div>
<h1>{post.title}</h1>
<span>Last updated: {lastUpdated}</span>
<div>{post.content}</div>
<CommentSection comments={userComments} />
</div>
)
}
App Router
// app/posts/[id]/page.tsx
async function PostPage({ params, searchParams }) {
// 동적 데이터 페칭
const post = await fetchPost(params.id, {
cache: 'no-store' // SSR 강제
})
// 쿠키 접근
const cookies = cookies()
const userToken = cookies.get('token')
// 헤더 접근
const headers = headers()
const userAgent = headers.get('user-agent')
// 여러 데이터 동시 조회
const [userComments, relatedPosts] = await Promise.all([
fetchUserComments(userToken),
fetchRelatedPosts(params.id)
])
return (
<div>
<h1>{post.title}</h1>
<span>Viewed on: {new Date().toLocaleString()}</span>
<div>{post.content}</div>
<CommentSection comments={userComments} />
<RelatedPosts posts={relatedPosts} />
</div>
)
}
ISR(Incremental Static Rendering, 점진적 정적 렌더링)
ISR은 정적 생성(SSG)의 장점을 유지하면서도 주기적으로 페이지를 재생성하는 방식으로,
SSG와 SSR의 중간 형태로, 정적 페이지의 성능과 동적 데이터의 최신성을 모두 확보할 수 있다.
("정적" 재생성이라는 점이 키워드로, revalidate가 존재하는 가에 ISR을 판가름 한다.)
특징
재검증 방식
- 시간 기반 재검증
- 설정된 시간 간격으로 페이지 재생성
- 첫 요청 시 캐시된 페이지 반환
- 백그라운드에서 새 버전 생성
- 온디맨드 재검증
- API 요청을 통해 수동으로 재생성 트리거
- CMS 업데이트나 웹훅과 연동 가능
캐싱
- 생성된 페이지는 CDN에서 캐시
- stale-while-revalidate 패턴 적용
- 점진적인 페이지 업데이트
장점
- 항상 최신 데이터
- 실시간 데이터 반영
- 사용자별 맞춤 콘텐츠 제공
- SEO 최적화
- 완성된 HTML이 검색 엔진에 제공됨
- 메타 태그 동적 생성 가능
- 보안
- 민감한 데이터를 클라이언트에 노출하지 않음
- 서버에서 인증/인가 처리
단점
- 성능
- 매 요청마다 페이지 생성
- TTFB(Time To First Byte) 증가
- 서버 자원 소비 증가
- 복잡성
- 상태 관리 복잡도 증가
- 서버-클라이언트 코드 분리 필요
- 비용
- 높은 서버 연산 비용
- 스케일링 필요성 증가
최적화 전략
1. 선택적 재검증
// 콘텐츠 타입별 재검증 주기 다르게 설정
const data = await fetch(url, {
next: {
revalidate: isPremiumContent ? 60 : 3600
}
})
2. 태그 기반 재검증
// 데이터 페칭 시 태그 지정
const data = await fetch(url, {
next: { tags: ['posts'] }
})
// 태그 기반 재검증
revalidateTag('posts')
3. 하이브리드 접근
// 일부는 SSR, 일부는 ISR로 구성
const [
staticData, // ISR
dynamicData // SSR
] = await Promise.all([
fetch(staticUrl, { next: { revalidate: 60 } }),
fetch(dynamicUrl, { cache: 'no-store' })
])
Pages Router
// pages/posts/[id].tsx
export async function getStaticPaths() {
// 초기 빌드 시 생성할 페이지 지정
const popularPosts = await fetchPopularPosts()
return {
paths: popularPosts.map(post => ({
params: { id: post.id.toString() }
})),
fallback: 'blocking' // 다른 페이지는 최초 요청 시 생성
}
}
export async function getStaticProps({ params }) {
const post = await fetchPost(params.id)
return {
props: {
post,
generatedAt: new Date().toISOString()
},
revalidate: 60, // 60초마다 재생성 검토
// notFound: post === null, // 데이터가 없으면 404
// redirect: condition ? { destination: '/' } : undefined // 조건부 리다이렉트
}
}
function PostPage({ post, generatedAt }) {
return (
<article>
<h1>{post.title}</h1>
<span>Generated: {generatedAt}</span>
<div>{post.content}</div>
</article>
)
}
App Router
// app/posts/[id]/page.tsx
export const revalidate = 60 // 전역 재검증 시간 설정
async function PostPage({ params }) {
// 개별 fetch 요청에 대한 재검증 설정
const post = await fetchPost(params.id, {
next: { revalidate: 60 }
})
// 시간 기반 재검증과 온디맨드 재검증 함께 사용
const [comments, analytics] = await Promise.all([
// 댓글은 1분마다 갱신
fetchComments(params.id, {
next: { revalidate: 60 }
}),
// 분석 데이터는 1시간마다 갱신
fetchAnalytics(params.id, {
next: { revalidate: 3600 }
})
])
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
<CommentSection comments={comments} />
<AnalyticsChart data={analytics} />
</article>
)
}
// 온디맨드 재검증을 위한 API 라우트
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const { path, token } = await request.json()
// 토큰 검증
if (token !== process.env.REVALIDATE_TOKEN) {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
// 특정 경로 재검증
revalidatePath(path)
// 또는 특정 태그 재검증
// revalidateTag('posts')
return Response.json({ revalidated: true })
}
PPR(Partial Pre-Rendering, 부분 사전 렌더링)
PPR은 Next.js 15의 실험적인 기능으로,
하나의 라우트 내에서 정적 컴포넌트와 동적 컴포넌트를 효과적으로 결합할 수 있게 한다.
PPR이 필요한 이유
기존 렌더링 방식의 한계
- SSG: 전체 페이지가 정적
- SSR: 전체 페이지가 동적
- ISR: 전체 페이지가 주기적으로 재생성
현실적인 요구사항
- 대부분의 웹 페이지는 정적/동적 콘텐츠 혼합
- 예: 상품 페이지
- 정적: 상품 정보, 이미지
- 동적: 장바구니, 재고 상태
PPR의 동작 방식
빌드 시점
- 가능한 많은 콘텐츠를 미리 렌더링
- 동적 코드 감지 시 Suspense 경계 설정
요청 시점
// app/components/StockStatus.tsx
import { cookies } from 'next/headers'
export async function StockStatus() {
// 동적 API 사용 시 자동으로 PPR 대상이 됨
const region = cookies().get('region')?.value
const stock = await fetchStockStatus(region)
return <div>재고: {stock}개</div>
}
스트리밍 최적화
- 정적 HTML 즉시 전송
- 동적 컴포넌트 병렬 스트리밍
- 단일 HTTP 요청으로 최적화
설정 방법
1. Canary 버전 설치
npm install next@canary
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
}
2. 기존 Next.js에서 라우트별 설정 방식
// app/page.tsx
export const experimental_ppr = true
export default function Page() {
return (...)
}
장점
- 성능 최적화
- 초기 로딩 속도 향상
- 클라이언트-서버 워터폴 방지
- 네트워크 요청 최소화
- 개발 경험
- 직관적인 컴포넌트 분리
- 점진적 도입 가능
- 세밀한 최적화 제어
단점
- 실험적 기능
- 현재까지는 Canary 버전에서만 사용 가능
- 프로덕션 사용 권장 X
- API 변경 가능성 있음
- 사용 제한
- 라우트별 명시적 활성화 필요
- 중첩 라우트 고려 필요
- 동적 API 사용시 Suspense 필수
용례: 전자상거래 페이지
// app/products/[id]/page.tsx
import { Suspense } from 'react'
export const experimental_ppr = true
export default function ProductPage({ params }) {
return (
<div>
{/* 정적 영역 */}
<ProductHeader />
<ProductDetails id={params.id} />
{/* 동적 영역 */}
<div className="dynamic-sections">
<Suspense fallback={<PriceLoadingSkeleton />}>
<DynamicPricing id={params.id} />
</Suspense>
<Suspense fallback={<CartLoadingSkeleton />}>
<ShoppingCartWidget />
</Suspense>
<Suspense fallback={<RecommendationsLoadingSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</div>
</div>
)
}
참고:
Next.js 공식 홈페이지 - Sever Components
Nextjs 에서 다양한 렌더링 방식들은 서로 어떤 연관이 있을까?(SSG, SSR, ISR, CSR, Static(Pre), Dynamic)
Nextjs 15버전이 가진 렌더링 방식의 한계와 그 해결책(Partial Pre Rendering)
아직 규모있는 Next 프로젝트를 해보지 않아서,
PPR의 필요성을 느끼진 못했는데, 추후에 다뤄봐야겠다.