hyunsu blog

홈노트

CSR·SSR·SSG·ISR·RSC·PPR 차이와 화면별로 고르는 기준

목차 · 8
  1. 01여섯 이름은 '언제 그리나' 와 '무엇을 보내나' 로 갈린다
  2. 02CSR·SSR·SSG 는 첫 화면 속도와 서버 일을 맞바꾼다
  3. 03ISR 은 만료된 정적 페이지를 옛 본으로 응답하며 뒤에서 다시 만든다
  4. 04RSC 는 렌더링 시점이 아니라 브라우저로 가는 코드를 줄인다
  5. 05PPR 은 한 라우트를 정적 껍데기와 요청 시점 조각으로 나눈다
  6. 06화면 성격을 보면 고를 방식이 정해진다
  7. 07정적에서 시작해 필요한 조각만 요청 때로 연다
  8. 08자주 묻는 질문

CSR·SSR·SSG·ISR·PPR 은 HTML 을 언제, 어디서 만드느냐로 갈린다. RSC 는 그와 별개로 어떤 컴포넌트 코드를 브라우저에 보내지 않을지를 정한다. 기본 선택은 URL 이 정해진 콘텐츠는 SSG, 잠깐 옛 내용이 보여도 되는 콘텐츠는 ISR(revalidate = 3600 같은 주기나 발행 시점 무효화), 사용자마다 다른 화면은 SSR, 로그인 뒤 상호작용이 대부분인 화면은 CSR 이다. 한 페이지에 공통 부분과 개인화 부분이 섞이면 Next.js 16 에서 cacheComponents: true 로 켜는 PPR 이 정적 껍데기를 먼저 보내고 나머지를 스트리밍한다.

01 여섯 이름은 '언제 그리나' 와 '무엇을 보내나' 로 갈린다

CSR(Client-Side Rendering)·SSR(Server-Side Rendering)·SSG(Static Site Generation)는 HTML 을 만드는 장소와 시점을 가리킨다. web.dev 의 「Rendering on the Web」은 SSG 를 static rendering 이라 부르는데, URL 마다 HTML 파일을 미리 하나씩 만들어 두는 방식이다. ISR(Incremental Static Regeneration)과 PPR(Partial Prerendering)은 Next.js 문서에서 쓰는 이름이고, 정적 렌더링과 동적 렌더링을 섞는 방법이다. RSC(React Server Components)만 결이 다르다. React 의 컴포넌트 종류여서, 시점이 아니라 "이 컴포넌트 코드가 브라우저 번들에 들어가느냐" 를 가른다.

방식결과를 만드는 때만드는 곳브라우저가 처음 받는 것
CSR페이지를 연 뒤브라우저최소한의 HTML 과 JS 번들
SSR요청마다서버완성된 HTML, 이어서 하이드레이션용 JS
SSG빌드 때빌드 환경(CI)CDN 에 올려 둔 HTML
ISR빌드 때, 만료된 뒤에는 요청이 오면 백그라운드에서 다시서버캐시된 HTML(만료됐으면 옛 본)
PPR껍데기는 빌드 때, 빈자리는 요청 때빌드 환경과 서버정적 껍데기와 fallback, 뒤이어 스트리밍 조각
RSC빌드 때 또는 요청마다서버(번들 밖)렌더 결과(RSC Payload). 컴포넌트 코드는 오지 않는다

그래서 "RSC 냐 SSR 이냐" 는 성립하지 않는 비교다. React 문서를 보면 Server Component 는 빌드 때 한 번 돌 수도 있고 요청마다 돌 수도 있어서, SSG 와도 SSR 과도 함께 쓴다. 페이지를 옮기는 방식(MPA·SPA)까지 섞으면 축이 하나 더 는다. 그 구분은 MPA·SPA 와 CSR·SSR·SSG 는 서로 다른 축이다에 따로 정리했다.

02 CSR·SSR·SSG 는 첫 화면 속도와 서버 일을 맞바꾼다

비교에 쓰는 지표부터 정리한다. TTFB(Time to First Byte)는 링크를 누른 뒤 첫 바이트가 도착할 때까지 걸린 시간이고, FCP(First Contentful Paint)는 요청한 내용이 처음 화면에 보이는 때다. INP(Interaction to Next Paint)는 사용자 입력에 꾸준히 빨리 반응하는지를 본다. TBT(Total Blocking Time)는 로딩 중 메인 스레드가 막힌 시간인데, INP 를 대신 가늠하는 값으로 쓴다. web.dev 는 표 대신 본문에서 방식마다 이 지표를 짚는다. 그 설명을 모으면 아래와 같고, 본문이 따로 말하지 않은 칸은 — 로 두었다.

방식TTFBFCPINP·TBT
SSR서버에서 페이지를 만드는 시간만큼 늘 수 있다대체로 빠르다보내는 JS 가 적으면 낮출 수 있다
SSG꾸준히 빠르다빠르다클라이언트 JS 가 적으면 낮다
CSR——앱이 커져 JS 가 늘수록 나빠진다
SSR + 하이드레이션—좋아질 수 있다FCP 가 좋아져도 크게 나빠질 수 있다

SSR 은 최신 데이터와 개인화에 강하다. 요청마다 새로 그리니 로그인한 사람의 이름이나 방금 바뀐 재고를 그대로 담을 수 있다. 대신 서버가 매번 렌더링해야 하고, 브라우저도 HTML 을 받은 뒤 JS 를 내려받아 이벤트 핸들러를 붙이는 하이드레이션(hydration)을 마쳐야 버튼이 반응한다. 이때 서버는 UI HTML, 직렬화한 데이터, 클라이언트 번들 전체를 함께 보낸다. web.dev 의 표현대로 앱 하나를 쓰려고 두 벌 값을 치르는 셈이다.

SSG 는 미리 만든 HTML 을 CDN 엣지에 캐시하므로 TTFB 가 꾸준히 빠르다. 약점은 URL 마다 파일이 하나씩 있어야 한다는 점이다. 페이지가 아주 많거나 어떤 URL 이 생길지 미리 알 수 없으면 빌드로 감당하기 어렵다. HTML 을 엣지에 두면 캐시 퍼지까지 끝나야 배포가 끝난다. 퍼지가 실패해 옛 HTML 이 남았던 기록은 배포는 성공했는데 옛 페이지가 보였다 — 엣지 캐시 퍼지 실패와 재시도에 있다.

CSR 은 데이터 요청, 템플릿, 라우팅을 모두 브라우저가 맡는다. JS 예산을 빡빡하게 지키고 서버 왕복을 줄이면 서버 렌더링에 가까운 성능도 낼 수 있다. 하지만 라이브러리, 폴리필, 서드파티 코드가 붙을수록 처리 시간을 서로 다투게 되어 INP 가 나빠진다. web.dev 는 코드 분할, 지연 로딩, 서비스 워커로 앱 셸(app shell)을 캐시해 재방문을 빠르게 하는 방법을 권한다. 크롤러가 JS 를 실행하긴 해도 렌더링에는 한계가 있으니, 검색에 노출돼야 하는 CSR 페이지는 따로 확인해야 한다.

03 ISR 은 만료된 정적 페이지를 옛 본으로 응답하며 뒤에서 다시 만든다

ISR 은 SSG 의 'URL 마다 빌드' 부담을 덜어 준다. 사이트 전체를 다시 빌드하지 않고 페이지 단위로 정적 결과를 새로 만들며, 대부분의 요청은 미리 렌더링한 페이지로 응답한다. Next.js App Router 에서 Cache Components 를 켜지 않은 기존 모델이라면 라우트에서 revalidate 를 export 하면 된다.

tsx
// app/blog/[id]/page.tsx — Cache Components 를 켜지 않은 기존 모델
import { getPost, getPosts } from '@/lib/data'

// 요청이 들어올 때 확인하되, 최대 60초에 한 번만 다시 만든다
export const revalidate = 60

export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ id: String(post.id) }))
}

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const post = await getPost(id)
  return <main><h1>{post.title}</h1><p>{post.content}</p></main>
}

Next.js ISR 가이드가 설명하는 동작 순서는 이렇다.

  1. next build 때 generateStaticParams 가 돌려준 글마다 페이지를 미리 만든다.
  2. /blog/1 같은 요청에는 캐시에서 바로 응답한다.
  3. 60초가 지난 뒤 들어온 첫 요청도 일단 캐시된 옛 페이지를 받는다.
  4. 그 요청이 계기가 되어 새 페이지를 백그라운드에서 만든다.
  5. 생성에 성공하면 다음 요청부터 새 페이지를 받는다. 실패하면 마지막으로 성공한 페이지를 계속 내보내고, 다음 요청 때 다시 시도한다.
  6. 빌드 때 없던 /blog/26 은 처음 요청될 때 만든다. 글이 없으면 404 를 응답한다.

주기를 짧게 잡을수록 내용은 신선하지만 재생성도 잦아진다. 공식 문서는 주기를 1초보다 1시간처럼 길게 잡으라고 권한다. 더 정밀하게 갱신해야 하면 주문형 재검증(on-demand revalidation)을, 실시간 데이터가 필요하면 동적 렌더링을 쓰라고 한다. 주문형 재검증은 글을 저장하는 Server Action 이나 Route Handler 안에서 캐시를 직접 무효화하는 방식이다.

ts
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'

export async function publishPost() {
  // ... 저장 처리
  revalidatePath('/posts')       // 경로 단위로 무효화
  revalidateTag('posts', 'max')  // 또는 'posts' 태그를 단 데이터만
}

revalidatePath 는 캐시를 무효화만 하고, 실제 재생성은 그 경로로 다음 요청이 올 때 일어난다. Next.js 16 부터 revalidateTag 가 stale-while-revalidate(옛 캐시로 먼저 응답하고 뒤에서 갱신하는 방식)로 동작하려면 두 번째 인자로 cacheLife 프로필을 넘겨야 한다. 인자 하나만 넘기는 형태는 폐기 예정(deprecated)이다. 2026년 10월 문서 기준 배포 조건은 다음과 같다.

  • Node.js 런타임에서만 동작한다. output: 'export' 로 만드는 Static Export 에서는 쓸 수 없다.
  • 인스턴스를 여러 개 띄우면 기본 파일 시스템 캐시가 인스턴스마다 따로 생긴다. 그래서 주문형 무효화는 호출을 받은 인스턴스에만 적용되고, 공유 캐시 핸들러를 붙여야 모든 인스턴스가 맞춰진다.
  • 백그라운드 재생성은 그 재생성을 일으킨 요청을 받은 인스턴스에서 돈다. 요청 단위로 과금하는 플랫폼에서는 이 작업도 연산 비용으로 잡힌다.
  • 라우트 안 fetch 중 하나라도 revalidate: 0 이거나 no-store 면 라우트 전체가 동적 렌더링으로 바뀐다.

04 RSC 는 렌더링 시점이 아니라 브라우저로 가는 코드를 줄인다

React 공식 문서의 정의로 Server Component 는 번들링 전에 미리 렌더링되는 컴포넌트다. 렌더링은 클라이언트 앱이나 SSR 서버와 분리된 환경에서 일어나고, 브라우저로는 컴포넌트 코드가 아니라 렌더 결과만 간다. CI 에서 빌드 때 한 번 돌며 파일이나 CMS 를 읽을 수 있고, 웹 서버에서 요청마다 돌며 API 를 따로 만들지 않고 데이터 계층을 바로 읽을 수도 있다.

React 문서의 마크다운 예를 보면 차이가 분명하다. 브라우저에서 렌더링하면 marked 와 sanitize-html 을 번들에 넣어야 하고, 화면이 뜬 뒤 useEffect 로 본문을 한 번 더 요청해야 한다. 같은 일을 Server Component 로 옮기면 두 라이브러리가 번들에서 빠져 gzip 기준 약 75K 를 덜 받고, 두 번째 요청도 사라진다.

대신 Server Component 에서는 useState 같은 상호작용 API 를 쓸 수 없다. 상호작용이 필요한 부분은 파일 맨 위에 'use client' 를 붙인 Client Component 로 떼어 낸다. Next.js App Router 에서는 레이아웃과 페이지가 기본으로 Server Component 다.

tsx
// app/[id]/page.tsx — 지시어가 없으면 Server Component 다
import LikeButton from '@/app/ui/like-button'
import { getPost } from '@/lib/data'

export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const post = await getPost(id) // 서버에서 데이터를 바로 읽는다
  return (
    <main>
      <h1>{post.title}</h1>
      <LikeButton likes={post.likes} />
    </main>
  )
}
tsx
// app/ui/like-button.tsx — 이 파일과 여기서 import 한 모듈이 브라우저 번들에 들어간다
'use client'
import { useState } from 'react'

export default function LikeButton({ likes }: { likes: number }) {
  const [count, setCount] = useState(likes)
  return <button onClick={() => setCount(count + 1)}>{count} likes</button>
}

'use client' 는 서버 모듈 그래프와 클라이언트 모듈 그래프의 경계다. 이 지시어를 붙인 파일이 import 하는 모듈과 직접 렌더링하는 컴포넌트는 모두 클라이언트 번들에 들어간다. 그러니 레이아웃 전체가 아니라 검색창 같은 잎 컴포넌트에만 붙여야 번들이 작아진다. Client Component 에 children 으로 넘긴 Server Component 는 예외로, 서버에서 렌더링한 결과가 전달된다. 덧붙여 Server Component 를 표시하는 지시어는 따로 없다. 'use server' 는 클라이언트에서 호출하는 Server Function 에 붙이는 지시어다.

첫 방문 때 Next.js 는 Server Component 를 RSC Payload 로 만든다. RSC Payload 는 렌더 결과, Client Component 가 들어갈 자리와 그 JS 파일 참조, 넘긴 props 를 담은 직렬화 형식이다. 이 Payload 와 Client Component 로 HTML 을 미리 렌더링하면, 브라우저는 그 HTML 로 상호작용 없는 미리보기를 먼저 띄운다. 이어서 RSC Payload 로 두 트리를 맞춘 뒤 JS 로 Client Component 만 하이드레이션한다. 이후 페이지 이동에는 HTML 없이 미리 받아 둔 RSC Payload 만 쓴다. SSR 만 쓸 때와 달리 하이드레이션 대상이 Client Component 로 좁혀지는 셈이다.

05 PPR 은 한 라우트를 정적 껍데기와 요청 시점 조각으로 나눈다

PPR 이전의 Next.js 는 URL 마다 정적 렌더링과 동적 렌더링 가운데 하나만 골랐다. 그래서 장바구니 하나 때문에 cookies() 를 읽어도 라우트 전체가 동적 렌더링으로 넘어갔다. PPR 은 2023년에 처음 소개됐고, Next.js 16 의 Cache Components 가 이를 기본 동작으로 만들었다. 15 에서 쓰던 experimental.ppr 플래그와 라우트 단위 experimental_ppr 은 16 에서 제거됐다.

빌드할 때 Next.js 는 컴포넌트 트리를 렌더링하면서, 컴포넌트가 무엇을 하느냐에 따라 처리를 나눈다.

컴포넌트가 하는 일빌드 때 처리
모듈 import, 동기 파일 읽기, 순수 계산그대로 정적 껍데기에 들어간다
'use cache' 로 캐시한 함수·컴포넌트수명이 너무 짧지 않으면 결과가 껍데기에 들어간다
<Suspense> 안에서 캐시하지 않은 데이터나 cookies()·headers() 를 읽음fallback 만 껍데기에 들어가고, 내용은 요청 때 스트리밍된다
Math.random()·Date.now() 사용connection() 과 <Suspense> 로 요청 때 만들거나 'use cache' 로 공유한다

이렇게 만든 정적 껍데기(static shell)에는 첫 방문용 HTML 과 클라이언트 이동용 RSC Payload 가 함께 들어 있어서, 원 서버를 거치지 않고 CDN 에서 바로 내보낼 수 있다. 켜는 설정은 next.config 두 줄이다. 2026년 10월 문서 기준으로 create-next-app 권장 기본값으로 만든 새 App Router 앱에는 두 옵션이 이미 켜져 있다. partialPrefetching 을 비워 두면 경고가 찍히고, 다음 메이저 버전부터는 두 기능이 기본이 되면서 옵션은 사라질 예정이다.

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

블로그 목록 페이지를 예로 든다. 제목은 정적이고, 글 목록은 'use cache' 와 cacheLife('hours') 로 캐시한다. 사용자 테마는 쿠키를 읽어야 하니 <Suspense> 안에 둔다.

tsx
// app/blog/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'
import { getPosts } from '@/lib/data'

export default function BlogPage() {
  return (
    <>
      <h1>Our Blog</h1>
      <BlogPosts />
      <Suspense fallback={<p>Loading...</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}
tsx
// 같은 파일 아래쪽
async function BlogPosts() {
  'use cache'
  cacheLife('hours')
  const posts = await getPosts()
  return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
}

async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value ?? 'light'
  return <p>Theme: {theme}</p>
}

빌드하면 껍데기에는 제목, 글 목록, "Loading..." fallback 이 들어가고, 요청 때 흘러 들어오는 것은 테마 부분뿐이다. await 가 트리 깊숙이 있을수록 껍데기가 커진다. 그래서 문서는 레이아웃 꼭대기에서 params 를 바로 await 하지 말고, Promise 그대로 <Suspense> 안의 자식에게 넘기라고 권한다.

ISR 도 이 모델 안으로 들어왔다. Partial Prefetching 을 켜 두면 generateStaticParams 에 없는 URL 에도 URL 과 무관한 App Shell 을 먼저 보내고, 그 URL 의 페이지는 백그라운드에서 마저 만들어 다음 방문자에게 준다. App Shell 을 바로 응답하는 동작은 Next.js 16.3 부터이고, 그 전 버전은 서버 렌더링이 끝날 때까지 응답을 기다린다. 이 동작은 params 를 <Suspense> 안에서 await 할 때만 성립한다. Partial Prefetching 을 끄면 목록에 없는 URL 의 첫 요청은 그 URL 의 렌더링이 끝날 때까지 기다리며, 이때는 params 를 <Suspense> 밖에서 await 해야 그 URL 의 ISR 항목이 생긴다.

걸리는 점은 두 가지다. 첫째, Cache Components 는 Node.js 런타임이 필요하고 runtime 라우트 세그먼트 설정을 지원하지 않는다. 둘째, 일부 봇은 메타데이터가 <head> 안에 있어야 하는데 스트리밍을 처리하지 못한다. 그래서 Next.js 는 user agent 로 이런 봇을 골라 껍데기 없이 요청 때 렌더링한다. 이때 빌드 때만 접근할 수 있는 데이터 소스에 기대고 있으면 봇이 500 오류를 받을 수 있다.

06 화면 성격을 보면 고를 방식이 정해진다

화면 예고를 방식이유
회사 소개·문서·랜딩 페이지SSGURL 이 정해져 있고 모두에게 같다. CDN 에서 바로 응답한다
블로그 글·상품 상세가 수천 개ISR인기 페이지만 빌드 때 만들고, 나머지는 첫 요청 때 만든다. 발행하면 revalidatePath 를 부른다
검색 결과·개인 피드SSR(동적 렌더링)쿼리와 쿠키마다 결과가 달라 미리 만들 수 없다
로그인 뒤 편집기·관리 화면CSR상호작용이 대부분이고 검색 노출이 필요 없다
상품 정보와 장바구니·추천이 한 페이지PPR내비게이션과 상품 정보는 껍데기에 두고, 장바구니와 추천은 <Suspense> 로 스트리밍한다
마크다운·코드 하이라이트가 많은 글RSC렌더링에만 쓰는 무거운 라이브러리를 번들에서 뺀다

보통은 한 앱 안에서 여러 방식을 섞어 쓴다. web.dev 는 Netflix 의 예를 드는데, 비교적 정적인 랜딩 페이지는 서버에서 렌더링하고 상호작용이 많은 페이지에 쓸 JS 는 미리 받아 둔다. Next.js App Router 에서는 RSC 가 기본값이라 따로 고를 대상이 아니다. 대신 Client Component 경계를 어디에 긋느냐가 문제로 남는다. 표의 CSR 행도 SPA 를 따로 만든다는 뜻이 아니라, 그 화면을 Client Component 위주로 채운다는 뜻으로 읽으면 된다.

07 정적에서 시작해 필요한 조각만 요청 때로 연다

web.dev 가 권하는 순서는 분명하다. 먼저 병목을 재고, 정적 렌더링이나 서버 렌더링으로 대부분을 해결할 수 있는지 본 다음, 클라이언트 JS 는 꼭 필요한 만큼만 보낸다. 이 순서를 여섯 방식에 대 보면 이런 구성이 나온다. 기본은 SSG·ISR 로 미리 만들고, 사용자마다 다른 조각만 SSR 이나 PPR 의 <Suspense> 자리로 열고, 상호작용은 잎 Client Component 에 가둔다.

Next.js 16 프로젝트라면 이 구성이 곧 Cache Components 의 기본 동작이다. 새로 익힐 것은 <Suspense> 와 'use cache' 를 어디에 두느냐 하나로 좁혀진다. Vue·Nuxt 나 11ty·Jekyll 같은 다른 스택에는 ISR·PPR 이라는 이름이 없지만, 조각을 언제 만들고 얼마나 오래 재사용할지는 똑같이 정해야 한다.

할 일은 다음과 같다.

  1. 페이지 목록을 놓고 모두에게 같은지, 얼마나 자주 바뀌는지, 검색에 노출돼야 하는지로 나눈 뒤 위 표의 행에 붙인다.
  2. 방식을 바꾸기 전에 TTFB·FCP·INP 를 재 둔다. 바꾼 뒤 무엇이 나아졌는지 비교할 기준이 된다.
  3. Next.js 16 이면 next.config 에 cacheComponents 와 partialPrefetching 이 켜져 있는지 본다. 그다음 dev overlay 의 blocking-route 안내를 따라 동적 읽기를 <Suspense> 안으로 옮긴다.
  4. 'use client' 가 레이아웃이나 페이지 꼭대기에 붙어 있으면 실제로 상호작용하는 잎 컴포넌트로 옮긴다.
  5. ISR 을 쓴다면 응답 헤더 x-nextjs-cache 로 캐시 상태를 확인한다. 로컬에서는 운영 모드로 띄워 캐시 적중 로그를 본다.
bash
# 응답 헤더로 ISR 캐시 상태 보기 (HIT·STALE·MISS·REVALIDATED)
curl -s -D - -o /dev/null https://example.com/blog/1 | grep -i x-nextjs-cache

# 로컬에서 운영 모드로 띄우고 캐시 적중·누락 로그 보기
echo 'NEXT_PRIVATE_DEBUG_CACHE=1' >> .env
npx next build && npx next start

08 자주 묻는 질문

RSC 를 쓰면 SSR 은 필요 없어지나?

아니다. Next.js 는 RSC Payload 와 Client Component 로 HTML 을 만든다. 그 HTML 을 빌드 때 만들면 SSG·ISR, 요청 때 만들면 SSR 이니, RSC 는 어느 쪽과도 함께 쓴다.

CSR 로 만든 페이지는 검색에 안 잡히나?

아니다, 아예 막히지는 않는다. 다만 크롤러의 JS 렌더링에는 한계가 있어서, web.dev 는 CSR 이 되긴 해도 추가 테스트와 수고가 든다고 본다. 검색 유입을 노리는 페이지라면 SSG·SSR 로 HTML 을 먼저 주는 편이 안전하다.

Next.js 15 에서 experimental.ppr 을 켜 두었다면?

16 에는 그 플래그가 없다. cacheComponents 와 partialPrefetching 을 켜면 PPR 이 기본 동작이 된다. 라우트마다 export 하던 experimental_ppr 은 지운다.