7월 첫 포스팅에 대한 주제로 RSC(React Server Component)를 정했습니다. 사실 계속해서 RSC에 대해 공부하고 있었습니다. 도대체 이 녀석을 뭘까? 지금도 솔직히 잘 모르겠습니다. 그래도 위안이 되는건 저뿐만이 아니라는 것입니다. 해외 여러 댓글을 살펴보면 혼란스럽다 라는 반응이 많습니다. 그만큼 RSC는 아직까지 사람들에게 익숙하지 않은 거 같습니다.
그래도 이렇게 글을 쓰는 이유는 "RSC From Scratch. Part 1: Server Components" 라는 좋은 글을 찾아서 번역 및 실습 내용을 공유해보면 좋을 거 같아서 입니다.
참고 : https://github.com/reactwg/server-components/discussions/5
어쩌다보니 Dan Abramov 이 분에 대해 알게 되었는데 React 개발자더군요. React 업계 쪽에서는 유명한가 봅니다. 이분 트위터와 깃허브를 뒤적거리다 보니 server-components에 대한 좋은 글이 있었습니다. 처음부터 세세하게 설명이 되어있는 것이 따라해보기 아주 좋았습니다.
이 기술적 심층 분석에서는 RSC(React Server Components)의 매우 단순화된 버전을 처음부터 구현합니다.
이 딥 다이브는 여러 부분으로 나누어 출판될 것입니다:
이 심층 분석에서는 React Server Components의 이점, RSC를 사용하여 앱을 구현하는 방법 또는 이를 사용하여 프레임워크를 구현하는 방법에 대해 설명하지 않습니다. 대신에, 당신이 처음부터 스스로 그것들을 "발명"하는 과정을 안내합니다.
당신이 어느 날 아침에 일어나서 다시 2003년이라는 것을 알게 되었다고 가정해봅시다. 웹 개발은 아직 초기 단계에 있습니다. 서버에 있는 텍스트 파일의 내용을 보여주는 개인 블로그 웹 사이트를 만들고 싶다고 가정해 보겠습니다. PHP에서는 다음과 같이 보일 수 있습니다.
<?php
$author = "Jae Doe";
$post_content = @file_get_contents("./posts/hello-world.txt");
?>
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr>
</nav>
<article>
<?php echo htmlspecialchars($post_content); ?>
</article>
<footer>
<hr>
<p><i>(c) <?php echo htmlspecialchars($author); ?>, <?php echo date("Y"); ?></i></p>
</footer>
</body>
</html>
브라우저에서 http://locahost:3000/hello-world
를 열면 이 PHP 스크립트는 ./posts/hello-world.tsx의 블로그 게시물이 포함된 HTML 페이지를 반환합니다. 오늘날의 Node.js API를 사용하여 작성된 동등한 Node.js 스크립트는 다음과 같습니다:
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import escapeHtml from 'escape-html'; // HTML에서 사용할 Escape string. ex) '&': '&'
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
`<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
${escapeHtml(postContent)}
</article>
<footer>
<hr>
<p><i>(c) ${escapeHtml(author)}, ${new Date().getFullYear()}</i></p>
</footer>
</body>
</html>`
);
}).listen(8080);
function sendHTML(res, html) {
res.setHeader("Content-Type", "text/html");
res.end(html);
}
작동하는 Node.js 엔진이 있는 CD-ROM을 2003년으로 가져가서 서버에서 이 코드를 실행할 수 있다고 상상해 보십시오. 만약 당신이 그 세계에 리액트의 패러다임을 가져오고 싶다면, 어떤 기능을 추가하고 어떤 순서로 추가할 건가요?
위의 코드에 대해 이상적이지 않은 첫 번째 것은 직접적인 문자열 조작입니다. 텍스트 파일의 내용을 실수로 HTML로 처리하지 않도록 escapeHtml(postContent)을 호출해야 했습니다.
참고) 특정 문자를 HTML로 변환하는 행위를 Escape(이스케이프)한다고 말합니다. 예를 들어, '<'를 그냥 쓰면 HTML 태그로 인식할 수 있습니다. 이런 것을 방지하기 위해
'<'
로 변환 됩니다. 또한, 보안 측면에서도 의미가 있습니다.
이 문제를 해결할 수 있는 한 가지 방법은 "템플릿"에서 논리를 분리한 다음, 텍스트와 속성에 동적 값을 주입하고 텍스트 내용을 안전하게 이스케이프하고 조건과 루프에 대한 도메인별 구문을 제공하는 별도의 템플릿 언어를 도입하는 것입니다. 그것이 2000년대에 가장 인기 있는 서버 중심 프레임워크 중 일부가 취한 접근 방식입니다. (참고로, node에서도 ejs 라는게 많이 사용되었지요. 지금도 많이 사용되는거 같습니다)
그러나 React에 대한 기존 지식을 통해 다음과 같은 작업을 수행할 수 있습니다:
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
{postContent}
</article>
<footer>
<hr />
<p><i>(c) {author}, {new Date().getFullYear()}</i></p>
</footer>
</body>
</html>
);
}).listen(8080);
글에서는 안나와있지만 node에서 jsx를 사용하기 위한 바벨 설정을 해주도록 합시다.
import babel from "@babel/core";
const babelOptions = {
babelrc: false,
ignore: [/\/(build|node_modules)\//],
plugins: [["@babel/plugin-transform-react-jsx", { runtime: "automatic" }]],
};
export async function load(url, context, defaultLoad) {
const result = await defaultLoad(url, context, defaultLoad);
if (result.format === "module") {
const opt = Object.assign({ filename: url }, babelOptions);
const newResult = await babel.transformAsync(result.source, opt);
if (!newResult) {
if (typeof result.source === "string") {
return result;
}
return {
source: Buffer.from(result.source).toString("utf8"),
format: "module",
};
}
return { source: newResult.code, format: "module" };
}
return defaultLoad(url, context, defaultLoad);
}
실행 스크립트까지 추가해주면 완벽.
"start": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server.js",
실행하면 아직 에러는 발생하지만 중요한건 html이 아래처럼 변환이 된것을 알 수 있습니다.
우리의 "템플릿"은 더 이상 문자열이 아닙니다. string interpolation 코드를 작성하는 대신 XML의 하위 집합을 JavaScript에 넣습니다. 즉, JSX를 "발명"했습니다. JSX는 관련 렌더링 로직에 가깝게 마크업을 유지할 수 있게 해주지만 string interpolation과 달리 HTML 태그의 불일치 또는 텍스트 내용 escape을 잊어버리는 등의 실수를 방지합니다.
참고) JSX가 바벨로 변환되면 React.createElement(type, props, children)이 되는데, 이를 실행하면 아래 처럼 React Element 객체를 반환하게 됩니다. 이 객체는 React에게 무엇을 렌더할지를 알려줍니다. - 참고
// console.dir(html, { depth: null });
{
$$typeof: Symbol.for("react.element"), // Tells React it's a JSX element (e.g. <html>)
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
children: {
$$typeof: Symbol.for("react.element"),
type: 'title',
props: { children: 'My blog' }
}
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'body',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'nav',
props: {
children: [{
$$typeof: Symbol.for("react.element"),
type: 'a',
props: { href: '/', children: 'Home' }
}, {
$$typeof: Symbol.for("react.element"),
type: 'hr',
props: null
}]
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'article',
props: {
children: postContent
}
},
{
$$typeof: Symbol.for("react.element"),
type: 'footer',
props: {
/* ...And so on... */
}
}
]
}
}
]
}
}
그러나 최종적으로 브라우저로 보내야 하는 것은 JSON 트리가 아닌 HTML입니다. (적어도 지금은!)
당신의 JSX를 HTML 문자열로 바꾸는 함수를 작성해 봅시다. 이렇게 하려면 다양한 유형의 노드(문자열, 숫자, 배열 또는 자식 노드가 있는 JSX 노드)가 HTML 조각으로 변환되는 방법을 지정해야 합니다:
function sendHTML(res, jsx) {
const html = renderJSXToHTML(jsx);
console.dir(html, { depth: null });
res.setHeader("Content-Type", "text/html");
res.end(html);
}
function renderJSXToHTML(jsx) {
if (typeof jsx === "string" || typeof jsx === "number") {
// This is a string. Escape it and put it into HTML directly.
return escapeHtml(jsx);
} else if (jsx == null || typeof jsx === "boolean") {
// This is an empty node. Don't emit anything in HTML for it.
return "";
} else if (Array.isArray(jsx)) {
// This is an array of nodes. Render each into HTML and concatenate.
return jsx.map((child) => renderJSXToHTML(child)).join("");
} else if (typeof jsx === "object") {
// Check if this object is a React JSX element (e.g. <div />).
if (jsx.$$typeof === Symbol.for("react.element")) {
// Turn it into an an HTML tag.
let html = "<" + jsx.type;
for (const propName in jsx.props) {
if (jsx.props.hasOwnProperty(propName) && propName !== "children") {
html += " ";
html += propName;
html += "=";
html += escapeHtml(jsx.props[propName]);
}
}
html += ">";
html += renderJSXToHTML(jsx.props.children);
html += "</" + jsx.type + ">";
return html;
} else throw new Error("Cannot render an object.");
} else throw new Error("Not implemented.");
}
그러면 아래와 같이 html로 변환이 되는 것을 볼 수 있습니다.
'<html><head><title>My blog</title></head><body><nav><a href=/>Home</a><hr></hr></nav><article>Hi everyone! This is my first blog post. I <3 React.\n' +
'</article><footer><hr></hr><p><i>(c) Jae Doe, 2023</i></p></footer></body></html>'
JSX를 HTML 문자열로 변환하는 것을 SSR(Server-Side Rendering)이라고 합니다. 중요한 점은 RSC와 SSR은 매우 다른 두 가지(함께 사용되는 경향이 있음)라는 것입니다. 이 안내서에서는 서버 환경에서 SSR을 가장 먼저 수행하는 것이 당연하기 때문에 SSR부터 시작합니다. 그러나 이것은 단지 첫 단계일 뿐이며, 나중에 상당한 차이를 보게 될 것입니다.
JSX 다음으로 원하는 기능은 컴포넌트 요소입니다. 코드가 클라이언트에서 실행되든 서버에서 실행되든 상관없이 UI를 여러 조각으로 분할하고 이름을 지정한 후 props으로 정보를 전달하는 것이 합리적입니다.
앞의 예를 BlogPostPage와 Footer이라는 두 가지 컴포넌트 요소로 나누어 보겠습니다:
function BlogPostPage({ postContent, author }) {
return (
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<article>
{postContent}
</article>
<Footer author={author} />
</body>
</html>
);
}
function Footer({ author }) {
return (
<footer>
<hr />
<p>
<i>
(c) {author} {new Date().getFullYear()}
</i>
</p>
</footer>
);
}
그런 다음 이전에 가지고 있던 인라인 JSX 트리를 아래와 같이 바꿔줍니다.
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(res, <BlogPostPage author={author} postContent={postContent} />);
}).listen(3000);
만약 당신이 RenderJSXToHTML 구현에 어떠한 변경도 없이 이 코드를 실행하려고 한다면, 결과적으로 HTML은 깨져 보일 것입니다.
<!-- This doesn't look like valid at HTML at all... -->
<function BlogPostPage({postContent,author}) {...}>
</function BlogPostPage({postContent,author}) {...}>
문제는 RenderJSXToHTML 함수(JSX를 HTML로 변환)가 jsx.type
이 항상 HTML 태그 이름("html", "footer" 또는 "p" 등)을 가진 문자열이라고 가정한다는 것입니다:
if (jsx.$$typeof === Symbol.for("react.element")) {
// Existing code that handles HTML tags (like <p>).
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
}
그러나 여기서 BlogPostPage는 function이므로 "<" + jsx.type + ">”
를 수행하면 소스 코드가 출력됩니다. HTML 태그 이름으로 해당 함수의 코드를 보내고 싶지 않을 것입니다. 대신 이 함수를 호출하고 HTML로 반환되는 JSX를 serialize 합니다:
if (jsx.$$typeof === Symbol.for("react.element")) {
if (typeof jsx.type === "string") { // Is this a tag like <div>?
// Existing code that handles HTML tags (like <p>).
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
} else if (typeof jsx.type === "function") { // Is it a component like <BlogPostPage>?
// Call the component with its props, and turn its returned JSX into HTML.
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = Component(props);
return renderJSXToHTML(returnedJsx);
} else throw new Error("Not implemented.");
}
이제 HTML 생성 중에 <BlogPostPage author="Jae Doe" />
와 같은 JSX 요소가 마주하면 함수로 BlogPostPage를 호출하여 { author: "Jae Doe" }
를 해당 함수로 전달합니다. 그 함수는 JSX를 반환할 것입니다. 그리고 당신은 이미 JSX를 다루는 방법을 알고 있습니다. 당신은 그것을 다시 renderJSXToHTML로 넘겨서 HTML을 계속 생성합니다.
이 변경만으로도 컴포넌트 및 props 넘겨주기에 대한 지원을 추가하기에 충분합니다. 확인해 보십시오:
컴포넌트가 작동하기 위한 기본 지원을 했으니, 블로그에 페이지 몇개를 더 추가하면 좋을 것 같습니다.
/hello-world와 같은 URL에서 ./posts/hello-world.txt의 내용이 포함된 개별 블로그 포스트 페이지를 표시해야 한다고 가정해 보겠습니다. 루트 / URL 에서는 모든 블로그 게시물의 내용이 포함된 색인을 표시해야 합니다. 즉, BlogPostPage와 레이아웃을 공유하지만 내용은 다른 새로운 BlogIndexPage를 추가해야 합니다.
현재 BlogPostPage 컴포넌트는 루트에서 전체 페이지를 나타냅니다. BlogPostPage에서 페이지 간(헤더 및 바닥글) 공유 UI 부분을 재사용 가능한 BlogLayout 구성 요소로 추출해 보겠습니다:
// 기본 레이아웃
function BlogLayout({ children }) {
const author = "Jae Doe";
return (
<html>
<head>
<title>My blog</title>
</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<main>
{children}
</main>
<Footer author={author} />
</body>
</html>
);
}
BlogPostPage 컴포넌트를 변경하여 해당 레이아웃에 삽입할 콘텐츠만 포함합니다:
// 개별 포스트 페이지
function BlogPostPage({ postSlug, postContent }) {
return (
<section>
<h2>
<a href={"/" + postSlug}>{postSlug}</a>
</h2>
<article>{postContent}</article>
</section>
);
}
다음은 <BlogLayout>
안에 <BlogPostPage>
가 중첩되었을때 어떻게 표시되는지 보여줍니다.
또한 모든 게시물을 ./posts/*.txt에 차례로 표시하는 새 BlogIndexPage 구성 요소를 추가합니다:
// 메인 페이지 포스트 목록
function BlogIndexPage({ postSlugs, postContents }) {
return (
<section>
<h1>Welcome to my blog</h1>
<div>
{postSlugs.map((postSlug, index) => (
<section key={postSlug}>
<h2>
<a href={"/" + postSlug}>{postSlug}</a>
</h2>
<article>{postContents[index]}</article>
</section>
))}
</div>
</section>
);
}
그런 다음 BlogLayout 내부에 중첩하여 동일한 header와 footer를 가질 수 있습니다.
마지막으로, URL을 기준으로 페이지를 선택하고 데이터를 로드한 후 레이아웃 내부에 해당 페이지를 렌더링하도록 서버 핸들러를 변경합니다:
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
// Match the URL to a page and load the data it needs.
const page = await matchRoute(url);
// Wrap the matched page into the shared layout.
sendHTML(res, <BlogLayout>{page}</BlogLayout>);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8080);
async function matchRoute(url) {
if (url.pathname === "/") {
// We're on the index route which shows every blog post one by one.
// Read all the files in the posts folder, and load their contents.
const postFiles = await readdir("./posts");
const postSlugs = postFiles.map((file) => file.slice(0, file.lastIndexOf(".")));
const postContents = await Promise.all(
postSlugs.map((postSlug) =>
readFile("./posts/" + postSlug + ".txt", "utf8")
)
);
return <BlogIndexPage postSlugs={postSlugs} postContents={postContents} />;
} else {
// We're showing an individual blog post.
// Read the corresponding file from the posts folder.
const postSlug = sanitizeFilename(url.pathname.slice(1));
try {
const postContent = await readFile("./posts/" + postSlug + ".txt", "utf8");
return <BlogPostPage postSlug={postSlug} postContent={postContent} />;
} catch (err) {
throwNotFound(err);
}
}
}
function throwNotFound(cause) {
const notFound = new Error("Not found.", { cause });
notFound.statusCode = 404;
throw notFound;
}
이제 블로그를 탐색할 수 있습니다. 하지만 코드가 좀 장황하고 투박해지고 있습니다. 다음에서 저희가 해결하겠습니다.
BlogIndexPage 및 BlogPostPage 컴포넌트의 이 부분은 정확히 동일하게 보입니다:
우리가 어떻게든 이것을 재사용 가능한 컴포넌트로 만들 수 있다면 좋을 것이다. 그러나 별도의 Post 컴포넌트에 렌더링 로직을 추출하더라도 각 게시물에 대한 내용을 어떻게든 "plumb down"해야 합니다:
function Post({ slug, content }) { // Someone needs to pass down the `content` prop from the file :-(
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
현재, 게시물을 위한 콘텐츠를 로딩하는 논리는 중복됩니다. readFile API는 비동기식이므로 컴포넌트 트리에서 직접 사용할 수 없기 때문에 컴포넌트 계층 외부에 로드하고 있습니다. (fs API에 동기화된 버전이 있다는 것은 무시합시다. 데이터베이스에서 읽었거나 일부 비동기 타사 라이브러리로 호출되었을 수 있습니다.)
아니면 우리가 할 수 있을까요…?
클라이언트 측 React에 익숙하다면 구성 요소에서 fs.readFile과 같은 API를 호출할 수 없다는 생각에 익숙해질 수 있습니다. 기존의 React SSR(서버 렌더링)을 사용하더라도 기존의 직관으로는 각 컴포넌트가 브라우저에서도 실행되어야 한다는 것을 알 수 있으므로 fs.readFile과 같은 서버 전용 API는 작동하지 않습니다.
하지만 만약 여러분이 2003년에 누군가에게 이것을 설명하려고 한다면, 그들은 이 한계를 다소 이상하게 여길 것입니다. fs.readFile은 안 돼, 정말?
우리가 첫 번째 원칙부터 모든 것에 접근하고 있다는 것을 기억하세요. 현재로서는 서버 환경만을 대상으로 하기 때문에 브라우저에서 실행되는 코드로 컴포넌트를 제한할 필요가 없습니다. 서버는 데이터가 로드되고 표시될 준비가 될 때까지 HTML을 내보내며 기다릴 수 있기 때문에 컴포넌트가 비동기적인 것도 완벽하게 좋습니다. 👏
content prop을 제거하고 대신 await readFile() 호출을 통해 파일 콘텐츠를 로드하는 비동기 함수를 Post로 설정합니다:
async function Post({ slug }) {
let content;
try {
content = await readFile("./posts/" + slug + ".txt", "utf8");
} catch (err) {
throwNotFound(err);
}
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
마찬가지로 BlogIndexPage를 await readdir()를 사용하여 게시물 열거를 처리하는 비동기 함수로 만듭시다.
async function BlogIndexPage() {
const postFiles = await readdir("./posts");
const postSlugs = postFiles.map((file) =>
file.slice(0, file.lastIndexOf("."))
);
return (
<section>
<h1>Welcome to my blog</h1>
<div>
{postSlugs.map((slug) => (
<Post key={slug} slug={slug} />
))}
</div>
</section>
);
}
이제 Post 및 BlogIndexPage가 직접 데이터를 로드하므로 matchRoute를 Route 컴포넌트로 바꿀 수 있습니다:
function Router({ url }) {
let page;
if (url.pathname === "/") {
page = <BlogIndexPage />;
} else {
const postSlug = sanitizeFilename(url.pathname.slice(1));
page = <BlogPostPage postSlug={postSlug} />;
}
return <BlogLayout>{page}</BlogLayout>;
}
마지막으로, 최상위 서버 핸들러는 모든 렌더링을 Router에 위임할 수 있습니다:
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
await sendHTML(res, <Router url={url} />);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8080);
하지만 잠시만요, 먼저 컴포넌트 내부에서 async/await가 작동하도록 만들어야 합니다. 우리가 이걸 어떻게 해요? renderJSXToHTML 구현에서 컴포넌트를 호출하는 위치를 찾아보겠습니다:
} else if (typeof jsx.type === "function") {
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = Component(props); // <--- This is where we're calling components
return renderJSXToHTML(returnedJsx);
} else throw new Error("Not implemented.");
이제 컴포넌트 기능이 비동기식이 될 수 있으므로 await 기능을 추가해 보겠습니다:
// ...
const returnedJsx = await Component(props);
// ...
이것은 renderJSXToHTML 자체가 비동기 함수여야 한다는 것을 의미하며, 이 함수에 대한 호출은 await이 되어야 합니다.
async function renderJSXToHTML(jsx) {
// ...
}
이 변경으로 트리의 모든 컴포넌트는 비동기가 될 수 있으며 결과 HTML은 해당 컴포넌트가 resolve 될 때까지 "대기"합니다.
새 코드에서 BlogIndexPage의 모든 파일 내용을 루프에서 "준비"하는 특별한 논리는 없습니다. 블로그 인덱스 페이지는 여전히 Post 컴포넌트의 배열을 렌더링하지만, 이제 각 Post는 자신의 파일을 읽는 방법을 알고 있습니다.
부가설명) 각 await은 “blocking”이기 때문에 이 구현은 이상적이지 않습니다. 예를 들어 HTML이 모두 생성될 때까지 전송을 시작할 수도 없습니다. 생성되는 서버 페이로드를 스트리밍하는 것이 이상적입니다. 이 작업은 더 복잡하며, 이 부분에서는 수행하지 않고 지금은 데이터 흐름에만 집중적으로 살펴보겠습니다. 그러나 나중에 컴포넌트 자체를 변경하지 않고 스트리밍을 추가할 수 있습니다. 각 컴포넌트는 자체 데이터를 자체 데이터를 대기하기 위해 await을 사용하지만(이것은 피할 수 없는 일), 상위 컴포넌트는 자식이 async인 경우에도 자녀를 await할 필요가 없습니다. 이것이 React가 자녀가 렌더링을 완료하기 전에 상위 컴포넌트의 출력을 스트리밍할 수 있는 이유입니다.
아. 이게 그… React 18 Streaming과 연관이 되는건가 봅니다. React 유니버스…ㄷㄷ
이전 React 18 Streaming 글 참고 : https://velog.io/@ckstn0777/New-Suspense-SSR-Architecture-React-18-Streaming
너무 글이 길어지는 거 같아서 1편, 2편 나눠서 작성하도록 하겠습니다.
1편에서는 아무것도 없는 노드 서버에다가 JSX를 추가해보고, 컴포넌트로 분리하고, 라우팅을 추가하고, 비동기 컴포넌트까지 발명해봤네요. 정말 재밌습니다. 리액트 개발팀이 무엇을 추구하고 있는지, 어떻게 발전되고 있는지 생각을 엿볼 수 있어서 좋았습니다.