왜 공부하게 되었나
React로 개인 블로그를 만들고 한 달 정도 글을 올렸는데, 구글에 검색해도 내 블로그가 안 나왔다. "구글 서치 콘솔"에 들어가보니 "크롤링은 했는데 내용이 없다"는 황당한 메시지를 받았다. 분명 글은 있는데?
검색해보니 "Create React App으로 만든 SPA는 SEO가 안 좋습니다"라는 글이 나왔다. CSR이 문제라고 했다. 그럼 SSR은 뭔데? Next.js를 쓰면 해결된다는데, 도대체 왜 그런 건지 이해가 안 갔다.
"페이지를 서버에서 그린다"는 게 무슨 말인지, React는 원래 브라우저에서 돌아가는 게 아닌가? 이 궁금증이 SSR/CSR 공부의 시작이었다.
처음엔 뭐가 이해가 안 갔나
처음 SSR/CSR을 공부할 때 혼란스러웠던 지점들이 있었다.
1. "렌더링"이란 단어 자체가 애매했다
Render가 "화면을 그리다"라는 건 알겠는데, 서버에서 화면을 그린다는 게 무슨 뜻인지 감이 안 왔다. 서버는 데이터베이스에서 데이터 가져와서 JSON으로 주는 거 아닌가? 화면은 브라우저가 그리는 거 아닌가?
2. React는 브라우저 전용 아닌가?
React는 document.getElementById('root')로 DOM을 조작하는 라이브러리인 줄 알았다. 그런데 서버에는 DOM이 없는데 어떻게 React를 서버에서 실행한다는 거지?
3. SEO가 왜 SSR에서만 잘되는지
구글 봇도 브라우저 아닌가? 브라우저처럼 JavaScript를 실행하면 CSR도 크롤링할 수 있을 텐데, 왜 안 되는 걸까?
4. Hydration이란 단어가 너무 생소했다
"수화(水化)"라는 뜻인데, 코드에 물을 붓는다는 건가? HTML에 JavaScript를 "주입"한다는 비유인 것 같은데, 정확히 뭘 하는 과정인지 몰랐다.
결국 서버와 브라우저의 역할 분담이 어떻게 되는지가 핵심이었다.
깨달음의 순간 - "요리 비유"
처음 이해했던 순간은 누군가의 블로그에서 본 "요리 비유"였다.
CSR (Client Side Rendering): 식당(서버)이 재료(JSON 데이터)만 준다 → 손님(브라우저)이 집에서 직접 요리(렌더링)한다
- 장점: 식당 부담 적음, 손님이 원하는 대로 조리 가능
- 단점: 재료 받아서 요리 완성까지 시간 걸림, 요리 냄새를 길에서 못 맡음(SEO 안 됨)
SSR (Server Side Rendering): 식당(서버)이 완성된 요리(완성된 HTML)를 준다
- 장점: 바로 먹을 수 있음(빠른 초기 로딩), 요리 냄새 맡을 수 있음(SEO 가능)
- 단점: 식당 부담 큼(서버 부하), 주문할 때마다 주방에서 요리해야 함
이 비유가 와닿았던 이유는, "렌더링 = 요리"로 치환하니 누가 어디서 하는지가 명확해졌기 때문이다. CSR은 재료만 받고 손님이 요리, SSR은 주방에서 요리 완성 후 배달. 이 차이가 성능과 SEO에 영향을 준다는 게 받아들여졌다.
CSR (Client Side Rendering) 깊게 파보기
CSR의 동작 과정
CSR은 결국 "브라우저가 JavaScript로 DOM을 만드는 과정"이다. Create React App으로 만든 앱이 대표적이다.
1. 브라우저: "GET / HTTP/1.1 (index.html 주세요)"
2. 서버: "여기요" (거의 빈 HTML 파일)
<!-- 서버가 보내는 index.html -->
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="root"></div> <!-- 텅 빔! -->
<script src="/static/js/bundle.js"></script> <!-- 2.5MB -->
</body>
</html>
3. 브라우저: bundle.js 다운로드 시작 (2.5MB, 3G 환경에서 2초 소요)
4. 브라우저: bundle.js 실행 (파싱 + 컴파일 0.5초)
5. React가 ReactDOM.render(<App />, document.getElementById('root')) 실행
6. Virtual DOM 생성 → Real DOM 조작 → 화면에 그려짐
7. 사용자: 드디어 화면 보임 (총 3~4초 경과)
핵심: HTML은 껍데기만 오고, JavaScript가 모든 UI를 만든다.
React SPA의 작동 원리
CSR의 대표격인 React SPA가 어떻게 동작하는지 이해하면 CSR 자체가 이해된다.
Virtual DOM과 렌더링
// src/App.js
function App() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
// 브라우저에서 실행될 때
ReactDOM.render(<App />, document.getElementById('root'));
이 코드가 실행되면:
- React가
<App />컴포넌트를 호출 → JSX를 React.createElement() 호출로 변환 - Virtual DOM 트리 생성 (JavaScript 객체)
- Real DOM과 비교 (Diffing)
- 변경된 부분만 Real DOM에 반영 (Reconciliation)
처음 로드할 때는 <div id="root"></div>가 텅 비어있다가, React가 실행되면서 채워진다. 이게 CSR의 핵심이다.
클라이언트 사이드 라우팅
// React Router 예시
import { BrowserRouter, Route, Link } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
<Route path="/" exact component={Home} />
<Route path="/about" component={About} />
</BrowserRouter>
);
}
여기서 /about 링크를 클릭하면:
- 브라우저 기본 동작(페이지 새로고침) 막음 (e.preventDefault())
- History API로 URL만 변경 (window.history.pushState)
- React Router가 URL 감지 → About 컴포넌트 렌더링
- 서버 요청 없음! → 빠른 페이지 전환
이게 SPA의 "Single Page"가 의미하는 바다. 실제로는 페이지가 하나뿐이고, JavaScript가 화면을 바꿔치기한다.
Code Splitting과 Lazy Loading
CSR의 문제는 초기 번들 크기가 크다는 것이었다. 이를 해결하기 위해 나온 게 Code Splitting이다.
// 전통적인 import (번들에 다 포함됨)
import HeavyComponent from './HeavyComponent';
// Lazy import (필요할 때만 로드)
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
);
}
이렇게 하면:
- 초기 번들: 500KB (App + Router + 필수 컴포넌트)
- HeavyComponent 번들: 1.5MB (필요할 때 로드)
- 초기 로딩 시간 단축!
하지만 여전히 첫 페이지는 빈 HTML이라는 문제는 남아있다.
CSR의 장점
1. 서버 부담이 거의 없다
서버는 nginx나 Apache로 정적 파일(HTML, JS, CSS, 이미지)만 서빙하면 된다. CDN에 올려두면 서버가 거의 할 일이 없다.
# nginx.conf
server {
listen 80;
root /var/www/html;
location / {
try_files $uri /index.html; # 모든 요청을 index.html로
}
}
렌더링은 사용자 브라우저가 알아서 하니까, 동시 접속자 10,000명이어도 서버는 파일만 전송하면 된다.
2. 페이지 전환이 매우 빠르다
// /posts 페이지에서 /posts/123 으로 이동
<Link to="/posts/123">
<h2>{post.title}</h2>
</Link>
// 클릭 시
// - 서버 요청 없음
// - HTML 다시 안 받음
// - JavaScript로 컴포넌트만 교체
// - 50ms 이내 화면 전환
이게 SPA의 가장 큰 매력이다. 앱처럼 부드러운 UX.
3. 서버와 클라이언트 완전 분리
Frontend: React App (S3 + CloudFront)
Backend: REST API (AWS Lambda)
프론트엔드는 정적 호스팅, 백엔드는 API 서버로 완전히 분리할 수 있다. 각자 독립적으로 배포하고 확장할 수 있다.
CSR의 단점
1. 초기 로딩이 느리다 (특히 모바일)
HTML 받음 (1KB) → 화면 빈 상태
↓
JS 다운로드 (2.5MB, 3G에서 3초)
↓
JS 파싱/컴파일 (모바일 CPU에서 1초)
↓
React 실행 → DOM 생성
↓
드디어 화면 보임 (총 5초 경과)
사용자는 5초 동안 하얀 화면만 본다. 이를 "White Screen of Death"라고 부른다.
2. SEO가 치명적으로 약하다
구글 봇이 크롤링할 때 받는 HTML:
<!-- 구글 봇이 받는 HTML -->
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="root"></div> <!-- 텅 비어있음! -->
<script src="/static/js/bundle.js"></script>
</body>
</html>
구글 봇이 보기엔 "이 페이지에 내용이 없네?" 하고 떠난다. 최근엔 구글 봇도 JavaScript를 실행하긴 하지만:
- 실행 우선순위가 낮다 (서버 리소스 절약)
- 모든 JS를 실행하지 않는다
- 동적 로딩은 잘 못 따라간다
결과: 검색 순위 하락.
3. 소셜 미디어 공유가 안 된다
<!-- Facebook/Twitter가 긁어가는 HTML -->
<html>
<head>
<meta property="og:title" content="???" />
<meta property="og:image" content="???" />
</head>
<body>
<div id="root"></div>
</body>
</html>
OG(Open Graph) 태그가 비어있으니, 카카오톡/슬랙에 링크 공유해도 썸네일 안 뜬다.
SSR (Server Side Rendering) 깊게 파보기
SSR의 동작 과정
SSR은 "서버가 React를 실행해서 HTML을 만든다"는 개념이다.
1. 브라우저: "GET / HTTP/1.1"
2. 서버: Node.js에서 React 실행
- ReactDOMServer.renderToString(<App />)
- 결과: HTML 문자열
3. 서버: "여기 완성된 HTML이요"
<!-- 서버가 보내는 HTML -->
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="root">
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>
<h1>Welcome to My Blog</h1>
<p>This content is server-rendered!</p>
</main>
</div>
<script src="/static/js/bundle.js"></script>
</body>
</html>
4. 브라우저: HTML 받자마자 즉시 렌더링 → 화면 보임! (0.3초)
5. bundle.js 다운로드 (백그라운드)
6. Hydration: 이벤트 리스너 붙이기
7. 인터랙티브하게 됨
핵심: HTML이 이미 내용이 꽉 차있다. 브라우저는 받자마자 그리기만 하면 된다.
React의 서버 사이드 렌더링 원리
React는 원래 브라우저 전용인 줄 알았는데, 어떻게 서버에서 실행될까?
ReactDOMServer.renderToString
// server.js (Node.js)
import express from 'express';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from './App';
const app = express();
app.get('*', (req, res) => {
// React 컴포넌트를 HTML 문자열로 변환
const html = ReactDOMServer.renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>SSR App</title>
</head>
<body>
<div id="root">${html}</div>
<script src="/bundle.js"></script>
</body>
</html>
`);
});
app.listen(3000);
renderToString이 하는 일:
<App />컴포넌트 실행- Virtual DOM 생성
- Virtual DOM을 HTML 문자열로 변환
- 이벤트 리스너는 붙이지 않음 (그냥 마크업만)
이게 가능한 이유는 React가 "플랫폼 독립적"으로 설계되었기 때문이다. React Core는 DOM을 몰라도 되고, ReactDOM이 DOM을 다룬다. 서버에선 ReactDOMServer가 HTML 문자열만 만든다.
Streaming SSR (React 18의 renderToPipeableStream)
전통적인 renderToString의 문제는 전체 HTML을 다 만들어야 응답을 보낸다는 것이었다.
// Before: renderToString (blocking)
const html = ReactDOMServer.renderToString(<App />);
// 모든 컴포넌트가 렌더링될 때까지 대기 (예: 2초)
// 그 후에야 응답 전송
res.send(html); // 2초 후 한 번에 전송
React 18에서 나온 renderToPipeableStream은 스트리밍을 지원한다.
// After: renderToPipeableStream (streaming)
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
// 껍데기(Shell)만 준비되면 바로 전송 시작
res.setHeader('Content-Type', 'text/html');
pipe(res);
}
});
});
// HTML을 조각조각 스트리밍으로 전송
// 사용자는 첫 화면을 0.3초 만에 보고,
// 나머지는 로딩되면서 채워짐
이게 SSR의 성능을 크게 개선했다.
SSR의 장점
1. 빠른 초기 로딩 (Time To View)
HTML 받음 (50KB) → 즉시 화면 렌더링 (0.3초)
↓
JS 다운로드 (백그라운드)
↓
Hydration → 인터랙티브
사용자는 0.3초 만에 "뭔가 보이는" 상태가 된다. CSR의 5초와 비교하면 엄청난 차이다.
TTV (Time To View): 사용자가 의미 있는 콘텐츠를 보는 시점. SSR이 압도적으로 빠르다.
2. SEO 최강
<!-- 구글 봇이 받는 HTML (SSR) -->
<html>
<head>
<title>Clean Code 원칙 정리 | Codemapo</title>
<meta name="description" content="변수명 짓기, 함수 작성법..." />
<meta property="og:image" content="/images/clean-code.png" />
</head>
<body>
<h1>Clean Code 원칙 정리</h1>
<p>Clean Code는 읽기 쉬운 코드를 말한다...</p>
<p>첫 번째 원칙은 의미 있는 변수명...</p>
</body>
</html>
내용이 꽉 차있으니:
- 구글이 페이지 내용 파악 → 키워드 인덱싱 → 검색 노출
- 소셜 미디어 OG 태그 읽기 → 썸네일 생성
- 검색 순위 상승
SSR을 적용하면 검색 엔진 최적화에 유리하다는 건 많이 알려진 사실이다. 실제로 큰 개선 효과가 있다고 한다.
3. 저사양 디바이스에서도 빠르다
CSR은 브라우저 CPU가 JavaScript를 실행해야 하는데, 저사양 폰은 느리다. SSR은 서버가 미리 HTML을 만들어주니, 브라우저는 렌더링만 하면 된다 (렌더링은 브라우저가 최적화가 잘 되어있음).
SSR의 단점
1. 서버 부담이 크다
// 동시 요청 100개가 오면
// Node.js 서버가 React를 100번 실행
// CPU 100% → 응답 지연
// 해결책: 캐싱
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 60 });
app.get('/posts/:id', (req, res) => {
const cached = cache.get(req.params.id);
if (cached) {
return res.send(cached);
}
const html = renderToString(<Post id={req.params.id} />);
cache.set(req.params.id, html);
res.send(html);
});
캐싱으로 어느 정도 완화하지만, 여전히 CSR보다 서버 부하가 크다.
2. TTFB (Time To First Byte)가 느리다
CSR:
브라우저 요청 → nginx가 index.html 읽어서 전송
TTFB: 50ms (파일 읽기만)
SSR:
브라우저 요청 → Node.js가 React 실행 → HTML 생성 → 전송
TTFB: 300ms (React 실행 시간)
TTFB는 느리지만, TTV는 빠르다. Trade-off.
3. 서버 환경과 브라우저 환경의 차이
// 이 코드는 서버에서 에러남
function MyComponent() {
const width = window.innerWidth; // ReferenceError: window is not defined
return <div>Width: {width}</div>;
}
// 해결: 브라우저에서만 실행
function MyComponent() {
const [width, setWidth] = useState(0);
useEffect(() => {
setWidth(window.innerWidth); // 브라우저에서만 실행됨
}, []);
return <div>Width: {width}</div>;
}
서버에는 window, document, localStorage 같은 게 없어서 조심해야 한다.
Hydration 깊게 이해하기
Hydration은 SSR의 핵심 개념인데, 처음엔 이해하기 어려웠다.
Hydration이란?
Hydration: 서버에서 만든 정적 HTML에 React의 "생명력"을 불어넣는 과정. 즉, 이벤트 리스너를 붙이고 상태 관리를 시작하는 과정.
<!-- 서버가 보낸 HTML (정적) -->
<button>Click me</button>
<!-- 이 버튼은 아직 클릭해도 아무 일도 안 일어남 -->
// 클라이언트에서 Hydration
import { hydrateRoot } from 'react-dom/client';
function App() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Click me</button>;
}
// Hydration 실행
hydrateRoot(document.getElementById('root'), <App />);
// 이제 버튼 클릭하면 카운트 증가!
hydrateRoot가 하는 일:
- 서버가 만든 HTML을 그대로 유지
- React가 Virtual DOM을 생성
- 기존 HTML과 Virtual DOM을 매칭
- 이벤트 리스너를 붙임 (
onClick등) - React 상태 관리 시작
Hydration Mismatch Error
SSR에서 자주 겪는 에러가 "Hydration mismatch"다.
// 서버 렌더링
function ServerTime() {
const now = new Date().toISOString();
return <div>Server time: {now}</div>;
}
// 서버: "2025-06-21T10:30:00Z"
// 클라이언트 Hydration 시: "2025-06-21T10:30:05Z" (5초 차이)
// → Hydration mismatch! 경고 발생
서버와 클라이언트에서 생성한 HTML이 달라서 생기는 문제다.
해결법:
function ServerTime() {
const [now, setNow] = useState(null);
useEffect(() => {
setNow(new Date().toISOString()); // 클라이언트에서만 실행
}, []);
if (!now) {
return <div>Loading...</div>; // 서버와 클라이언트 첫 렌더링 일치
}
return <div>Client time: {now}</div>;
}
Selective Hydration (React 18)
React 18에서 나온 "선택적 Hydration"은 성능을 크게 개선했다.
// Before: 전체 트리를 한 번에 Hydration (blocking)
hydrateRoot(document, <App />);
// 모든 컴포넌트가 Hydrate될 때까지 인터랙티브 안 됨
// After: Suspense로 우선순위 지정
function App() {
return (
<div>
<Header /> {/* 즉시 Hydration */}
<Suspense fallback={<Spinner />}>
<Comments /> {/* 나중에 Hydration */}
</Suspense>
</div>
);
}
사용자가 클릭한 부분부터 우선적으로 Hydration하는 "똑똑한" 방식이다.
SSG (Static Site Generation): 가장 빠른 방법
SSR과 CSR 사이에 또 하나의 방법이 있다. SSG (Static Site Generation): 빌드 시점에 미리 HTML을 만들어두는 방법.
SSG의 동작 원리
# 빌드 타임
npm run build
→ Next.js가 모든 페이지를 미리 렌더링
→ /out/index.html (완성된 HTML)
→ /out/posts/1.html (완성된 HTML)
→ /out/posts/2.html (완성된 HTML)
# 런타임
사용자 요청 → nginx가 정적 HTML 전송
→ 서버 부담 0, 속도 최고
Next.js에서 SSG 사용
// pages/posts/[id].js
export async function getStaticProps({ params }) {
// 빌드 타임에 한 번만 실행
const post = await fetchPost(params.id);
return {
props: { post },
revalidate: 60, // ISR: 60초마다 재생성
};
}
export async function getStaticPaths() {
// 어떤 페이지들을 미리 만들지 지정
const posts = await fetchAllPosts();
return {
paths: posts.map(p => ({ params: { id: p.id } })),
fallback: 'blocking', // 없는 페이지는 요청 시 생성
};
}
function Post({ post }) {
return <article>{post.content}</article>;
}
ISR (Incremental Static Regeneration)
ISR은 SSG의 단점(빌드 후 업데이트 안 됨)을 해결한다.
export async function getStaticProps() {
const data = await fetch('https://api.example.com/data');
return {
props: { data },
revalidate: 10, // 10초마다 백그라운드에서 재생성
};
}
동작 방식:
- 사용자 요청 → 캐시된 페이지 즉시 전송 (빠름!)
- 10초가 지났으면 백그라운드에서 재생성
- 다음 사용자는 새 페이지 받음
"정적 페이지의 속도 + 동적 데이터의 최신성"을 둘 다 잡았다.
React Server Components (RSC): 새로운 패러다임
2023년에 나온 React Server Components는 SSR을 한 단계 더 발전시켰다.
RSC란?
React Server Components: 서버에서만 실행되고, 클라이언트로 JavaScript 번들이 전송되지 않는 컴포넌트.
// app/page.js (Server Component - 기본값)
async function HomePage() {
// 서버에서만 실행 (데이터베이스 직접 접근 가능)
const posts = await db.query('SELECT * FROM posts');
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// 이 컴포넌트의 JavaScript는 클라이언트로 전송되지 않음!
// 번들 크기: 0KB
Server Component vs Client Component
// app/PostList.server.js (Server Component)
async function PostList() {
const posts = await db.posts.findMany(); // DB 직접 접근
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// 클라이언트 번들에 포함 안 됨
// app/LikeButton.client.js (Client Component)
'use client'; // 명시적으로 클라이언트 컴포넌트 선언
export function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
);
}
// 클라이언트 번들에 포함됨 (인터랙티브해야 하니까)
핵심: 인터랙티브가 필요 없는 부분은 Server Component로, 필요한 부분만 Client Component로.
RSC의 장점
// Before: 전통적인 SSR
// - 서버에서 HTML 생성
// - 클라이언트에서 모든 컴포넌트 코드 다운로드 (2MB)
// - Hydration
// After: RSC
// - 서버에서 HTML 생성
// - 클라이언트는 Client Component만 다운로드 (200KB)
// - Hydration도 Client Component만
번들 크기가 90% 감소! 이게 RSC의 핵심 가치다.
Next.js: App Router vs Pages Router
Next.js는 두 가지 라우터를 제공한다.
Pages Router (전통적인 방식)
pages/
index.js → / (SSR/SSG/CSR 선택)
about.js → /about
posts/[id].js → /posts/:id
// pages/posts/[id].js
export async function getServerSideProps({ params }) {
// SSR: 매 요청마다 실행
const post = await fetchPost(params.id);
return { props: { post } };
}
// 또는
export async function getStaticProps({ params }) {
// SSG: 빌드 타임에 실행
const post = await fetchPost(params.id);
return { props: { post } };
}
function PostPage({ post }) {
return <article>{post.content}</article>;
}
App Router (Next.js 13+, RSC 지원)
app/
page.js → / (기본 Server Component)
about/page.js → /about
posts/[id]/page.js → /posts/:id
// app/posts/[id]/page.js (Server Component)
async function PostPage({ params }) {
// async 컴포넌트 가능! (서버에서만 실행)
const post = await fetchPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton /> {/* Client Component */}
</article>
);
}
// app/LikeButton.js (Client Component)
'use client';
export function LikeButton() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Like {count}</button>;
}
차이점:
- Pages Router:
getServerSideProps로 데이터 페칭 - App Router: 컴포넌트 자체가 async, 서버에서 직접 페칭
App Router가 더 직관적이고, RSC 덕분에 번들 크기도 작다.
성능 메트릭 - SSR/CSR이 미치는 영향
웹 성능을 측정하는 주요 지표들과 SSR/CSR의 영향을 정리해본다.
Core Web Vitals
구글이 중요하게 보는 3가지 지표:
1. LCP (Largest Contentful Paint)
"가장 큰 콘텐츠가 화면에 렌더링되는 시간"
CSR: 3.5초 (JS 로드 후 렌더링)
SSR: 0.8초 (HTML에 이미 포함)
SSG: 0.3초 (캐시된 HTML)
목표: 2.5초 이내
SSR/SSG가 압도적으로 유리.
2. FID (First Input Delay) → INP (Interaction to Next Paint)
"사용자 입력부터 반응까지 지연 시간"
CSR: 300ms (Hydration 완료 후)
SSR: 200ms (Hydration이 빠름)
SSG: 150ms
목표: 100ms 이내
3. CLS (Cumulative Layout Shift)
"페이지 로딩 중 레이아웃이 얼마나 밀리는지"
CSR: 0.15 (JS 로드 후 콘텐츠가 갑자기 나타남 → 레이아웃 shift)
SSR: 0.05 (HTML에 이미 있어서 shift 적음)
목표: 0.1 이하
기타 중요 지표
FCP (First Contentful Paint)
"첫 콘텐츠가 화면에 그려지는 시간"
CSR: 2.5초 (JS 로드 후)
SSR: 0.5초 (HTML 즉시)
TTI (Time To Interactive)
"페이지가 완전히 인터랙티브해지는 시간"
CSR: 3.8초 (JS 로드 + 실행)
SSR: 2.5초 (Hydration)
SSG: 2.0초
실제 배포 환경 비교
각 렌더링 방식을 실제로 배포하는 방법과 비용을 비교해본다.
1. CSR 배포: S3 + CloudFront
# 빌드
npm run build # → build/ 폴더에 정적 파일 생성
# S3에 업로드
aws s3 sync build/ s3://my-bucket --delete
# CloudFront로 CDN 배포
# → 전 세계 엣지에서 빠르게 제공
비용:
- S3: $0.023/GB (스토리지) + $0.09/GB (전송)
- CloudFront: $0.085/GB
- 월 1만 방문자: 약 $5
장점:
- 매우 저렴
- 무한 확장 가능 (CDN이라 트래픽 폭증해도 OK)
- 배포 간단
단점:
- SEO 약함
- 초기 로딩 느림
2. SSR 배포: Vercel
# Next.js 프로젝트를 Vercel에 배포
vercel deploy
# Vercel이 자동으로:
# - Node.js 서버 실행
# - 엣지 네트워크에 분산
# - 자동 스케일링
비용:
- Hobby 플랜: 무료 (개인 프로젝트)
- Pro 플랜: $20/월 (상용)
장점:
- SSR/SSG/ISR 모두 지원
- 배포 초간단
- 자동 스케일링
단점:
- 비용이 CSR보다 비쌈
- Vercel에 종속됨 (vendor lock-in)
3. SSG 배포: Cloudflare Pages
# Next.js SSG 빌드
next build && next export
# Cloudflare Pages에 배포
wrangler pages publish out/
비용:
- 무료 (월 500 빌드, 무제한 트래픽!)
장점:
- 거의 공짜
- CDN 속도 빠름
- SSR보다 안정적 (정적이니까)
단점:
- 동적 데이터는 클라이언트에서 fetch 해야 함
4. SSR 직접 배포: AWS ECS
# Dockerfile
FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
RUN npm run build
CMD ["npm", "start"]
# Docker 이미지 빌드 및 ECS 배포
docker build -t my-app .
aws ecr push my-app
aws ecs update-service --service my-app
비용:
- ECS Fargate: $0.04/vCPU/hour + $0.004/GB/hour
- ALB: $16/월 + $0.008/LCU-hour
- 월 1만 방문자: 약 $50
장점:
- 완전한 제어 가능
- Vendor lock-in 없음
단점:
- 복잡함
- 비쌈
실제 코드 예제
Next.js Pages Router: SSR vs SSG
// pages/posts/[id].js
// 방법 1: SSR (매 요청마다 서버 렌더링)
export async function getServerSideProps({ params, req, res }) {
// 매 요청마다 실행됨
console.log('SSR 실행 중...');
// 캐싱 헤더 설정 (선택사항)
res.setHeader(
'Cache-Control',
'public, s-maxage=10, stale-while-revalidate=59'
);
const post = await fetch(`https://api.example.com/posts/${params.id}`)
.then(res => res.json());
if (!post) {
return { notFound: true };
}
return {
props: {
post,
renderedAt: new Date().toISOString(),
},
};
}
// 방법 2: SSG (빌드 타임에 한 번만)
export async function getStaticProps({ params }) {
// 빌드 시 한 번만 실행됨
console.log('SSG 실행 중...');
const post = await fetch(`https://api.example.com/posts/${params.id}`)
.then(res => res.json());
return {
props: { post },
revalidate: 60, // ISR: 60초마다 재생성
};
}
export async function getStaticPaths() {
// 어떤 경로를 미리 생성할지 지정
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json());
return {
paths: posts.map(p => ({ params: { id: String(p.id) } })),
fallback: 'blocking', // 없는 경로는 요청 시 생성
};
}
// 컴포넌트
function PostPage({ post, renderedAt }) {
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{renderedAt && <small>Rendered at: {renderedAt}</small>}
</article>
);
}
export default PostPage;
Next.js App Router: Server Component + Client Component
// app/posts/[id]/page.js (Server Component)
import { Suspense } from 'react';
import { LikeButton } from './LikeButton';
import { Comments } from './Comments';
// 서버 컴포넌트는 async 가능!
async function PostPage({ params }) {
// 데이터베이스 직접 접근 (서버에서만 실행)
const post = await db.posts.findUnique({
where: { id: params.id },
});
if (!post) {
notFound(); // 404 페이지로
}
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Client Component: 인터랙티브 */}
<LikeButton postId={post.id} />
{/* Suspense로 로딩 처리 */}
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={post.id} />
</Suspense>
</article>
);
}
export default PostPage;
// app/posts/[id]/LikeButton.js (Client Component)
'use client'; // 클라이언트 컴포넌트 명시
import { useState } from 'react';
export function LikeButton({ postId }) {
const [liked, setLiked] = useState(false);
const [count, setCount] = useState(0);
const handleLike = async () => {
setLiked(!liked);
// API 호출
const res = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
});
const data = await res.json();
setCount(data.likeCount);
};
return (
<button onClick={handleLike}>
{liked ? '❤️' : '🤍'} {count}
</button>
);
}
// app/posts/[id]/Comments.js (Server Component)
async function Comments({ postId }) {
// 서버에서 데이터 페칭
const comments = await db.comments.findMany({
where: { postId },
});
return (
<div>
<h3>Comments</h3>
{comments.map(comment => (
<div key={comment.id}>
<strong>{comment.author}</strong>
<p>{comment.content}</p>
</div>
))}
</div>
);
}
Hybrid: 페이지별로 다르게 적용
// Next.js 프로젝트 구조
pages/
index.js // SSG (메인 페이지, 자주 안 바뀜)
blog/[slug].js // SSG + ISR (블로그 글)
dashboard/index.js // CSR (로그인 필요, SEO 필요 없음)
products/[id].js // SSR (실시간 재고 정보)
// pages/index.js (SSG)
export async function getStaticProps() {
return {
props: {
posts: await fetchLatestPosts(),
},
revalidate: 3600, // 1시간마다 재생성
};
}
// pages/blog/[slug].js (SSG + ISR)
export async function getStaticProps({ params }) {
return {
props: {
post: await fetchPost(params.slug),
},
revalidate: 60, // 1분마다 재생성
};
}
// pages/dashboard/index.js (CSR)
function Dashboard() {
const [data, setData] = useState(null);
useEffect(() => {
// 클라이언트에서만 데이터 페칭
fetch('/api/user/dashboard')
.then(res => res.json())
.then(setData);
}, []);
if (!data) return <div>Loading...</div>;
return <div>{data.username}'s Dashboard</div>;
}
// getServerSideProps 없음 = CSR
// pages/products/[id].js (SSR)
export async function getServerSideProps({ params }) {
// 매 요청마다 최신 재고 정보
return {
props: {
product: await fetchProduct(params.id),
},
};
}
나의 경험 - 블로그 마이그레이션
내 블로그를 CSR(Create React App) → SSR(Next.js)로 바꾼 경험을 정리해본다.
Before: CRA로 만든 블로그
기술 스택:
- Create React App
- React Router
- Markdown 파싱 (클라이언트)
- S3 + CloudFront 배포
문제점:
1. 구글 검색 안 잡힘 (치명적)
2. 초기 로딩 3.5초 (모바일에서 5초)
3. 링크 공유해도 썸네일 안 뜸
4. Lighthouse SEO 점수: 45점
After: Next.js로 마이그레이션
기술 스택:
- Next.js 14 (App Router)
- Server Components
- MDX (서버에서 파싱)
- Vercel 배포
개선 결과:
1. 구글 검색 노출 시작 (SSR 전환 후 크롤링 가능해짐)
2. 초기 로딩 0.8초 (4배 빠름)
3. OG 이미지 정상 작동
4. Lighthouse SEO 점수: 100점
5. Core Web Vitals 모두 통과
마이그레이션 과정에서 배운 것
1. 모든 페이지를 SSR로 할 필요 없다
// 블로그 글: SSG + ISR (가장 빠름)
export const revalidate = 3600;
// 검색 페이지: CSR (SEO 필요 없음)
'use client';
// 실시간 댓글: Server Component (SEO 필요)
async function Comments() { }
2. 이미지 최적화가 중요하다
// Before
<img src="/images/cover.jpg" /> // 2MB 원본
// After
import Image from 'next/image';
<Image
src="/images/cover.jpg"
width={800}
height={400}
alt="Cover"
loading="lazy"
/>
// Next.js가 자동으로 WebP 변환, 리사이징, lazy loading
3. 번들 크기 줄이기
// Before: 모든 라이브러리가 메인 번들에
import { format } from 'date-fns'; // 전체 라이브러리 import
// After: 필요한 것만
import format from 'date-fns/format'; // Tree-shaking
// 결과: 번들 크기 2.5MB → 800KB
언제 무엇을 쓸까?
결국 이해한 결론은 "상황에 따라 다르다"였다.
CSR을 쓸 때
1. SEO가 필요 없는 경우
예시:
- 관리자 대시보드
- 사내 툴
- 로그인 후에만 보는 페이지
이유: 어차피 구글에 노출될 필요 없으면 CSR이 가장 간단하고 저렴하다.
2. 실시간 상호작용이 중요한 경우
예시:
- 채팅 앱
- 실시간 협업 툴 (Figma, Notion)
- 게임
이유: SSR은 초기 로딩만 빠르고, 이후 상호작용은 결국 클라이언트에서 일어난다. 처음부터 CSR로 만들면 더 간단하다.
3. 서버 비용을 최소화하고 싶을 때
월 10만 방문자 기준:
- CSR (S3 + CloudFront): $10
- SSR (Vercel Pro): $20
- SSR (자체 서버): $100
SSR을 쓸 때
1. SEO가 필수인 경우
예시:
- 블로그
- 뉴스 사이트
- 전자상거래 (상품 페이지)
- 기업 홈페이지
이유: 구글 검색 유입이 핵심이면 SSR은 필수다.
2. 소셜 미디어 공유가 중요할 때
<!-- OG 태그가 서버에서 생성되어야 함 -->
<meta property="og:title" content="실제 제목" />
<meta property="og:image" content="실제 이미지" />
카카오톡, 슬랙에 링크 공유 시 썸네일이 필요하면 SSR.
3. 초기 로딩 속도가 매우 중요할 때
예시:
- 뉴스 사이트 (독자가 바로 떠나지 않게)
- 랜딩 페이지 (전환율과 직결)
- 모바일 웹 (저사양 기기)
SSG를 쓸 때
1. 내용이 자주 안 바뀌는 경우
예시:
- 문서 사이트 (Docs)
- 블로그 (글 수정이 드묾)
- 포트폴리오
- 마케팅 페이지
이유: 가장 빠르고, 저렴하고, 안정적이다.
2. 모든 사용자에게 같은 내용을 보여줄 때
개인화가 필요 없으면 SSG가 최고다.
3. ISR로 "정적 + 동적"을 함께
export const revalidate = 60; // 1분마다 재생성
// 첫 사용자: 캐시된 페이지 (빠름)
// 1분 후 사용자: 백그라운드 재생성 → 최신 데이터
"정적 페이지의 속도 + 동적 데이터의 신선함"을 둘 다 잡을 수 있다.
정리하면
이 글을 쓰면서 내가 받아들인 핵심 교훈들을 정리해본다.
1. CSR vs SSR은 Trade-off다
무조건 SSR이 좋은 게 아니다. 각각 장단점이 명확하다.
CSR:
✅ 서버 부담 적음, 배포 간단, 비용 저렴
❌ SEO 약함, 초기 로딩 느림
SSR:
✅ SEO 강함, 초기 로딩 빠름
❌ 서버 부담 큼, 배포 복잡, 비용 비쌈
SSG:
✅ 가장 빠름, 저렴, 안정적
❌ 동적 데이터 제한적
2. Hybrid가 현실적인 답이다
Next.js가 인기 있는 이유는 "둘 다 쓸 수 있어서"다.
// 페이지별로 다르게
pages/
index.js → SSG (메인 페이지)
blog/[slug].js → SSG + ISR (블로그)
dashboard.js → CSR (대시보드)
products/[id].js → SSR (실시간 재고)
한 프로젝트에서 용도에 맞게 섞어 쓰는 게 정답이었다.
3. 성능은 숫자로 측정하자
"빠르다/느리다"는 주관적이다. Lighthouse로 측정하면 객관적이다.
측정 지표:
- LCP: 2.5초 이내
- FID: 100ms 이내
- CLS: 0.1 이하
- Lighthouse Performance: 90점 이상
4. SEO가 필요한지부터 질문하자
처음 프로젝트 시작할 때:
Q1. 구글 검색 노출이 필요한가?
→ YES: SSR/SSG 고려
→ NO: CSR로 충분
Q2. 소셜 미디어 공유가 중요한가?
→ YES: SSR/SSG
→ NO: CSR
Q3. 초기 로딩이 매우 중요한가?
→ YES: SSR/SSG
→ NO: CSR도 괜찮음
이 질문들에 답하면 선택이 명확해진다.
5. 미래는 RSC 방향으로 간다
React Server Components가 게임 체인저라고 이해했다.
Before: 모든 컴포넌트가 클라이언트 번들에 포함
After: 인터랙티브한 것만 클라이언트 번들에
결과: 번들 크기 90% 감소 가능
Next.js App Router, Remix 등 주요 프레임워크가 RSC를 채택했다. 이 방향으로 공부하는 게 맞다고 받아들였다.
6. 완벽한 솔루션은 없다
결국 "정답은 없다"가 정답이었다.
- 트래픽 적으면 CSR로 시작하고, 필요할 때 SSR 전환
- 블로그는 SSG, 검색 기능은 CSR
- 상품 목록은 SSG, 상품 상세는 SSR
프로젝트 상황에 맞게 유연하게 선택하는 게 핵심이다.