이번 글은 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
renderToNodeStream
은 renderToString
의 결과물과 완전히 동일하지만 두 가지 차이점이 존재합니다.
renderToString
과 renderToStaticMarkup
은 브라우저에서도 실행할 수는 있지만 renderToNodeStream
은 브라우저에서 사용하는 것이 완전히 불가능하다는 점입니다. 즉, 노드 환경에 의존하고 있는 AP입니다.
또 다른 차이점은 결과물의 타입입니다. renderToString
은 말 그대로 결과물이 string
인 문자열이지만, renderToNodeStream
의 결과물은 Node.js
의 ReadableStream
입니다. ReadableStream
은 utf-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
renderToString
에 renderToStaticMarkup
이 있다면 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
는 클라이언트에서만 실행되는, 렌더링과 이벤트 핸들러 추가 등 리액트를 기반으로 한 온전한 웹페이지를 만드는데 필요한 모든 작업을 수행합니다.
hydrate
도 render
와 비슷하게 인수를 넘기게 되는데요,
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 정보를 넘겨준다면 에러가 발생하게 됩니다.