데이터 페칭
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
복사
직렬화의 제약은 여기도 적용된다
서버에서 가져온 데이터는 HTML에 포함되어 클라이언트로 전달되고, Hydration 과정에서 복원되어 사용돼요. 따라서 전달하는 데이터도 직렬화 가능해야 해요.
// ❌ 잘못된 예: 함수는 직렬화 불가
const userData = {
name: "홍길동",
onClick: () => console.log("클릭"), // 전달 불가!
};
// ❌ 잘못된 예: Date 객체는 문자열로 변환됨
const userData = {
name: "홍길동",
createdAt: new Date(), // 클라이언트에서는 문자열
};
// ✅ 올바른 예: 직렬화 가능한 값만
const userData = {
name: "홍길동",
createdAt: new Date().toISOString(), // 명시적으로 문자열
};
TypeScript
복사
데이터를 클라이언트에 전달하기
Hydration이 제대로 동작하려면, 서버에서 사용한 데이터를 클라이언트에도 전달해야 해요. 그래야 클라이언트가 같은 상태로 시작할 수 있죠.
왜 script 태그인가?
서버에서 클라이언트로 데이터를 전달하는 방법은 제한되어 있어요. 둘은 네트워크로 분리되어 있고, 유일한 연결고리는 서버가 보내는 HTML 응답이에요.
서버의 JavaScript 객체 → ??? → 클라이언트의 JavaScript 객체
Plain Text
복사
이 물음표를 채울 수 있는 건 HTML에 포함된 문자열뿐이에요. HTML은 문자열이니까요.
// 서버에서: 객체를 JSON 문자열로 변환
const user = { name: "홍길동", id: 123 };
const jsonString = JSON.stringify(user);
// → '{"name":"홍길동","id":123}'
TypeScript
복사
이 문자열을 HTML 어딘가에 넣어야 해요. <script> 태그가 자연스러운 선택이에요. 브라우저가 HTML을 파싱할 때 script 태그 안의 코드를 실행하니까요.
전체 흐름: 직렬화 → 전달 → 복원
┌─────────────────────────────────────────────────────────────────┐
│ 서버 │
│ │
│ { name: "홍길동" } │
│ │ │
│ ▼ JSON.stringify() │
│ '{"name":"홍길동"}' │
│ │ │
│ ▼ HTML 문자열에 삽입 │
│ <script>window.__DATA__ = {"name":"홍길동"}</script> │
│ │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTP 응답 (HTML 문자열)
▼
┌─────────────────────────────────────────────────────────────────┐
│ 클라이언트 (브라우저) │
│ │
│ HTML 파싱 → <script> 태그 발견 → JavaScript 실행 │
│ │ │
│ ▼ window.__DATA__에 객체 할당됨 │
│ { name: "홍길동" } ← 다시 JavaScript 객체! │
│ │
└─────────────────────────────────────────────────────────────────┘
Plain Text
복사
여기서 핵심은: JSON.stringify()로 직렬화한 문자열이 script 태그 안에서 그대로 JavaScript 리터럴로 해석된다는 점이에요. 클라이언트에서 별도로 JSON.parse()를 호출할 필요가 없어요.
구현 예시
// server.ts
app.get("/user/:id", async (c) => {
const user = await db.getUser(c.req.param("id"));
const html = renderToString(<UserProfile user={user} />);
// 데이터를 script 태그로 HTML에 삽입
const fullHtml = `
<!DOCTYPE html>
<html>
<head>
<script>
// JSON.stringify 결과가 여기에 들어감
// 브라우저가 이 script를 실행하면 window에 객체가 할당됨
window.__INITIAL_DATA__ = ${JSON.stringify(user)};
</script>
</head>
<body>
<div id="root">${html}</div>
<script type="module" src="/client.tsx"></script>
</body>
</html>
`;
return c.html(fullHtml);
});
TypeScript
복사
클라이언트에서 복원하기
클라이언트 코드가 실행될 때는 이미 window.__INITIAL_DATA__에 객체가 들어있어요.
// client.tsx
// 별도의 파싱 없이 바로 사용 가능
const initialData = window.__INITIAL_DATA__;
hydrateRoot(
document.getElementById("root")!,
<UserProfile user={initialData} />
);
TypeScript
복사
서버에서 JSON.stringify(user)의 결과가 {"name":"홍길동","id":123}이라면, HTML에는 이렇게 들어가요:
<script>
window.__INITIAL_DATA__ = {"name":"홍길동","id":123};
</script>
HTML
복사
브라우저가 이 script를 실행하면, {"name":"홍길동","id":123} 부분이 JavaScript 객체 리터럴로 해석되어 window.__INITIAL_DATA__에 할당돼요. JSON 문법과 JavaScript 객체 리터럴 문법이 호환되기 때문에 가능한 거예요.
보안 고려사항: XSS 방지
사용자 입력이 포함된 데이터를 JSON.stringify()로 그대로 넣으면 XSS 공격에 취약해요.
// ❌ 위험: 사용자가 입력한 name에 악성 스크립트가 있다면?
const user = { name: "</script><script>alert('XSS')</script>" };
const html = `<script>window.__DATA__ = ${JSON.stringify(user)}</script>`;
// 결과: </script>가 script 태그를 닫아버림!
TypeScript
복사
안전하게 처리하려면 특수 문자를 이스케이프해야 해요:
function safeStringify(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, "\\u003c") // < 이스케이프
.replace(/>/g, "\\u003e") // > 이스케이프
.replace(/&/g, "\\u0026"); // & 이스케이프
}
// ✅ 안전한 방식
const html = `<script>window.__DATA__ = ${safeStringify(user)}</script>`;
TypeScript
복사
프레임워크들은 이런 보안 처리를 내부적으로 해줘요.
프레임워크는 이걸 추상화한다
이 패턴은 대부분의 SSR 프레임워크가 내부적으로 사용하는 방식이에요. 프레임워크는 이 과정을 추상화해서 개발자가 직접 window.__INITIAL_DATA__를 다루지 않아도 되게 해줘요.
// Next.js의 getServerSideProps가 하는 일
export async function getServerSideProps() {
const user = await db.getUser(id);
return { props: { user } }; // 프레임워크가 직렬화와 전달을 처리
}
// Remix의 loader가 하는 일
export async function loader() {
const user = await db.getUser(id);
return json(user); // 프레임워크가 직렬화와 전달을 처리
}
TypeScript
복사
내부적으로는 모두 같은 원리예요: JSON.stringify → HTML script 태그 → 클라이언트에서 객체로 복원.
