Next.js 완벽 마스터
Next.js의 핵심 개념과 실전 활용법
학습 항목
이미지 로딩 중...
Next.js 렌더링 전략 선택 가이드
ISR, SSR, SSG 등 Next.js의 다양한 렌더링 전략을 비교하고, 각 상황에 맞는 최적의 렌더링 방식을 선택하는 방법을 알아봅니다. 실무에서 바로 적용할 수 있는 구체적인 기준과 예제를 제공합니다.
목차
- SSG(Static Site Generation) - 빌드 타임 정적 생성
- SSR(Server-Side Rendering) - 요청 시 동적 렌더링
- ISR(Incremental Static Regeneration) - 증분 정적 재생성
- CSR(Client-Side Rendering) - 클라이언트 사이드 렌더링
- Streaming SSR - 스트리밍 서버 사이드 렌더링
- Static Export - 완전 정적 사이트 생성
- Partial Prerendering (PPR) - 부분 사전 렌더링
- 렌더링 전략 선택 기준 - 실전 의사결정 가이드
- 성능 최적화 전략 - Core Web Vitals 개선
- 렌더링 전략 마이그레이션 - 전략 전환 가이드
1. SSG(Static Site Generation) - 빌드 타임 정적 생성
시작하며
여러분이 블로그나 마케팅 페이지를 만들 때, 매번 서버에서 같은 콘텐츠를 생성하느라 리소스를 낭비하고 있지는 않나요? 콘텐츠가 자주 변하지 않는데도 사용자가 접속할 때마다 데이터베이스를 조회하고 HTML을 생성하는 비효율이 발생합니다.
이런 문제는 실제 개발 현장에서 서버 비용 증가와 느린 응답 속도로 이어집니다. 특히 트래픽이 많은 서비스에서는 불필요한 서버 부하가 운영 비용을 크게 높이고, 사용자 경험도 저하시킵니다.
바로 이럴 때 필요한 것이 SSG(Static Site Generation)입니다. 빌드 시점에 모든 페이지를 미리 생성해두면, 사용자 요청 시 이미 만들어진 HTML 파일을 즉시 제공할 수 있어 최고의 성능을 보장합니다.
개요
간단히 말해서, SSG는 빌드 타임에 페이지를 미리 생성하여 정적 HTML 파일로 저장하는 렌더링 방식입니다. 이 방식이 필요한 이유는 성능과 비용 최적화 때문입니다.
한 번 생성된 HTML을 CDN에 배포하면 전 세계 어디서든 빠르게 접근할 수 있고, 서버 부하도 거의 없습니다. 예를 들어, 회사 소개 페이지나 제품 카탈로그 같은 경우에 매우 유용합니다.
기존 CSR(Client-Side Rendering)에서는 JavaScript 번들을 다운로드하고 실행한 후에야 콘텐츠가 보였다면, 이제는 즉시 완성된 HTML을 제공할 수 있습니다. SSG의 핵심 특징은 첫째, 빌드 시 모든 페이지가 생성되어 가장 빠른 로딩 속도를 제공하고, 둘째, SEO에 완벽하게 최적화되며, 셋째, 서버 비용이 거의 들지 않는다는 점입니다.
이러한 특징들이 마케팅 사이트나 문서 사이트에서 SSG를 최우선으로 선택하는 이유입니다.
코드 예제
// app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts } from '@/lib/posts'
// 빌드 시 생성할 페이지 경로들을 정의합니다
export async function generateStaticParams() {
const posts = await getAllPosts()
// 각 포스트의 slug를 반환하여 페이지를 생성합니다
return posts.map((post) => ({
slug: post.slug,
}))
}
// 페이지 컴포넌트 - 빌드 타임에 실행됩니다
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
설명
이것이 하는 일: SSG는 프로젝트를 빌드할 때 모든 블로그 포스트 페이지를 미리 생성하여 정적 HTML 파일로 만듭니다. 사용자가 페이지를 요청하면 서버는 계산 없이 이미 만들어진 파일을 그대로 전달합니다.
첫 번째로, generateStaticParams 함수는 빌드 시점에 실행되어 생성할 모든 페이지의 경로를 Next.js에 알려줍니다. getAllPosts()로 모든 블로그 포스트 목록을 가져온 후, 각각의 slug를 반환하면 Next.js가 /blog/[slug] 형태의 페이지를 자동으로 생성합니다.
예를 들어 포스트가 10개라면 10개의 HTML 파일이 생성됩니다. 그 다음으로, 실제 페이지 컴포넌트가 각 slug에 대해 실행되면서 getPostBySlug()로 해당 포스트의 데이터를 가져옵니다.
이 과정도 빌드 타임에 일어나므로, 데이터베이스나 파일 시스템에서 콘텐츠를 읽어와 HTML에 포함시킵니다. 내부적으로는 React 컴포넌트가 서버에서 렌더링되어 완성된 HTML 문자열로 변환됩니다.
마지막으로, 빌드가 완료되면 .next 폴더에 각 페이지의 HTML, JSON 데이터, JavaScript 번들이 저장됩니다. 사용자가 /blog/hello-world를 요청하면, 서버는 이미 만들어진 hello-world.html을 즉시 반환하므로 응답 속도가 밀리초 단위로 매우 빠릅니다.
여러분이 이 방식을 사용하면 첫째, 페이지 로딩 속도가 극대화되어 사용자 경험이 향상되고, 둘째, 검색 엔진이 완전한 HTML을 크롤링할 수 있어 SEO 점수가 높아지며, 셋째, CDN에 배포하여 서버 없이도 운영할 수 있어 비용이 절감됩니다.
실전 팁
💡 콘텐츠가 자주 변하지 않는 마케팅 페이지, 블로그, 문서 사이트에는 SSG를 최우선으로 선택하세요. 빌드 후 재배포가 필요하지만, 대부분의 정적 콘텐츠에는 이것이 가장 효율적입니다.
💡 fallback: 'blocking' 옵션을 사용하면 빌드 시 생성하지 않은 페이지도 요청 시 생성할 수 있습니다. 수천 개의 상품 페이지가 있는 경우, 인기 상품만 미리 생성하고 나머지는 요청 시 생성하세요.
💡 데이터가 변경되면 전체 재빌드가 필요하므로, 콘텐츠 업데이트 빈도를 고려하세요. 하루에 여러 번 업데이트되는 콘텐츠라면 ISR이나 SSR을 고려해야 합니다.
💡 generateStaticParams에서 너무 많은 페이지를 생성하면 빌드 시간이 길어집니다. 10,000개 이상의 페이지가 있다면 incremental static regeneration과 함께 사용하거나, 자주 접근하는 페이지만 미리 생성하세요.
💡 환경 변수나 API 키가 필요한 경우, 빌드 타임에 접근 가능한지 확인하세요. 서버 컴포넌트에서는 process.env로 직접 접근할 수 있지만, 런타임 환경 변수는 사용할 수 없습니다.
2. SSR(Server-Side Rendering) - 요청 시 동적 렌더링
시작하며
여러분이 사용자별로 다른 콘텐츠를 보여줘야 하는 대시보드나 개인화된 피드를 만들 때, 정적 페이지로는 해결할 수 없는 상황이 있습니다. 로그인한 사용자의 정보, 실시간 데이터, 개인 설정에 따라 페이지 내용이 완전히 달라져야 하는 경우입니다.
이런 문제는 CSR로 해결할 수도 있지만, 초기 로딩이 느리고 SEO에 불리하며, 사용자가 빈 화면을 보는 시간이 길어집니다. 특히 중요한 콘텐츠가 JavaScript 실행 후에야 나타나면 검색 엔진이 제대로 인덱싱하지 못합니다.
바로 이럴 때 필요한 것이 SSR(Server-Side Rendering)입니다. 서버에서 사용자 요청마다 페이지를 새로 생성하여 완전한 HTML을 제공하므로, 개인화된 콘텐츠와 빠른 초기 로딩, SEO를 모두 달성할 수 있습니다.
개요
간단히 말해서, SSR은 사용자가 페이지를 요청할 때마다 서버에서 HTML을 실시간으로 생성하여 전달하는 렌더링 방식입니다. 이 방식이 필요한 이유는 동적이고 개인화된 콘텐츠를 SEO 친화적으로 제공하기 위해서입니다.
매 요청마다 최신 데이터를 가져와 렌더링하므로, 사용자마다 다른 정보나 실시간으로 변하는 데이터를 보여줄 수 있습니다. 예를 들어, 소셜 미디어 피드, 주식 거래 대시보드, 개인 계정 페이지 같은 경우에 매우 유용합니다.
기존 CSR에서는 빈 HTML을 받은 후 JavaScript가 데이터를 fetch하고 렌더링했다면, 이제는 서버에서 완성된 HTML을 받아 즉시 콘텐츠를 볼 수 있습니다. SSR의 핵심 특징은 첫째, 요청 시마다 최신 데이터로 페이지를 생성하여 항상 최신 상태를 유지하고, 둘째, 완전한 HTML을 제공하여 SEO와 초기 로딩 속도를 개선하며, 셋째, 쿠키나 헤더 등 요청 정보를 활용한 개인화가 가능하다는 점입니다.
이러한 특징들이 동적이면서도 SEO가 중요한 페이지에서 SSR을 선택하는 이유입니다.
코드 예제
// app/dashboard/page.tsx
import { cookies, headers } from 'next/headers'
import { getUserDashboard } from '@/lib/api'
// 이 페이지가 SSR을 사용하도록 명시적으로 설정합니다
export const dynamic = 'force-dynamic'
// 서버 컴포넌트 - 매 요청마다 실행됩니다
export default async function Dashboard() {
// 쿠키에서 사용자 세션을 가져옵니다
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
// 요청 헤더에서 사용자 정보를 확인합니다
const headersList = await headers()
const userAgent = headersList.get('user-agent')
// 최신 대시보드 데이터를 실시간으로 가져옵니다
const dashboardData = await getUserDashboard(sessionToken)
return (
<div>
<h1>환영합니다, {dashboardData.user.name}님</h1>
<p>마지막 로그인: {new Date(dashboardData.lastLogin).toLocaleString()}</p>
<ul>
{dashboardData.recentActivities.map(activity => (
<li key={activity.id}>{activity.message}</li>
))}
</ul>
</div>
)
}
설명
이것이 하는 일: SSR은 사용자가 /dashboard 페이지를 요청할 때마다 서버에서 컴포넌트를 실행하고, 현재 사용자의 최신 데이터를 가져와 HTML로 렌더링한 후 클라이언트에 전달합니다. 첫 번째로, dynamic = 'force-dynamic' 설정은 Next.js에게 이 페이지를 절대 캐시하지 말고 항상 서버에서 새로 렌더링하도록 지시합니다.
이 설정이 없으면 Next.js가 자동으로 정적 페이지로 최적화할 수 있으므로, SSR이 필요한 경우 반드시 명시해야 합니다. cookies()나 headers() 같은 동적 함수를 사용하면 자동으로 SSR로 전환되지만, 명시적으로 설정하는 것이 의도를 분명히 합니다.
그 다음으로, 컴포넌트 내부에서 cookies()와 headers()를 통해 요청 컨텍스트에 접근합니다. 쿠키에서 세션 토큰을 추출하고, 헤더에서 사용자 에이전트나 IP 주소 같은 정보를 가져올 수 있습니다.
이 정보들은 getUserDashboard API 호출에 사용되어, 현재 로그인한 사용자의 개인화된 데이터를 데이터베이스에서 조회합니다. 이 모든 과정이 서버에서 일어나므로, API 키나 데이터베이스 연결 정보가 클라이언트에 노출되지 않습니다.
마지막으로, 데이터를 받아오면 React가 컴포넌트를 HTML 문자열로 렌더링하고, Next.js는 이를 사용자에게 전송합니다. 사용자는 페이지에 접속하자마자 자신의 이름과 최근 활동 내역을 즉시 볼 수 있습니다.
검색 엔진 크롤러도 완성된 HTML을 받기 때문에 콘텐츠를 제대로 인덱싱할 수 있습니다. 여러분이 이 방식을 사용하면 첫째, 사용자마다 다른 콘텐츠를 안전하게 제공할 수 있고, 둘째, 데이터가 항상 최신 상태로 유지되며, 셋째, SEO와 초기 로딩 속도를 동시에 개선할 수 있습니다.
다만 매 요청마다 서버에서 렌더링하므로 서버 부하가 높고, CDN 캐싱의 이점을 활용하기 어렵다는 점을 고려해야 합니다.
실전 팁
💡 인증이 필요한 페이지, 사용자별 대시보드, 실시간 데이터 표시 페이지에는 SSR을 선택하세요. 하지만 모든 페이지에 SSR을 적용하면 서버 비용이 크게 증가하므로 정말 필요한 곳에만 사용하세요.
💡 Next.js 15부터는 기본적으로 서버 컴포넌트가 캐시되므로, 실시간 데이터가 필요하면 명시적으로 dynamic = 'force-dynamic'을 설정하거나 cookies(), headers() 같은 동적 함수를 사용하세요.
💡 SSR 페이지에서도 부분적으로 캐싱을 활용할 수 있습니다. fetch() 호출 시 { cache: 'no-store' } 옵션으로 특정 데이터만 실시간으로 가져오고, 나머지는 캐시하여 성능을 최적화하세요.
💡 데이터베이스 쿼리나 외부 API 호출이 느리면 사용자가 대기해야 하므로, 응답 시간을 모니터링하고 최적화하세요. 필요하다면 Suspense와 Streaming을 사용하여 페이지를 점진적으로 렌더링할 수 있습니다.
💡 SSR 페이지의 성능 병목을 찾으려면 Server Timing API를 활용하세요. headers().set('Server-Timing', 'db;dur=100')로 각 작업의 소요 시간을 측정하여 브라우저 개발자 도구에서 확인할 수 있습니다.
3. ISR(Incremental Static Regeneration) - 증분 정적 재생성
시작하며
여러분이 전자상거래 사이트에서 수천 개의 상품 페이지를 관리할 때, SSG는 빌드 시간이 너무 길고, SSR은 서버 비용이 너무 높은 딜레마에 빠진 적이 있나요? 상품 정보는 가끔 업데이트되지만 실시간일 필요는 없고, 모든 페이지를 매번 재빌드하기에는 시간이 너무 오래 걸립니다.
이런 문제는 대규모 콘텐츠 사이트에서 매우 흔합니다. 수만 개의 페이지를 가진 뉴스 사이트, 쇼핑몰, 부동산 플랫폼에서는 모든 페이지를 정적으로 생성하면 빌드에 몇 시간이 걸리고, 모든 페이지를 SSR로 처리하면 서버 비용이 감당할 수 없을 정도로 높아집니다.
바로 이럴 때 필요한 것이 ISR(Incremental Static Regeneration)입니다. 정적 페이지의 속도와 SSR의 최신성을 결합하여, 일정 시간마다 백그라운드에서 페이지를 재생성함으로써 최고의 균형점을 찾을 수 있습니다.
개요
간단히 말해서, ISR은 정적으로 생성된 페이지를 일정 주기로 백그라운드에서 자동으로 재생성하여 최신 상태로 유지하는 렌더링 방식입니다. 이 방식이 필요한 이유는 정적 페이지의 성능과 동적 콘텐츠의 최신성을 동시에 달성하기 위해서입니다.
첫 요청은 캐시된 정적 페이지를 즉시 제공하고, 지정된 시간이 지나면 백그라운드에서 페이지를 재생성하여 다음 방문자에게는 업데이트된 콘텐츠를 제공합니다. 예를 들어, 블로그 포스트, 상품 상세 페이지, 뉴스 기사 같은 경우에 매우 유용합니다.
기존 SSG에서는 콘텐츠 업데이트마다 전체 재빌드가 필요했다면, 이제는 변경된 페이지만 자동으로 재생성할 수 있습니다. ISR의 핵심 특징은 첫째, 사용자는 항상 빠른 정적 페이지를 받고, 둘째, 콘텐츠는 설정한 주기에 따라 자동으로 업데이트되며, 셋째, 전체 재빌드 없이 개별 페이지만 재생성되어 효율적이라는 점입니다.
이러한 특징들이 대규모 콘텐츠 사이트에서 ISR을 가장 많이 사용하는 이유입니다.
코드 예제
// app/products/[id]/page.tsx
import { getProduct, getAllProductIds } from '@/lib/products'
// 인기 상품 100개만 빌드 시 생성합니다
export async function generateStaticParams() {
const topProducts = await getAllProductIds({ limit: 100 })
return topProducts.map((id) => ({
id: id.toString(),
}))
}
// 서버 컴포넌트 - 60초마다 재생성됩니다
export const revalidate = 60 // 60초 후 재검증
export default async function ProductPage({ params }: { params: { id: string } }) {
// 최신 상품 정보를 가져옵니다
const product = await getProduct(params.id)
// 상품이 없으면 404 페이지를 반환합니다
if (!product) {
notFound()
}
return (
<div>
<h1>{product.name}</h1>
<p>가격: {product.price.toLocaleString()}원</p>
<p>재고: {product.stock}개</p>
<p>마지막 업데이트: {new Date().toLocaleString()}</p>
</div>
)
}
설명
이것이 하는 일: ISR은 빌드 시 인기 상품 100개를 정적 페이지로 생성하고, 사용자가 페이지를 방문할 때 캐시된 페이지를 즉시 제공합니다. 그리고 60초가 지나면 백그라운드에서 최신 데이터로 페이지를 재생성하여 캐시를 업데이트합니다.
첫 번째로, generateStaticParams는 빌드 시 가장 인기 있는 100개 상품만 미리 생성합니다. 전체 상품이 10,000개라도 자주 접근하는 페이지만 생성하여 빌드 시간을 단축합니다.
나머지 상품 페이지는 첫 방문자가 요청할 때 온디맨드로 생성되며, 한 번 생성되면 정적 페이지처럼 캐시됩니다. 이를 "On-Demand ISR"이라고 합니다.
그 다음으로, revalidate = 60 설정은 이 페이지의 캐시 유효 기간을 60초로 지정합니다. 사용자가 페이지를 요청하면 Next.js는 먼저 캐시된 HTML을 즉시 반환합니다.
만약 마지막 생성 시간이 60초를 넘었다면, 백그라운드에서 getProduct()를 다시 호출하여 최신 데이터로 페이지를 재생성합니다. 중요한 점은 이 재생성이 백그라운드에서 일어나므로, 현재 사용자는 기다릴 필요 없이 기존 캐시를 받는다는 것입니다.
마지막으로, 재생성이 완료되면 새로운 HTML이 캐시를 대체하여, 다음 방문자부터는 업데이트된 콘텐츠를 보게 됩니다. 예를 들어, 상품 재고가 변경되었다면 최대 60초 후에는 모든 사용자가 최신 재고를 볼 수 있습니다.
에러가 발생하면 기존 캐시를 유지하므로 서비스가 중단되지 않습니다. 여러분이 이 방식을 사용하면 첫째, 대부분의 요청이 정적 캐시로 처리되어 응답 속도가 매우 빠르고, 둘째, 콘텐츠가 자동으로 업데이트되어 관리 부담이 적으며, 셋째, 서버 부하가 낮아 대규모 트래픽을 효율적으로 처리할 수 있습니다.
revalidate 시간을 조정하여 콘텐츠 신선도와 서버 비용 사이의 균형을 맞출 수 있습니다.
실전 팁
💡 블로그, 뉴스, 전자상거래 상품 페이지처럼 자주 업데이트되지만 실시간일 필요는 없는 콘텐츠에 ISR을 사용하세요. revalidate 시간은 콘텐츠 특성에 맞게 설정하되, 너무 짧으면 서버 부하가 높고 너무 길면 최신성이 떨어집니다.
💡 On-Demand Revalidation을 사용하면 콘텐츠가 업데이트될 때 즉시 재생성할 수 있습니다. CMS에서 포스트를 수정하면 revalidatePath('/blog/[slug]')를 호출하여 해당 페이지만 즉시 재생성하세요.
💡 ISR은 첫 방문자에게 stale 데이터를 제공할 수 있으므로, 절대적으로 최신 데이터가 필요한 곳(주식 가격, 실시간 채팅)에는 SSR을 사용하세요. ISR은 "약간 오래된 데이터를 허용할 수 있는" 경우에 적합합니다.
💡 빌드 시 생성할 페이지 수를 제한하여 빌드 시간을 단축하세요. 나머지는 첫 요청 시 생성되고 캐시됩니다. fallback: true를 사용하면 아직 생성되지 않은 페이지에 로딩 상태를 보여줄 수 있습니다.
💡 Vercel이나 Next.js 지원 호스팅에서는 ISR이 자동으로 작동하지만, self-hosted 환경에서는 추가 설정이 필요할 수 있습니다. 커스텀 캐시 핸들러를 구현하거나 Redis 같은 외부 캐시를 사용할 수 있습니다.
4. CSR(Client-Side Rendering) - 클라이언트 사이드 렌더링
시작하며
여러분이 고도로 인터랙티브한 대시보드나 실시간 협업 툴을 만들 때, 서버에서 렌더링하기에는 너무 동적이고 복잡한 UI를 다뤄야 하는 경우가 있습니다. 사용자의 모든 클릭, 입력, 드래그 앤 드롭에 즉각 반응하고, WebSocket으로 실시간 데이터를 받아 화면을 업데이트해야 합니다.
이런 문제는 전통적인 서버 렌더링으로는 해결하기 어렵습니다. 매번 서버에 요청하면 네트워크 지연이 발생하고, 페이지 전체가 새로고침되어 부드러운 사용자 경험을 제공할 수 없습니다.
특히 복잡한 상태 관리가 필요한 SPA(Single Page Application)에서는 클라이언트에서 모든 것을 처리하는 것이 더 효율적입니다. 바로 이럴 때 필요한 것이 CSR(Client-Side Rendering)입니다.
JavaScript가 브라우저에서 실행되어 DOM을 직접 조작하므로, 즉각적인 반응성과 풍부한 인터랙션을 제공할 수 있습니다.
개요
간단히 말해서, CSR은 서버에서 최소한의 HTML만 보내고, JavaScript가 브라우저에서 실행되어 콘텐츠를 렌더링하고 데이터를 가져오는 방식입니다. 이 방식이 필요한 이유는 고도로 인터랙티브한 UI와 실시간 업데이트를 효율적으로 구현하기 위해서입니다.
모든 로직이 클라이언트에서 실행되므로 서버 부하가 없고, 페이지 전환 없이 부드러운 SPA 경험을 제공할 수 있습니다. 예를 들어, 구글 문서, 피그마, 트렐로 같은 실시간 협업 툴이나 복잡한 데이터 시각화 대시보드에 매우 유용합니다.
기존 SSR에서는 모든 상호작용이 서버 요청을 필요로 했다면, 이제는 클라이언트에서 즉시 반응하고 필요한 데이터만 API로 가져올 수 있습니다. CSR의 핵심 특징은 첫째, 초기 로딩 후 페이지 전환이 매우 빠르고, 둘째, 복잡한 상태 관리와 인터랙션을 자유롭게 구현할 수 있으며, 셋째, 서버 부하가 최소화되어 정적 호스팅만으로 충분하다는 점입니다.
하지만 초기 로딩이 느리고 SEO에 불리하다는 단점이 있어, Next.js에서는 서버 컴포넌트와 혼합하여 사용하는 것이 일반적입니다.
코드 예제
// components/Dashboard.tsx
'use client' // 클라이언트 컴포넌트로 명시
import { useState, useEffect } from 'react'
import { fetchUserData } from '@/lib/api'
export default function Dashboard() {
// 클라이언트 상태 관리
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// 컴포넌트 마운트 시 데이터를 가져옵니다
useEffect(() => {
async function loadData() {
try {
const result = await fetchUserData()
setData(result)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
loadData()
}, [])
if (loading) return <div>로딩 중...</div>
if (error) return <div>에러: {error}</div>
return (
<div>
<h1>{data.user.name}님의 대시보드</h1>
<InteractiveChart data={data.chartData} />
</div>
)
}
설명
이것이 하는 일: CSR은 서버에서 기본 HTML 셸과 JavaScript 번들을 전송하고, 브라우저에서 JavaScript가 실행되어 React 컴포넌트를 렌더링하고 API에서 데이터를 가져와 화면을 완성합니다. 첫 번째로, 'use client' 지시어는 Next.js에게 이 컴포넌트를 클라이언트에서 실행하도록 알립니다.
서버는 이 컴포넌트를 렌더링하지 않고, JavaScript 번들에 포함시켜 브라우저로 전송합니다. 사용자가 페이지를 방문하면 브라우저는 먼저 빈 HTML과 로딩 스피너를 보여주고, JavaScript 번들을 다운로드하고 파싱한 후 컴포넌트를 실행합니다.
그 다음으로, useState와 useEffect 같은 React 훅들이 브라우저에서 실행됩니다. 컴포넌트가 마운트되면 useEffect가 실행되어 fetchUserData() API를 호출합니다.
이 과정은 완전히 클라이언트에서 일어나므로, 서버는 데이터베이스에 접근하지 않고 단지 API 엔드포인트만 제공합니다. API 응답이 도착하면 setData()로 상태를 업데이트하고, React가 컴포넌트를 재렌더링하여 실제 콘텐츠를 화면에 표시합니다.
마지막으로, 사용자와의 모든 인터랙션이 클라이언트에서 처리됩니다. 버튼 클릭, 폼 입력, 차트 조작 등이 즉시 반응하며, 필요한 경우에만 API를 호출하여 데이터를 업데이트합니다.
페이지 전환도 JavaScript가 처리하여 전체 새로고침 없이 부드럽게 이동합니다. 이것이 SPA의 핵심 장점입니다.
여러분이 이 방식을 사용하면 첫째, 복잡한 인터랙션과 실시간 업데이트를 자유롭게 구현할 수 있고, 둘째, 서버 부하가 최소화되어 정적 호스팅으로도 운영 가능하며, 셋째, 초기 로딩 후 페이지 전환이 매우 빠릅니다. 하지만 초기 JavaScript 번들 크기가 크면 로딩이 느리고, 검색 엔진이 빈 HTML만 보므로 SEO가 필요한 페이지에는 부적합합니다.
실전 팁
💡 Next.js에서는 기본적으로 서버 컴포넌트를 사용하고, 인터랙션이 필요한 부분만 'use client'로 클라이언트 컴포넌트로 만드세요. 전체를 CSR로 만들 필요는 없습니다. 예를 들어, 정적 헤더는 서버 컴포넌트로, 인터랙티브 차트만 클라이언트 컴포넌트로 분리하세요.
💡 SEO가 중요하지 않은 대시보드, 어드민 패널, 로그인 후 페이지에는 CSR을 자유롭게 사용하세요. 하지만 랜딩 페이지, 블로그, 제품 페이지처럼 검색 노출이 중요한 곳에는 서버 렌더링을 사용해야 합니다.
💡 초기 로딩 성능을 개선하려면 Code Splitting을 사용하여 JavaScript 번들을 나누고, 필요한 부분만 lazy load하세요. React.lazy()와 dynamic import를 활용하면 초기 번들 크기를 크게 줄일 수 있습니다.
💡 클라이언트 컴포넌트에서 환경 변수를 사용할 때는 NEXT_PUBLIC_ 접두사를 붙여야 합니다. 일반 환경 변수는 서버에서만 사용 가능하므로, API 키 같은 민감한 정보가 클라이언트에 노출되지 않도록 주의하세요.
💡 useEffect에서 API를 호출할 때 cleanup 함수를 제공하여 메모리 누수를 방지하세요. 특히 컴포넌트가 언마운트되기 전에 비동기 작업이 완료되지 않으면 에러가 발생할 수 있습니다.
5. Streaming SSR - 스트리밍 서버 사이드 렌더링
시작하며
여러분이 SSR 페이지를 만들 때, 데이터베이스 쿼리나 외부 API 호출이 느려서 사용자가 빈 화면을 오래 보는 경험을 한 적이 있나요? 특히 여러 데이터 소스에서 정보를 가져와야 하는 복잡한 페이지에서는, 가장 느린 요청이 완료될 때까지 전체 페이지가 블로킹됩니다.
이런 문제는 전통적인 SSR의 큰 단점입니다. 서버가 모든 데이터를 가져오고 전체 HTML을 완성할 때까지 클라이언트는 아무것도 볼 수 없습니다.
10개의 섹션이 있는데 9개는 빠르게 로드되고 1개만 느리다면, 사용자는 모든 것을 기다려야 합니다. 바로 이럴 때 필요한 것이 Streaming SSR입니다.
HTML을 청크 단위로 나누어 준비되는 대로 순차적으로 전송하므로, 사용자는 즉시 콘텐츠를 보기 시작하고, 느린 부분은 나중에 도착하여 점진적으로 화면이 완성됩니다.
개요
간단히 말해서, Streaming SSR은 HTML을 한 번에 보내는 대신 준비된 부분부터 순차적으로 스트리밍하여, 사용자가 전체 페이지 로딩을 기다리지 않고 콘텐츠를 즉시 볼 수 있게 하는 방식입니다. 이 방식이 필요한 이유는 Time to First Byte(TTFB)와 First Contentful Paint(FCP)를 개선하여 체감 성능을 크게 향상시키기 위해서입니다.
페이지의 일부가 빠르게 준비되면 즉시 보여주고, 느린 부분은 로딩 상태를 표시하다가 나중에 도착하면 자동으로 교체합니다. 예를 들어, 뉴스 피드, 소셜 미디어 타임라인, 복잡한 대시보드처럼 여러 데이터 소스를 조합하는 페이지에 매우 유용합니다.
기존 SSR에서는 모든 콘텐츠가 준비될 때까지 사용자가 빈 화면을 봤다면, 이제는 즉시 헤더와 네비게이션을 보고, 콘텐츠 섹션들이 차례로 나타나는 부드러운 경험을 제공합니다. Streaming의 핵심 특징은 첫째, TTFB가 크게 개선되어 사용자가 즉시 반응을 느끼고, 둘째, 느린 데이터 소스가 전체 페이지를 블로킹하지 않으며, 셋째, SEO에도 유리하게 완전한 HTML을 제공한다는 점입니다.
React 18의 Suspense와 결합하면 선언적으로 로딩 상태를 관리할 수 있어, Next.js 13+에서 권장되는 패턴입니다.
코드 예제
// app/feed/page.tsx
import { Suspense } from 'react'
import { Header } from '@/components/Header'
import { UserProfile } from '@/components/UserProfile'
import { FeedPosts } from '@/components/FeedPosts'
import { Recommendations } from '@/components/Recommendations'
// 메인 페이지 컴포넌트 - 즉시 스트리밍 시작
export default function FeedPage() {
return (
<div>
{/* 즉시 렌더링되는 정적 헤더 */}
<Header />
{/* Suspense로 감싸서 독립적으로 로딩 */}
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
{/* 피드 포스트는 별도로 스트리밍 */}
<Suspense fallback={<FeedSkeleton />}>
<FeedPosts />
</Suspense>
{/* 추천 섹션도 독립적으로 로딩 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</div>
)
}
// 느린 컴포넌트 - 독립적으로 데이터를 가져옵니다
async function FeedPosts() {
const posts = await fetchPosts() // 느린 API 호출
return (
<div>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
)
}
설명
이것이 하는 일: Streaming SSR은 페이지를 여러 청크로 나누어, Header 같은 빠른 부분은 즉시 HTML로 전송하고, FeedPosts 같은 느린 부분은 Suspense fallback을 먼저 보낸 후 데이터가 준비되면 실제 콘텐츠로 교체합니다. 첫 번째로, 사용자가 페이지를 요청하면 Next.js는 즉시 HTML 스트림을 열고 Header 컴포넌트를 렌더링하여 전송합니다.
사용자는 수백 밀리초 내에 헤더와 레이아웃을 보기 시작합니다. 동시에 서버는 Suspense 경계를 만나면 fallback UI(스켈레톤 로더)를 즉시 전송하고, 백그라운드에서 실제 컴포넌트를 렌더링하기 시작합니다.
그 다음으로, 각 Suspense로 감싼 컴포넌트는 독립적으로 비동기 작업을 수행합니다. UserProfile이 먼저 완료되면 해당 HTML 청크가 스트림에 추가되고, 클라이언트의 React는 이를 받아 UserProfileSkeleton을 실제 콘텐츠로 교체합니다.
FeedPosts와 Recommendations도 각자 완료되는 대로 순차적으로 전송되므로, 가장 느린 컴포넌트가 다른 컴포넌트를 블로킹하지 않습니다. 마지막으로, 모든 Suspense 경계가 해소되면 스트림이 완료되고, 클라이언트는 완전한 인터랙티브 페이지를 가지게 됩니다.
이 과정은 Progressive Hydration과 결합되어, 각 컴포넌트가 도착하는 대로 인터랙티브하게 됩니다. 사용자는 전체 페이지가 로드될 때까지 기다릴 필요 없이, 이미 도착한 부분부터 클릭하고 상호작용할 수 있습니다.
여러분이 이 방식을 사용하면 첫째, 체감 성능이 크게 향상되어 사용자가 즉시 콘텐츠를 보고, 둘째, 느린 데이터 소스가 전체 페이지를 블로킹하지 않으며, 셋째, 선언적인 Suspense API로 로딩 상태를 쉽게 관리할 수 있습니다. 특히 복잡한 대시보드나 여러 외부 API를 호출하는 페이지에서 큰 차이를 체감할 수 있습니다.
실전 팁
💡 Next.js 13+ App Router에서는 Streaming이 기본으로 활성화되어 있습니다. Suspense를 사용하기만 하면 자동으로 작동하므로, 느린 컴포넌트를 Suspense로 감싸는 습관을 들이세요.
💡 Suspense 경계를 어디에 둘지 신중하게 선택하세요. 너무 세밀하게 나누면 스켈레톤이 너무 많이 깜빡이고, 너무 크게 묶으면 Streaming의 이점이 줄어듭니다. 일반적으로 독립적인 섹션 단위로 나누는 것이 좋습니다.
💡 fallback UI는 실제 콘텐츠와 레이아웃이 유사한 스켈레톤을 사용하여 Layout Shift를 최소화하세요. 갑자기 크기가 변하면 사용자 경험이 나빠집니다.
💡 에러 처리를 위해 Error Boundary를 Suspense와 함께 사용하세요. error.tsx 파일을 만들면 해당 Suspense 경계에서 발생한 에러를 격리하여 처리할 수 있고, 나머지 페이지는 정상 작동합니다.
💡 loading.tsx 파일을 사용하면 페이지 레벨에서 자동으로 Suspense 경계가 생성됩니다. 이는 전체 페이지 로딩 상태를 관리하는 편리한 방법이며, 내부적으로 Streaming SSR을 활용합니다.
6. Static Export - 완전 정적 사이트 생성
시작하며
여러분이 Next.js로 개발했지만 Node.js 서버 없이 순수 정적 파일만으로 배포하고 싶은 경우가 있나요? GitHub Pages, S3, Netlify 같은 정적 호스팅 서비스에 배포하거나, CDN만으로 전 세계에 서비스를 제공하고 싶을 때입니다.
이런 문제는 특히 랜딩 페이지, 문서 사이트, 포트폴리오처럼 서버가 전혀 필요 없는 사이트에서 발생합니다. Next.js의 기본 빌드는 Node.js 서버를 요구하지만, 여러분의 프로젝트는 순수 HTML, CSS, JavaScript만으로도 충분합니다.
바로 이럴 때 필요한 것이 Static Export입니다. Next.js를 전통적인 정적 사이트 생성기처럼 사용하여, 모든 페이지를 HTML 파일로 내보내고 서버 없이 어디서든 배포할 수 있습니다.
개요
간단히 말해서, Static Export는 Next.js 애플리케이션을 완전한 정적 사이트로 변환하여, Node.js 서버 없이 순수 HTML/CSS/JS 파일만으로 배포할 수 있게 하는 방식입니다. 이 방식이 필요한 이유는 서버 비용을 완전히 제거하고, 정적 호스팅의 단순함과 보안성을 활용하기 위해서입니다.
모든 페이지가 빌드 시 HTML 파일로 생성되므로, Nginx나 Apache 같은 간단한 웹 서버, 또는 CDN만으로도 배포할 수 있습니다. 예를 들어, 회사 홈페이지, 제품 소개 사이트, 개인 블로그처럼 동적 기능이 필요 없는 경우에 매우 유용합니다.
기존 SSG가 Next.js 서버를 필요로 했다면, 이제는 완전히 서버를 제거하고 Gatsby나 Jekyll처럼 순수 정적 파일만 생성할 수 있습니다. Static Export의 핵심 특징은 첫째, Node.js 런타임이 전혀 필요 없어 배포가 매우 간단하고, 둘째, 보안 취약점이 거의 없으며, 셋째, 호스팅 비용이 거의 무료에 가깝다는 점입니다.
다만 SSR, ISR, API Routes 같은 서버 기능을 사용할 수 없으므로, 완전히 정적인 콘텐츠에만 적합합니다.
코드 예제
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Static Export 활성화
output: 'export',
// 이미지 최적화는 외부 서비스 사용
images: {
unoptimized: true, // 또는 외부 이미지 CDN 사용
},
// trailing slash 추가 (선택사항)
trailingSlash: true,
}
module.exports = nextConfig
// app/page.tsx
export default function HomePage() {
return (
<div>
<h1>정적 사이트에 오신 것을 환영합니다</h1>
<p>이 페이지는 순수 HTML로 빌드되었습니다.</p>
</div>
)
}
// package.json - 빌드 후 out 폴더에 정적 파일 생성
// "scripts": {
// "build": "next build"
// }
설명
이것이 하는 일: Static Export는 next build 실행 시 모든 페이지를 HTML 파일로 생성하여 out 폴더에 저장합니다. 이 폴더를 그대로 웹 서버에 업로드하면 즉시 서비스가 가능합니다.
첫 번째로, next.config.js에서 output: 'export'를 설정하면 Next.js가 정적 내보내기 모드로 전환됩니다. 빌드 프로세스가 시작되면 Next.js는 모든 라우트를 탐색하고, 각 페이지 컴포넌트를 서버에서 렌더링하여 HTML 문자열로 변환합니다.
generateStaticParams로 정의된 동적 라우트도 모두 개별 HTML 파일로 생성됩니다. 예를 들어 /blog/[slug]가 있고 포스트가 10개라면, blog/post-1.html, blog/post-2.html 형태로 10개 파일이 생성됩니다.
그 다음으로, images.unoptimized: true 설정은 Next.js의 이미지 최적화 기능을 비활성화합니다. 기본 이미지 최적화는 서버에서 동적으로 이미지를 변환하므로 정적 내보내기와 호환되지 않습니다.
대신 Cloudinary, Imgix 같은 외부 이미지 CDN을 사용하거나, 빌드 시 이미지를 미리 최적화해야 합니다. trailingSlash: true 옵션은 /about을 /about/로 변환하여, /about/index.html 형태로 저장되게 합니다.
마지막으로, next build가 완료되면 out 폴더에 완전한 정적 사이트가 생성됩니다. 이 폴더 안에는 각 페이지의 HTML 파일, CSS, JavaScript 번들, 이미지 등 모든 에셋이 포함되어 있습니다.
이 폴더를 Vercel, Netlify, GitHub Pages, S3, 또는 단순 Nginx 서버에 업로드하면 즉시 서비스됩니다. 서버 사이드 기능(API Routes, getServerSideProps, ISR)은 작동하지 않으므로, 빌드 시 에러가 발생하거나 제거해야 합니다.
여러분이 이 방식을 사용하면 첫째, 호스팅 비용이 거의 무료에 가까워지고 (GitHub Pages, Netlify 무료 티어), 둘째, 서버 관리나 보안 패치가 필요 없어 운영이 매우 간단하며, 셋째, CDN에 배포하여 전 세계 어디서든 빠른 속도를 보장할 수 있습니다. 다만 동적 기능이 필요하면 클라이언트 사이드에서 API를 호출하거나, 외부 서비스(Headless CMS, Firebase)를 사용해야 합니다.
실전 팁
💡 Static Export를 사용하기 전에 프로젝트에 SSR, ISR, API Routes, Server Actions가 없는지 확인하세요. 이러한 서버 기능은 정적 내보내기와 호환되지 않으며, 빌드 시 에러가 발생합니다.
💡 동적 기능이 필요하면 클라이언트 컴포넌트에서 외부 API를 호출하세요. Firebase, Supabase, Headless CMS 같은 서비스를 사용하면 백엔드 없이도 동적 콘텐츠를 제공할 수 있습니다.
💡 이미지 최적화를 위해 next-image-export-optimizer 같은 플러그인을 사용하거나, 빌드 스크립트에서 Sharp로 이미지를 미리 최적화하세요. 또는 Cloudinary 같은 이미지 CDN을 사용하는 것도 좋은 방법입니다.
💡 SPA처럼 동작하게 하려면 404.html을 index.html로 리다이렉트하도록 설정하세요. Netlify에서는 _redirects 파일에 /* /index.html 200을 추가하면 클라이언트 사이드 라우팅이 작동합니다.
💡 환경 변수는 빌드 타임에 번들에 포함되므로, API 키 같은 민감한 정보를 클라이언트에 노출하지 않도록 주의하세요. NEXT_PUBLIC_ 접두사가 붙은 변수만 사용하고, 서버 전용 키는 외부 백엔드에서 관리하세요.
7. Partial Prerendering (PPR) - 부분 사전 렌더링
시작하며
여러분이 페이지의 일부는 정적으로 캐시하고 싶지만, 다른 일부는 사용자별로 동적으로 렌더링해야 하는 상황에 직면한 적이 있나요? 예를 들어, 제품 상세 페이지에서 제품 정보는 모든 사용자에게 동일하지만, "장바구니에 추가" 버튼이나 개인화된 추천은 사용자마다 달라야 합니다.
이런 문제는 전통적으로 해결하기 어려웠습니다. 전체 페이지를 SSG로 만들면 동적 부분을 처리할 수 없고, SSR로 만들면 정적인 부분까지 매번 재생성해야 해서 비효율적입니다.
ISR도 페이지 전체를 재생성하므로 부분적인 개인화에는 적합하지 않습니다. 바로 이럴 때 필요한 것이 Partial Prerendering(PPR)입니다.
하나의 페이지에서 정적 부분은 빌드 시 생성하여 캐시하고, 동적 부분만 요청 시 렌더링하여, 최고의 성능과 유연성을 동시에 달성할 수 있습니다.
개요
간단히 말해서, PPR은 단일 페이지 내에서 정적 부분과 동적 부분을 분리하여, 정적 셸은 즉시 제공하고 동적 콘텐츠는 스트리밍으로 나중에 채우는 하이브리드 렌더링 방식입니다. 이 방식이 필요한 이유는 페이지의 대부분이 정적이지만 일부만 동적인 일반적인 웹 패턴을 효율적으로 처리하기 위해서입니다.
제품 설명, 이미지, 리뷰 같은 정적 콘텐츠는 CDN에서 즉시 제공하고, 사용자별 가격, 재고, 추천 같은 동적 콘텐츠만 서버에서 실시간으로 생성합니다. 예를 들어, 전자상거래 제품 페이지, 뉴스 기사에 개인화된 광고, 소셜 미디어 프로필에 사용자별 관계 표시 같은 경우에 매우 유용합니다.
기존에는 페이지 전체를 하나의 렌더링 전략으로 처리해야 했다면, 이제는 컴포넌트 단위로 렌더링 전략을 선택할 수 있습니다. PPR의 핵심 특징은 첫째, 정적 셸이 즉시 로드되어 TTFB와 FCP가 매우 빠르고, 둘째, 동적 부분만 서버 렌더링하여 서버 부하를 최소화하며, 셋째, Suspense를 사용한 선언적 API로 간단하게 구현할 수 있다는 점입니다.
Next.js 14+에서 실험적 기능으로 제공되며, 미래의 표준 렌더링 패턴이 될 것으로 예상됩니다.
코드 예제
// next.config.js - PPR 활성화 (실험적 기능)
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true, // Partial Prerendering 활성화
},
}
module.exports = nextConfig
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { getProduct } from '@/lib/products'
import { ProductInfo } from '@/components/ProductInfo'
import { DynamicPrice } from '@/components/DynamicPrice'
import { PersonalizedRecommendations } from '@/components/PersonalizedRecommendations'
// 정적 부분 - 빌드 시 생성
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
return (
<div>
{/* 정적 콘텐츠 - 즉시 제공 */}
<ProductInfo product={product} />
{/* 동적 콘텐츠 - 요청 시 렌더링 */}
<Suspense fallback={<PriceSkeleton />}>
<DynamicPrice productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations userId={getUserId()} />
</Suspense>
</div>
)
}
설명
이것이 하는 일: PPR은 빌드 시 ProductInfo 같은 정적 컴포넌트를 HTML로 생성하여 캐시하고, 사용자 요청 시 DynamicPrice와 PersonalizedRecommendations만 서버에서 렌더링하여 스트리밍으로 전송합니다. 첫 번째로, 빌드 프로세스 중에 Next.js는 페이지를 분석하여 Suspense 경계 밖의 콘텐츠를 정적 셸로 식별합니다.
getProduct()가 실행되어 제품 정보를 가져오고, ProductInfo 컴포넌트가 렌더링되어 HTML로 저장됩니다. Suspense로 감싼 부분은 특수한 플레이스홀더로 표시되어, 런타임에 교체될 위치를 기록합니다.
이 정적 셸은 CDN에 배포되어 모든 사용자에게 즉시 제공됩니다. 그 다음으로, 사용자가 제품 페이지를 요청하면 CDN이 즉시 정적 셸(제품 정보, 이미지, 설명)을 반환합니다.
사용자는 수십 밀리초 내에 대부분의 콘텐츠를 보기 시작합니다. 동시에 브라우저는 Next.js 서버에 동적 부분을 요청하고, 서버는 현재 사용자의 세션을 확인하여 DynamicPrice와 PersonalizedRecommendations를 렌더링합니다.
예를 들어, 로그인한 사용자에게는 할인된 가격을, 비회원에게는 정가를 보여줄 수 있습니다. 마지막으로, 동적 콘텐츠가 준비되면 Streaming을 통해 클라이언트로 전송되고, React가 Suspense fallback을 실제 콘텐츠로 교체합니다.
이 과정은 Selective Hydration과 결합되어, 동적 부분이 도착하는 대로 인터랙티브하게 됩니다. 결과적으로 사용자는 정적 콘텐츠를 즉시 보고, 1-2초 내에 개인화된 콘텐츠까지 완성된 페이지를 경험합니다.
여러분이 이 방식을 사용하면 첫째, 대부분의 요청이 CDN에서 처리되어 서버 부하가 최소화되고, 둘째, 정적 콘텐츠가 즉시 로드되어 체감 성능이 우수하며, 셋째, 동적 부분으로 사용자별 개인화를 자유롭게 구현할 수 있습니다. 전자상거래, 뉴스, 소셜 미디어처럼 정적 콘텐츠에 동적 요소를 추가해야 하는 대부분의 현대 웹 애플리케이션에 이상적입니다.
실전 팁
💡 PPR은 Next.js 14+에서 experimental.ppr = true로 활성화해야 합니다. 아직 실험적 기능이므로 프로덕션에 사용하기 전에 충분히 테스트하세요. Next.js 15에서는 안정화될 예정입니다.
💡 동적으로 만들고 싶은 부분만 Suspense로 감싸세요. Suspense 밖의 모든 것은 정적으로 생성되므로, cookies()나 headers() 같은 동적 함수를 사용하면 안 됩니다. 페이지 레벨에서는 정적으로 유지하고, 특정 컴포넌트만 동적으로 만드는 것이 핵심입니다.
💡 정적 셸에 레이아웃과 스타일을 충분히 포함시켜 Layout Shift를 방지하세요. Suspense fallback은 실제 콘텐츠와 크기가 동일한 스켈레톤을 사용해야 합니다.
💡 동적 부분의 데이터 fetching을 최적화하여 응답 시간을 줄이세요. 데이터베이스 쿼리를 최적화하고, 필요하다면 Redis 같은 캐시를 사용하여 동적 부분도 빠르게 렌더링하세요.
💡 PPR은 기본적으로 route segment 레벨에서 작동하므로, 특정 라우트만 PPR을 사용하고 나머지는 기존 방식을 유지할 수 있습니다. 점진적으로 도입하여 팀이 익숙해지도록 하세요.
8. 렌더링 전략 선택 기준 - 실전 의사결정 가이드
시작하며
여러분이 새 프로젝트를 시작할 때마다 "이 페이지에는 어떤 렌더링 전략을 써야 하지?"라는 질문에 막막함을 느낀 적이 있나요? SSG, SSR, ISR, CSR, Streaming, PPR까지 너무 많은 선택지가 있고, 각각의 장단점을 모두 고려하면 결정하기가 어렵습니다.
이런 문제는 실무에서 잘못된 렌더링 전략을 선택하면 성능 저하, 높은 서버 비용, 나쁜 SEO로 이어집니다. 예를 들어, 정적 콘텐츠에 SSR을 사용하면 불필요한 서버 비용이 발생하고, 동적 콘텐츠에 SSG를 사용하면 오래된 데이터를 보여주게 됩니다.
바로 이럴 때 필요한 것이 명확한 의사결정 기준입니다. 콘텐츠 특성, 업데이트 빈도, SEO 요구사항, 트래픽 규모를 체계적으로 평가하여 최적의 렌더링 전략을 선택하는 방법을 알아봅시다.
개요
간단히 말해서, 렌더링 전략 선택은 콘텐츠의 동적 정도, 업데이트 빈도, SEO 중요도, 개인화 필요성을 기준으로 체계적으로 결정해야 합니다. 이 의사결정이 필요한 이유는 잘못된 선택이 성능과 비용에 직접적인 영향을 미치기 때문입니다.
각 페이지의 특성을 정확히 파악하고 적절한 전략을 선택하면, 최고의 사용자 경험을 제공하면서도 서버 비용을 최소화할 수 있습니다. 예를 들어, 한 애플리케이션 내에서도 홈페이지는 SSG, 대시보드는 SSR, 제품 페이지는 ISR 또는 PPR로 다르게 구성할 수 있습니다.
기존에는 경험과 직관에 의존했다면, 이제는 명확한 체크리스트로 체계적으로 결정할 수 있습니다. 의사결정 프레임워크의 핵심은 첫째, 콘텐츠가 모든 사용자에게 동일한지 확인하고, 둘째, 업데이트 빈도와 최신성 요구사항을 평가하며, 셋째, SEO 중요도와 초기 로딩 성능을 고려하는 것입니다.
이러한 기준들을 순서대로 적용하면 대부분의 경우 명확한 답이 나옵니다.
코드 예제
// 의사결정 플로우를 코드 주석으로 표현
// app/decision-flowchart.ts
/**
* 렌더링 전략 선택 플로우
*/
// 1단계: 콘텐츠가 완전히 정적인가?
if (contentNeverChanges && sameForAllUsers) {
// ✅ SSG + Static Export
// 예: 회사 소개, 이용약관, 마케팅 랜딩 페이지
return 'SSG with Static Export'
}
// 2단계: 콘텐츠가 가끔 변경되지만 실시간일 필요는 없는가?
if (updatesOccasionally && canTolerateStaleData) {
if (hasThousandsOfPages) {
// ✅ ISR with On-Demand Revalidation
// 예: 블로그, 전자상거래 상품 페이지
return 'ISR (revalidate: 60-3600초)'
} else {
// ✅ SSG with Rebuild on Content Change
// 예: 소규모 블로그, 포트폴리오
return 'SSG with CI/CD trigger'
}
}
// 3단계: 사용자별로 다른 콘텐츠인가?
if (requiresUserAuthentication || isPersonalized) {
if (needsSEO) {
if (pageHasStaticParts && pageSupportsPPR) {
// ✅ Partial Prerendering (PPR)
// 예: 제품 페이지 (정적 정보 + 개인화된 가격)
return 'PPR (static shell + dynamic parts)'
} else {
// ✅ SSR with Streaming
// 예: 사용자 대시보드, 소셜 미디어 피드
return 'SSR with Suspense Streaming'
}
} else {
// ✅ CSR
// 예: 어드민 패널, 내부 툴, 로그인 후 페이지
return 'Client-Side Rendering'
}
}
// 4단계: 실시간 데이터가 필수인가?
if (requiresRealTimeData) {
// ✅ SSR 또는 CSR + WebSocket
// 예: 주식 거래, 실시간 채팅, 라이브 스포츠 점수
return 'SSR (dynamic) or CSR with real-time updates'
}
설명
이것이 하는 일: 이 의사결정 플로우는 여러분의 페이지 특성을 단계별로 질문하여, 가장 효율적이고 비용 효과적인 렌더링 전략을 추천합니다. 첫 번째로, 콘텐츠의 정적 정도를 평가합니다.
만약 콘텐츠가 거의 변하지 않고 모든 사용자에게 동일하다면 SSG가 최선입니다. 회사 소개 페이지, 이용약관, 마케팅 랜딩 페이지가 여기에 해당합니다.
더 나아가 서버가 아예 필요 없다면 Static Export를 사용하여 GitHub Pages나 S3에 배포할 수 있습니다. 이 경우 호스팅 비용이 거의 무료이고 성능이 가장 빠릅니다.
그 다음으로, 콘텐츠가 주기적으로 변경되는지 확인합니다. 블로그 포스트, 뉴스 기사, 제품 정보처럼 하루에 몇 번 또는 주기적으로 업데이트되는 콘텐츠라면 ISR이 이상적입니다.
revalidate 시간을 콘텐츠 특성에 맞게 설정하세요. 예를 들어, 뉴스는 60초, 제품 정보는 600초, 블로그는 3600초가 적당할 수 있습니다.
페이지가 수천 개라면 인기 페이지만 빌드 시 생성하고 나머지는 On-Demand로 처리하세요. 세 번째로, 사용자별 개인화가 필요한지 판단합니다.
로그인한 사용자의 대시보드, 개인 설정, 맞춤 추천처럼 사용자마다 다른 콘텐츠라면 동적 렌더링이 필요합니다. 하지만 여기서 SEO가 중요한지 확인하세요.
대시보드나 어드민 패널은 SEO가 필요 없으므로 CSR이 적합합니다. 반면 제품 페이지에 개인화된 가격이나 추천이 들어간다면, PPR로 정적 부분(제품 정보)과 동적 부분(개인화)을 분리하는 것이 최선입니다.
마지막으로, 실시간성이 절대적으로 필요한지 평가합니다. 주식 가격, 실시간 채팅, 라이브 스포츠 점수처럼 몇 초의 지연도 허용되지 않는다면 SSR (force-dynamic) 또는 CSR + WebSocket을 사용해야 합니다.
다만 서버 비용이 높으므로, 정말 실시간이 필요한 부분만 이렇게 처리하고 나머지는 캐싱하세요. 여러분이 이 프레임워크를 사용하면 첫째, 각 페이지에 최적화된 전략을 선택하여 성능과 비용을 균형있게 유지하고, 둘째, 팀 내에서 일관된 기준으로 의사결정하여 아키텍처가 체계적으로 유지되며, 셋째, 요구사항이 변경되어도 빠르게 다른 전략으로 전환할 수 있습니다.
실전 팁
💡 한 애플리케이션에서 여러 렌더링 전략을 혼합하는 것이 일반적이고 권장됩니다. 홈페이지는 SSG, 블로그는 ISR, 대시보드는 SSR, 어드민은 CSR로 각각 다르게 구성하세요.
💡 처음에는 보수적으로 시작하세요. 확실하지 않으면 ISR로 시작하여 revalidate 시간을 짧게 설정하고, 모니터링 후 정적 또는 동적으로 전환할 수 있습니다. 성급한 최적화보다 점진적 개선이 안전합니다.
💡 비용을 고려하세요. SSR과 ISR은 서버 비용이 발생하고, 트래픽이 많으면 비용이 급증합니다. 예산이 제한적이라면 최대한 SSG와 CSR을 활용하고, 정말 필요한 곳만 서버 렌더링하세요.
💡 Analytics와 RUM(Real User Monitoring)을 사용하여 실제 성능을 측정하세요. Core Web Vitals(LCP, FID, CLS)를 추적하여 렌더링 전략의 효과를 정량적으로 평가하고 개선하세요.
💡 Next.js의 기본 동작을 이해하세요. App Router에서는 기본적으로 서버 컴포넌트가 캐시되므로, 동적 동작이 필요하면 명시적으로 설정해야 합니다. fetch() 옵션, revalidate, dynamic 설정을 정확히 이해하세요.
9. 성능 최적화 전략 - Core Web Vitals 개선
시작하며
여러분이 렌더링 전략을 선택한 후에도, 실제 사용자가 느끼는 성능이 기대에 미치지 못하는 경우가 있나요? Google의 Core Web Vitals 점수가 낮거나, 페이지가 느리게 느껴지거나, 사용자가 이탈하는 문제를 겪고 있다면 단순히 렌더링 전략만으로는 부족합니다.
이런 문제는 렌더링 방식뿐만 아니라 번들 크기, 이미지 최적화, 폰트 로딩, JavaScript 실행 시간 등 다양한 요소에 영향을 받습니다. 특히 LCP(Largest Contentful Paint), FID(First Input Delay), CLS(Cumulative Layout Shift)는 SEO와 사용자 경험에 직접적인 영향을 미칩니다.
바로 이럴 때 필요한 것이 체계적인 성능 최적화 전략입니다. 렌더링 전략과 함께 다양한 최적화 기법을 적용하여, 실제 사용자가 체감하는 성능을 극대화할 수 있습니다.
개요
간단히 말해서, 성능 최적화는 렌더링 전략을 선택한 후 번들 크기 줄이기, 이미지 최적화, 폰트 전략, 우선순위 로딩을 통해 Core Web Vitals를 개선하는 과정입니다. 이 최적화가 필요한 이유는 렌더링 전략만으로는 충분하지 않고, 실제 사용자 경험은 수많은 세부 요소에 의해 결정되기 때문입니다.
LCP를 2.5초 이하로, FID를 100ms 이하로, CLS를 0.1 이하로 유지하면 Google 검색 순위가 올라가고, 사용자 이탈률이 감소합니다. 예를 들어, 이커머스 사이트에서 LCP가 1초 개선되면 전환율이 7% 증가한다는 연구 결과가 있습니다.
기존에는 개발자가 느낌으로 성능을 판단했다면, 이제는 Core Web Vitals라는 명확한 지표로 측정하고 개선할 수 있습니다. 성능 최적화의 핵심 영역은 첫째, JavaScript 번들 크기를 줄여 초기 로딩을 빠르게 하고, 둘째, 이미지를 최적화하여 LCP를 개선하며, 셋째, 폰트와 CSS를 최적화하여 CLS를 방지하는 것입니다.
이러한 최적화들이 누적되어 사용자가 체감하는 성능이 크게 향상됩니다.
코드 예제
// next.config.js - 성능 최적화 설정
/** @type {import('next').NextConfig} */
const nextConfig = {
// 이미지 최적화 설정
images: {
formats: ['image/avif', 'image/webp'], // 최신 포맷 우선
deviceSizes: [640, 750, 828, 1080, 1200],
imageSizes: [16, 32, 48, 64, 96],
},
// 번들 분석기 (개발 시에만)
// npm install @next/bundle-analyzer
// webpack: (config, { isServer }) => {
// if (!isServer) {
// config.optimization.splitChunks.cacheGroups = {
// vendor: {
// test: /[\\/]node_modules[\\/]/,
// name: 'vendors',
// chunks: 'all',
// },
// }
// }
// return config
// },
}
// app/products/page.tsx
import Image from 'next/image'
import dynamic from 'next/dynamic'
// 무거운 컴포넌트는 lazy load
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <ChartSkeleton />,
ssr: false, // 클라이언트에서만 로드
})
export default function ProductsPage() {
return (
<div>
{/* 우선순위 높은 이미지는 priority 속성 */}
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // LCP 이미지에 사용
/>
{/* 지연 로딩되는 이미지 */}
<Image
src="/product.jpg"
alt="Product"
width={400}
height={300}
loading="lazy" // 기본값, 명시적 표시
/>
{/* 무거운 컴포넌트는 동적 import */}
<HeavyChart />
</div>
)
}
설명
이것이 하는 일: 성능 최적화 설정은 Next.js의 자동 최적화 기능을 활성화하고, 이미지를 최신 포맷으로 변환하며, 무거운 JavaScript를 지연 로딩하여 초기 페이지 로드를 빠르게 합니다. 첫 번째로, Image 컴포넌트는 Next.js의 강력한 이미지 최적화를 활용합니다.
formats: ['image/avif', 'image/webp']는 브라우저가 지원하는 경우 AVIF나 WebP 같은 최신 포맷으로 자동 변환하여 파일 크기를 50-80% 줄입니다. priority 속성을 LCP 이미지(보통 hero 이미지)에 추가하면, 해당 이미지가 최우선으로 로드되어 LCP 시간이 크게 개선됩니다.
반면 아래쪽 이미지는 loading="lazy"로 지연 로딩되어 초기 페이지 로드를 방해하지 않습니다. 그 다음으로, dynamic import는 무거운 컴포넌트(차트 라이브러리, 에디터, 복잡한 UI)를 초기 번들에서 분리합니다.
HeavyChart는 사용자가 해당 섹션에 도달할 때만 다운로드되므로, 초기 JavaScript 번들이 작아지고 Time to Interactive(TTI)가 빨라집니다. ssr: false 옵션은 서버에서 렌더링하지 않고 클라이언트에서만 로드하므로, 서버 렌더링 시간도 단축됩니다.
loading 속성으로 스켈레톤을 보여주면 CLS도 방지할 수 있습니다. 세 번째로, 폰트 최적화를 위해 next/font를 사용하세요.
next/font/google로 구글 폰트를 import하면 자동으로 self-hosting되어 외부 요청을 제거하고, font-display: swap이 적용되어 FOIT(Flash of Invisible Text)를 방지합니다. 또한 CSS에 font-face를 직접 정의할 필요 없이 Next.js가 최적화된 CSS를 자동 생성합니다.
마지막으로, @next/bundle-analyzer를 사용하여 번들 크기를 시각적으로 분석하세요. npm run build 후 번들 리포트를 열어 어떤 라이브러리가 크기가 큰지 확인하고, 대안을 찾거나 tree-shaking을 적용할 수 있습니다.
예를 들어, moment.js 대신 date-fns를 사용하거나, lodash 전체 대신 개별 함수만 import하면 번들 크기를 크게 줄일 수 있습니다. 여러분이 이러한 최적화를 적용하면 첫째, LCP가 1-2초 개선되어 사용자가 콘텐츠를 빠르게 보고, 둘째, JavaScript 번들이 작아져 모바일 사용자도 빠르게 인터랙션할 수 있으며, 셋째, CLS가 줄어들어 사용자가 실수로 잘못된 버튼을 클릭하는 일이 없어집니다.
Google PageSpeed Insights에서 90점 이상을 달성할 수 있습니다.
실전 팁
💡 Lighthouse와 PageSpeed Insights를 정기적으로 실행하여 Core Web Vitals를 모니터링하세요. 개발자 도구의 Performance 탭으로 병목을 찾고, Coverage 탭으로 사용하지 않는 코드를 식별하세요.
💡 Third-party 스크립트(Google Analytics, 광고, 채팅 위젯)는 next/script의 strategy 속성으로 로딩을 제어하세요. strategy="lazyOnload"로 중요하지 않은 스크립트를 지연 로딩하면 초기 성능이 크게 개선됩니다.
💡 CSS-in-JS 라이브러리는 런타임 오버헤드가 있으므로, Tailwind CSS나 CSS Modules 같은 정적 CSS를 우선 고려하세요. 불가피하게 Styled-components를 사용한다면 babel 플러그인으로 성능을 개선하세요.
💡 Prefetching을 활용하세요. Next.js는 Link 컴포넌트가 viewport에 들어오면 자동으로 prefetch하지만, prefetch={false}로 비활성화하거나 router.prefetch()로 수동 제어할 수 있습니다.
💡 Edge Runtime을 고려하세요. export const runtime = 'edge'로 설정하면 Vercel Edge Network나 Cloudflare Workers에서 실행되어 전 세계 사용자에게 낮은 latency를 제공합니다. 다만 Node.js API가 제한되므로 단순한 로직에만 적합합니다.
10. 렌더링 전략 마이그레이션 - 전략 전환 가이드
시작하며
여러분이 프로젝트를 운영하다가 현재 렌더링 전략이 더 이상 적합하지 않다는 것을 깨달은 적이 있나요? 트래픽이 급증하여 SSR 서버 비용이 감당할 수 없거나, SSG로 만든 사이트가 콘텐츠 업데이트마다 전체 재빌드를 해야 하는 불편함이 있거나, 반대로 과도한 동적 렌더링으로 성능이 저하되는 경우입니다.
이런 문제는 비즈니스 요구사항이 변경되거나, 트래픽 패턴이 달라지거나, 새로운 기능이 추가되면서 자연스럽게 발생합니다. 처음에는 올바른 선택이었지만, 시간이 지나면서 다른 전략이 더 적합해질 수 있습니다.
바로 이럴 때 필요한 것이 렌더링 전략 마이그레이션입니다. 기존 코드를 최소한으로 수정하면서 다른 렌더링 방식으로 안전하게 전환하는 방법을 알아봅시다.
개요
간단히 말해서, 렌더링 전략 마이그레이션은 프로젝트의 요구사항이 변경되었을 때 기존 코드를 점진적으로 수정하여 다른 렌더링 방식으로 전환하는 과정입니다. 이 마이그레이션이 필요한 이유는 비즈니스 성장, 트래픽 변화, 새로운 기능 요구에 따라 초기 아키텍처 결정을 재평가해야 하기 때문입니다.
적절한 시점에 전략을 전환하면 서버 비용을 크게 줄이거나, 사용자 경험을 개선하거나, 개발 생산성을 높일 수 있습니다. 예를 들어, 스타트업이 성장하여 트래픽이 10배 증가했다면, SSR에서 ISR로 전환하여 서버 부하를 줄이는 것이 합리적입니다.
기존에는 전체를 다시 개발해야 한다고 생각했다면, 이제는 Next.js의 유연한 아키텍처를 활용하여 점진적으로 마이그레이션할 수 있습니다. 마이그레이션 전략의 핵심은 첫째, 페이지별로 독립적으로 전환하여 리스크를 최소화하고, 둘째, A/B 테스트로 성능과 비용을 비교하며, 셋째, 롤백 계획을 준비하여 문제 발생 시 빠르게 복구하는 것입니다.
이러한 접근법으로 안전하고 효율적인 마이그레이션을 수행할 수 있습니다.
코드 예제
// SSR에서 ISR로 마이그레이션 예제
// 기존 SSR 코드 (app/blog/[slug]/page.tsx)
// export const dynamic = 'force-dynamic' // 제거
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return <Article post={post} />
}
// 1단계: ISR로 전환 - revalidate 추가
export const revalidate = 300 // 5분마다 재검증
// 2단계: 성능 모니터링
// - Vercel Analytics로 Core Web Vitals 추적
// - 서버 로그로 재생성 빈도 확인
// 3단계: On-Demand Revalidation 추가
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret')
// 인증 확인
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ message: 'Invalid secret' }, { status: 401 })
}
const { slug } = await request.json()
// 특정 경로만 재검증
revalidatePath(`/blog/${slug}`)
return NextResponse.json({ revalidated: true, now: Date.now() })
}
// CMS webhook 설정: 포스트 업데이트 시 호출
// POST https://your-domain.com/api/revalidate?secret=YOUR_SECRET
// Body: { "slug": "updated-post" }
설명
이것이 하는 일: 렌더링 전략 마이그레이션은 기존 SSR 페이지를 ISR로 전환하여 서버 부하를 줄이고, On-Demand Revalidation으로 콘텐츠 업데이트 시 즉시 재생성할 수 있게 합니다. 첫 번째로, 마이그레이션을 시작하기 전에 현재 상태를 측정합니다.
서버 CPU/메모리 사용률, 응답 시간, 비용을 기록하여 마이그레이션 후 비교할 기준선을 만듭니다. Vercel Analytics나 DataDog 같은 모니터링 도구로 실제 사용자 지표(Core Web Vitals)도 추적하세요.
그 다음 가장 트래픽이 낮은 페이지부터 시작하여 리스크를 최소화합니다. 그 다음으로, dynamic = 'force-dynamic'을 제거하고 revalidate를 추가하여 ISR로 전환합니다.
초기에는 짧은 revalidate 시간(60-300초)으로 시작하여 동작을 확인하고, 점차 늘려가며 최적점을 찾습니다. 배포 후 몇 시간 동안 모니터링하여 응답 시간이 개선되고, 서버 부하가 줄어들었는지 확인하세요.
문제가 없으면 다음 페이지로 넘어갑니다. 세 번째로, On-Demand Revalidation API를 추가하여 CMS나 백오피스에서 콘텐츠를 업데이트할 때 즉시 페이지를 재생성할 수 있게 합니다.
Headless CMS(Contentful, Sanity, Strapi)의 webhook 기능을 설정하여, 포스트가 발행/수정될 때 자동으로 /api/revalidate 엔드포인트를 호출하도록 합니다. 이렇게 하면 ISR의 효율성과 실시간 업데이트를 동시에 달성할 수 있습니다.
반드시 secret 토큰으로 인증을 구현하여 무단 재검증을 방지하세요. 마지막으로, A/B 테스트를 고려하세요.
Next.js Middleware에서 쿠키나 IP를 기반으로 사용자를 두 그룹으로 나누어, 한 그룹은 기존 SSR, 다른 그룹은 새 ISR로 보냅니다. 일주일 정도 운영하면서 Core Web Vitals, 전환율, 서버 비용을 비교하여 확실히 개선되었는지 검증합니다.
문제가 발생하면 즉시 롤백할 수 있도록 Git branch 전략을 준비하세요. 여러분이 이러한 마이그레이션 프로세스를 따르면 첫째, 서버 비용을 50-80% 절감할 수 있고, 둘째, 사용자 경험을 유지하거나 개선하며, 셋째, 점진적 전환으로 비즈니스 리스크를 최소화할 수 있습니다.
특히 트래픽이 많은 서비스에서는 렌더링 전략 최적화가 운영 비용에 직접적인 영향을 미칩니다.
실전 팁
💡 마이그레이션은 한 번에 하지 말고 페이지 단위로 점진적으로 진행하세요. 트래픽이 적은 페이지부터 시작하여 경험을 쌓고, 핵심 페이지는 마지막에 전환하세요.
💡 Feature Flag를 사용하여 런타임에 렌더링 전략을 전환할 수 있게 하세요. 환경 변수나 LaunchDarkly 같은 서비스로 A/B 테스트를 쉽게 구현할 수 있습니다.
💡 캐시 무효화 전략을 명확히 하세요. ISR로 전환하면 캐시된 페이지가 일정 시간 동안 유지되므로, 긴급한 업데이트가 필요한 경우를 대비한 수동 재검증 방법을 준비하세요.
💡 Pages Router에서 App Router로 마이그레이션하는 경우, 한 번에 전체를 전환하지 말고 점진적으로 마이그레이션하세요. Next.js는 두 라우터를 동시에 지원하므로, 새 페이지는 app 디렉토리에, 기존 페이지는 pages에 남겨두고 서서히 이동하세요.
💡 마이그레이션 후에도 최소 1-2주는 집중 모니터링하세요. 트래픽 패턴에 따라 예상치 못한 문제가 발생할 수 있으므로, 알람을 설정하고 빠르게 대응할 준비를 하세요.