Search

03_SSR_직접_구현하기

SSR 직접 구현하기

SSR은 “서버에서 HTML을 만들어서 보내는 것”이에요. 특별한 기술이 아니라, 20년 전 PHP가 하던 일과 본질적으로 같아요.
이번 장에서는 이 단순한 원리를 직접 구현해볼게요. 프레임워크 없이, 가장 단순한 형태부터 시작해서 React를 얹는 것까지.

가장 단순한 SSR

Hono로 서버를 하나 만들어볼게요. Hono는 Express와 비슷한 Node.js 웹 프레임워크인데, 더 가볍고 TypeScript 지원이 좋아요. Express를 써봤다면 익숙할 거예요.
// server.ts import { Hono } from "hono"; const app = new Hono(); app.get("/", (c) => { const name = "Frontend Developer"; const html = ` <!DOCTYPE html> <html> <head> <title>SSR Example</title> </head> <body> <h1>Hello,${name}!</h1> <p>이 HTML은 서버에서 만들어졌습니다.</p> </body> </html> `; return c.html(html); }); export default app;
TypeScript
복사
Vite와 함께 실행하면 브라우저에서 http://localhost:5173에 접속했을 때 “Hello, Frontend Developer!”가 보여요.
이게 SSR이에요. 서버가 요청을 받으면 HTML 문자열을 만들어서 응답으로 보내는 것. 그게 전부예요.
코드를 보면 특별한 게 없어요. 템플릿 리터럴로 HTML 문자열을 조합하고, c.html()로 보내는 것뿐이죠. 여기서 name 변수를 데이터베이스에서 가져온 값으로 바꾸면? 동적인 SSR이 돼요.
app.get("/user/:id", async (c) => { // 데이터베이스에서 사용자 정보를 가져온다고 가정 const user = await db.getUser(c.req.param("id")); const html = ` <!DOCTYPE html> <html> <body> <h1>${user.name}님의 프로필</h1> <p>가입일:${user.createdAt}</p> </body> </html> `; return c.html(html); });
TypeScript
복사
요청이 들어올 때마다 데이터를 조회하고, 그 데이터로 HTML을 만들어서 보내는 거예요. 이게 동적 SSR의 전부예요.

React를 얹어보기

위 예제의 문제는 HTML을 문자열로 직접 조합해야 한다는 거예요. 간단한 페이지는 괜찮지만, 조건부 렌더링이나 반복 렌더링이 필요하면 코드가 금방 지저분해져요.
// 문자열로 조건부 렌더링 - 읽기 어렵다 app.get("/profile", (c) => { const user = { name: "홍길동", isPremium: true, posts: [] }; let html = "<div>"; html += "<h1>" + user.name + "</h1>"; if (user.isPremium) { html += '<span style="color: gold;">⭐ 프리미엄 회원</span>'; } else { html += "<span>일반 회원</span>"; } html += "<ul>"; if (user.posts.length > 0) { for (let i = 0; i < user.posts.length; i++) { html += "<li>" + user.posts[i].title + "</li>"; } } else { html += "<li>작성한 게시물이 없습니다</li>"; } html += "</ul>"; html += "</div>"; return c.html(html); });
TypeScript
복사
그래서 React를 써요. React는 컴포넌트 단위로 UI를 선언적으로 작성할 수 있게 해주니까요. 그리고 React는 react-dom/server라는 패키지를 통해 서버에서도 동작해요.
Vite는 서버 사이드에서도 JSX/TSX를 바로 해석할 수 있으므로, 빌드 설정 없이 바로 JSX를 사용할 수 있어요.
// App.tsx function App({ name }: { name: string }) { return ( <div> <h1>Hello, {name}!</h1> <p>HTML은 서버에서 만들어졌습니다.</p> </div> ); } export default App;
TypeScript
복사
// server.ts import { Hono } from "hono"; import { renderToString } from "react-dom/server"; import App from "./App"; const app = new Hono(); app.get("/", (c) => { const name = "Frontend Developer"; // React 컴포넌트를 HTML 문자열로 변환 const content = renderToString(<App name={name} />); const html = ` <!DOCTYPE html> <html> <head> <title>SSR Example</title> </head> <body> <div id="root">${content}</div> </body> </html> `; return c.html(html); }); export default app;
TypeScript
복사
핵심은 renderToString 함수예요. 이 함수가 하는 일은 단순해요. React 컴포넌트를 받아서 HTML 문자열을 반환하는 것. 그게 전부예요.
<App name={name} /> → renderToString() → '<div><h1>Hello, ...</h1>...</div>'
Plain Text
복사
앞서 문자열을 직접 조합했던 것과 결과는 같아요. 다만 React의 컴포넌트 시스템을 활용할 수 있게 된 것뿐이죠.

정리: 서버에서 HTML 만들어서 보낸다

SSR의 본질을 다시 정리하면 이래요.
1.
클라이언트가 서버에 페이지를 요청한다
2.
서버가 HTML을 생성한다 (문자열 조합이든, renderToString이든)
3.
생성된 HTML을 응답으로 보낸다
4.
브라우저가 HTML을 받아서 화면에 표시한다
[요청] → [서버: HTML 생성] → [응답] → [브라우저: 화면 표시]
Plain Text
복사
Next.js의 getServerSideProps도, Remix의 loader도, Nuxt의 asyncData도 결국 이 흐름 안에서 동작해요. 2번 단계에서 데이터를 가져오는 방법, 3번 단계에서 캐싱하는 방법 등이 다를 뿐이지, 기본 원리는 같아요.
프레임워크가 복잡해 보이는 건 이 단순한 과정 위에 여러 기능을 얹었기 때문이에요.
URL에 따라 다른 컴포넌트를 렌더링해야 하니까 → 라우팅
HTML 만들기 전에 데이터를 가져와야 하니까 → 데이터 페칭
클라이언트에서도 인터랙션이 되어야 하니까 → Hydration
JavaScript 번들이 너무 크면 안 되니까 → 코드 스플리팅
SSR에 기능 더하기에서 이 기능들을 하나씩 붙여보면서, 프레임워크가 왜 그런 식으로 설계되었는지를 알아볼 수 있어요.

서버 환경의 특성

SSR 코드를 작성할 때 한 가지 중요한 차이를 알아야 해요. 서버는 여러 사용자의 요청을 동시에 처리해요.
브라우저에서는 한 사용자만 있어요. 전역 변수를 써도 그 사용자만 영향을 받죠. 서버는 달라요. 하나의 서버 프로세스가 수천 명의 요청을 동시에 처리해요.

요청 격리 (Request Isolation)

// ❌ 위험: 전역 상태 let currentUser = null; app.get("/profile", (c) => { currentUser = getUserFromSession(c); // 사용자 A 저장 // 이 순간 다른 요청이 currentUser를 덮어쓸 수 있음! return c.html(renderProfile(currentUser)); // 사용자 B의 프로필이 보일 수도 }); // ✅ 안전: 요청 스코프 내에서만 사용 app.get("/profile", (c) => { const currentUser = getUserFromSession(c); // 이 요청에만 유효 return c.html(renderProfile(currentUser)); });
TypeScript
복사
서버에서 전역 변수를 쓰면 다른 사용자의 데이터가 섞일 수 있어요. 항상 요청 스코프 내에서 데이터를 다뤄야 해요.

흔한 실수: QueryClient 전역 선언

CSR/SPA 환경에서만 개발해본 사람들이 자주 하는 실수가 있어요. React Query의 QueryClient를 전역 스코프에 선언하는 거예요.
// ❌ 절대 이렇게 하면 안 돼요 const queryClient = new QueryClient(); export default function App() { return ( <QueryClientProvider client={queryClient}> <MyComponent /> </QueryClientProvider> ); }
TypeScript
복사
CSR에서는 이게 정상적으로 동작해요. 브라우저에서는 한 사용자만 있으니까요.
하지만 SSR 환경에서는 치명적인 보안 문제가 돼요:
캐시가 모든 요청 간에 공유됨
사용자 A의 데이터가 사용자 B에게 보임
민감한 데이터 유출 가능
TanStack Query 공식 문서에서도 이렇게 경고해요:
Creating the queryClient at the file root level makes the cache shared between all requests and means all data gets passed to all users. Besides being bad for performance, this also leaks any sensitive data.
올바른 방법은 요청마다 새로운 QueryClient를 생성하는 거예요:
// ✅ 안전: 요청마다 새로 생성 export default function App() { const [queryClient] = useState( () => new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, }, }, }) ); return ( <QueryClientProvider client={queryClient}> <MyComponent /> </QueryClientProvider> ); }
TypeScript
복사
useState의 초기화 함수를 사용하면 컴포넌트가 마운트될 때 한 번만 QueryClient가 생성되고, 각 요청마다 별도의 인스턴스가 만들어져요.
핵심: 서버에서 전역 상태를 쓰면 다른 사용자의 데이터가 섞일 수 있어요. 상태는 항상 요청 스코프 내에서 관리해야 해요.

그런데 한 가지 문제가 있다

방금 만든 예제에 버튼을 하나 추가해볼게요.
// App.tsx function App({ name }: { name: string }) { const handleClick = () => { alert("클릭!"); }; return ( <div> <h1>Hello, {name}!</h1> <button onClick={handleClick}>클릭해보세요</button> </div> ); }
TypeScript
복사
서버를 실행하고 브라우저에서 버튼을 클릭해보면… 아무 일도 일어나지 않아요.
브라우저 개발자 도구에서 HTML 소스를 확인해보면 이유를 알 수 있어요.
<div> <h1>Hello, Frontend Developer!</h1> <button>클릭해보세요</button> <!-- onClick이 없다! --> </div>
HTML
복사
onClick 핸들러가 사라졌어요. 왜 그럴까요?
이 질문에 답하려면 “직렬화”라는 개념을 이해해야 해요. 서버에서 클라이언트로 데이터를 보내려면 문자열로 변환해야 하는데, JavaScript 함수는 문자열로 변환할 수 없거든요.
04-1_Hydration에서 이 문제를 해결하는 방법을 다뤄요.