SSR에 기능 더하기
이전 장에서 SSR의 기본 원리를 확인했습니다. 서버에서 HTML을 만들어서 보내는 것. 그런데 버튼을 추가했더니 클릭해도 아무 일도 일어나지 않았죠.
이번 장에서는 이 문제를 해결하고, SSR에 필요한 기능들을 하나씩 붙여보겠습니다:
•
이벤트 핸들러가 사라지는 문제 (Hydration)
•
여러 페이지를 다루는 문제 (라우팅)
•
데이터를 가져오는 문제 (데이터 페칭)
•
매번 렌더링하는 비효율 (SSG, ISR)
4-1. 직렬화와 Hydration
왜 이벤트 핸들러가 사라지는가
이전 장에서 봤던 코드를 다시 보겠습니다.
function App() {
const handleClick = () => {
alert("클릭!");
};
return <button onClick={handleClick}>클릭</button>;
}
TypeScript
복사
이 컴포넌트를 renderToString으로 변환하면 <button>클릭</button>이 됩니다. onClick이 사라졌습니다.
왜 그럴까요? 서버에서 클라이언트로 데이터를 보내려면 직렬화(Serialization)가 필요하기 때문입니다.
직렬화란
직렬화는 객체를 문자열로 변환하는 과정입니다. HTTP 통신은 문자열(바이트 스트림)만 전송할 수 있기 때문에, 서버의 JavaScript 객체를 클라이언트로 보내려면 문자열로 바꿔야 합니다.
이 “텍스트로 변환”이라는 제약 덕분에 Python 서버와 JavaScript 클라이언트처럼 서로 다른 언어 사이에서도 통신이 가능합니다. JSON이라는 공통 형식만 지키면 상대방의 내부 구현을 몰라도 되니까요. (이 개념은 5-1장에서 더 깊이 다룹니다.)
// 직렬화
const obj = { name: "홍길동", count: 42 };
const str = JSON.stringify(obj); // '{"name":"홍길동","count":42}'
// 역직렬화
const restored = JSON.parse(str); // { name: "홍길동", count: 42 }
JavaScript
복사
문제는 모든 것이 직렬화 가능한 건 아니라는 점입니다.
const data = {
name: "홍길동", // ✅ 문자열: 직렬화 가능
count: 42, // ✅ 숫자: 직렬화 가능
active: true, // ✅ 불리언: 직렬화 가능
onClick: () => {}, // ❌ 함수: 직렬화 불가!
date: new Date(), // ⚠️ Date 객체: 문자열로 변환됨
};
JSON.stringify(data);
// 결과: {"name":"홍길동","count":42,"active":true,"date":"2025-01-03T..."}
// onClick은 완전히 사라짐!
JavaScript
복사
함수는 직렬화할 수 없습니다. 그래서 renderToString이 컴포넌트를 HTML 문자열로 변환할 때 onClick 같은 이벤트 핸들러가 사라지는 겁니다.
그래서 Hydration이 필요하다
서버가 보낸 HTML은 껍데기입니다. 보이기만 할 뿐, 클릭해도 아무 일도 일어나지 않습니다.
이 상태로 두면 어떻게 될까요?
•
•
•
•
완전히 정적인 페이지가 됩니다. 신문 기사 프린트물처럼요.
이 껍데기에 이벤트 핸들러를 다시 연결해야 합니다. 이 과정을 Hydration(수화)이라고 부릅니다.
“수화”라는 표현은 직관적입니다. 직렬화 과정에서 “말라버린” HTML에 이벤트 핸들러라는 “수분”을 다시 채워넣는 것이죠.
서버 렌더링 HTML (정적, 이벤트 없음)
↓ [hydrate]
React 앱 (상호작용 가능)
Plain Text
복사
Hydration 구현하기
서버 코드부터 수정합니다. 클라이언트 JavaScript 번들을 로드하는 스크립트 태그를 추가해야 합니다.
// server.ts
import { Hono } from "hono";
import { renderToString } from "react-dom/server";
import App from "./App";
const app = new Hono();
app.get("/", (c) => {
const content = renderToString(<App />);
const html = `
<!DOCTYPE html>
<html>
<head>
<title>SSR with Hydration</title>
</head>
<body>
<div id="root">${content}</div>
<script type="module" src="/client.tsx"></script>
</body>
</html>
`;
return c.html(html);
});
export default app;
TypeScript
복사
그리고 클라이언트 진입점 파일을 만듭니다.
// client.tsx
import { hydrateRoot } from "react-dom/client";
import App from "./App";
hydrateRoot(document.getElementById("root")!, <App />);
TypeScript
복사
hydrateRoot가 하는 일은 이렇습니다:
1.
서버가 보낸 DOM을 찾음
2.
React 컴포넌트 트리를 메모리에 구축 (실제 DOM 생성은 안 함)
3.
기존 DOM 노드에 이벤트 핸들러를 연결
핵심은 DOM을 새로 만들지 않고 기존 DOM을 재사용한다는 점입니다. 그래서 화면 깜빡임 없이 인터랙션만 추가됩니다.
이제 버튼을 클릭하면 alert('클릭!')이 실행됩니다.
Hydration Mismatch
Hydration이 제대로 동작하려면 서버와 클라이언트의 렌더링 결과가 같아야 합니다. 다르면 어떻게 될까요?
function Clock() {
return <div>현재 시간: {new Date().toLocaleTimeString()}</div>;
}
TypeScript
복사
이 컴포넌트는 문제가 있습니다. 서버에서 렌더링할 때와 클라이언트에서 Hydration할 때 시간이 다르기 때문입니다.
서버 (12:30:45): <div>현재 시간: 12:30:45</div>
클라이언트 (12:30:47): <div>현재 시간: 12:30:47</div>
→ Mismatch!
Plain Text
복사
React는 이런 불일치를 감지하면 경고를 표시합니다. 불일치를 무시하면 이벤트 핸들러가 잘못된 요소에 연결되는 심각한 문제가 생길 수 있어요.
그래서 React는 불일치가 크면 기존 DOM을 버리고 전체를 다시 렌더링합니다. 이게 바로 “Hydration mismatch” 에러의 원인입니다.
[처음부터 다시 렌더링하면]
1. 서버가 보낸 HTML이 화면에 보임
2. JavaScript가 로드됨
3. 기존 DOM을 버리고 새로 렌더링
4. 화면이 깜빡이거나 레이아웃이 흔들림
Plain Text
복사
정상적인 Hydration은 이 문제를 피합니다. 기존 DOM 노드를 재사용하면서 이벤트 핸들러만 연결하기 때문에, 화면이 깜빡이지 않습니다. 사용자 입장에서는 HTML이 도착한 순간부터 콘텐츠가 보이고, JavaScript가 로드되면 자연스럽게 인터랙션이 가능해집니다.
대신 이 최적화가 동작하려면, 서버와 클라이언트의 렌더링 결과가 동일해야 합니다. 그래서 mismatch가 문제가 되는 것입니다.
Mismatch의 원인: 비결정적 값
Hydration mismatch가 발생하는 근본 원인은 비결정적 값(Non-deterministic Value)입니다. 같은 코드를 실행해도 매번 다른 결과가 나오는 값들이죠.
대표적인 비결정적 값:
•
new Date() - 실행 시점에 따라 다름
•
Math.random() - 매번 다른 값
•
crypto.randomUUID() - 매번 다른 ID
서버와 클라이언트는 다른 시점에, 다른 환경에서 실행됩니다. 그래서 이런 값들은 필연적으로 불일치를 일으킵니다.
해결 원칙: 비결정적 값은 서버에서 한 번 생성하고, 클라이언트에 전달해서 재사용합니다.
function Clock({ initialTime }: { initialTime: string }) {
const [time, setTime] = useState(initialTime);
useEffect(() => {
// Hydration 완료 후에만 시간 업데이트
const timer = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>현재 시간: {time}</div>;
}
TypeScript
복사
서버에서 초기 시간을 props로 전달하고, 클라이언트에서 Hydration이 완료된 후에만 시간을 업데이트하는 방식입니다.
정리: 직렬화 한계가 만든 구조
[근본 문제]
네트워크는 문자열만 전송할 수 있다
↓
함수는 직렬화할 수 없다
↓
이벤트 핸들러가 사라진다
↓
[해결책: Hydration]
클라이언트에서 이벤트 핸들러를 다시 연결한다
Plain Text
복사
이 직렬화 한계는 SSR 전반에 걸쳐 반복적으로 등장합니다. 프레임워크의 'use client' 같은 지시어도 결국 “이 코드는 클라이언트에서 실행되어야 한다”를 선언하는 것인데, 그 이유가 바로 이벤트 핸들러와 상태 때문입니다. 이 부분은 5장에서 더 자세히 다룹니다.
4-2. 라우팅
이제 인터랙션 문제는 해결했습니다. 다음 질문은 “URL이 여러 개면 어떻게 하지?”입니다.
HTML 보일러플레이트 추출
여러 페이지를 다루기 전에, 4-1에서 반복되던 HTML 템플릿을 헬퍼 함수로 추출하겠습니다.
// server.ts
function wrapHtml(content: string) {
return `
<!DOCTYPE html>
<html>
<body>
<div id="root">${content}</div>
<script type="module" src="/client.tsx"></script>
</body>
</html>
`;
}
TypeScript
복사
이제 라우트마다 이 함수를 재사용할 수 있습니다.
URL별로 다른 컴포넌트 렌더링
가장 직관적인 방법은 서버에서 URL에 따라 다른 컴포넌트를 렌더링하는 것입니다.
// server.ts
import Home from "./pages/Home";
import About from "./pages/About";
import NotFound from "./pages/NotFound";
app.get("/", (c) => {
const html = renderToString(<Home />);
return c.html(wrapHtml(html));
});
app.get("/about", (c) => {
const html = renderToString(<About />);
return c.html(wrapHtml(html));
});
app.get("*", (c) => {
const html = renderToString(<NotFound />);
return c.html(wrapHtml(html), 404);
});
TypeScript
복사
중복 코드 줄이기
위 코드에서 반복되는 패턴이 보입니다. renderToString → wrapHtml → c.html 흐름이죠. 헬퍼 함수로 추출할 수 있습니다.
// server.ts
function renderPage(c: Context, Component: React.FC, status = 200) {
const html = renderToString(<Component />);
return c.html(wrapHtml(html), status);
}
app.get("/", (c) => renderPage(c, Home));
app.get("/about", (c) => renderPage(c, About));
app.get("*", (c) => renderPage(c, NotFound, 404));
TypeScript
복사
동적 라우팅
/posts/1, /posts/2 같은 동적 URL은 어떻게 처리할까요? Hono에서는 :param 문법을 사용합니다.
// server.ts
app.get("/posts/:id", (c) => {
const id = c.req.param("id");
const html = renderToString(<Post id={id} />);
return c.html(wrapHtml(html));
});
TypeScript
복사
URL 파라미터를 추출해서 컴포넌트에 전달합니다. /posts/42 요청이 오면 id가 "42"가 됩니다.
// pages/Post.tsx
function Post({ id }: { id: string }) {
return <h1>Post #{id}</h1>;
}
TypeScript
복사
정리: 라우팅의 본질
결국 라우팅은 “URL을 분석해서 적절한 컴포넌트를 렌더링하는 것”입니다.
URL 요청 → URL 파싱 → 컴포넌트 선택 → 렌더링 → HTML 응답
Plain Text
복사
페이지가 많아지면 라우트 등록이 번거로워집니다. 프레임워크는 이 반복 작업을 자동화합니다 (파일 기반 라우팅 등). 하지만 내부적으로는 같은 원리입니다.
4-3. 데이터 페칭
HTML을 만들 때 데이터가 필요하면 어떻게 할까요?
렌더링 전에 데이터가 있어야 한다
function UserProfile({ user }: { user: User }) {
return <h1>{user.name}님의 프로필</h1>;
}
TypeScript
복사
SSR에서는 renderToString을 호출하는 시점에 데이터가 준비되어 있어야 합니다. 클라이언트에서 useEffect로 데이터를 가져오는 방식은 SSR에서 동작하지 않습니다.
해결책: 렌더링 전에 데이터 페칭
서버 핸들러에서 데이터를 먼저 가져오고, 그 데이터로 렌더링합니다.
// server.ts
app.get("/user/:id", async (c) => {
// 1. 먼저 데이터를 가져온다
const userId = c.req.param("id");
const user = await db.getUser(userId);
// 2. 데이터를 props로 전달하며 렌더링
const html = renderToString(<UserProfile user={user} />);
return c.html(wrapHtml(html));
});
TypeScript
복사
이 패턴이 SSR 데이터 페칭의 본질입니다:
요청 → 데이터 페칭 → 컴포넌트에 props 전달 → 렌더링 → HTML 응답
Plain Text
복사
직렬화의 제약은 여기도 적용된다
서버에서 가져온 데이터는 Hydration 과정에서 클라이언트로 전달됩니다. 따라서 전달하는 데이터도 직렬화 가능해야 합니다.
// ❌ 잘못된 예: 함수는 직렬화 불가
const userData = {
name: "홍길동",
onClick: () => console.log("클릭"), // 전달 불가!
};
// ❌ 잘못된 예: Date 객체는 문자열로 변환됨
const userData = {
name: "홍길동",
createdAt: new Date(), // 클라이언트에서는 문자열
};
// ✅ 올바른 예: 직렬화 가능한 값만
const userData = {
name: "홍길동",
createdAt: new Date().toISOString(), // 명시적으로 문자열
};
TypeScript
복사
데이터를 클라이언트에 전달하기
Hydration이 제대로 동작하려면, 서버에서 사용한 데이터를 클라이언트에도 전달해야 합니다. 그래야 클라이언트가 같은 상태로 시작할 수 있죠.
// server.ts
app.get("/user/:id", async (c) => {
const user = await db.getUser(c.req.param("id"));
const html = renderToString(<UserProfile user={user} />);
// 데이터를 스크립트 태그로 전달
const fullHtml = `
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(user)};
</script>
<script type="module" src="/client.tsx"></script>
</body>
</html>
`;
return c.html(fullHtml);
});
TypeScript
복사
클라이언트에서는 이 데이터를 사용해 Hydration합니다:
// client.tsx
const initialData = window.__INITIAL_DATA__;
hydrateRoot(
document.getElementById("root")!,
<UserProfile user={initialData} />
);
TypeScript
복사
이 패턴은 대부분의 SSR 프레임워크가 내부적으로 사용하는 방식입니다. 프레임워크는 이 과정을 추상화해서 개발자가 직접 window.__INITIAL_DATA__를 다루지 않아도 되게 해줍니다.
4-4. 정적 생성 (SSG)
지금까지 구현한 SSR은 요청이 올 때마다 렌더링합니다. 하지만 블로그 글처럼 내용이 자주 바뀌지 않는 페이지는 어떨까요?
요청 1: /blog/hello → renderToString() → HTML
요청 2: /blog/hello → renderToString() → HTML ← 같은 결과
요청 3: /blog/hello → renderToString() → HTML ← 계속 반복
Plain Text
복사
매번 똑같은 결과를 만드는 건 낭비입니다.
아이디어: 미리 렌더링해두기
정적 생성(Static Site Generation, SSG)의 핵심은 간단합니다. 빌드 시점에 HTML을 미리 만들어서 파일로 저장해두는 것입니다.
// build.ts - 빌드 스크립트
import { renderToString } from "react-dom/server";
import fs from "fs";
import BlogPost from "./pages/BlogPost";
// 빌드할 페이지 목록
const posts = [
{ id: "hello", title: "Hello World", content: "첫 번째 글입니다." },
{ id: "ssr", title: "SSR 이해하기", content: "SSR은..." },
];
// 각 페이지를 미리 렌더링
for (const post of posts) {
const html = renderToString(<BlogPost post={post} />);
const fullHtml = `
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(post)};
</script>
<script type="module" src="/client.tsx"></script>
</body>
</html>
`;
// 파일로 저장
fs.writeFileSync(`./dist/blog/${post.id}.html`, fullHtml);
}
console.log(`${posts.length}개의 페이지가 생성되었습니다.`);
TypeScript
복사
이 스크립트를 빌드 시점에 실행하면:
dist/
blog/
hello.html ← 미리 렌더링된 HTML
ssr.html ← 미리 렌더링된 HTML
Plain Text
복사
정적 파일 서빙
이제 서버는 렌더링할 필요 없이 파일만 보내면 됩니다.
// server.ts
import { Hono } from "hono";
import { serveStatic } from "hono/serve-static";
const app = new Hono();
// 정적 파일 서빙
app.use("/blog/*", serveStatic({ root: "./dist" }));
export default app;
TypeScript
복사
요청: /blog/hello
서버: dist/blog/hello.html 파일을 그대로 전송
Plain Text
복사
렌더링 비용이 0입니다. CDN에 올려두면 전 세계 어디서든 빠르게 응답할 수 있죠.
SSG의 한계
하지만 SSG에는 한계가 있습니다.
1.
빌드 시점에 모든 페이지를 알아야 한다: 동적으로 생성되는 페이지는 처리하기 어렵습니다.
2.
내용이 바뀌면 다시 빌드해야 한다: 글 하나 수정해도 전체 빌드가 필요합니다.
3.
빌드 시간이 길어질 수 있다: 페이지가 수천 개라면?
이 한계를 해결하는 방법이 ISR입니다.
4-5. 증분 정적 재생성 (ISR)
ISR(Incremental Static Regeneration)은 SSG의 장점은 유지하면서 단점을 보완합니다. 핵심 아이디어는:
1.
처음 요청 시 렌더링하고 캐시에 저장
2.
이후 요청은 캐시된 HTML 반환 (빠름)
3.
일정 시간이 지나면 백그라운드에서 재생성
직접 구현해보기
// server.ts
import { Hono } from "hono";
import { renderToString } from "react-dom/server";
import BlogPost from "./pages/BlogPost";
const app = new Hono();
// 캐시 저장소
const cache = new Map<string, { html: string; timestamp: number }>();
const REVALIDATE_SECONDS = 60; // 60초마다 재검증
app.get("/blog/:id", async (c) => {
const id = c.req.param("id");
const cacheKey = `/blog/${id}`;
const now = Date.now();
// 1. 캐시 확인
const cached = cache.get(cacheKey);
if (cached) {
const age = (now - cached.timestamp) / 1000;
if (age < REVALIDATE_SECONDS) {
// 캐시가 신선함 → 그대로 반환
return c.html(cached.html);
}
// 캐시가 오래됨 → 일단 반환하고, 백그라운드에서 재생성
regenerateInBackground(cacheKey, id);
return c.html(cached.html);
}
// 2. 캐시 없음 → 새로 렌더링
const html = await renderPage(id);
cache.set(cacheKey, { html, timestamp: now });
return c.html(html);
});
async function renderPage(id: string) {
const post = await fetchPost(id);
const content = renderToString(<BlogPost post={post} />);
return `
<!DOCTYPE html>
<html>
<body>
<div id="root">${content}</div>
<script>
window.__INITIAL_DATA__ = ${JSON.stringify(post)};
</script>
<script type="module" src="/client.tsx"></script>
</body>
</html>
`;
}
function regenerateInBackground(cacheKey: string, id: string) {
// 백그라운드에서 재생성 (요청을 블로킹하지 않음)
setImmediate(async () => {
const html = await renderPage(id);
cache.set(cacheKey, { html, timestamp: Date.now() });
console.log(`[ISR] ${cacheKey} 재생성 완료`);
});
}
TypeScript
복사
ISR의 동작 흐름
[첫 번째 요청] /blog/hello
→ 캐시 없음 → 렌더링 → 캐시 저장 → 응답
[두 번째 요청] (30초 후)
→ 캐시 있음 (30초 < 60초) → 캐시된 HTML 응답
[세 번째 요청] (70초 후)
→ 캐시 있음 (70초 > 60초, stale)
→ 캐시된 HTML 응답 (사용자는 기다리지 않음)
→ 백그라운드에서 재생성 시작
[네 번째 요청] (71초 후)
→ 새로 생성된 캐시 응답
Plain Text
복사
stale-while-revalidate 패턴입니다. 오래된 캐시라도 일단 빠르게 응답하고, 백그라운드에서 갱신합니다.
SSR vs SSG vs ISR
방식 | 렌더링 시점 | 장점 | 단점 |
SSR | 매 요청 | 항상 최신 데이터 | 서버 부하 |
SSG | 빌드 시 | 가장 빠름 | 업데이트 어려움 |
ISR | 첫 요청 + 주기적 | 빠름 + 업데이트 | 구현 복잡 |
어떤 방식이 좋은지는 페이지 특성에 따라 다릅니다:
•
실시간 데이터 (주식, 채팅) → SSR
•
거의 안 바뀌는 콘텐츠 (문서, 블로그) → SSG
•
가끔 바뀌는 콘텐츠 (상품 목록, 뉴스) → ISR
정리: 직접 구현으로 본 SSR
이번 장에서 다룬 내용을 정리하면 이렇습니다.
문제 | 해결책 | 직접 구현 핵심 |
이벤트가 안 되는데? | Hydration | hydrateRoot로 이벤트 핸들러 연결 |
URL이 여러 개면? | 라우팅 | URL별 다른 컴포넌트 렌더링 |
데이터가 필요하면? | 렌더링 전 페칭 | 핸들러에서 데이터 fetch → props로 전달 |
매번 렌더링이 비효율적 | SSG | 빌드 시 renderToString → 파일 저장 |
정적 파일 업데이트가 어려움 | ISR | 캐시 + TTL + 백그라운드 재생성 |
각각이 별개의 기능처럼 보이지만, 모두 “서버에서 HTML을 만들어서 보내는” 기본 원리 위에 쌓인 것들입니다.
하지만 직접 구현하다 보면 금방 한계를 느낍니다:
•
매번 라우트마다 같은 코드를 반복해야 하고
•
번들이 커지면 성능 문제가 생기고
•
이미지 최적화까지 직접 하려면 복잡해지고
•
에러 처리, 로딩 상태, 캐시 무효화 같은 엣지 케이스가 많아지고
다음 장에서는 프레임워크가 이런 문제들을 어떻게 해결하는지 살펴보겠습니다.
