
SSR vs CSR: 화면을 어디서 그릴 것인가
서버에서 완성된 요리를 주기(SSR) vs 재료만 주고 브라우저가 요리하기(CSR). SEO와 초기 로딩 속도의 트레이드오프. Next.js가 둘을 합친 이유.

서버에서 완성된 요리를 주기(SSR) vs 재료만 주고 브라우저가 요리하기(CSR). SEO와 초기 로딩 속도의 트레이드오프. Next.js가 둘을 합친 이유.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

프론트엔드 개발자가 알아야 할 4가지 저장소의 차이점과 보안 이슈(XSS, CSRF), 그리고 언제 무엇을 써야 하는지에 대한 명확한 기준.

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

React로 개인 블로그를 만들고 한 달 정도 글을 올렸는데, 구글에 검색해도 내 블로그가 안 나왔다. "구글 서치 콘솔"에 들어가보니 "크롤링은 했는데 내용이 없다"는 황당한 메시지를 받았다. 분명 글은 있는데?
검색해보니 "Create React App으로 만든 SPA는 SEO가 안 좋습니다"라는 글이 나왔다. CSR이 문제라고 했다. 그럼 SSR은 뭔데? Next.js를 쓰면 해결된다는데, 도대체 왜 그런 건지 이해가 안 갔다.
"페이지를 서버에서 그린다"는 게 무슨 말인지, React는 원래 브라우저에서 돌아가는 게 아닌가? 이 궁금증이 SSR/CSR 공부의 시작이었다.
처음 SSR/CSR을 공부할 때 혼란스러웠던 지점들이 있었다.
Render가 "화면을 그리다"라는 건 알겠는데, 서버에서 화면을 그린다는 게 무슨 뜻인지 감이 안 왔다. 서버는 데이터베이스에서 데이터 가져와서 JSON으로 주는 거 아닌가? 화면은 브라우저가 그리는 거 아닌가?
React는 document.getElementById('root')로 DOM을 조작하는 라이브러리인 줄 알았다. 그런데 서버에는 DOM이 없는데 어떻게 React를 서버에서 실행한다는 거지?
구글 봇도 브라우저 아닌가? 브라우저처럼 JavaScript를 실행하면 CSR도 크롤링할 수 있을 텐데, 왜 안 되는 걸까?
"수화(水化)"라는 뜻인데, 코드에 물을 붓는다는 건가? HTML에 JavaScript를 "주입"한다는 비유인 것 같은데, 정확히 뭘 하는 과정인지 몰랐다.
결국 서버와 브라우저의 역할 분담이 어떻게 되는지가 핵심이었다.처음 이해했던 순간은 누군가의 블로그에서 본 "요리 비유"였다.
CSR (Client Side Rendering): 식당(서버)이 재료(JSON 데이터)만 준다 → 손님(브라우저)이 집에서 직접 요리(렌더링)한다
- 장점: 식당 부담 적음, 손님이 원하는 대로 조리 가능
- 단점: 재료 받아서 요리 완성까지 시간 걸림, 요리 냄새를 길에서 못 맡음(SEO 안 됨)
SSR (Server Side Rendering): 식당(서버)이 완성된 요리(완성된 HTML)를 준다
- 장점: 바로 먹을 수 있음(빠른 초기 로딩), 요리 냄새 맡을 수 있음(SEO 가능)
- 단점: 식당 부담 큼(서버 부하), 주문할 때마다 주방에서 요리해야 함
이 비유가 와닿았던 이유는, "렌더링 = 요리"로 치환하니 누가 어디서 하는지가 명확해졌기 때문이다. CSR은 재료만 받고 손님이 요리, SSR은 주방에서 요리 완성 후 배달. 이 차이가 성능과 SEO에 영향을 준다는 게 받아들여졌다.
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를 만든다.
CSR의 대표격인 React SPA가 어떻게 동작하는지 이해하면 CSR 자체가 이해된다.
// 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'));
이 코드가 실행되면:
<App /> 컴포넌트를 호출 → JSX를 React.createElement() 호출로 변환처음 로드할 때는 <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 링크를 클릭하면:
이게 SPA의 "Single Page"가 의미하는 바다. 실제로는 페이지가 하나뿐이고, JavaScript가 화면을 바꿔치기한다.
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>
);
}
이렇게 하면:
하지만 여전히 첫 페이지는 빈 HTML이라는 문제는 남아있다.
서버는 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명이어도 서버는 파일만 전송하면 된다.
// /posts 페이지에서 /posts/123 으로 이동
<Link to="/posts/123">
<h2>{post.title}</h2>
</Link>
// 클릭 시
// - 서버 요청 없음
// - HTML 다시 안 받음
// - JavaScript로 컴포넌트만 교체
// - 50ms 이내 화면 전환
이게 SPA의 가장 큰 매력이다. 앱처럼 부드러운 UX.
Frontend: React App (S3 + CloudFront)
Backend: REST API (AWS Lambda)
프론트엔드는 정적 호스팅, 백엔드는 API 서버로 완전히 분리할 수 있다. 각자 독립적으로 배포하고 확장할 수 있다.
HTML 받음 (1KB) → 화면 빈 상태
↓
JS 다운로드 (2.5MB, 3G에서 3초)
↓
JS 파싱/컴파일 (모바일 CPU에서 1초)
↓
React 실행 → DOM 생성
↓
드디어 화면 보임 (총 5초 경과)
사용자는 5초 동안 하얀 화면만 본다. 이를 "White Screen of Death"라고 부른다.
구글 봇이 크롤링할 때 받는 HTML:
<!-- 구글 봇이 받는 HTML -->
<html>
<head>
<title>My Blog</title>
</head>
<body>
<div id="root"></div> <!-- 텅 비어있음! -->
<script src="/static/js/bundle.js"></script>
</body>
</html>
구글 봇이 보기엔 "이 페이지에 내용이 없네?" 하고 떠난다. 최근엔 구글 봇도 JavaScript를 실행하긴 하지만:
결과: 검색 순위 하락.
<!-- 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은 "서버가 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는 원래 브라우저 전용인 줄 알았는데, 어떻게 서버에서 실행될까?
// 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 /> 컴포넌트 실행이게 가능한 이유는 React가 "플랫폼 독립적"으로 설계되었기 때문이다. React Core는 DOM을 몰라도 되고, ReactDOM이 DOM을 다룬다. 서버에선 ReactDOMServer가 HTML 문자열만 만든다.
전통적인 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의 성능을 크게 개선했다.
HTML 받음 (50KB) → 즉시 화면 렌더링 (0.3초)
↓
JS 다운로드 (백그라운드)
↓
Hydration → 인터랙티브
사용자는 0.3초 만에 "뭔가 보이는" 상태가 된다. CSR의 5초와 비교하면 엄청난 차이다.
TTV (Time To View): 사용자가 의미 있는 콘텐츠를 보는 시점. SSR이 압도적으로 빠르다.
<!-- 구글 봇이 받는 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>
내용이 꽉 차있으니:
SSR을 적용하면 검색 엔진 최적화에 유리하다는 건 많이 알려진 사실이다. 실제로 큰 개선 효과가 있다고 한다.
CSR은 브라우저 CPU가 JavaScript를 실행해야 하는데, 저사양 폰은 느리다. SSR은 서버가 미리 HTML을 만들어주니, 브라우저는 렌더링만 하면 된다 (렌더링은 브라우저가 최적화가 잘 되어있음).
// 동시 요청 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보다 서버 부하가 크다.
CSR:
브라우저 요청 → nginx가 index.html 읽어서 전송
TTFB: 50ms (파일 읽기만)
SSR:
브라우저 요청 → Node.js가 React 실행 → HTML 생성 → 전송
TTFB: 300ms (React 실행 시간)
TTFB는 느리지만, TTV는 빠르다. Trade-off.
// 이 코드는 서버에서 에러남
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은 SSR의 핵심 개념인데, 처음엔 이해하기 어려웠다.
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가 하는 일:
onClick 등)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>;
}
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하는 "똑똑한" 방식이다.
SSR과 CSR 사이에 또 하나의 방법이 있다. SSG (Static Site Generation): 빌드 시점에 미리 HTML을 만들어두는 방법.
# 빌드 타임
npm run build
→ Next.js가 모든 페이지를 미리 렌더링
→ /out/index.html (완성된 HTML)
→ /out/posts/1.html (완성된 HTML)
→ /out/posts/2.html (완성된 HTML)
# 런타임
사용자 요청 → nginx가 정적 HTML 전송
→ 서버 부담 0, 속도 최고
// 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은 SSG의 단점(빌드 후 업데이트 안 됨)을 해결한다.
export async function getStaticProps() {
const data = await fetch('https://api.example.com/data');
return {
props: { data },
revalidate: 10, // 10초마다 백그라운드에서 재생성
};
}
동작 방식:
"정적 페이지의 속도 + 동적 데이터의 최신성"을 둘 다 잡았다.
2023년에 나온 React Server Components는 SSR을 한 단계 더 발전시켰다.
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
// 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로.
// Before: 전통적인 SSR
// - 서버에서 HTML 생성
// - 클라이언트에서 모든 컴포넌트 코드 다운로드 (2MB)
// - Hydration
// After: RSC
// - 서버에서 HTML 생성
// - 클라이언트는 Client Component만 다운로드 (200KB)
// - Hydration도 Client Component만
번들 크기가 90% 감소! 이게 RSC의 핵심 가치다.
Next.js는 두 가지 라우터를 제공한다.
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/
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>;
}
차이점:
getServerSideProps로 데이터 페칭App Router가 더 직관적이고, RSC 덕분에 번들 크기도 작다.
웹 성능을 측정하는 주요 지표들과 SSR/CSR의 영향을 정리해본다.
구글이 중요하게 보는 3가지 지표:
"가장 큰 콘텐츠가 화면에 렌더링되는 시간"
CSR: 3.5초 (JS 로드 후 렌더링)
SSR: 0.8초 (HTML에 이미 포함)
SSG: 0.3초 (캐시된 HTML)
목표: 2.5초 이내
SSR/SSG가 압도적으로 유리.
"사용자 입력부터 반응까지 지연 시간"
CSR: 300ms (Hydration 완료 후)
SSR: 200ms (Hydration이 빠름)
SSG: 150ms
목표: 100ms 이내
"페이지 로딩 중 레이아웃이 얼마나 밀리는지"
CSR: 0.15 (JS 로드 후 콘텐츠가 갑자기 나타남 → 레이아웃 shift)
SSR: 0.05 (HTML에 이미 있어서 shift 적음)
목표: 0.1 이하
"첫 콘텐츠가 화면에 그려지는 시간"
CSR: 2.5초 (JS 로드 후)
SSR: 0.5초 (HTML 즉시)
"페이지가 완전히 인터랙티브해지는 시간"
CSR: 3.8초 (JS 로드 + 실행)
SSR: 2.5초 (Hydration)
SSG: 2.0초
각 렌더링 방식을 실제로 배포하는 방법과 비용을 비교해본다.
# 빌드
npm run build # → build/ 폴더에 정적 파일 생성
# S3에 업로드
aws s3 sync build/ s3://my-bucket --delete
# CloudFront로 CDN 배포
# → 전 세계 엣지에서 빠르게 제공
비용:
장점:
단점:
# Next.js 프로젝트를 Vercel에 배포
vercel deploy
# Vercel이 자동으로:
# - Node.js 서버 실행
# - 엣지 네트워크에 분산
# - 자동 스케일링
비용:
장점:
단점:
# Next.js SSG 빌드
next build && next export
# Cloudflare Pages에 배포
wrangler pages publish out/
비용:
장점:
단점:
# 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
비용:
장점:
단점:
// 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;
// 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>
);
}
// 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)로 바꾼 경험을 정리해본다.
기술 스택:
- Create React App
- React Router
- Markdown 파싱 (클라이언트)
- S3 + CloudFront 배포
문제점:
1. 구글 검색 안 잡힘 (치명적)
2. 초기 로딩 3.5초 (모바일에서 5초)
3. 링크 공유해도 썸네일 안 뜸
4. Lighthouse SEO 점수: 45점
기술 스택:
- 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 모두 통과
// 블로그 글: SSG + ISR (가장 빠름)
export const revalidate = 3600;
// 검색 페이지: CSR (SEO 필요 없음)
'use client';
// 실시간 댓글: Server Component (SEO 필요)
async function Comments() { }
// 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
// Before: 모든 라이브러리가 메인 번들에
import { format } from 'date-fns'; // 전체 라이브러리 import
// After: 필요한 것만
import format from 'date-fns/format'; // Tree-shaking
// 결과: 번들 크기 2.5MB → 800KB
결국 이해한 결론은 "상황에 따라 다르다"였다.
예시:
- 관리자 대시보드
- 사내 툴
- 로그인 후에만 보는 페이지
이유: 어차피 구글에 노출될 필요 없으면 CSR이 가장 간단하고 저렴하다.
예시:
- 채팅 앱
- 실시간 협업 툴 (Figma, Notion)
- 게임
이유: SSR은 초기 로딩만 빠르고, 이후 상호작용은 결국 클라이언트에서 일어난다. 처음부터 CSR로 만들면 더 간단하다.
월 10만 방문자 기준:
- CSR (S3 + CloudFront): $10
- SSR (Vercel Pro): $20
- SSR (자체 서버): $100
예시:
- 블로그
- 뉴스 사이트
- 전자상거래 (상품 페이지)
- 기업 홈페이지
이유: 구글 검색 유입이 핵심이면 SSR은 필수다.
<!-- OG 태그가 서버에서 생성되어야 함 -->
<meta property="og:title" content="실제 제목" />
<meta property="og:image" content="실제 이미지" />
카카오톡, 슬랙에 링크 공유 시 썸네일이 필요하면 SSR.
예시:
- 뉴스 사이트 (독자가 바로 떠나지 않게)
- 랜딩 페이지 (전환율과 직결)
- 모바일 웹 (저사양 기기)
예시:
- 문서 사이트 (Docs)
- 블로그 (글 수정이 드묾)
- 포트폴리오
- 마케팅 페이지
이유: 가장 빠르고, 저렴하고, 안정적이다.
개인화가 필요 없으면 SSG가 최고다.
export const revalidate = 60; // 1분마다 재생성
// 첫 사용자: 캐시된 페이지 (빠름)
// 1분 후 사용자: 백그라운드 재생성 → 최신 데이터
"정적 페이지의 속도 + 동적 데이터의 신선함"을 둘 다 잡을 수 있다.
이 글을 쓰면서 내가 받아들인 핵심 교훈들을 정리해본다.
무조건 SSR이 좋은 게 아니다. 각각 장단점이 명확하다.
CSR:
✅ 서버 부담 적음, 배포 간단, 비용 저렴
❌ SEO 약함, 초기 로딩 느림
SSR:
✅ SEO 강함, 초기 로딩 빠름
❌ 서버 부담 큼, 배포 복잡, 비용 비쌈
SSG:
✅ 가장 빠름, 저렴, 안정적
❌ 동적 데이터 제한적
Next.js가 인기 있는 이유는 "둘 다 쓸 수 있어서"다.
// 페이지별로 다르게
pages/
index.js → SSG (메인 페이지)
blog/[slug].js → SSG + ISR (블로그)
dashboard.js → CSR (대시보드)
products/[id].js → SSR (실시간 재고)
한 프로젝트에서 용도에 맞게 섞어 쓰는 게 정답이었다.
"빠르다/느리다"는 주관적이다. Lighthouse로 측정하면 객관적이다.
측정 지표:
- LCP: 2.5초 이내
- FID: 100ms 이내
- CLS: 0.1 이하
- Lighthouse Performance: 90점 이상
처음 프로젝트 시작할 때:
Q1. 구글 검색 노출이 필요한가?
→ YES: SSR/SSG 고려
→ NO: CSR로 충분
Q2. 소셜 미디어 공유가 중요한가?
→ YES: SSR/SSG
→ NO: CSR
Q3. 초기 로딩이 매우 중요한가?
→ YES: SSR/SSG
→ NO: CSR도 괜찮음
이 질문들에 답하면 선택이 명확해진다.
React Server Components가 게임 체인저라고 이해했다.
Before: 모든 컴포넌트가 클라이언트 번들에 포함
After: 인터랙티브한 것만 클라이언트 번들에
결과: 번들 크기 90% 감소 가능
Next.js App Router, Remix 등 주요 프레임워크가 RSC를 채택했다. 이 방향으로 공부하는 게 맞다고 받아들였다.
결국 "정답은 없다"가 정답이었다.