계속해서 React Server Component를 딥다이브하다보니 RSC를 직접 구현하는 방식에 대해서 알려주는 영상들이 몇몇이 있었습니다. Next.js가 내부적으로 해주는 일들과 비슷하게 구현할 수 있도록 되어 있었으며, 이를 통해 RSC에 대해 좀 더 자세히 이해할 수 있게 되었습니다.
Zustand의 메인테이너이신 DAISHI KATO님께서 React Server Component에서 중요하게 생각하는 것은 Serialization(직렬화)라고 합니다. RSC 전에는 리액트는 하나의 메모리 공간에서 동작했지만 RSC가 나오게 되면서 리액트는 여러 공간에서 사용가능해졌다고 합니다. (server와 client, 브라우저의 워커쓰레드까지...)
실습
실습을 통해 설명을 해보도록 하겠습니다.
`npm i react@canary react-dom@canary react-server-dom-webpack@canary`
위 명령어를 통해 패키지를 설치해보도록 합시다.
1. RSC가 나오기 이전의 리액트
const { createElement } = require("react");
const App = () => createElement("h1", null, "Hello!");
console.log(createElement(App));
`node <파일명>.js`로 파일을 실행하게 되면 다음과 같은 결과를 받게 됩니다.
{
'$$typeof': Symbol(react.transitional.element),
type: [Function: App],
key: null,
props: {},
_owner: null,
_store: {}
}
이렇게 실행된 객체를 기반으로 Fiber 아키텍처 내에서 실행이 되었던 것이죠. 이 때, type을 보게 되면 `App`함수가 들어와 있는 것을 볼 수 있으며, 함수는 serializable하지 않습니다.
2. React로 구현한 SSR (HTML Generation)
이번에는 기존 리액트 컴포넌트를 리액트 server API인 `renderToPipeableStream`을 통해 HTML로 만들어봅시다.
const { createElement } = require("react");
const { renderToPipeableStream } = require("react-dom/server");
const App = () => createElement("h1", null, "Hello!");
renderToPipeableStream(createElement(App)).pipe(process.stdout);
`node <파일명>`을 통해 실행해보면 다음과 같은 결과를 반환받습니다.
<h1>Hello!</h1>
SSR은 HTML을 서버에서 만들어주며, RSC는 HTML을 생성하지 않는다는 점에서 차이가 있습니다.
`renderToPipeableStream`은 stream을 반환하므로 `process.stdout`을 사용해야하며, HTML을 반환하는 것을 확인하실 수 있습니다.
이 점이 제가 의아했던 점인데 Next.js의 경우 RSC를 사용하더라도 Prerendered된 HTML이 넘어져오는데, Next.js에서는 RSC Payload를 서버에서 만든 후, 이 Payload를 사용해 HTML을 한 번 더 만들어주는 작업을 거친다고 알게되었습니다
3. Serialization
이번에는 RSC Payload에 대해서 좀 더 알아보는 시간을 가져보도록 하겠습니다.
const { createElement } = require("react");
const { renderToPipeableStream } = require("react-server-dom-webpack/server");
const App = () => createElement("h1", null, "Hello!");
renderToPipeableStream(createElement(App)).pipe(process.stdout);
먼저 처음보는 패키지가 등장했습니다. 바로 `react-server-dom-webpack`입니다.
새로운 패키지의 등장에 당황스러웠던 저는 ChatGPT에게 물어봤습니다.
react-server-dom-webpack는 React에서 사용되는 패키지로, 서버와 클라이언트 간에 컴포넌트를 전송할 때 사용되는 포맷인 "React Server Components"를 지원하기 위한 도구입니다. 이 패키지는 특히 Webpack과 함께 사용되며, React 애플리케이션의 서버 사이드 렌더링을 최적화하는 데 도움을 줍니다.
React Server Components는 클라이언트 사이드와 서버 사이드 간의 경계를 흐리게 하여, 서버에서 컴포넌트를 렌더링하고 클라이언트로 직접 스트리밍할 수 있도록 합니다. 이를 통해 초기 로드 시간을 줄이고, 전체 애플리케이션의 효율을 개선할 수 있습니다.
서버 컴포넌트는 클라이언트에 전송되기 전에 서버에서 렌더링되므로, 전송해야 할 데이터의 양을 줄일 수 있으며, 클라이언트의 자바스크립트 처리 부담도 감소시킵니다. react-server-dom-webpack 패키지는 이러한 서버 컴포넌트들을 적절히 처리하고, Webpack을 통해 애플리케이션을 번들링할 때 필요한 설정과 연동을 지원합니다.
저는 `react-server-dom-webpack/server`의 `renderToPipeableStream`과 `react-dom/server`의 `renderToPipeableStream`과의 차이점이 궁금해져서 ChatGPT에 또 물어보게 되었습니다.
react-server-dom-webpack의 renderToPipeableStream과 React 18에서 도입된 서버 API인 react-dom/server의 renderToPipeableStream 사이에는 주요 차이점이 있습니다. 기본적으로 두 함수 모두 React 컴포넌트를 서버에서 렌더링하고 이를 스트리밍을 통해 클라이언트로 전송하는 역할을 하지만, 사용 방법과 통합 점에서 차이가 나타납니다.
React DOM Server의 renderToPipeableStream
일반적 사용: 이 API는 React 컴포넌트를 서버에서 HTML로 렌더링하고, 이를 클라이언트로 점진적으로 스트리밍할 수 있도록 합니다.환경: 순수한 React 컴포넌트와 함께 사용되며, 서버에서 클라이언트로의 초기 HTML 응답을 최적화하는 데 초점을 맞춥니다.용도: 주로 서버 사이드 렌더링(SSR)을 통해 초기 페이지 로딩 시간을 단축하고 검색 엔진 최적화(SEO)를 향상시키는 데 사용됩니다.
react-server-dom-webpack의 renderToPipeableStream
특화된 사용: 이 함수는 React Server Components를 처리하고, Webpack과의 통합을 위해 설계되었습니다. React Server Components는 서버에서 렌더링하고 클라이언트로 직접 스트리밍하는 컴포넌트 형식입니다.환경: Webpack과의 통합을 통해 서버와 클라이언트 사이의 모듈 또는 컴포넌트 전송을 관리합니다. 이는 서버 컴포넌트가 클라이언트에 전송되기 전에 최적화되고, 필요한 자원만 로드되도록 합니다.용도: 서버 컴포넌트의 전송과 클라이언트 사이드 최적화를 중심으로 개발되어, 애플리케이션의 전반적인 성능과 사용자 경험을 향상시키는 데 초점을 맞춥니다.
두 API는 비슷한 목적으로 사용되지만, react-server-dom-webpack의 경우는 React Server Components와의 통합에 특화되어 있으며, 이를 통해 서버와 클라이언트 간의 더 효율적인 데이터 처리와 리소스 최적화를 제공합니다. 반면, 일반적인 react-dom/server의 renderToPipeableStream은 보다 전통적인 SSR 접근 방식에 더 가깝습니다
이제 위 코드를 실행해보도록 합시다.
`node --conditions react-server <파일명>.js` 명령어를 통해 파일을 실행해야 합니다.
`--conditions react-server`란 무엇일까?
support runtime `--conditions` flag like node.js for custom conditional exports
https://nodejs.org/api/cli.html#-c-condition---conditionscondition
You must configure Node.js using the `--conditions react-server` flag
https://github.com/facebook/react/blob/59831c98cffe0edf706238b067928e7cf54d1065/packages/react-server-dom-esm/server.js#L12
추가적으로 공부해보면 좋을 링크
https://github.com/reactjs/rfcs/blob/main/text/0227-server-module-conventions.md
1:{"name":"App","env":"Server","owner":null}
0:D"$1"
0:["$","h1",null,{"children":"Hello!"},"$1"]
결과는 다음과 같이 RSC Payload가 나오게 됩니다.
RSC Payload란?
1. internal representation
2. RSC는 renderer independent 함
RSC는 DOM 이외의 환경에서도 사용가능하기 때문에 React Native에서도 사용가능하다고 합니다.
4. Deserialization
이번에는 직렬화된 데이터를 역직렬화를 통해 다시 RSC Payload를 JSX Element로 만들도록 해봅시다.
const { createElement } = require("react");
const { renderToPipeableStream } = require("react-server-dom-webpack/server");
const { createFromNodeStream } = require("react-server-dom-webpack/client");
const { PassThrough } = require("node:stream");
const App = () => createElement("h1", null, "Hello!");
const passthrough = new PassThrough();
renderToPipeableStream(createElement(App)).pipe(passthrough);
createFromNodeStream(passthrough).then(console.log);
`createFromNodeStream` 메소드는 RSC Payload를 Deserialize 하는 역할을 합니다.
`PassThrough` 클래스는 `Transform` 스트림의 일종으로 데이터를 변형하지 않고 그대로 통과시키는 역할을 합니다.
주요 사례
1. 디버깅과 로깅: 데이터의 흐름을 모니터링하거나 로깅할 때 유용하게 사용할 수 있습니다.
2. 스트림 체인의 일부: 복잡한 스트림 처리 파이프라인에서 특정 조건에 따라 데이터 변환을 건너 뛰어야 할 때 사용할 수 있습니다.
3. 스트림 테스트: 스트림의 행동을 테스트할 때 사용할 수 있습니다.
4. 추가적인 스트림 작업: 다른 스트림과 결합하여 복잡한 데이터 처리 작업을 수행할 때 사용할 수 있습니다.
추가적으로 `pipe`는 HTTP 네트워크와 같은 환경이라고 생각하시면 될 것 같습니다.
`node --conditions react-server <파일명>.js`을 통해 실행해보면 다음과 같은 결과를 얻게 됩니다.
{
'$$typeof': Symbol(react.transitional.element),
type: 'h1',
key: null,
props: { children: 'Hello!' },
_owner: { name: 'App', env: 'Server', owner: null },
_store: {}
}
서버 컴포넌트는 Deserialize가 잘 되는 것을 볼 수 있습니다.
추가적으로 이것을 실행하게 되면 에러가 발생할 수 있는데, 에러가 발생했다면 `react-server-dom-webpack/cjs/react-server-dom-webpack-client.node.unbundled.development.js` 파일을 다음과 같이 수정해야 합니다. (옵셔널 체이닝을 추가해줘야 에러가 발생하지 않습니다)
function createFromNodeStream(stream, ssrManifest, options) {
var response = createResponse(
ssrManifest?.moduleMap,
ssrManifest?.moduleLoading,
noServerCall,
options ? options?.encodeFormAction : undefined,
options && typeof options?.nonce === "string"
? options?.nonce
: undefined,
undefined // TODO: If encodeReply is supported, this should support temporaryReferences
);
stream.on("data", function (chunk) {
processBinaryChunk(response, chunk);
});
stream.on("error", function (error) {
reportGlobalError(response, error);
});
stream.on("end", function () {
return close(response);
});
return getRoot(response);
}
exports.createFromNodeStream = createFromNodeStream;
exports.createServerReference = createServerReference;
})();
5. use client
이제는 클라이언트 컴포넌트가 추가되었을 경우를 고려해야 합니다.
다음과 같은 클라이언트 컴포넌트가 있다고 가정해봅시다.
"use client";
const { createElement, useState } = require("react");
const Counter = () => {
const [count, setCount] = useState(0);
return createElement(
"div",
null,
createElement("span", null, `Count: ${count}`),
createElement(
"button",
{ onClick: () => setCount((prev) => prev + 1) },
"+1"
)
);
};
module.exports = Counter;
이 클라이언트 컴포넌트를 컴포넌트 트리에 포함시키는 코드를 작성해봅시다.
const { createElement } = require("react");
const { renderToPipeableStream } = require("react-server-dom-webpack/server");
require("react-server-dom-webpack/node-register")();
const Counter = require("./Counter.js");
const App = () =>
createElement(
"h1",
null,
createElement("h1", null, "Hello!"),
createElement(Counter)
);
const manifest = {
[`file://${__dirname}/Counter.js`]: {
id: "<id>",
chunks: ["<chunk>"],
name: "default",
async: true,
},
};
renderToPipeableStream(createElement(App), manifest).pipe(process.stdout);
클라이언트 컴포넌트가 컴포넌트 트리에 포함되어 있다면, 이를 위한 별도의 처리를 해줘야 하는데 이 때 사용되는 패키지가 `react-server-dom-webpack/node-register`이고, 이를 적절하게 처리할 수 있도록 설정해주는 객체(`manifest`)가 필요합니다.
`react-server-dom-webpack/node-register` 패키지는 React Server Components를 사용할 때 Node.js 환경에서 서버 측 컴포넌트를 적절히 로드하고 처리할 수 있도록 하는 역할을 합니다. 이 패키지는 주로 서버에서 React 컴포넌트 코드를 실행할 때 필요한 Webpack 설정과 통합을 관리합니다.
핵심 역할
1. 서버 컴포넌트 로딩: 서버에서 React Server Components를 사용할 수 있도록 지원하며 컴포넌트들이 서버 상에서 올바르게 로드되고 실행되도록 합니다.
2. 모듈 해석: Node.js에서 실행하는 동안 서버 사이드에서 사용되는 React 컴포넌트들의 모듈 해석 방식을 제어합니다. 이는 서버에서 클라이언트 컴포넌트와 다르게 취급되어야 하는 모듈들을 적절하게 구분하고 처리하는데 중요합니다.
3. 웹팩 통합: `node-register`는 Webpack과의 통합을 제공하여 서버 컴포넌트가 Webpack을 통해 번들링된 애플리케이션과 원활하게 작동할 수 있도록 지원합니다. 이를 통해 SSR에서 발생할 수 있는 다양한 문제들을 해결할 수 있습니다.
4. 빌드 및 배포 최적화: 서버 컴포넌트를 효율적으로 로드하고 실행하기 위해 필요한 빌드 과정을 최적화합니다. 이는 서버 리소스를 절약하고 응답시간을 단축하는데 도움을 줍니다.
사용 예
`react-server-dom-webpack/node-register`는 Node.js에서 서버 사이드 렌더링을 구현할 때 (특히 RSC를 사용하는 경우) 필요한 패키지입니다. 이를 통해서 서버에서 실행되는 React 컴포넌트들이 클라이언트에서 사용되는 컴포넌트와의 호환성 문제없이 정상적으로 로드되고 실행될 수 있도록 보장합니다.
이제 위 파일을 `node --conditions react-server <파일명>.js`로 실행해보도록 합시다.
2:I["<id>",["<chunk>"],"default"]
1:{"name":"App","env":"Server","owner":null}
0:D"$1"
0:["$","h1",null,{"children":[["$","h1",null,{"children":"Hello!"},"$1"],["$","$L2",null,{},"$1"]]},"$1"]
실행할 때 RSC Payload 형태로 나오게 됨을 확인할 수 있는데 여기서 첫 번째 줄의 값이 우리가 manifest 객체로 설정해준 데이터와 동일하다는 사실을 알 수 있었습니다. 이러한 결과를 토대로 Next.js와 같은 프레임워크들이 빌드 시에 이러한 파일 의존성을 manifest.json으로 관리할 수 있도록 내부적으로 세팅해주는 걸로 이해할 수 있었습니다.
6. use client를 render
이제 클라이언트 컴포넌트를 포함한 트리구조를 렌더링할 수 있도록 소스코드를 수정해보겠습니다.
const { createElement } = require("react");
const { renderToPipeableStream } = require("react-server-dom-webpack/server");
require("react-server-dom-webpack/node-register")();
const { createFromNodeStream } = require("react-server-dom-webpack/client");
const { PassThrough } = require("node:stream");
const Counter = require("./Counter.js");
const App = () =>
createElement(
"h1",
null,
createElement("h1", null, "Hello!"),
createElement(Counter)
);
const manifest = {
[`file://${__dirname}/Counter.js`]: {
id: "./Counter.js",
chunks: ["./Counter.js"],
name: "default",
async: true,
},
};
const config = {
moduleMap: {
"./Counter.js": { default: {} }, //// HACK
},
};
const passthrough = new PassThrough();
renderToPipeableStream(createElement(App), manifest).pipe(passthrough);
createFromNodeStream(passthrough, config).then((x) =>
console.dir(x, { depth: 4 })
);
1. createFromeNodeStream api에 manifest로 설정된 객체에 해당하는 클라이언트 컴포넌트를 삽입하기 위해 config 객체를 추가
2. manifest의 id 속성과 chunks 속성을 파일 정보 기반으로 변경
이 두가지 정보를 바탕으로 어떤 클라이언트 컴포넌트 정보가 필요한지를 받아올 수 있게 됩니다.
실제로 `node --conditions react-server <파일명>.js`명령어를 통해서 실행하면 다음과 같은 결과를 받아올 수 있었습니다.
{
'$$typeof': Symbol(react.transitional.element),
type: 'div',
key: null,
props: {
children: [
{
'$$typeof': Symbol(react.transitional.element),
type: 'h1',
key: null,
props: { children: 'Hello!' },
_owner: { name: 'App', env: 'Server', owner: null },
_store: {}
},
{
'$$typeof': Symbol(react.transitional.element),
type: {
'$$typeof': Symbol(react.lazy),
_payload: [Promise],
_init: [Function: readChunk],
_debugInfo: []
},
key: null,
props: {},
_owner: { name: 'App', env: 'Server', owner: null },
_store: {}
}
]
},
_owner: { name: 'App', env: 'Server', owner: null },
_store: {}
}
이 결과를 토대로 Client Component와 Server Component는 리액트 트리에서 interleave되어 있다는 것을 확인할 수 있었고,
이론적으로는 이 트리를 렌더링할 경우, 컨텐츠를 볼 수 있게 되어 있습니다.
여기서 그치지 않고, Ben Holmes 씨의 React Server Component와 관련된 영상을 보게 되었고, 위와 같은 모델을 바탕으로 다시 보니 더 이해가 잘 되는 듯한 느낌을 받았습니다. 이와 관련된 글은 다음에 추가적으로 작성해보도록 하겠습니다.
출처
https://www.youtube.com/watch?v=_SLh2g5N3mk
https://www.youtube.com/watch?v=36uY-c0E_EQ
https://www.youtube.com/watch?v=MaebEqhZR84
https://portal.gitnation.org/contents/react-server-components-from-scratch