Search

04-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
복사

직렬화의 제약은 여기도 적용된다

서버에서 가져온 데이터는 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
복사
head vs body: script 태그를 head에 넣으면 클라이언트 코드가 실행되기 전에 데이터가 준비돼요. body 끝에 넣어도 동작하지만, 순서 보장을 위해 head에 넣는 게 일반적이에요.

클라이언트에서 복원하기

클라이언트 코드가 실행될 때는 이미 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 태그 → 클라이언트에서 객체로 복원.