요즘 FE 개발 할때에 Next.js를 많이 사용중인데 내부에서 동작하는 React Server API를 한번 간단히 살펴보고자 작성하는 글이다.
서버에서 HTML을 렌더링하기 위해 사용되는 React Dom Server API 의 가장 대표적인 API인 renderToString, renderToStaticMarkup, renderToReadableStream, renderToPipeableStream 4가지를 알아보자.
먼저, API 테스트 위한 간단한 node.js 서버를 만들었다. 각각의 라우트로 진입했을 경우 해당하는 React Dom Server API를 활용한 HTML 을 보여줄 예정이다.
import { createServer } from "http";
const server = createServer((req, res) => {
const { url } = req;
res.setHeader("Content-Type", "text/html; charset=utf-8");
switch (url) {
case "/render-to-string": {
...
}
case "/render-to-static-markup": {
...
}
default: {
res.writeHead(404);
res.end("Not Found");
}
}
});
const PORT = 3000;
server.listen(PORT, () => {
console.log(`Server is running at http://localhost:${PORT}`);
});
renderToString
먼저 renderToString API이다. 이 함수는 인수로 넘겨받은 리액트 컴포넌트를 HTML로 렌더링해주는 함수다. 간단한 React 컴포넌트와 함께 renderToString 함수를 사용해봤다.
// App.tsx
function App() {
return (
<div>
<h1>App 컴포넌트</h1>
</div>
);
}
export default App;
// server.ts
switch (url) {
case "/render-to-string": {
try {
const html = renderToString(createElement(App));
res.writeHead(200);
res.end(html);
} catch (error) {
console.error("Error during rendering:", error);
res.writeHead(500);
res.end("Internal Server Error");
}
return;
}
default: {
res.writeHead(404);
res.end("Not Found");
}
}
위 코드를 실행해보면 당연하게도 아래와 같은 화면이 렌더링된다.
그렇다면 props를 사용하는 컴포넌트를 만들어 renderToString을 사용하면 어떻게 될까?
function ChildComponent({ fruits }: { fruits: string[] }) {
useEffect(() => {
console.log("useEffect");
}, []);
const handleClick = () => {
console.log("click");
};
return (
<div>
<h1>과일 리스트</h1>
<ul>
{fruits.map((fruit, index) => (
<li key={index}>{fruit}</li>
))}
</ul>
<button onClick={handleClick}>클릭</button>
</div>
);
}
function ParentComponent() {
return (
<div>
<h1>App 컴포넌트</h1>
<ChildComponent fruits={["사과", "바나나", "딸기"]} />
</div>
);
}
예상대로 다음과 같은 화면이 렌더링된다.
여기서 유의깊게 봐야 할 부분은 ChildComponent 내에 useEffect와 onClick 이벤트 핸들러는 결과에 포함되어 있지 않다는 것이다. useEffect가 실행되지 않아 콘솔로그가 출력되지 않고 버튼을 클릭해도 마찬가지로 로그가 출력되지 않는다.
renderToStaticMarkup
HTML 문자열 결과물을 만들어 낸다는것은 renderToString과 동일하지만 renderToStaticMarkup 공식문서를 확인해보면 다음과 같이 적혀있다.
주의하세요!
이 메서드는 Hydrate 될 수 없는 상호작용하지 않는 HTML을 렌더링합니다. 이 메서드는 React를 간단한 정적 페이지 생성기로 사용하거나, 이메일과 같은 완전히 정적인 콘텐츠를 렌더링할 때 유용합니다.
상호작용을 위한 앱은 서버에서 renderToString, 클라이언트에서는 hydrateRoot를 사용해야 합니다.
즉 ,renderToStaticMarkup으로 만들어진 결과물은 hydrate 될 수 없고 이벤트 리스너 조차 필요가 없는 완전 순수한 HTML을 만들때 사용되어 아무런 액션이 필요하지 않은, 주로 정적인 내용의 페이지를 보여주고자 할때 사용된다.
case "/render-to-static-markup": {
try {
const html = renderToStaticMarkup(createElement(App));
res.writeHead(200);
res.end(html);
} catch (error) {
...
...
...
}
return;
}
코드를 확인해보면 renderToString의 사용법과 동일하다. 렌더링 결과도 renderToString의 화면과 동일하다.
위에서 살펴본 renderToString, renderToStaticMarkup 은 스트림을 지원하지 않는 환경에서만 사용할 수 있다. 때문에 브라우저에서도 실행할 수 있긴 하지만 스트리밍이나 Suspense와 같이 사용할 수 없다. 이제부터 살펴볼 API들은 서버(Node.js) 환경에서 사용할 수 있다.
renderToReadableStream
Readable Web Stream API를 활용한 React tree를 그리는 함수이다. React tree를 스트림 데이터 형태로 나타낼 수 있게 된다.
앞서 살펴봤던 renderToString, renderToStaticMarkup의 결과물은 문자열 형태였지만, renderToReadableStream의 결과물은 이름에서도 알 수 있듯이 ReadableStream 이다.
import { renderToReadableStream } from 'react-dom/server';
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js']
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}
사용방법이 조금 다른데 server에서 handler를 위와같이 구성해줘야 한다. 그리고 유의할 점은 root 컴포넌트와 함께, bootstrap <script> 경로 리스트를 제공해줘야 한다. 그래서 react는 doctype과 boostrap <script> 태그들을 결과 HTML 스트림에 주입하게 된다.
<!DOCTYPE html>
<html>
<!-- ... 사용자가 직접 작성한 컴포넌트의 HTML ... -->
</html>
<script src="/main.js" async=""></script>
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(document, <App />);
서버에서 제공된 HTML 스트림으로 클라이언트 사이드에서 hydrateRoot를 호출해 document를 하이드레이션 하게 된다.
renderToPipeableStream
renderToPipeableStream은 React 트리를 pipe사용이 가능한 Node.js 스트림으로 렌더링한다. renderToPipeableStream은 두 개의 메서드를 반환하는데 pipe와 abort 이다.
pipe는 HTML을 제공된 쓰기 가능한 Node.js 스트림으로 출력하는 것이고 스트리밍을 활성화하려면 onShellReady에서, 크롤러와 정적 생성을 사용하려면 onAllReady에서 pipe를 호출하면 된다. abort는 서버 렌더링을 중단하고 나머지는 클라이언트에서 렌더링할 수 있는 메서드이다.
import { renderToPipeableStream } from 'react-dom/server';
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
사용법은 renderToReadableStream과 비슷한데 다양한 option을 제공한다. 위 예시 코드에서 사용한 onShellReady는 초기 셸이 렌더링된 직후에 실행되는 콜백이다. 여기서 response header를 설정하고 pipe를 호출하여 스트리밍을 할 수 있다. 셸(shell) 이란 앱에서 <Suspense> 경계 밖에 있는 부분을 shell이라고 한다. 더 다양한 옵션은 여기에서 확인할 수 있다.
위에서 살펴본 4가지 API가 아마 Next.js 내부에서 동작하고 있을것이다. 지금은 간략히 어떤 API가 있는지와 간단한 사용법을 알아보았는데 내부에서 정확히 어떻게 처리하고 있을지 한번 살펴보면서 서버 사이드 렌더링을 이해하는데 도움이 되도록 추후에 다시 다뤄보고자 한다.
'Frontend' 카테고리의 다른 글
TDD로 배우는 웹 프론트엔드 강의 후기 (0) | 2024.04.28 |
---|---|
Artillery 서버 부하테스트 오픈소스 알아보기(1) (0) | 2024.03.30 |
[Tanstack-Query] 핵심 로직 딥다이브 (0) | 2024.01.07 |
[TanStack Query] v5 주요 변경 사항 (0) | 2023.12.10 |
나는 웹 성능 지표를 잘 알고 있었나? (0) | 2023.06.04 |