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의 필요성을 느끼진 못했는데, 추후에 다뤄봐야겠다.
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의 필요성을 느끼진 못했는데, 추후에 다뤄봐야겠다.