main

리액트 딥다이브 살펴보기 VIII

Log
12

이번 글은 4-2장을 읽고 정리하는 포스팅입니다.
next.js가 렌더링하는 과정에 대한 블로그나 아티클을 찾아보았을 때, renderToString이나 다른 리액트 API를 보았을 때, 이게 어떤 API?인가에 대한 의문점이 있었는데, 이번 글을 읽고 확실히 이해할 수 있었습니다.

먼저, 리액트는 리액트 애플리케이션을 서버 환경에서 렌더링할 수 있는 API를 제공하고 있는데요, 이 API들은 window환경에서 실행하면 에러가 발생할 수 있습니다.

📌 renderToString

import ReactDOMServer from 'react-dom/server';
 
function ChildrenComponent({ fruits }: { fruits: Array<string> }) {
  useEffect(() => {
    console.log(fruits);
  }, [fruits]);
 
  function handleClick() {
    console.log('hello');
  }
 
  return (
    <ul>
      {fruits.map((fruit)=> (
        <li key={fruit} onClick={handleClick}>{fruit}</li>
      ))}
    </ul>
  )
}
 
function SampleComponent() {
  return (
    <>
      <div>hello</div>
      <ChildrenComponent fruits={['apple', 'banana', 'peach']}/>
    </>
  )
}
 
const result = ReactDOMServer.renderToString(
  React.createElement('div', {id: root}, <SampleComponent />),
)

위 예제의 결과는 아래와 같습니다.

<div id="root" data-reactroot="">
  <div>hello</div>
  <ul>
    <li>apple</li>
    <li>banana</li>
    <li>peach</li>
  </ul>
</div>

위 예제는 renderToString을 사용해서 실제 브라우저가 그려야할 HTML 결과로 만들어 낸 모습입니다. 눈여겨봐야할 것은 ChildrenComponent에 있는 useEffect와 같은 훅과 handleClick과 같은 이벤트 핸들러는 결과물에 포함되지 않았다는 것입니다. 이것은 의도된 것으로 renderToString은 인수로 주어진 리액트 컴포넌트를 기준으로 빠르게 브라우저가 렌더링할 수 있는 HTML을 제공하는 데 목적이 있는 함수일 뿐입니다. 따라서 클라이언트에서 실행되어야하는 자바스크립트 코드를 포함시키거나 렌더링하는 역할까지 해주지는 않습니다.

필요한 자바스크립트 코드는 여기에서 생성된 HTML코드와 별도로 제공해 브라우저에 제공돼야 합니다.
따라서 결론적으로 renderToString을 사용하면 서버사이드의 이점, 클라이언트에서 실행되지 않고 먼저 완성된 HTML을 서버에 제공할 수 있으므로 초기 렌더링에서 뛰어난 성능을 보일 수 있습니다. 또한 검색 엔진이나 SNS 공유를 위한 메타 정보도 renderToString에서 미리 준비한 채로 제공할 수 있습니다.

이외에 자바스크립트 코드들 (훅이나 이벤트 핸들러)는 별도로 클라이언트쪽에 모두 다운로드, 파싱, 실행하는 과정을 거쳐야합니다.


📌 renderToStaticMarkup

renderToString와 매우 유사한 함수지만, data-reactroot와 같은 리액트에서만 사용하는 추가적인 DOM속성을 만들지 않는다는 점에서 차이가 존재합니다.

const result = ReactDOMServer.renderToStaticMarkup(
  React.createElement('div', {id: root}, <SampleComponent />),
)
 
<div id="root">
  <div>hello</div>
  <ul>
    <li>apple</li>
    <li>banana</li>
    <li>peach</li>
  </ul>
</div>

결과적으로 리액트 관련 코드인 data-reactroot가 사라진 완전히 순수한 HTML 문자열이 반환된다는 것을 확인할 수 있습니다. 하지만 이 함수를 실행한 결과로 렌더링을 실행하면 클라이언트에서는 리액트에서 제공하는 useEffect와 같은 브라우저 API를 절대로 실행할 수 없게 됩니다. 만약 renderToStaticMarkup의 결과물을 기반으로 리액트의 자바스크립트를 이벤트 리스너로 등록하는 hydrate를 수행하면 서버와 클라이언트의 내용이 맞지 않다는 에러가 발생하게 됩니다. 그 이유로는 보다시피 renderToStaticMarkup의 결과물은 hydrate를 수행하지 않는다는 가정하에 순수한 HTML만 반환하기 때문입니다. 즉, renderToStaticMarkup은 리액트의 이벤트 리스너가 필요 없는 완전 순수한 HTML을 만들 때만 사용됩니다. 블로그 글이나 상품의 약관 정보와 같이 아무런 브라우저 액션이 없는 정적인 내용만 필요한 경우에 유용합니다.


📌 renderToNodeStream

renderToNodeStreamrenderToString의 결과물과 완전히 동일하지만 두 가지 차이점이 존재합니다.
renderToStringrenderToStaticMarkup은 브라우저에서도 실행할 수는 있지만 renderToNodeStream은 브라우저에서 사용하는 것이 완전히 불가능하다는 점입니다. 즉, 노드 환경에 의존하고 있는 AP입니다.

또 다른 차이점은 결과물의 타입입니다. renderToString은 말 그대로 결과물이 string인 문자열이지만, renderToNodeStream의 결과물은 Node.jsReadableStream입니다. ReadableStreamutf-8로 인코딩된 바이트 스트림으로, 노드 환경에서만 사용할 수 있습니다. 궁극적으로 브라우저가 원하는 결과물, 즉 string을 얻기 위해서는 추가적인 처리가 필요하게 됩니다.

renderToString이 생성하는 HTML 결과물의 크기가 작다면 한 번에 생성하든 스트림으로 하든 문제가 되지 않는데요, 하지만 HTML 크기가 매우 크다면 큰 문자열을 한번에 메모리에 두고 응답을 수행해야해서 Node.js가 실행되는 서버에 큰 부담이 될 수 있습니다. 따라서 대신 스트림을 활용하면 큰 크기의 데이터를 한번에 보내지 않고 청크 단위로 분리해 순차적으로 처리할 수 있다는 장점이 있어서 renderToNodeStream을 사용하게 되는 것입니다.

export default function App({ todos }: { todos: Array<TodoResponse> }) {
  return (
    <>
      <h1>나의 할 일!</h1>
      <ul>
        {todos.map((todo, index)=> (
          <Todo key={index} todo={todo} />
        ))}
      </ul>
    </>
  )
}

위의 App은 todos를 순회하며 렌더링하는데, 이 todos가 엄청나게 많다고 가정해보겠습니다. renderToString은 이를 모두 한번에 렌더링하려고 하기 때문에 시간이 많이 소요될 것 입니다. 즉, HTML 파일이 모두 완성될 때까지 기다려야 하지만, 스트림을 사용하게 되면 브라우저에 제공할 큰 HTML을 작은 단위로 쪼개 연속적으로 작성함으로써 리액트 애플리케이션을 렌더링하는 Node.js 서버의 부담을 덜 수 있게 됩니다. 대부분 널리 알려진 리액트 서버 사이드 렌더링 프레임워크는 모두 renderToString대신 renderToNodeStream을 채택하고 있습니다.
Next.js에서도 채택하고 있는 streaming


📌 renderToStaticNodeStream

renderToStringrenderToStaticMarkup이 있다면 renderToNodeStream에는 renderToStaticNodeStream이 있습니다. renderToNodeStream과 결과물은 동일하나, renderToStaticMarkup과 마찬가지로 리액트 자바스크립트에 필요한 리액트 속성이 제공되지 않습니다. 마찬가지로 hydrate를 할 필요가 없는 순수 HTML 결과물이 필요할 때 사용하는 메서드입니다.


📌 hydrate

hydrate는 잘알려있다시피, renderToString이나 renderToNodeStream으로 생성된 HTML 콘텐츠에 자바스크립트 핸들러나 이벤트를 붙이는 역할을 담당합니다. 앞서 언급한것처럼 renderToString의 결과물은 단순히 서버에서 렌더링한 HTML 결과물을 사용자에게 보여줄 순 있지만, 사용자가 페이지와 상호작용하는 것은 불가능합니다. 따라서 이렇게 정적으로 생성된 HTML에 이벤트 핸들러를 붙여 완전한 웹페이지 결과물을 만드는 것이 hydrate입니다.

hydrate와 비슷한 render메서드를 먼저 살펴보자면,

import * as ReactDOM from 'react-dom';
import App from './App';
 
const rootElement = document.getElementById('root');
 
ReactDOM.render(<App />, rootElement);

render함수는 컴포넌트와 HTML의 요소를 인수로 받게 되는데요, 이렇게 인수로 받은 두 정보를 바탕으로 HTML의 요소에 해당 컴포넌트를 렌더링하게 되고, 이벤트 핸들러를 붙이는 작업까지 모두 한번에 수행하게 됩니다.
따라서 render는 클라이언트에서만 실행되는, 렌더링과 이벤트 핸들러 추가 등 리액트를 기반으로 한 온전한 웹페이지를 만드는데 필요한 모든 작업을 수행합니다.

hydraterender와 비슷하게 인수를 넘기게 되는데요,

import * as ReactDOM from 'react-dom';
import App from './App';
 
// 서버에서 렌더링되 HTML의 특정 위치를 의미합니다.
const element = document.getElementById(containerId);
 
ReactDOM.hydrate(<App />, element);

render와의 차이점은 hydrate는 기본적으로 이미 렌더링된 HTML이 있다는 가정하에 작업이 수행됩니다. 그리고 이 렌더링된 HTML을 기반으로 이벤트를 붙이는 작업만 수행하게 됩니다. 만약 hydrate의 두번째 인수로 renderToStaticMarkup등으로 생성된, 리액트 관련 정보가 없는 순수한 HTML 정보를 넘겨준다면 에러가 발생하게 됩니다.


Reference Doc

Next.js 의 렌더링 과정에 대하여
Next.js의 렌더링 과정(Hydrate) 알아보기


김다은 이모지
Daeun Kim
Junior Frontend Engineer