오늘은 브라우저 렌더링 방식인 SSG, CSR, SSR, ISR 내부 작동원리에 대해 살펴볼 것이다.
1. SSG (Static Site Generation) 작동원리
전체 흐름도
[빌드 타임] [런타임]
개발자 코드 사용자 요청
↓ ↓
npm run build CDN/Server
↓ ↓
데이터 페칭 (API 호출) 정적 HTML 파일 전송
↓ ↓
HTML 생성 (모든 페이지) 브라우저에서 즉시 렌더링
↓ ↓
.next/static 폴더에 저장 (Hydration으로 인터랙티브)
1. 빌드타임(Build Time)
- 개발자가 코드를 작성하고 nun run build 명령어를 실행하면, 모든 페이지가 미리 HTML로 생성된다.
- 이 과정에서 필요한 데이터가 있다면 API를 호출해 가져오고, 가져온 데이터를 바탕으로 HTML 파일을 만들어 .next/static (Next라면) 같은 폴더에 저장한다.
- 즉, 사용자가 페이지를 요청하기 전 미리 모든 페이지를 준비하는 단계이다.
2. 런타임(Runtime)
- 사용자가 브라우저에서 페이지를 요청하면, 서버나 CDN이 미리 만들어둔 정적 HTML파일을 즉시 전송한다.
- 브라우저에서는 받은 HTML을 바로 화면에 보여주고, 필요하면 React의 Hydration 과정을 통해 인터랙티브 기능(버튼 클릭, 폼 입력 등)을 사용할 수 있다.
상세 작동 과정
1단계: 빌드 타임 (Build Time)
npm run build
내부에서 일어나는 일
1. Next.js가 모든 페이지 스캔
├─ app/page.tsx
├─ app/about/page.tsx
├─ app/blog/[slug]/page.tsx
└─ ...
2. 각 페이지별로 실행:
├─ generateStaticParams() 호출 (동적 라우트)
├─ 데이터 페칭 함수 실행
├─ React 컴포넌트 렌더링
└─ HTML 파일 생성
3. 출력물:
├─ .next/server/app/page.html
├─ .next/server/app/about.html
├─ .next/server/app/blog/post-1.html
└─ JavaScript 번들 (.next/static/chunks/)
Next.js SSG 내부에서 일어나는 과정
- 페이지 스캔
- Next.js는 빌드 과정에서 프로젝트 내 모든 페이지를 확인합니다.
- 예를 들어:
- app/page.tsx → 홈 페이지
- app/about/page.tsx → 소개 페이지
- app/blog/[slug]/page.tsx → 동적 블로그 글 페이지
- 이렇게 모든 페이지를 자동으로 찾아서 처리 대상으로 등록합니다.
- 페이지별 처리
- 동적 라우트 처리: generateStaticParams() 함수가 호출되어, 동적 URL(slug 등)에 필요한 모든 조합을 생성합니다.
- 데이터 가져오기: 각 페이지의 데이터 페칭 함수(API 호출 등)를 실행해서 필요한 데이터를 불러옵니다.
- React 컴포넌트 렌더링: 가져온 데이터를 이용해 React 컴포넌트를 HTML로 렌더링합니다.
- HTML 파일 생성: 렌더링된 결과를 정적 HTML 파일로 변환합니다.
- 출력물 생성
- 생성된 HTML 파일과 JavaScript 번들이 프로젝트 내부 .next 폴더에 저장됩니다.
- 예시:
- .next/server/app/page.html → 홈 페이지
- .next/server/app/about.html → 소개 페이지
- .next/server/app/blog/post-1.html → 블로그 글
- .next/static/chunks/ → React 기능과 인터랙티브를 위한 JS 번들
Next.js 코드 예제
// app/blog/[slug]/page.tsx
// 1. 빌드 시 생성할 경로 정의
export async function generateStaticParams() {
console.log('🏗️ 빌드 타임: 경로 생성 중...');
// API에서 모든 포스트 가져오기
const posts = await fetch('<https://api.example.com/posts>')
.then(res => res.json());
// 각 포스트마다 정적 페이지 생성
return posts.map((post: any) => ({
slug: post.slug, // /blog/post-1, /blog/post-2 등
}));
}
// 2. 각 페이지의 데이터 페칭
export default async function BlogPost({
params
}: {
params: Promise<{ slug: string }>
}) {
console.log(`🏗️ 빌드 타임: ${params.slug} 페이지 생성 중...`);
const { slug } = await params;
// 이 fetch는 빌드 시 한 번만 실행됨
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
cache: 'force-cache' // SSG의 핵심 설정(DEFAULT)
}).then(res => res.json());
// React 컴포넌트를 HTML로 변환
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
2단계: 빌드 결과물
.next/
├── static/
│ ├── chunks/ # JavaScript 코드 조각들
│ │ ├── main.js # React 런타임
│ │ └── pages/ # 페이지별 JS
│ └── css/ # CSS 파일들
├── server/
│ └── app/
│ ├── page.html # 미리 생성된 HTML
│ ├── about.html
│ └── blog/
│ ├── post-1.html # 각 포스트의 HTML
│ └── post-2.html
생성된 HTML 예시
<!DOCTYPE html>
<html>
<head>
<title>My Blog Post</title>
<link rel="stylesheet" href="/_next/static/css/app.css" />
</head>
<body>
<!-- 이미 렌더링된 콘텐츠 -->
<article>
<h1>내 첫 번째 포스트</h1>
<div>
<p>이것은 내용입니다...</p>
</div>
</article>
<!-- JavaScript는 Hydration을 위해 로드 -->
<script src="/_next/static/chunks/main.js"></script>
<script src="/_next/static/chunks/pages/blog/post-1.js"></script>
</body>
</html>
3단계: 사용자 요청 (Request Time)
사용자가 /blog/post-1 접속
↓
CDN/Server가 미리 생성된 HTML 전송
↓
브라우저가 HTML 즉시 렌더링 (0.1초)
↓
JavaScript 다운로드 & 실행
↓
Hydration (HTML에 이벤트 리스너 연결)
↓
인터랙티브한 페이지 완성
Hydration 과정
// 1. 서버에서 생성된 HTML (정적)
<button>클릭하세요</button>
// 2. JavaScript 로드 후 Hydration
<button onClick={() => alert('클릭!')}>클릭하세요</button>
// ↑ 이벤트 리스너가 추가됨
Hydration 코드 흐름:
// React가 내부적으로 수행
ReactDOM.hydrateRoot(
document.getElementById('root'),
<App />
);
// 과정:
// 1. 기존 HTML 요소 찾기
// 2. Virtual DOM 생성
// 3. 기존 DOM과 비교
// 4. 이벤트 리스너 연결
// 5. 상태 관리 활성화
2. ISR (Incremental Static Regeneration) 작동원리
전체 흐름도
[빌드 타임] [첫 요청] [revalidate 후]
모든 페이지 생성 → 정적 HTML 제공 → 백그라운드 재생성
↓ ↓
캐시된 버전 제공 새 버전으로 교체
ISR 상세 작동 과정
시나리오: revalidate: 60 설정
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: {
revalidate: 60 // 60초마다 재검증
}
}).then(res => res.json());
return <article>{post.title}</article>;
}
타임라인 분석
시간 요청 응답 내부 동작
──────────────────────────────────────────────────────────
00:00 빌드 완료 - HTML v1 생성
(초기 상태)
01:00 사용자 A HTML v1 제공 ✅ 캐시 히트
방문 (0.1초) (타임스탬프: 00:00)
01:30 사용자 B HTML v1 제공 ✅ 캐시 히트
방문 (0.1초) (아직 60초 안됨)
02:00 사용자 C HTML v1 제공 🔄 재생성 트리거!
방문 (0.1초) (60초 경과)
백그라운드에서:
- API 재호출
- HTML v2 생성
- 캐시 교체 (10초 소요)
02:05 사용자 D HTML v1 제공 ⏳ 재생성 진행 중
방문 (0.1초)
02:10 재생성 완료 - ✅ 캐시 업데이트
HTML v2 준비 완료
02:15 사용자 E HTML v2 제공 ✅ 새 버전!
방문 (0.1초)
03:00 사용자 F HTML v2 제공 ✅ 캐시 히트
방문 (0.1초)
ISR 내부 캐시 메커니즘
// Next.js 내부 캐시 구조 (단순화)
const cache = {
'/blog/post-1': {
html: '<article>...</article>',
timestamp: 1704067200000, // 생성 시각
revalidateAfter: 60, // 재검증 간격 (초)
isRevalidating: false // 재생성 중인지
}
};
// 요청 처리 로직
async function handleRequest(path) {
const cached = cache[path];
const now = Date.now();
const age = (now - cached.timestamp) / 1000; // 초 단위
// 1. 캐시가 유효한 경우
if (age < cached.revalidateAfter) {
console.log('✅ 캐시 히트: 신선한 데이터');
return cached.html;
}
// 2. 캐시가 만료되었지만 재생성 중이 아닌 경우
if (!cached.isRevalidating) {
console.log('🔄 Stale-While-Revalidate: 오래된 캐시 제공 + 백그라운드 재생성');
// 오래된 캐시 즉시 반환
const staleResponse = cached.html;
// 백그라운드에서 재생성
cached.isRevalidating = true;
regeneratePage(path).then(newHtml => {
cache[path] = {
html: newHtml,
timestamp: Date.now(),
revalidateAfter: 60,
isRevalidating: false
};
console.log('✅ 재생성 완료: 캐시 업데이트됨');
});
return staleResponse;
}
// 3. 이미 재생성 중인 경우
console.log('⏳ 재생성 진행 중: 오래된 캐시 제공');
return cached.html;
}
ISR의 Stale-While-Revalidate 전략
ISR의 핵심은 "오래된 콘텐츠를 제공하면서 동시에 새 콘텐츠를 준비"하는 것입니다.
요청 → 캐시 확인
├─ 신선함 → 즉시 반환 ✅
└─ 오래됨 → 오래된 것 반환 + 백그라운드 갱신 🔄
(다음 요청부터 새 버전)
On-Demand Revalidation
수동으로 캐시를 무효화할 수도 있습니다.
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path');
if (path) {
// 특정 경로 재검증
revalidatePath(path);
console.log(`🔄 수동 재검증: ${path}`);
return Response.json({ revalidated: true });
}
return Response.json({ revalidated: false });
}
// 사용 예:
// POST /api/revalidate?path=/blog/post-1
ISR 내부 동작:
// revalidatePath 내부 로직
function revalidatePath(path) {
// 1. 캐시에서 해당 경로 찾기
const cached = cache[path];
if (cached) {
// 2. 타임스탬프를 과거로 설정 (만료시킴)
cached.timestamp = 0;
console.log(`✅ ${path} 캐시 무효화됨`);
// 3. 다음 요청 시 자동으로 재생성됨
}
}
3. SSR (Server-Side Rendering) 작동원리
전체 흐름도
사용자 요청
↓
Next.js 서버
↓
데이터 페칭 (API 호출)
↓
React 컴포넌트 렌더링
↓
HTML 생성
↓
브라우저로 전송
↓
Hydration
상세 작동 과정
요청별 처리 흐름
// app/dashboard/page.tsx
export default async function Dashboard() {
console.log('🖥️ 서버에서 실행 중...');
const data = await fetch('<https://api.example.com/user>', {
cache: 'no-store', // SSR 활성화
headers: {
cookie: cookies().toString() // 쿠키 포함
}
}).then(res => res.json());
return (
<div>
<h1>환영합니다, {data.name}님</h1>
<p>마지막 로그인: {data.lastLogin}</p>
</div>
);
}
SSR 타임라인 분석
사용자 A가 /dashboard 요청 (10:00:00)
│
├─ [10:00:00.000] 요청 도착
│ └─ 서버: "새 요청 받음"
│
├─ [10:00:00.050] 쿠키/헤더 파싱
│ └─ 서버: "인증 토큰 확인"
│
├─ [10:00:00.100] API 호출 시작
│ └─ 서버 → API: "사용자 데이터 요청"
│
├─ [10:00:00.500] API 응답 대기 중...
│ └─ 서버: "응답 대기 중"
│
├─ [10:00:00.800] API 응답 도착
│ └─ 서버: "데이터 수신 완료"
│
├─ [10:00:00.850] React 렌더링
│ └─ 서버: "컴포넌트를 HTML로 변환"
│
├─ [10:00:00.900] HTML 생성 완료
│ └─ 서버: "최종 HTML 준비"
│
└─ [10:00:00.950] 브라우저로 전송
└─ 사용자: "페이지 표시"
──────────────────────────────────────────
사용자 B가 /dashboard 요청 (10:00:01)
│
└─ 위 과정 반복 (캐시 없음!)
└─ 총 소요시간: ~1초
서버 내부 처리
// Next.js 서버의 요청 핸들러 (단순화)
async function handleSSRRequest(req, res) {
console.log('🖥️ SSR 요청 처리 시작');
const startTime = Date.now();
try {
// 1. 라우트 매칭
const route = matchRoute(req.url);
console.log(`📍 라우트: ${route.path}`);
// 2. 페이지 컴포넌트 가져오기
const PageComponent = await import(route.componentPath);
// 3. 서버 컴포넌트 실행 (데이터 페칭 포함)
console.log('🔄 데이터 페칭 시작...');
const props = await PageComponent.getServerSideProps?.(req, res);
// 4. React 렌더링
console.log('🎨 React 렌더링 시작...');
const html = ReactDOMServer.renderToString(
<PageComponent {...props} />
);
// 5. HTML 템플릿에 삽입
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<title>${props.title}</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="root">${html}</div>
<script src="/runtime.js"></script>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(props)};
</script>
</body>
</html>
`;
const endTime = Date.now();
console.log(`✅ 렌더링 완료 (${endTime - startTime}ms)`);
// 6. 응답 전송
res.setHeader('Content-Type', 'text/html');
res.send(fullHtml);
} catch (error) {
console.error('❌ SSR 에러:', error);
res.status(500).send('Server Error');
}
}
SSR 인증/쿠키 처리
// app/profile/page.tsx
import { cookies, headers } from 'next/headers';
export default async function Profile() {
// 1. 쿠키 읽기
const cookieStore = cookies();
const token = cookieStore.get('auth_token');
console.log('🔐 인증 토큰:', token?.value);
if (!token) {
redirect('/login');
}
// 2. 헤더 읽기
const headersList = headers();
const userAgent = headersList.get('user-agent');
console.log('👤 User-Agent:', userAgent);
// 3. 인증된 API 호출
const user = await fetch('<https://api.example.com/profile>', {
headers: {
'Authorization': `Bearer ${token.value}`,
'User-Agent': userAgent || ''
},
cache: 'no-store'
}).then(res => res.json());
return
성능 최적화: Streaming SSR
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>대시보드</h1>
{/* 즉시 렌더링 */}
<QuickStats />
{/* 느린 부분은 나중에 */}
<Suspense fallback={<Loading />}>
<SlowComponent />
</Suspense>
</div>
);
}
async function SlowComponent() {
// 3초 걸리는 API 호출
const data = await fetch('https://slow-api.com/data', {
cache: 'no-store'
});
return <HeavyChart data={data} />;
}
Streaming 동작:
클라이언트로 전송되는 HTML:
[0.1초]
<div>
<h1>대시보드</h1>
<div>빠른 통계: ...</div>
<div id="suspense-boundary">로딩 중...</div>
</div>
[3.1초 - 추가 청크]
<script>
// Suspense 경계 업데이트
replaceSuspenseBoundary('suspense-boundary', `
<div>느린 컴포넌트 내용...</div>
`);
</script>
4. CSR (Client-Side Rendering) 작동원리
전체 흐름도
사용자 요청
↓
빈 HTML + JavaScript 번들 전송
↓
브라우저에서 JavaScript 실행
↓
React 앱 초기화
↓
API 호출 (fetch/axios)
↓
컴포넌트 렌더링
↓
화면에 표시
상세 작동 과정
초기 HTML (거의 비어있음)
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<!-- 거의 빈 껍데기 -->
<div id="root"></div>
<!-- JavaScript 번들만 로드 -->
<script src="/static/js/runtime.js"></script>
<script src="/static/js/vendors.js"></script>
<script src="/static/js/main.js"></script>
</body>
</html>
JavaScript 실행 흐름
// app/dashboard/page.tsx
'use client'; // CSR 명시
import { useState, useEffect } from 'react';
export default function Dashboard() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log('🌐 브라우저에서 실행 중...');
console.log('📡 API 호출 시작...');
fetch('/api/user')
.then(res => {
console.log('✅ API 응답 받음');
return res.json();
})
.then(data => {
console.log('📦 데이터 파싱 완료');
setUser(data);
setLoading(false);
})
.catch(error => {
console.error('❌ 에러:', error);
setLoading(false);
});
}, []);
if (loading) {
return <div>로딩 중...</div>;
}
return (
<div>
<h1>환영합니다, {user.name}님!</h1>
</div>
);
}
CSR 타임라인 분석
[0.0초] 사용자가 /dashboard 요청
│
├─ [0.1초] HTML 다운로드 (5KB)
│ └─ 화면: 빈 페이지
│
├─ [0.2초] JavaScript 다운로드 시작
│ ├─ runtime.js (50KB)
│ ├─ vendors.js (200KB - React 등)
│ └─ main.js (100KB - 앱 코드)
│ └─ 화면: 여전히 빈 페이지
│
├─ [1.0초] JavaScript 파싱 & 실행
│ └─ 화면: 여전히 빈 페이지
│
├─ [1.2초] React 앱 초기화
│ └─ ReactDOM.render(<App />)
│ └─ 화면: "로딩 중..." 표시
│
├─ [1.3초] useEffect 실행
│ └─ fetch('/api/user') 호출
│ └─ 화면: "로딩 중..."
│
├─ [1.8초] API 응답 도착
│ └─ 화면: "로딩 중..."
│
└─ [2.0초] setState 완료 & 리렌더링
└─ 화면: 실제 콘텐츠 표시 ✅
CSR 브라우저 내부 처리
// React의 CSR 초기화 (단순화)
window.addEventListener('DOMContentLoaded', () => {
console.log('📄 DOM 로드 완료');
// 1. React 루트 생성
const root = ReactDOM.createRoot(document.getElementById('root'));
console.log('🌱 React 루트 생성');
// 2. 앱 렌더링
console.log('🎨 앱 렌더링 시작');
root.render(<App />);
// 3. 컴포넌트 마운트
// App → Dashboard → useEffect 순서로 실행
});
CSR 상태 관리와 리렌더링
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
console.log('🔄 Counter 컴포넌트 렌더링:', count);
const increment = () => {
console.log('➕ 증가 버튼 클릭');
setCount(prev => {
const newCount = prev + 1;
console.log(`상태 변경: ${prev} → ${newCount}`);
return newCount;
});
// 상태 변경 후 자동으로 리렌더링 트리거
};
return (
<div>
<p>카운트: {count}</p>
<button onClick={increment}>증가</button>
</div>
);
}
실행 흐름:
[초기 렌더링]
└─ 🔄 Counter 컴포넌트 렌더링: 0
[버튼 클릭]
├─ ➕ 증가 버튼 클릭
├─ 상태 변경: 0 → 1
└─ 🔄 Counter 컴포넌트 렌더링: 1
└─ Virtual DOM 생성
└─ 실제 DOM과 비교 (Reconciliation)
└─ 변경된 부분만 업데이트
└─ <p>카운트: 1</p> ✅
React의 Reconciliation (재조정)
// React 내부 로직 (단순화)
function updateComponent(Component, newProps) {
// 1. 새로운 Virtual DOM 생성
const newVDOM = Component(newProps);
console.log('🆕 새 Virtual DOM:', newVDOM);
// 2. 이전 Virtual DOM과 비교
const diff = compareVDOM(oldVDOM, newVDOM);
console.log('🔍 변경 사항:', diff);
// 3. 변경된 부분만 실제 DOM 업데이트
diff.forEach(change => {
if (change.type === 'UPDATE_TEXT') {
const element = document.querySelector(change.selector);
element.textContent = change.newValue;
console.log(`✏️ 텍스트 업데이트: ${change.oldValue} → ${change.newValue}`);
}
});
// 4. 이전 VDOM을 새 VDOM으로 교체
oldVDOM = newVDOM;
}
'Developing👩💻 > Front-end' 카테고리의 다른 글
| [원문 번역] 서버 상태에서 클라이언트 상태 끌어오기 - tkdodo (0) | 2025.10.07 |
|---|---|
| [원문 번역] useCallback의 쓸모없음 - tkdodo (0) | 2025.10.07 |
| [Next.js] nuqs의 useQueryState (0) | 2025.10.06 |
| [FE] 비동기 대신 동시성을 구현하는 Web Worker, WebAssembly 알아보기(멀티스레드) (0) | 2025.08.24 |
| [FE] Webpack, Rollup.js, 그리고 Vite (0) | 2025.08.22 |

