본문으로 바로가기

화면 표현을 클라이언트에게 모두 전가하느냐(CSR), 아니면 서버가 어느정도 개입하느냐(SSR) 딱 이 차이다. 이걸 평소에 쉽게 하려고 Next.js를 써왔는데 그런데 그걸 Next.js 없이 React에서 해보자. 이겁니다.

 

일반 CSR의 경우

CRA를 생성한 후, postman으로 get 명령을 날려보았습니다. 보시듯, root 내부에 아무런 DOM도 생성되지 않았고, javascript 번들만 추가된 것을 확인하실 수 있습니다. 검색 크롤러의 입장에서는 get 명령을 날렸더니 서버에서 이렇게 빈 dom을 주니 어떤 내용이 있는지 알 수가 없는거죠.

 

이런 경우 root 아이디를 가진 DOM을 찾아서 render함으로 끝납니다.

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(<App />, document.getElementById("root"));

 

ReactDOMServer

 

ko.reactjs.org/docs/react-dom-server.html

 

ReactDOMServer – React

A JavaScript library for building user interfaces

ko.reactjs.org

ReactDOMServer 객체를 통해 컴포넌트를 정적 마크업으로 렌더링할 수 있습니다. 대체로 이것은 Node 서버에서 사용됩니다.

const ReactDOMServer = require("react-dom/server");

 

다음 메서드는 서버와 브라우저 환경에서 사용할 수 있습니다. 딱 메서드가 2개 밖에 없네요.

다음 추가 메서드는 서버에서만 사용할 수 있는 stream 패키지에 의존성이 있어 브라우저에서는 작동하지 않습니다.

즉, SSR 처리하는 서버에서만 사용하라는 거네요.

여기서는 renderToString외에는 사용할 이유가 없습니다.

 

renderToStaticMarkup도 SSR에 이용되지만 interactive하지 않은, 단순 마크업을 해주는 메서드이므로 여기서는 사용하지 않겠습니다. 앞으로도 별로 사용할 일이 없을 겁니다. react-dataroot 같은 속성을 덜 만들어준다고 이걸 사용하기보다 그냥 renderToString을 쓰는 것이 좋습니다.

 

import { renderToString } from "react-dom/server";
renderToString(element)

 

공식 문서의 설명을 가져오자면 renderToString의 역할을 다음과 같습니다.

 

renderToString는 React 엘리먼트의 초기 HTML을 렌더링합니다. React는 HTML 문자열을 반환합니다. 빠른 페이지 로드를 위해 초기 요청 시에 서버에서 HTML을 생성하여 마크업을 보내거나, 검색 엔진 최적화를 위해 검색 엔진이 페이지를 크롤링할 수 있도록 하는데 사용할 수 있습니다.

 

이미 서버 렌더링 된 마크업이 있는 노드에서 ReactDOM.hydrate()를 호출할 경우 React는 이를 보존하고 이벤트 핸들러만 연결함으로써 매우 뛰어난 첫 로드 성능을 보여줍니다.

 

 

renderToString를 이용해 간단한 SSR을 구현해보자

* babel 세팅 등은 생략하도록하겠습니다. jsx 때문에 어차피 babel 세팅은 해야 합니다.

github.com/satansdeer/ssr-example 여기를 참고하십시오.

 

 

SSR의 순서는 이렇습니다.

1. 우선 React 프로젝트를 빌드한 후,

2. 빌드 결과물을 정적 파일로 서버에서 제공하되

3. 서버에서 서빙할 때 renderToString으로 React 엘리먼트의 초기 HTML을 렌더링하고 그 내용물을 hydrate하면 됩니다.

이 순서 어딘가 익숙하지 않습니까? Next에서도 프로젝트를 시작하면, 내용물을 먼저 필드한후 서버로 제공하잖습니까? 바로 그 순서입니다.

 

다만, 여기서는 SSR의 개념을 이해하기 위한 포스팅이므로 단순히 빌드 없이 진행하겠습니다.

express에서 정적 파일 서빙하는 것처럼 그대로 해주는데, 단지 renderToString을 통해 <App /> 컴포넌트를 html 문자열로 보내는 겁니다.

import React from "react";
import { renderToString } from "react-dom/server";
import express from "express";
import App from "../src/App";

const app = express();
const PORT = 3005;

app.get("/", (req, res) => {
  res.send(`
      <!DOCTYPE html>
      <html lang="en">
        <head>
          <title>React App</title>
        </head>
        <body>
          <div id="root">${renderToString(<App />)}</div>
          <div>hello from server side</div>
        </body>
      </html>
  `);
});

app.listen(PORT, () => console.log(`http://localhost:${PORT}`));

 

그리고 renderToString를 통해 변환된 html을 hydrate하기위해 아래와 같이 해줍시다.

ReactDOM.hydrate(<App />, document.getElementById("root"));

 

get 요청을 날렸을 때 이제, ssr에서 지정한 내용이 함께 렌더됩니다.

 

여기서 data-reactroot는 React에서 내부적으로 사용하는 추가적인 DOM 속성입니다.

만약 renderToStaticMarkup를 사용했다면 이런 속성이 붙지 않지만, interactive해지지 않습니다. 단순 마크업만을 위해 renderToStaticMarkup를 사용합시다.

 

 

여기서, 실제 프로덕트에서 다양한 경로마다 server 단에서 다른 내용을 정적 서빙하면 된다는 아이디어를 얻을 수 있습니다! 그리고 코드 스플리팅과 맞물리면 꽤나 힘들어질 것 또한 예측할 수 있습니다.

 

 

* hydrate는 뭐야?

기존에 서버 사이드 렌더링된 결과물이 있을 경우 새로 렌더링하지 않고 기존에 존재하는 UI에 이벤트만 연동하여 애플리케이션을 초기 구동할 때 필요한 리소스를 최소화함으로써 성능을 최적화해줍니다. - 출처 : 리액트를 다루는 기술

네. Next에서도 server와 client 간에 내용이 불일치한다고 hydrate 관련 에러가 발생해서 공부한 내용과 일치하네요.

조금 더 알아보기 위해 공식 문서를 봅시다.

 

ko.reactjs.org/docs/react-dom.html#hydrate

 

ReactDOM – React

A JavaScript library for building user interfaces

ko.reactjs.org

render()와 동일하지만 HTML 콘텐츠가 ReactDOMServer로 렌더링 된 컨테이너에 이벤트를 보충하기 위해 사용됩니다. React는 기존 마크업에 이벤트 리스너를 연결합니다.

 

React는 렌더링 된 콘텐츠가 서버와 클라이언트 간에 같을 것으로 예상합니다. React가 텍스트 콘텐츠의 차이를 고칠 수는 있지만 이러한 불일치를 버그로 취급하여 고쳐야 합니다. 개발 모드에서 React는 이벤트 보충 중 발생하는 불일치에 대해 경고합니다. 불일치가 발생하는 경우에 어트리뷰트 차이를 고친다는 보장이 없습니다. 대다수의 어플리케이션에서 불일치가 발생하는 경우는 많지 않으며 발생하는 경우 모든 마크업을 검증하는 것이 매우 큰 비용을 수반하기 때문에 성능상의 이유로 중요한 문제입니다.

 

서버와 클라이언트 사이에서 단일 엘리먼트의 어트리뷰트나 텍스트가 불가피하게 다르다면(예를 들어 timestamp의 경우) 그 엘리먼트에 suppressHydrationWarning={true}를 추가하는 것으로 경고를 끌 수 있습니다. 이는 한 단계까지만 작동하며 의도된 해결책입니다. 절대 남용하지 마세요. 텍스트가 아니라면 React는 해당 엘리먼트를 고치지 않을 것이며 이후의 업데이트까지 일치하지 않은 채로 남아있을 것입니다.

 

서버와 클라이언트 간의 차이를 의도한다면 2단계 렌더링을 사용할 수 있습니다. 클라이언트에서 다르게 렌더링 되는 컴포넌트는 componentDidMount()에서 true로 설정할 수 있는 this.state.isClient와 같은 상태 변수를 읽을 수 있습니다. 이 방식으로 초기 렌더 단계는 서버와 같은 콘텐츠를 렌더링하여 불일치를 방지하지만, 이벤트 보충 직후에 추가적인 단계가 동기적으로 발생합니다. 이 방식은 컴포넌트를 두 번 렌더링하게 만들어 속도를 느리게 할 수 있기 때문에 주의를 기울여야 합니다.

 

느린 연결에서의 사용자 경험에 유의해야 합니다. JavaScript 코드는 최초 HTML 렌더링보다 매우 늦게 로드될 수 있으며 만약 클라이언트 전용 단계에서 다른 무언가를 렌더링한다면 그 전환 과정에서 방해를 받을 수 있습니다. 그러나 정상적으로 실행된다면 서버에 어플리케이션 “shell”을 렌더링하고 클라이언트에서 일부 추가 위젯만 표시하는 것이 효과적일 수 있습니다. 마크업 불일치 문제없이 이 방식을 사용하길 원한다면 이전 단락의 설명을 참고해주세요.

 

 

 

추가 정보들)

 

관련 글 + 예시

medium.com/better-programming/demystifying-reacts-server-side-render-de335d408fe4 

github.com/alexnm/react-ssr

 

쉽게 사용할 수 있는 boilerplate 참고!

github.com/manuelbieh/react-ssr-setup

 

manuelbieh/react-ssr-setup

React Starter Project with Webpack 4, Babel 7, TypeScript, CSS Modules, Server Side Rendering, i18n and some more niceties - manuelbieh/react-ssr-setup

github.com

 


darren, dev blog
블로그 이미지 DarrenKwonDev 님의 블로그
VISITOR 오늘 / 전체