적용 환경: React 19+, Next.js 15+ (App Router)
들어가며
SSG, SSR, ISR은 Next.js Pages Router 시절부터 존재하던 개념입니다. getStaticProps, getServerSideProps라는 이름으로 렌더링 전략을 명시적으로 선택했고, 그 구분은 비교적 명확했습니다.
App Router로 넘어오면서 여기에 React Server Component라는 새로운 층이 추가되었습니다. Server Component와 Client Component라는 컴포넌트 수준의 구분이 생기면서, 기존의 페이지 수준 렌더링 전략과 어떤 관계인지가 불분명해졌습니다. "서버 컴포넌트"와 "서버 사이드 렌더링"은 이름부터 비슷한데 실제로는 다른 관심사를 가리키고 있기 때문입니다.
이 글에서는 Next.js App Router의 렌더링 모델을 두 개의 독립된 축으로 분리하여 정리합니다. 이 프레임을 잡고 나면, 모든 조합이 명확해집니다.
두 개의 축
Next.js App Router의 렌더링 모델은 두 가지 질문으로 정리됩니다.
| 질문 | 축 | 선택지 |
|---|---|---|
| 컴포넌트의 JS가 브라우저에 전송되는가? | 컴포넌트 타입 | Server Component / Client Component |
| HTML을 언제 생성하는가? | 렌더링 전략 | SSG / SSR / ISR |
이 두 축은 완전히 독립적입니다. Server Component이면서 SSG일 수도 있고, Client Component이면서 SSR일 수도 있습니다. 하나를 선택한다고 다른 하나가 결정되지 않으며, 모든 조합이 기술적으로 가능합니다.
핵심 프레임
컴포넌트 타입 = JS 번들이 브라우저에 가느냐 마느냐 (어디서 존재하는가)
렌더링 전략 = HTML을 빌드 시점에 만드느냐, 요청 시점에 만드느냐 (언제 만드는가)
이 두 가지를 분리해서 사고하면, Next.js의 렌더링 모델이 명확해집니다.
컴포넌트 타입: Server Component vs Client Component
Server Component
App Router에서 컴포넌트의 기본값입니다. 아무런 지시어 없이 작성하면 Server Component가 됩니다.
// Server Component (기본값)
export default async function PostPage() {
const posts = await db.query("SELECT * FROM posts");
return (
<ul>
{posts.map(post => <li key={post.id}>{post.title}</li>)}
</ul>
);
}이 컴포넌트는 서버에서 실행되어 HTML로 변환된 뒤, JS 코드는 브라우저에 전송되지 않습니다. db.query같은 서버 전용 코드를 직접 사용할 수 있는 이유가 여기에 있습니다. 브라우저에 JS가 전달되지 않으므로, 번들 크기에 영향을 주지 않고, 브라우저에서는 이 컴포넌트가 존재했다는 흔적조차 남지 않습니다.
Client Component
"use client" 지시어를 파일 최상단에 선언하면 Client Component가 됩니다.
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}useState, useEffect, onClick 같은 브라우저 인터랙션이 필요한 컴포넌트에 사용합니다. 이 컴포넌트의 JS 코드는 브라우저에 전송되어, hydration을 통해 인터랙티브한 상태로 활성화됩니다.
"Client"라는 이름의 함정
Client Component에 대한 가장 흔한 오해는 "브라우저에서만 실행된다"는 것입니다. 실제로는 그렇지 않습니다.
| Server Component | Client Component | |
|---|---|---|
| 서버에서 실행 | O | O |
| 브라우저에서 실행 | X | O |
| HTML 생성 | O | O |
| JS 번들 전송 | X | O |
| hydration | 불필요 | 필요 |
Client Component도 서버에서 한 번 실행되어 HTML을 생성합니다. 브라우저는 이 HTML을 먼저 화면에 표시하고, JS 로드가 완료되면 hydration을 통해 이벤트 핸들러를 연결합니다. 따라서 JS가 로드되기 전에도 Client Component의 초기 렌더링 결과는 화면에 보입니다.
"Server Component"의 "Server"는 "서버에서 렌더링된다"가 아니라 "서버에서만 존재한다"로 읽는 것이 정확합니다. 모든 컴포넌트가 서버에서 렌더링되지만, Server Component만이 서버에서만 존재하고 브라우저에는 흔적을 남기지 않습니다.
실무 패턴: 경계 분리
실무에서는 페이지 수준의 Server Component 안에 필요한 부분만 Client Component로 분리하는 것이 일반적입니다.
// page.tsx — Server Component
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await getProduct(id); // 서버에서 데이터 fetch
return (
<div>
<h1>{product.name}</h1> {/* Server: HTML만 */}
<p>{product.description}</p> {/* Server: HTML만 */}
<AddToCartButton /> {/* Client: HTML + JS */}
<ReviewSection /> {/* Client: HTML + JS */}
</div>
);
}데이터를 가져오고 레이아웃을 구성하는 부분은 Server Component로, 사용자 인터랙션이 필요한 부분만 Client Component로 나누면, 브라우저에 전송되는 JS 번들을 최소화하면서도 풍부한 인터랙션을 제공할 수 있습니다.
렌더링 전략: SSG vs SSR vs ISR
두 번째 축은 HTML을 언제 생성하느냐의 문제입니다. 이것은 컴포넌트 타입과 무관하게 라우트 단위로 설정합니다.
SSG (Static Site Generation)
빌드 시점에 HTML을 한 번 생성하고, 이후 모든 요청에 동일한 정적 파일을 서빙합니다.
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.content}</article>;
}generateStaticParams가 있으면 Next.js는 빌드 타임에 반환된 모든 경로의 HTML을 미리 생성합니다. CDN에 캐싱하여 즉시 서빙할 수 있으므로 응답 속도가 가장 빠릅니다. 블로그, 문서 사이트, 마케팅 페이지처럼 내용이 자주 바뀌지 않는 페이지에 적합합니다.
SSR (Server-Side Rendering)
매 요청마다 서버에서 HTML을 새로 생성합니다.
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const metrics = await getRealtimeMetrics();
return <Dashboard data={metrics} />;
}요청 시점의 최신 데이터를 반영해야 하는 페이지에 사용합니다. 대시보드, 사용자별 맞춤 페이지, 검색 결과 등 매번 다른 내용을 보여줘야 하는 경우에 적합하며, 대신 매 요청마다 서버에서 처리가 필요하므로 SSG에 비해 응답 시간이 길어질 수 있습니다.
ISR (Incremental Static Regeneration)
SSG와 SSR의 중간 지점입니다. 빌드 시점에 정적 HTML을 생성하되, 지정한 주기마다 백그라운드에서 페이지를 재생성합니다.
export const revalidate = 3600; // 1시간마다 재생성
export default async function ProductPage({ params }) {
const { id } = await params;
const product = await getProduct(id);
return <ProductDetail product={product} />;
}첫 요청에는 캐싱된 정적 페이지를 즉시 반환하면서도, revalidate 주기가 지나면 백그라운드에서 최신 데이터로 페이지를 다시 생성합니다. 상품 목록, 뉴스 피드처럼 주기적으로 업데이트되지만 실시간까지는 필요 없는 페이지에 적합합니다.
Route Segment Config
렌더링 전략은 page.tsx(또는 layout.tsx)에서 특정 변수를 export하는 것으로 설정합니다. Next.js가 빌드 타임에 정적으로 분석하여 자동 인식합니다.
// 빌드 시점에 없는 경로 접근 시 동작 (true: 런타임 생성, false: 404)
export const dynamicParams = false;
// 페이지 렌더링 모드
export const dynamic = "force-dynamic"; // 'auto' | 'force-dynamic' | 'error' | 'force-static'
// ISR 재검증 주기 (초 단위, false면 재검증 안 함)
export const revalidate = 3600;
// 실행 런타임
export const runtime = "nodejs"; // 'nodejs' | 'edge'| 변수 | 역할 | 기본값 |
|---|---|---|
dynamicParams | generateStaticParams에 없는 경로 허용 여부 | true |
dynamic | 정적/동적 렌더링 강제 | 'auto' |
revalidate | ISR 재검증 주기 (초) | false |
runtime | 실행 환경 선택 | 'nodejs' |
이 변수들은 반드시 정적 상수여야 합니다. 런타임에 계산되는 값은 사용할 수 없으며, 변수명과 타입이 정확히 맞으면 별도의 설정 파일이나 등록 과정 없이 Next.js가 자동으로 인식합니다.
조합의 실제
두 축이 독립적이므로 이론적으로 모든 조합이 가능합니다. 하지만 실무에서는 각 조합의 실익이 다릅니다.
조합별 빌드 결과물
| 조합 | 빌드 결과 | 브라우저에 전달 | 실무적 의미 |
|---|---|---|---|
| SC + SSG | 완성된 HTML (빌드 시) | HTML만 | 문서 사이트의 이상적 형태. 빠르고 가벼움 |
| SC + SSR | 완성된 HTML (요청 시) | HTML만 | DB 직접 접근이 필요한 동적 페이지 |
| SC + ISR | HTML + 주기적 재생성 | HTML만 | 상품 목록 등 준실시간 데이터 |
| CC + SSG | 초기 HTML + JS 번들 (빌드 시) | HTML + JS | 인터랙티브하지만 데이터는 정적인 페이지 |
| CC + SSR | 초기 HTML + JS 번들 (요청 시) | HTML + JS | 인터랙티브 + 매 요청 최신 데이터. 세션/인증 의존 페이지에 흔함 |
| CC + ISR | 초기 HTML + JS 번들 + 재생성 | HTML + JS | 가능은 하지만 실익이 적음 |
Client Component와 ISR의 조합이 실익이 적은 이유는, Client Component의 서버 렌더링 결과가 초기 state 기준으로만 만들어지기 때문입니다. ISR로 1시간마다 재생성해도, useState(0)의 초기값은 항상 0이므로 HTML이 달라지지 않습니다. ISR의 이점은 서버에서 데이터를 fetch하는 Server Component와 결합할 때 발휘됩니다.
실제 프로젝트 예시
이 문서 사이트의 두 페이지를 비교하면 조합의 차이가 명확하게 드러납니다.
// src/app/docs/[...slug]/page.tsx — Server Component
export const dynamicParams = false;
export async function generateStaticParams() {
return docs.filter(doc => doc.published).map(doc => ({
slug: doc.slugAsParams.split("/"),
}));
}
export default async function DocPage({ params }) {
const { slug } = await params;
const doc = await getDocFromParams(slug);
// Server Component → HTML만 생성, JS 번들 없음
return (
<article>
<MDXContent code={doc.body} />
<TableOfContents toc={doc.toc} /> {/* 이 부분만 Client */}
</article>
);
}docs 페이지는 generateStaticParams와 dynamicParams = false를 통해 빌드 시점에 모든 문서의 HTML을 완성해 둡니다. 본문 전체가 HTML에 포함되어 있어 JS 없이도 콘텐츠가 보이며, TableOfContents같은 인터랙티브 요소만 Client Component로 분리되어 해당 부분의 JS만 전송됩니다.
반면 admin 페이지는 useSession()으로 인증 상태에 의존하므로 Next.js가 자동으로 Dynamic(SSR)으로 처리합니다. 매 요청마다 서버에서 렌더링되며, 페이지 전체의 JS 번들(framer-motion, useSession 등)이 브라우저에 전송됩니다. npm run build 결과에서 admin 경로가 ƒ (Dynamic)으로 표시되는 것이 이를 확인해 줍니다.
CSR은 어디에 위치하는가
지금까지의 모든 조합에서 공통되는 점이 하나 있습니다. Server Component든 Client Component든, 서버에서 HTML이 생성된다는 것입니다. Client Component조차 서버에서 초기 HTML을 만들어 보내고, 브라우저에서 hydration합니다.
그렇다면 서버 렌더링을 아예 하지 않는 순수 CSR(Client-Side Rendering)은 어떨까요? Next.js에서는 dynamic import의 ssr: false 옵션으로 특정 컴포넌트의 서버 렌더링을 건너뛸 수 있습니다.
import dynamic from "next/dynamic";
const Chart = dynamic(() => import("./Chart"), {
ssr: false,
loading: () => <ChartSkeleton />,
});
export default function AnalyticsPage() {
return (
<div>
<h1>분석 대시보드</h1> {/* 서버에서 HTML 생성 */}
<Chart /> {/* 서버 렌더링 건너뜀 → 브라우저에서만 실행 */}
</div>
);
}ssr: false로 불러온 컴포넌트는 서버에서 렌더링하지 않으므로 HTML에 포함되지 않습니다. 브라우저에서 JS가 로드된 후에 처음부터 렌더링되며, 비교할 서버 HTML이 없으므로 hydration 자체가 발생하지 않고, 따라서 hydration mismatch도 원천적으로 불가능합니다.
이 방식은 차트 라이브러리, 코드 에디터, 지도 컴포넌트처럼 SSR을 지원하지 않는 서드파티 라이브러리를 사용하거나, 서버에서 렌더링할 의미가 없는 순수 인터랙티브 컴포넌트에 적합합니다. 다만 해당 영역의 콘텐츠가 HTML에 포함되지 않으므로, SEO가 필요한 콘텐츠에는 사용하지 않는 것이 좋습니다.
빌드 결과물로 이해하기
개념으로만 접하면 추상적으로 느껴질 수 있습니다. npm run build를 실행한 뒤 .next/ 디렉토리에 실제로 어떤 파일이 생성되는지 살펴보면, 각 조합의 차이가 구체적으로 와닿습니다.
SSG + Server Component 페이지
이 사이트의 문서 페이지(/docs/react/advanced/hydration-mismatch 등)는 빌드 시점에 다음과 같은 결과물을 생성합니다.
.next/server/app/docs/react/advanced/
├── hydration-mismatch.html ← 본문 전체가 포함된 완성 HTML (220KB)
├── hydration-mismatch.meta ← 라우트 메타데이터
└── hydration-mismatch.rsc ← React Server Component 페이로드
서버 디렉토리에 완성된 HTML 파일이 존재하며, 문서 본문이 전부 포함되어 있습니다. 해당 페이지만을 위한 JS 번들은 생성되지 않고, 브라우저는 이 HTML을 받아 그대로 화면에 표시합니다. 페이지 내의 Client Component(예: TableOfContents)에 대한 JS만 공유 청크를 통해 별도로 로드됩니다.
SSR + Client Component 페이지
admin 페이지(/admin)는 "use client" 페이지이면서 세션에 의존하므로, Next.js가 Dynamic(SSR)으로 처리합니다. 빌드 결과물에 HTML 파일이 미리 생성되지 않으며, 요청 시점에 서버에서 렌더링됩니다.
.next/server/app/admin/
├── page.js ← 런타임 렌더링 핸들러
└── page_client-reference-manifest.js ← Client Component 참조 매니페스트
SSG 페이지와 달리 .html 파일이 존재하지 않습니다. 대신 page.js라는 런타임 핸들러가 매 요청마다 실행되어 HTML을 생성하며, 그 결과에 페이지 전체의 JS 번들이 함께 전달됩니다. npm run build 출력에서도 이 차이가 명확하게 표시됩니다.
● /docs/[...slug] ← SSG: 빌드 시 HTML 미리 생성
ƒ /admin ← Dynamic: 요청 시 서버에서 렌더링
이 차이를 정리하면, Server Component는 HTML에 전체 내용이 담기고 JS가 없으며, Client Component는 JS 번들이 함께 전달됩니다. 렌더링 전략(SSG/SSR/ISR)은 이 HTML이 언제 만들어지느냐를 결정하고, 컴포넌트 타입은 무엇이 브라우저에 전달되느냐를 결정합니다.
정리
핵심 정리
- Next.js의 렌더링 모델은 컴포넌트 타입(JS가 브라우저에 가는가)과 렌더링 전략(HTML을 언제 만드는가)이라는 두 개의 독립된 축으로 이루어져 있습니다.
- 모든 컴포넌트는 서버에서 렌더링됩니다. Server Component와 Client Component의 차이는 "서버에서 렌더링되느냐"가 아니라, JS 번들이 브라우저에 전달되느냐입니다.
"use client"는 "브라우저에서만 실행"이 아니라, "브라우저에서도 실행"을 의미합니다. Client Component도 서버에서 HTML을 생성하며, 이것이 hydration mismatch가 발생할 수 있는 이유이기도 합니다.- 렌더링 전략은 라우트 단위로 설정하며,
generateStaticParams(SSG),dynamic(SSR),revalidate(ISR) 등의 Route Segment Config를 export하는 것만으로 제어할 수 있습니다.