2026년 기준 앱 유형별 렌더링 전략: CSR·SSR·SSG·PPR·RSC·Edge 선택의 실제 기준
솔직히 말하면, 저도 처음 Next.js를 쓸 때 getServerSideProps와 getStaticProps를 페이지 성격과 관계없이 뒤섞어 쓰다가 배포 이후에야 "왜 이 페이지가 이렇게 느리지?"를 뒤늦게 깨달은 적이 있습니다. "나중에 최적화하면 되겠지"로 미뤘다가 라우트 구조 전체를 뜯어고쳐야 했던 그 고통은 지금 생각해도 아찔합니다. 여기를 읽고 계신다면, 비슷한 상황에 처했거나 그 전에 방향을 잡고 싶은 분일 거라 생각합니다.
선택지는 CSR·SSR·SSG의 삼각형을 훌쩍 넘어섰습니다. ISR의 온디맨드 무효화, PPR의 정적 셸+동적 스트리밍, RSC의 서버/클라이언트 경계, Edge Rendering의 CDN 레이어 처리까지 더해지면서 한 라우트 안에서도 전략을 섞는 하이브리드가 사실상 표준이 됐습니다. 처음 아키텍처를 설계하거나 레거시 앱을 마이그레이션할 때 어디서 시작해야 할지 막막해지는 게 당연합니다. 이 글에서는 블로그·이커머스·SaaS 대시보드·AI 앱처럼 실제로 마주치는 앱 유형별로 어떤 전략이 왜 맞는지, 선택의 근거와 흔한 실수까지 함께 살펴봅니다.
한 가지 미리 밝혀두면, 코드 예시는 Next.js App Router 기준으로 작성되어 있습니다. 콘텐츠 사이트 섹션은 Astro도 다루지만, 나머지는 Next.js를 전제하고 있으니 다른 프레임워크를 쓰고 계신다면 개념 수준으로 참고하시면 좋습니다.
핵심 개념
렌더링 전략 한눈에 보기
렌더링 아키텍처는 결국 "HTML을 어디서, 언제 만드느냐"의 문제입니다. 네 가지 고전 전략부터 짚어보겠습니다.
| 전략 | HTML 생성 시점 | 생성 위치 | 비고 |
|---|---|---|---|
| CSR | 브라우저에서 JS 실행 후 | 클라이언트 | 번들 크기가 성능을 결정 |
| SSR | 매 요청마다 | 서버 | TTFB가 서버 처리 시간에 비례 |
| SSG | 빌드 타임 | 서버 (사전 생성) | CDN으로 즉시 서빙 가능 |
| ISR | 빌드 타임 + 재생성 | 서버 | 주기적 갱신 + 이벤트 기반 무효화 |
여기에 2025~2026년에 자리잡은 두 가지가 더해집니다.
PPR (Partial Prerendering): 빌드 타임에 정적 셸(레이아웃, 네비게이션, 상단 영역)을 미리 만들고, 동적 콘텐츠만 React Suspense 스트리밍으로 채웁니다. "전체 정적"과 "전체 동적" 사이의 이분법을 한 라우트 안에서 해소한 개념입니다. Next.js 15에서는
next.config.js에experimental: { ppr: true }를 추가해야 사용할 수 있고, Next.js 16부터는 별도 플래그 없이 기본 동작합니다.
Edge Rendering: Cloudflare Workers, Vercel Edge Functions 같은 CDN 엣지 노드에서 렌더링과 API 처리를 수행합니다. 별도 서버 인프라 없이 글로벌 저지연을 실현하지만, Node.js 런타임 API 일부가 지원되지 않고 128MB 메모리 제한과 콜드 스타트 문제가 실무에서 자주 걸립니다.
ISR: 주기 재생성만 알면 반쪽짜리
ISR을 "revalidate 시간을 설정하면 끝"으로만 알고 있다면, 2020년대 초반 수준에서 멈춰 있는 겁니다. revalidateTag()를 이용한 이벤트 기반 온디맨드 무효화가 이미 일반화되어 있습니다. 재고가 변경되거나 CMS에서 글을 발행할 때, 시간 주기를 기다리지 않고 즉시 특정 캐시를 무효화하는 방식입니다.
// 재고 업데이트 API에서 태그 무효화
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
const { productId } = await req.json();
revalidateTag(`product-${productId}`);
return Response.json({ revalidated: true });
}2026년 기준으로 바뀐 것들
Core Web Vitals에서 FID가 INP(Interaction to Next Paint) 로 완전히 대체됐습니다.
INP (Interaction to Next Paint): 사용자 상호작용(클릭, 키 입력 등) 후 다음 화면 갱신까지의 시간입니다. 200ms 이하가 "좋음" 기준이며, 2025년 Web Almanac 기준으로 모바일에서 CWV를 통과하는 페이지는 전체의 48%에 불과합니다.
TTFB (Time to First Byte): 브라우저가 요청을 보낸 뒤 서버로부터 첫 번째 바이트를 받을 때까지의 시간입니다. SSR은 서버 처리 시간이 더해지므로 SSG·CDN 대비 길어집니다.
RSC(React Server Components)는 이제 실험적 기능이 아닙니다. 서버에서만 실행되는 컴포넌트는 클라이언트 번들에서 완전히 빠지고, 데이터 패칭 로직을 RSC로 이전했을 때 초기 렌더 성능이 드라마틱하게 개선되는 사례들이 쌓이고 있습니다. 당장 저희 프로젝트에서도 대시보드 데이터 패칭을 서버로 옮겼을 때 번들 크기가 눈에 띄게 줄었습니다.
실전 적용
콘텐츠 사이트: 블로그·문서·랜딩 페이지
콘텐츠가 자주 바뀌지 않고 SEO가 중요한 사이트라면 SSG가 가장 자연스러운 출발점입니다. Astro는 기본적으로 0 JS 출력에 Island Architecture를 지원하기 때문에, 정적 HTML 위에 필요한 곳에만 React·Vue·Svelte 컴포넌트를 "섬처럼" 삽입하는 방식으로 성능과 인터랙션을 동시에 잡을 수 있습니다.
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
import CommentSection from '../../components/CommentSection.tsx';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<Layout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<Content />
<!-- 댓글 영역만 클라이언트 컴포넌트로 하이드레이션 -->
<CommentSection client:load postId={post.slug} />
</article>
</Layout>client:load가 붙은 컴포넌트에만 JavaScript가 하이드레이션됩니다. 나머지 페이지는 순수 HTML입니다. 인터랙티브한 댓글 기능은 그 섬 안에서만 동작하고, 아티클 본문 자체는 JS 없이 렌더링됩니다.
| 지표 | Astro SSG | Next.js SSG (최적화) |
|---|---|---|
| JS 번들 기본값 | 0 KB (컴포넌트 외) | 프레임워크 런타임 포함 |
| LCP | 매우 낮음 | 낮음 |
| Island 지원 | 네이티브 | RSC로 유사 구현 가능 |
| SEO 적합성 | ★★★★★ | ★★★★★ |
이커머스: 상품 목록·상세 페이지·체크아웃
이커머스는 한 서비스 안에서 여러 전략이 공존해야 하는 전형적인 케이스입니다. 상품 목록·상세·체크아웃에 같은 전략을 적용하려다 둘 다 어중간해지는 실수를 현장에서 자주 봐왔고, 솔직히 저도 그랬습니다.
상품 목록은 SEO가 중요하고 가격·재고는 1시간 단위로 갱신해도 충분한 경우가 많아 ISR이 잘 맞습니다. 온디맨드 무효화까지 함께 구성해두면 더 안정적입니다.
// app/products/page.tsx — ISR로 상품 목록 캐싱
export const revalidate = 3600; // 1시간 단위 재생성
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }, // revalidateTag('products')로 즉시 무효화 가능
}).then(r => r.json());
return <ProductGrid products={products} />;
}반면 체크아웃은 사용자별 장바구니 상태와 인증이 얽혀 있어 캐싱 자체가 불가능합니다. SSR이 정답입니다.
// app/checkout/page.tsx — SSR로 장바구니·인증 상태 실시간 처리
import { getServerSession } from 'next-auth'; // next-auth 또는 auth.js
import { getCart } from '@/lib/cart'; // 커스텀 장바구니 유틸
export const dynamic = 'force-dynamic';
export default async function CheckoutPage() {
const session = await getServerSession();
const cart = await getCart(session.userId);
return <CheckoutForm cart={cart} />;
}상품 상세 페이지는 그 중간 어딘가입니다. 상품명·설명·이미지는 거의 바뀌지 않지만 실시간 재고는 매번 달라질 수 있죠. PPR이 이 상황을 위해 만들어진 개념입니다. 정적 셸로 상품 정보를 먼저 내려주고, 재고 정보만 Suspense 경계 안에서 스트리밍합니다.
// app/products/[id]/page.tsx — PPR: 셸은 정적, 재고만 동적
import { Suspense } from 'react';
import StockStatus from './StockStatus';
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id); // 정적 셸에 포함
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* 실시간 재고만 Suspense로 스트리밍 */}
<Suspense fallback={<span>재고 확인 중...</span>}>
<StockStatus productId={params.id} />
</Suspense>
</div>
);
}StockStatus가 실제로 동적 영역으로 동작하려면 컴포넌트 내부에서 캐싱을 명시적으로 해제해야 합니다. 이 부분을 빠뜨리면 PPR의 동적 스트리밍이 의도대로 작동하지 않습니다.
// app/products/[id]/StockStatus.tsx
import { unstable_noStore as noStore } from 'next/cache';
export default async function StockStatus({ productId }: { productId: string }) {
noStore(); // 이 컴포넌트를 PPR에서 동적 영역으로 만드는 핵심
const { available, count } = await fetch(
`/api/stock/${productId}`,
{ cache: 'no-store' }
).then(r => r.json());
return (
<span className={available ? 'text-green-600' : 'text-red-500'}>
{available ? `재고 ${count}개` : '품절'}
</span>
);
}| 페이지 | 전략 | 선택 이유 |
|---|---|---|
| 상품 목록 | ISR (1시간 + 온디맨드) | SEO 필요, 가격·재고는 주기 갱신으로 충분 |
| 상품 상세 | PPR | 상품 정보는 정적, 실시간 재고만 동적 |
| 장바구니/체크아웃 | SSR | 사용자별 상태, 캐시 불가 |
| 개인화 추천 | Edge Rendering | 지역별 가격, A/B 테스트를 서버 없이 처리 |
SaaS 대시보드: RSC와 CSR의 경계
대시보드는 로그인 후 장시간 체류하고 SEO가 불필요하다는 점에서 CSR(SPA)도 여전히 충분히 합리적인 선택입니다. 하지만 DB 쿼리가 많고 복잡한 차트가 들어간다면, 데이터 패칭 로직을 서버로 옮기는 RSC가 번들 크기와 클라이언트 연산 모두에서 유리합니다.
RSC를 처음 쓸 때 가장 헷갈리는 부분이 서버·클라이언트 경계입니다. 서버 컴포넌트는 DB를 직접 조회할 수 있지만, 차트 라이브러리처럼 DOM을 다루는 코드는 클라이언트 컴포넌트가 필요합니다. 이 둘을 연결할 때 데이터를 JSON으로 직렬화해서 props로 내려주는 방식만 허용되는데, 서버-클라이언트 경계를 넘을 때는 함수나 클래스 인스턴스 같이 JSON으로 표현할 수 없는 값을 전달할 수 없기 때문입니다.
// app/dashboard/analytics/page.tsx — RSC: DB 조회를 서버에서 처리
import { db } from '@/lib/db';
import AnalyticsChart from './AnalyticsChart';
export default async function AnalyticsPage() {
// 이 파일 자체가 클라이언트 번들에 포함되지 않음 — DB 로직이 서버에만 존재
const stats = await db.query<{ date: string; revenue: number; users: number }[]>(`
SELECT date, revenue, users
FROM daily_stats
WHERE date >= NOW() - INTERVAL '30 days'
`);
// JSON으로 직렬화 가능한 데이터만 클라이언트 컴포넌트로 전달
return <AnalyticsChart data={stats} />;
}// app/dashboard/analytics/AnalyticsChart.tsx — 클라이언트 컴포넌트
'use client';
import { LineChart, Line, XAxis, YAxis } from 'recharts';
interface StatRow {
date: string;
revenue: number;
users: number;
}
export default function AnalyticsChart({ data }: { data: StatRow[] }) {
// recharts는 DOM 접근이 필요하므로 'use client' 필수
return (
<LineChart width={600} height={300} data={data}>
<XAxis dataKey="date" />
<YAxis />
<Line type="monotone" dataKey="revenue" stroke="#8884d8" />
</LineChart>
);
}RSC의 핵심 원칙: 서버 컴포넌트는 클라이언트 컴포넌트를 children으로 받을 수 있지만, 클라이언트 컴포넌트는 서버 컴포넌트를 import할 수 없습니다.
'use client'는 인터랙션이 실제로 필요한 leaf 컴포넌트에만 붙이고, 데이터 패칭은 서버 컴포넌트에 두는 구조가 번들 최적화의 핵심입니다.
AI 인터랙티브 앱: 스트리밍 SSR
GPT API를 처음 붙였을 때의 경험이 기억납니다. 첫 토큰이 나오기까지 3~5초 동안 화면이 완전히 멈춰 있는 UX가 얼마나 답답한지, 써보셨다면 아실 겁니다. 사용자 입장에서는 요청이 처리 중인지 실패했는지조차 알 수 없거든요. 응답 전체가 완성될 때까지 기다리게 하는 대신, 토큰이 생성되는 즉시 브라우저로 흘려보내면 첫 글자가 나타나는 순간부터 "응답이 오고 있다"는 피드백이 됩니다.
// app/api/chat/route.ts — 스트리밍 API 라우트
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = await streamText({
model: openai('gpt-4o'),
messages,
});
return result.toDataStreamResponse();
}서버에서 스트림을 내보내는 것만으로는 부족합니다. 클라이언트에서 이를 실시간으로 소비하는 컴포넌트가 있어야 합니다. Vercel AI SDK의 useChat 훅이 스트리밍 응답을 청크 단위로 받아 화면에 점진적으로 렌더링하는 역할을 담당합니다.
// app/chat/AIResponseStream.tsx — 스트리밍 소비 클라이언트 컴포넌트
'use client';
import { useChat } from 'ai/react'; // Vercel AI SDK
export default function AIResponseStream() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
api: '/api/chat',
});
return (
<div>
<div className="messages">
{messages.map(m => (
<div key={m.id} className={m.role === 'user' ? 'user' : 'assistant'}>
{m.content}
</div>
))}
{isLoading && <div className="assistant typing">▌</div>}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="메시지를 입력하세요..."
/>
<button type="submit">전송</button>
</form>
</div>
);
}// app/chat/page.tsx — Suspense로 스트리밍 경계 설정
import { Suspense } from 'react';
import AIResponseStream from './AIResponseStream';
export default function ChatPage() {
return (
<Suspense fallback={<div>AI 연결 중...</div>}>
<AIResponseStream />
</Suspense>
);
}엣지 렌더링: 글로벌 개인화를 서버 없이 처리하기
지역별 가격 표시나 A/B 테스트처럼 "사용자가 어디서 접속하느냐"에 따라 응답이 달라지는 경우, 중앙 서버로 라운드트립이 발생하면 레이턴시가 심각하게 늘어납니다. Edge Middleware는 이 처리를 CDN 레이어에서 직접 수행합니다.
// middleware.ts — Vercel Edge Middleware: 지역 정보 주입
import { NextRequest, NextResponse } from 'next/server';
export const config = { matcher: '/products/:path*' };
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? 'KR';
const currency = country === 'US' ? 'USD' : 'KRW';
const response = NextResponse.next();
// 헤더로 지역 정보를 전달하면 RSC나 API Route에서 읽어 사용
response.headers.set('x-user-country', country);
response.headers.set('x-user-currency', currency);
return response;
}한 가지 주의할 점은 Edge Runtime이 Node.js 전체를 제공하지 않는다는 겁니다. fs, 네이티브 모듈, Node.js 전용 crypto API에 의존하는 라이브러리는 Edge에서 실행이 불가능합니다. 런타임 메모리 제한이 128MB라서 대용량 데이터를 메모리에 올리는 패턴도 피해야 하고, 트래픽이 간헐적인 라우트에서는 콜드 스타트로 인해 첫 요청 레이턴시가 예상보다 높게 나올 수 있습니다. Edge를 도입하기 전에 사용하려는 패키지들의 Edge Runtime 호환성을 먼저 확인하는 게 실무에서 많은 삽질을 줄여줍니다.
장단점 분석
아래 표를 처음 만들었을 때 별점으로 모든 걸 표현하려다가, 상황에 따라 너무 달라진다는 걸 깨달았습니다. 특히 SSR의 LCP는 서버 응답 속도와 캐싱 전략에 따라 SSG에 맞먹을 수도 있고, 반대로 한참 느릴 수도 있습니다. 아래 표는 경향성으로 읽어주시면 좋습니다.
| 전략 | 주요 장점 | 대표 단점 | 대응 방안 | 적합한 케이스 |
|---|---|---|---|---|
| SSG | 최고 LCP, 최저 서버 비용, CDN 즉시 서빙 | 수십만 페이지 → 빌드 시간 폭증 | ISR 또는 온디맨드 ISR로 전환 | 블로그, 문서, 마케팅 랜딩 |
| ISR | SSG 성능 + 주기적 최신화 | 캐시 무효화 타이밍 예측 어려움 | revalidateTag()로 이벤트 기반 무효화 병행 |
뉴스, 이커머스 상품 목록 |
| SSR | 항상 최신 데이터, 강력한 SEO | TTFB 증가, 서버 비용 높음 | Edge 이전 또는 캐싱 레이어 추가 | 개인화 콘텐츠, 인증 필요 영역 |
| CSR | 풍부한 인터랙션, 서버 비용 최저 | SEO 불리, 초기 JS 로드 느림 | 공개 페이지에는 지양 | 대시보드, 어드민, 로그인 후 영역 |
| PPR | 정적 LCP + 동적 업데이트 동시 충족 | Next.js 전용 (벤더 의존도 높음) | Vercel 없이 self-host도 가능 | 이커머스 상세, 뉴스 포털 |
| Edge | 글로벌 저지연, 개인화 처리 | Node.js API 제한, 128MB 메모리, 콜드 스타트 | Edge Runtime 호환성 사전 검증 필수 | 지역별 가격, A/B 테스트 |
개인적으로 이커머스 프로젝트에서 ISR 캐시 무효화 타이밍이 가장 까다로웠습니다. revalidate = 3600으로 설정해뒀는데 가격이 변경됐을 때 사용자에게 이전 가격이 그대로 보이는 일이 있었거든요. 그 이후로 ISR을 쓸 때는 반드시 revalidateTag()를 함께 구성해두는 습관이 생겼습니다.
실무에서 가장 흔한 실수
-
SEO가 불필요한 어드민에 SSR을 억지로 적용하는 경우. 로그인 후 영역은 검색 노출이 없고 데이터가 실시간으로 바뀌므로 CSR이나 RSC 조합이 더 적합합니다. SSR을 쓰면 서버 비용만 늘어나고 사용자 경험 개선도 없습니다.
-
ISR의
revalidate를 설정하고 온디맨드 무효화를 빠뜨리는 경우. 재고·가격처럼 변동이 잦은 데이터에revalidate = 86400(24시간)을 설정하면 사용자에게 오래된 정보를 보여줍니다.revalidateTag()를 함께 구성해두는 것을 권장합니다. -
RSC와 클라이언트 컴포넌트 경계를 너무 위에서 그어버리는 경우.
'use client'를 루트나 상위 컴포넌트에 붙이면 하위 트리 전체가 클라이언트 번들에 포함됩니다. 인터랙션이 실제로 필요한 leaf 컴포넌트에만 붙이고, 데이터 패칭은 서버 컴포넌트에 두는 구조가 번들 크기와 성능 모두에 유리합니다.
마치며
렌더링 전략은 앱 전체에 하나를 고르는 게 아니라, 각 라우트의 성격(SEO 필요성, 데이터 변경 빈도, 인증 여부, 빌드 규모)에 맞게 조합하는 것이 2026년의 표준입니다.
지금 시작할 수 있는 3단계입니다.
-
먼저 현재 상태를 측정합니다. PageSpeed Insights에서 주요 페이지의 LCP와 INP를 확인해 수치를 기록해두세요. 전략을 바꾸기 전에 기준선을 잡아두지 않으면 개선 여부를 비교할 방법이 없습니다.
-
라우트를 유형별로 분류하고 전략을 조정합니다. "이 페이지가 공개인지, SEO가 필요한지, 데이터가 얼마나 자주 바뀌는지"를 기준으로 스프레드시트에 정리해보시면 전략이 명확해집니다. Next.js App Router 기준으로는
export const revalidate와export const dynamic값 하나씩을 조정하는 것만으로 라우트 단위 전략 변경이 가능합니다. -
전략 변경 후 다시 측정해서 비교합니다. 1단계에서 기록해둔 수치와 비교해보시면, 어떤 선택이 실제 사용자 경험에 영향을 주는지 직접 확인할 수 있습니다. 이 사이클 자체가 아키텍처 감각을 키워줍니다.
참고 자료
입문용
- CSR vs SSR vs SSG vs ISR: Best Rendering Method in 2026
- Rendering Patterns in Next.js: CSR, SSR, SSG, ISR, RSC, PPR, and DPR
- Next.js Partial Prerendering Docs
- The Complete Guide to Frontend Architecture Patterns in 2026
심화용
- Partial Prerendering (PPR) in Production: Architecture Patterns (2026 Edition)
- Partial Prerendering Guide (2026) - PageGlass
- React Server Components in 2026: Patterns, Pitfalls, and When to Actually Use Them
- Edge Computing Frontend 2026 - Serverless Edge Functions Guide
- Core Web Vitals 2026: New Thresholds and Tuning
- SSR vs CSR vs SSG vs ISR: The 2026 Frontend Tradeoff
- Next.js vs Remix vs Astro vs SvelteKit in 2026: The Definitive Framework Decision Guide