이번 장에서는 SSR을 직접 구현해봅니다. 프레임워크 없이, 가장 단순한 형태부터 시작해서 React를 얹는 것까지. 이 과정을 통해 SSR이 특별한 기술이 아니라 “서버에서 HTML을 만들어서 보내는 것”에 불과하다는 걸 확인할 수 있습니다.
가장 단순한 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 문자열을 만들어서 응답으로 보내는 것. 20년 전 PHP가 하던 일과 본질적으로 같습니다.
코드를 보면 특별한 게 없습니다. 템플릿 리터럴로 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을 문자열로 직접 조합해야 한다는 겁니다. 간단한 페이지는 괜찮지만, 복잡한 UI를 만들려면 문자열 조합이 금방 지저분해집니다. 조건부 렌더링, 반복 렌더링 같은 것들을 문자열로 처리하려면 코드가 엉망이 되죠.
// 문자열로 조건부 렌더링 - 읽기 어렵다
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 번들이 너무 크면 안 되니까 → 코드 스플리팅
다음 장에서는 이 기능들을 하나씩 붙여보면서, 프레임워크가 왜 그런 식으로 설계되었는지를 알아보겠습니다.
그런데 한 가지 문제가 있다
방금 만든 예제에 버튼을 하나 추가해보겠습니다.
// 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 함수는 문자열로 변환할 수 없기 때문입니다.
다음 장에서 이 문제를 해결하는 방법인 Hydration을 다루겠습니다.
