React SSR은 대체로 Next.js 프레임웍이 담당하는 추세이다. 하지만 좀 더 라이트하게 SSR을 하고 싶을 땐 어떻게 할까?
Next.js는 거대 프로젝트인 만큼 모두의 니즈에 맞출 수는 없으며, 대체로 엔터프라이즈용 기능이 out-of-the-box 탑재되어있다.
소프트웨어의 복잡도와 성능(컴퓨팅 속도의)은 상충관계이므로 가벼운 용도의 SSR 서버를 직접 만들어 보는 것도 좋은 접근일 것이다.
그렇다면 React SSR은 어떻게 설계해야 할까? 먼저 React의 작동 원리부터 생각해보자.
React Virtual DOM
Virtual DOM은 의미 그대로 가상으로 DOM을 관리하겠다는 말이다. 리액트 앱은 Virtual DOM을 메모리에서 관리하면서 상태 변화(diff)를 감지하여 브라우저 UI에 반영시킨다.
그런데 이 가상 DOM이 메모리에서 관리된다면, 브라우저가 아닌 서버 메모리에서 생성해도 될 것이다.
즉, 요청으로 받은 상태를 기반으로 서버 메모리에서 가상 DOM을 만들고, 이를 기반으로 한 HTML를 응답으로 보낸다면, 사용자는 SSR된 리액트 앱을 사용할 수 있다.
이것이 React SSR의 기본 개념이다.
사실 이 방식은 흔히 쓰이는 템플릿엔진을 이용한 SSR과 같고, 리액트를 템플릿으로 하여 만들어진 DOM을 HTML에 주입시키는 것으로 보면 된다.
이 과정을 그럼 브라우저와 서버의 통신 과정에서 정리해보자.
먼저, 브라우저가 서버에 요청을 보내면, 서버는 브라우저가 제공하는 정보(헤더, 상태 등)을 기반으로 가상DOM을 만든다.
이 DOM은 다음과 같이 서버에서 렌더된 후 그대로 HTML 템플릿에 주입되어 보내진다.
// Express.js 서버에서 React SSR을 만드는 과정
const App = <h1>Hello World!</h1>;
const content = renderToString(App); // 가상 DOM을 렌더링 후 string 반환
// 렌더링이 완료된 리액트 요소를 템플릿에 주입
const template = (content) => `
<html>
<body>
<div id="app">
${content}
</div>
<script src="bundle.js"></script>
</body>
</html>
`;
res.send(content); // Express.js response 사용을 가정한다
리액트는 SSR을 염두해
ReactDOMServer.renderToString
등의 메서드를 제공하며, 이는 렌더링된 HTML string을 반환한다. (클라이언트의ReactDOM.render
을 대체한다)
그 후 실제 브라우저가 받는 응답은 다음과 같다.
<html>
<body>
<div id="app">
<h1>Hello World!</h1>
<div>
</body>
<script src="bundle.js"></script>
</html>
보다시피 리액트가 성공적으로 렌더링 된 것을 볼 수 있다!
그런데 <script>
번들은 어떻게 만들까?
아무리 서버에서 렌더링이 완료된 HTML를 가져왔어도, interactive한 UI를 사용하려면 당연히 JavaScript가 필요하다.
필수 패키지들을 효율적으로 번들링하여 가져올 수는 있지만, 이 번들에 리액트를 어떻게 포함시켜야 하는지가 관건이다.
즉, 리액트가 제대로 작동하기 위해 리액트 의존성을 번들링하는 것은 문제가 없지만 리액트 컴포넌트를 어떻게 관리할 것이냐에 대한 고민이 생긴다.
Isomorphic App
React SSR을 개발할 때 isomorphic 구조로 컴포넌트를 관리하는 것은 필수적이다.
Isomorohic의 사전적 의미는 '동일'이며 isomorphic 리액트 앱이라 함은 서버와 클라이언트의 컴포넌트 구조를 동일하게 관리하는 형태를 일컫는다.
이렇게 동일 구조를 유지하면 클라이언트에서 <script>
번들의 리액트앱을 렌더링 할 경우 ReactDOM
은 이미 Paint가 완료된 SSR의 HTML과 번들의 가상DOM을 비교하여 리액트JS를 바인딩(또는 hydration)한다.
hydration 용도로 클라이언트는
ReactDOM.render
대신ReactDOM.hydrate
를 사용한다.
이 때, isomorphic하게 앱을 관리하지 않는다면 리액트는 진상을 부릴 것이고, 우리의 뜻대로 리액트가 바인딩되지 않을 것이다.
// 클라이언트의 리액트
const App = () => {
// handler와 같은 JS 요소들이 hydration을 통해 corresponding component에 바인딩된다.
const handler = () => {
console.log('hydration success!');
};
return (
<>
<div>
<h1>Misplaced Component</h1>
<button onClick={handler}>Click Me!</button>
</div>
</>
);
};
ReactDOM.hydrate(App, document.getElementById('app'));
// 서버의 리액트
// 클라이언트와 구조가 다르다
const App = (
<>
<h1>Misplaced Component</h1>
<div>
<button>Click Me!</button>
</div>
</>
);
const content = renderToString(App);
res.send(content);
위의 예시와 같이 클라이언트와 서버의 리액트 구조가 다르면 경우 컴포넌트(Virtual DOM 포함)가 다시 생성되거나 구조를 멋대로 해석해 기능이 제대로 작동하지 않는다.
미스매치에 대한 ReactDOM.hydrate
의 대응은 safeguard로써 존재하지만, 이런 버그를 그냥 놔둔다면 퍼포먼스는 더 악화되며 SSR을 사용하는 의미가 없다.
SSR의 목표는 JS요청을 기다리기 전에 빠르게 HTML을 Paint하겠다는 것이다.
응답받은 HTML이 다시 렌더링 된다면 의미가 퇴색된다.
위 그림에서 브라우저는 첫 번째 요청↔응답에서 바로 마크업을 받아볼 수 있다.
그 다음 요청인 <script>
번들(bundle.js)은 대체로 HTML보다 사이즈가 훨씬 크다. 느린 네트워크 환경에선 먼저 렌더링된 UI부터 보는 것이 사용자경험에 이로울 것이다.
최종적으로 서버는 알맞은 번들을 보내고, 클라이언트는 React app을 바인딩하여 interactive한 기능을 사용할 수 있다.
Top comments (0)