Next.js 하이브리드 렌더링 완전 가이드: SSG·ISR·SSR·PPR을 언제, 왜 쓰는가
웹 개발을 하다 보면 한 번쯤 이런 딜레마에 빠집니다. "SEO가 중요하니까 SSR을 써야 하는데, 그러면 서버 비용이 너무 올라가고... 그렇다고 SSG만 쓰면 실시간 데이터 반영이 안 되고..." 저도 처음 Next.js를 도입할 때 '그냥 전부 SSR로 하면 되지 않나?' 하고 안일하게 생각했다가, 트래픽이 늘면서 서버 비용 청구서를 보고 현실을 깨달은 적이 있습니다. ISR로 전환하고 나서 서버 요청이 70% 가까이 줄었을 때의 그 쾌감이 아직도 기억납니다.
하이브리드 렌더링은 바로 이 딜레마를 해결하는 전략입니다. 하나의 애플리케이션 안에서 CSR, SSR, SSG, ISR을 라우트 단위 혹은 컴포넌트 단위로 골라 쓰는 방식인데요. "모든 걸 하나의 방식으로 처리하겠다"는 욕심을 내려놓는 순간, 성능과 비용 모두를 잡을 수 있게 됩니다. 이 글에서는 각 렌더링 전략의 특성과 실전에서 어떤 기준으로 조합하는지, 그리고 2025~2026년 현재 가장 주목받는 최신 패턴까지 한 번에 정리합니다.
대상 독자는 Next.js 기초를 이미 알고 있는 프론트엔드 개발자입니다. App Router 문법이 익숙하다면 코드 예시를 바로 실무에 적용해볼 수 있을 겁니다.
핵심 개념
렌더링 전략, 딱 한 장으로 정리하기
어떤 페이지에 무엇을 써야 할지 고민될 때마다 이 표를 기준으로 판단해보시면 꽤 도움이 됩니다.
| 전략 | 렌더링 시점 | 적합한 용도 | 주요 단점 |
|---|---|---|---|
| SSG | 빌드 타임 | 마케팅 페이지, 블로그 | 콘텐츠 갱신 느림 |
| ISR | 빌드 타임 + 백그라운드 재생성 | 상품 목록, 뉴스 | 캐시 무효화 복잡 |
| SSR | 요청 타임 | 사용자 맞춤 대시보드, 실시간 데이터 | 서버 부하, TTFB |
| CSR | 클라이언트 | 인증 후 인터랙티브 영역 | SEO 취약, 초기 로딩 |
| Streaming SSR | 요청 타임 (청크 단위) | 데이터 의존 컴포넌트 병렬 로딩 | 구현 복잡도 |
한 가지 자주 받는 질문이 있습니다. "Streaming SSR이 SSR보다 무조건 좋은 거 아닌가요?" 꼭 그렇지는 않습니다. TTFB는 두 방식이 동일합니다. 서버가 요청을 받아 처리를 시작하는 시점 자체는 같거든요. Streaming SSR이 유리한 부분은 FCP(First Contentful Paint)와 LCP(Largest Contentful Paint)입니다. 느린 API에 의존하는 컴포넌트를 기다리는 동안 나머지 콘텐츠를 먼저 브라우저로 보낼 수 있으니까요.
ISR(Incremental Static Regeneration): 빌드 타임에 정적 HTML을 생성하되, 지정한 시간(revalidate)이 지나면 백그라운드에서 자동으로 페이지를 재생성하는 방식입니다. 완전한 정적(SSG)과 완전한 동적(SSR)의 중간 지점이라고 보면 됩니다.
한 가지 중요한 동작 방식을 짚고 넘어갈게요. ISR의 stale-while-revalidate 특성상, revalidate 시간이 지난 후 첫 번째 방문자는 여전히 이전 버전 페이지를 받고 그 요청이 백그라운드 재생성을 트리거합니다. 두 번째 방문자부터 새 페이지를 받게 됩니다. 실시간 재고 정보처럼 즉각적인 갱신이 필요한 데이터에 ISR만 그대로 쓰면 사용자가 오래된 정보를 볼 수 있으니 주의해야 합니다.
정적과 동적을 점점 더 세밀하게 합치는 흐름
이어지는 세 가지 개념은 "한 페이지 안에서 정적과 동적을 어디까지 섞을 수 있나"를 계속 밀어붙인 결과물입니다. PPR이 라우트 단위에서 정적/동적을 나누고, Server Islands가 컴포넌트 단위로 내려가고, Resumability는 아예 하이드레이션이라는 개념 자체를 없애는 방향으로 발전해온 흐름입니다.
Partial Prerendering(PPR) — 같은 URL에서 정적과 동적을 동시에
솔직히 PPR을 처음 들었을 때 "그게 무슨 소리야?"라는 반응이 먼저 나왔습니다. 같은 페이지에서 일부는 CDN에서 즉시 내려주고, 일부는 서버에서 스트리밍으로 채운다는 개념이 처음엔 좀 생소하거든요.
핵심 아이디어는 간단합니다. <Suspense> 경계를 기준으로 바깥 영역은 빌드 타임에 정적으로 생성해 CDN 엣지에 캐싱하고, <Suspense> 안쪽 동적 컴포넌트는 요청 시점에 스트리밍으로 채워 넣는 방식입니다. 결과적으로 초기 HTML이 엣지에서 즉시 전송되니 LCP가 크게 개선됩니다.
// next.config.ts
export default {
experimental: { ppr: 'incremental' }
}// app/products/page.tsx
export const experimental_ppr = true
export default function ProductsPage() {
return (
<>
{/* 빌드 타임에 CDN에 캐싱 — async 데이터 페칭이 없는 순수 정적 컴포넌트여야 합니다 */}
<StaticHero />
<CategoryNav />
{/* 요청 시점에 서버에서 스트리밍으로 주입 */}
<Suspense fallback={<ProductSkeleton />}>
<DynamicProductFeed />
</Suspense>
<Suspense fallback={<RecommendSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</>
)
}주의할 점은 <Suspense> 바깥에 위치한 컴포넌트(StaticHero, CategoryNav 등)는 진짜로 정적이어야 한다는 겁니다. async로 동적 데이터를 가져오는 로직이 있으면 빌드 타임 캐싱 대상에서 제외됩니다. 아무 컴포넌트나 <Suspense> 밖에 두면 되는 게 아니에요.
PPR은 현재 experimental 단계이며, 안정화 시점은 Next.js 공식 문서에서 최신 상태를 확인해보시는 것을 권장합니다. 지금은 incremental 모드로 원하는 라우트에서만 점진적으로 실험해볼 수 있습니다.
Server Islands — 정적 사이트에 동적 "섬"을 심다
PPR이 Next.js 생태계의 접근이라면, Astro가 2024년 말 안정화한 Server Islands는 콘텐츠 중심 사이트에 특화된 다른 접근입니다. Next.js를 주로 쓰는 분께도 이 개념이 왜 주목받는지 알아두면 아키텍처 결정에 도움이 됩니다.
페이지 대부분은 CDN에 무기한 캐싱하되, 개인화 데이터(장바구니 수량, 사용자 이름 등)가 필요한 부분만 서버 렌더링 "섬"으로 삽입합니다.
---
// pages/blog/[slug].astro
// getStaticPaths는 반드시 frontmatter(---) 안에 위치해야 동작합니다
import { getPost, getAllPosts } from '../../lib/cms';
import CommentSection from '../../components/CommentSection.astro';
export async function getStaticPaths() {
const posts = await getAllPosts();
return posts.map(p => ({ params: { slug: p.slug } }));
}
const { slug } = Astro.params;
const post = await getPost(slug);
---
<article>
<!-- 본문: 빌드 타임에 정적 생성, CDN 무기한 캐싱 -->
<h1>{post.title}</h1>
<Fragment set:html={post.content} />
</article>
<!-- server:defer — 서버에서 비동기로 렌더링되는 island -->
<CommentSection server:defer postId={post.id}>
<div slot="fallback">댓글 불러오는 중...</div>
</CommentSection>Islands Architecture: 정적 HTML 바다(ocean)에 인터랙티브한 컴포넌트 "섬(island)"을 배치하는 패턴입니다. 각 섬은 독립적으로 하이드레이션되므로, 나머지 정적 영역에는 JavaScript가 전혀 실행되지 않습니다.
Resumability — 하이드레이션을 버린 새로운 패러다임
이건 Qwik 프레임워크가 주도하는 개념으로, Next.js나 Astro와는 별개의 생태계입니다. 그럼에도 2025~2026년 렌더링 트렌드를 논할 때 빠지지 않고 등장합니다.
전통적인 SSR에서는 서버가 HTML을 만들어 보내고, 브라우저가 그 HTML을 다시 JavaScript로 "되살리는(hydration)" 과정을 거칩니다. 이 과정에서 모든 컴포넌트 코드를 다시 실행해야 하므로 JS 번들 크기가 TTI(Time to Interactive)에 직접적인 영향을 줍니다.
Resumability는 서버가 컴포넌트 상태와 이벤트 리스너 정보를 HTML에 직렬화해 보내고, 브라우저는 컴포넌트 트리를 처음부터 재실행하지 않고 그 상태를 "재개(resume)"합니다. 전체 컴포넌트 트리를 재실행하지 않아도 되기 때문에 JS 파싱·실행 비용이 거의 0에 수렴합니다. 이게 바로 Qwik이 콜드 스타트 TTI 벤치마크에서 다른 프레임워크를 앞서는 이유입니다.
실전 적용
전자상거래 플랫폼에서의 하이브리드 렌더링
실무에서 가장 자주 마주치는 시나리오입니다. 쇼핑몰 하나만 봐도 페이지마다 요구사항이 전부 다르거든요.
| 영역 | 렌더링 전략 | 이유 |
|---|---|---|
| 메인·마케팅 페이지 | SSG | 변경 드물고 트래픽 높음, CDN 캐싱 효과 극대화 |
| 상품 목록 | ISR (5분 간격) | SEO 필요 + 주기적 가격·재고 변동 반영 |
| 상품 상세 | SSR | 재고 실시간 반영, 사용자별 추천 |
| 장바구니·결제 | CSR | 인증 후 인터랙션 집중, SEO 불필요 |
저 팀에서는 상품 상세 페이지에 SSR을 선택했는데, 실시간 재고보다 추천 API 응답 시간이 더 큰 문제였습니다. 그래서 추천 섹션만 <Suspense>로 감싸 스트리밍 처리했고, 전체 페이지 응답 대기 시간이 눈에 띄게 줄었습니다.
// app/products/page.tsx — 상품 목록: ISR
export const revalidate = 300 // 5분마다 재생성
export default async function ProductListPage() {
const products = await fetchProducts()
return <ProductGrid products={products} />
}// app/products/[id]/page.tsx — 상품 상세: SSR
export const dynamic = 'force-dynamic'
export default async function ProductDetailPage({
params,
}: {
params: { id: string }
}) {
const [product, recommendations] = await Promise.all([
fetchProduct(params.id),
fetchRecommendations(params.id),
])
return (
<>
<ProductDetail product={product} />
<Suspense fallback={<RecommendSkeleton />}>
<Recommendations items={recommendations} />
</Suspense>
</>
)
}SaaS 대시보드에서의 Streaming SSR + CSR 조합
로그인 전과 후의 성격이 완전히 다른 전형적인 SaaS 구조입니다. 이 부분에서 제일 많이 헤맸던 건 "어디서 SSR을 끊고 CSR을 시작하느냐"였는데요. 기준은 결국 단순합니다. 실시간 외부 이벤트(WebSocket, SSE)에 반응해야 하면 CSR, 그렇지 않으면 Streaming SSR로 처리하면 됩니다.
// app/page.tsx — 랜딩 페이지: SSG
// revalidate 없음 = 빌드 타임에 한 번만 생성
export default function LandingPage() {
return (
<>
<HeroSection />
<PricingTable />
<Testimonials />
</>
)
}// app/dashboard/page.tsx — 대시보드: Streaming SSR
export default async function DashboardPage() {
return (
<DashboardLayout>
{/* 빠르게 로딩되는 요약 정보 */}
<Suspense fallback={<StatsSkeleton />}>
<StatsOverview />
</Suspense>
{/* 무거운 차트는 별도 스트림으로 */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* 실시간 알림은 클라이언트 컴포넌트 */}
<RealtimeNotifications />
</DashboardLayout>
)
}// components/RealtimeNotifications.tsx — CSR + WebSocket
'use client'
import { useEffect, useState } from 'react'
type Notification = {
id: string
message: string
type: 'info' | 'warning' | 'error'
createdAt: string
}
export function RealtimeNotifications() {
const [notifications, setNotifications] = useState<Notification[]>([])
useEffect(() => {
// NEXT_PUBLIC_WS_URL 환경변수를 .env.local에 반드시 설정해야 합니다
const wsUrl = process.env.NEXT_PUBLIC_WS_URL
if (!wsUrl) throw new Error('NEXT_PUBLIC_WS_URL 환경변수가 설정되지 않았습니다')
const ws = new WebSocket(wsUrl)
ws.onmessage = (event) => {
setNotifications(prev => [JSON.parse(event.data) as Notification, ...prev])
}
return () => ws.close()
}, [])
return <NotificationList items={notifications} />
}콘텐츠/미디어 사이트에서의 Astro Server Islands
블로그나 미디어 사이트처럼 콘텐츠 중심인 경우, Astro로 Lighthouse 95+ 점수를 달성한 팀들이 즐겨 쓰는 패턴입니다. 처음 Astro를 써볼 때 제가 실수한 게 있었는데, getStaticPaths를 frontmatter 바깥에 두면 실제로 동작하지 않습니다. Astro에서는 반드시 --- 안에 위치해야 합니다.
---
// pages/blog/[slug].astro
import { getPost, getAllPosts } from '../../lib/cms';
import CommentSection from '../../components/CommentSection.astro';
import RelatedPosts from '../../components/RelatedPosts.astro';
// getStaticPaths는 반드시 frontmatter(---) 안에 있어야 동작합니다
export async function getStaticPaths() {
const posts = await getAllPosts();
return posts.map(p => ({ params: { slug: p.slug } }));
}
const { slug } = Astro.params;
const post = await getPost(slug);
---
<article>
<!-- 본문: 빌드 타임에 정적 생성, CDN 캐싱 -->
<h1>{post.title}</h1>
<Fragment set:html={post.content} />
</article>
<!-- 댓글: 서버 island로 동적 렌더링 -->
<CommentSection server:defer postId={post.id}>
<div slot="fallback">댓글 불러오는 중...</div>
</CommentSection>
<!-- 관련 글: 서버 island -->
<RelatedPosts server:defer postId={post.id}>
<div slot="fallback">관련 글 불러오는 중...</div>
</RelatedPosts>장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 성능 최적화 | 각 경로 특성에 맞는 전략으로 LCP·INP·CLS를 최대치로 끌어올릴 수 있음 |
| SEO + 인터랙티비티 동시 확보 | 서버 렌더링 HTML로 크롤러 색인 + 클라이언트 인터랙션 유지 |
| 인프라 비용 절감 | 정적 콘텐츠 CDN 캐싱으로 원본 서버 요청 감소, 트래픽 비용 절약 |
| 점진적 마이그레이션 | 기존 CSR 앱의 일부 라우트에만 SSR/SSG를 적용해 리스크 최소화 가능 |
| 엣지 배포 효과 | Vercel Edge Functions, Cloudflare Workers 활용 시 SSR TTFB 60~80% 단축 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 아키텍처 복잡도 증가 | 경로마다 다른 캐싱 전략, 서버 환경 차이, 데이터 페칭 패턴 관리 필요 | 팀 내 렌더링 전략 결정 가이드라인 문서화 |
| 하이드레이션 미스매치 | SSR HTML과 CSR 결과 불일치 시 React Hydration Error 발생 | Date.now(), Math.random(), window 접근을 서버/클라이언트 분리 |
| ISR 캐시 무효화 복잡성 | stale-while-revalidate 특성상 데이터 변경이 즉시 반영되지 않아 stale 콘텐츠 노출 가능 | revalidatePath() / revalidateTag()를 사용하는 On-Demand Revalidation 병행 |
| 콜드 스타트 비용 | 서버리스 환경 SSR 엔드포인트의 콜드 스타트 지연이 TTFB에 영향 | 엣지 배포로 완화, Edge Runtime 제약 사전 확인 |
| 번들 비대화 위험 | CSR 구간에 과도한 JS 포함 시 하이브리드 전략 이점 반감 | Dynamic Import + Tree Shaking 적극 활용 |
TTFB(Time To First Byte): 브라우저가 서버에 요청을 보내고 첫 번째 바이트를 받기까지 걸리는 시간입니다. SSR에서 가장 영향받기 쉬운 지표로, 엣지 배포를 통해 물리적 거리를 줄이는 것이 효과적입니다.
Hydration Error: React SSR에서 서버가 만든 HTML과 브라우저에서 렌더링한 결과가 다를 때 발생합니다. 서버에서는
window나localStorage가 없으므로, 이런 객체에 접근하는 코드는 반드시 클라이언트 전용으로 분리해야 합니다.
실무에서 가장 흔한 실수
어디서나 나오는 얘기지만, 직접 겪어봐야 진짜로 기억에 남습니다. 저도 아래 세 가지 다 한 번씩은 밟았습니다.
-
"일단 전부 SSR" — 동적 데이터가 필요 없는 페이지까지 SSR로 설정해 불필요한 서버 부하를 만드는 경우입니다. 메인 페이지나 About 페이지 같은 정적 콘텐츠는 SSG로 충분합니다. 렌더링 전략을 결정하기 전에 "이 데이터가 요청마다 달라지는가?"라는 질문을 먼저 던져보시는 걸 권장합니다.
-
캐시 무효화 전략 없이 ISR 도입 —
revalidate시간만 설정해두고 실제로 데이터가 변경됐을 때 즉시 갱신하는 On-Demand Revalidation을 빠뜨리는 경우입니다. 가격이 바뀌었는데 5분씩 기다리게 하면 안 되겠죠. Next.js의revalidatePath()또는revalidateTag()를 Server Action이나 웹훅에서 호출하는 구조를 처음부터 함께 설계해두는 것이 좋습니다. -
'use client'컴포넌트를 최상위에 배치 — 불필요하게 큰 컴포넌트에'use client'를 붙이면 하위 트리 전체가 클라이언트 번들에 포함됩니다. 인터랙티브한 부분만 최소 단위로 분리하는 게 중요합니다. 예를 들어 좋아요 버튼 하나 때문에 카드 컴포넌트 전체를 클라이언트 컴포넌트로 만들 필요는 없습니다.
마치며
복잡해 보여도 처음부터 완벽하게 설계할 필요는 없습니다. 지금 쓰고 있는 앱을 뜯어보면서 "이 페이지는 정말 SSR이 필요한가?"라는 질문 하나만 던져봐도 개선 포인트가 보이기 시작합니다. 이 기준들을 실제로 내 프로젝트 라우트에 대입해보면, 지금까지 별생각 없이 내린 렌더링 전략 결정들이 달리 보이기 시작합니다. 데이터의 동적성·SEO 필요 여부·서버 비용이라는 세 축을 기준으로 판단하면, 아키텍처 결정이 훨씬 명확해집니다.
지금 바로 시작해볼 수 있는 3단계:
- 현재 앱의 라우트 목록을 작성하고 동적성 분류해보기 — 스프레드시트나 노션 테이블에 라우트별로 "데이터가 자주 바뀌나?", "로그인이 필요한가?", "SEO가 중요한가?" 세 가지를 체크하는 것만으로도 렌더링 전략의 윤곽이 잡힙니다.
- 가장 트래픽이 높은 정적 페이지부터 SSG로 전환해보기 — Next.js라면 해당 페이지 파일에서
export const dynamic = 'force-static'한 줄을 추가하거나,revalidate없이 async 데이터 페칭을 제거하는 것으로 시작할 수 있습니다. 전환 전후 LCP 수치를 비교해보시면 효과가 눈에 보입니다. Vercel Analytics 외에도 Lighthouse CI나 PageSpeed Insights로 측정해볼 수 있습니다. - 무거운 데이터 의존 컴포넌트에
<Suspense>감싸기 — SSR 페이지에서 느린 API 호출이 있는 컴포넌트를<Suspense fallback={<Skeleton />}>으로 감싸면 Streaming SSR이 활성화되어, 전체 페이지가 완성될 때까지 기다리지 않고 콘텐츠를 청크 단위로 사용자에게 먼저 전달할 수 있습니다.
다음 글: Vercel Edge Functions와 Cloudflare Workers를 활용한 엣지 렌더링 실전 가이드 — A/B 테스트, 지역화, 개인화를 서버 부하 없이 엣지에서 구현하는 법
참고 자료
- How to choose the best rendering strategy for your app | Vercel
- Partial Prerendering | Next.js 공식 문서
- Partial prerendering: Building towards a new default rendering model | Vercel
- Islands Architecture | Astro 공식 문서
- Server Islands | Astro 공식 문서
- The Next.js 15 Streaming Handbook | freeCodeCamp
- Resumable | Qwik 공식 문서
- What Are the Emerging Trends in Server-Side Rendering for a JavaScript Framework? | Sencha
- Edge Computing for Frontend Developers | daily.dev
- Islands Architecture | patterns.dev
- Advanced SSR 2025: Selective Hydration, RSCs, and Edge Rendering | blog.madrigan.com
- CSR vs SSR vs SSG vs ISR: Best Rendering Method in 2026 | hashbyt