해당 게시물은 Dan이 작성한 RSC From Scratch. Part 1: Server Components의 번역본입니다.
RSC(React Server Component)에 대한 관심이 높아지고, 리액트의 미래라고 불리고 있는 와중, RSC에 대해 완벽하게 이해하고 있는 사람은 드물다고 생각합니다.
이런 상황속에서 Dan과 함께 직접 RSC를 바닥에서부터 쌓아 올리는 경험을 통해 우린 좀 더 깊게 RSC에 대해 이해할 수 있으리라 생각합니다.
주의: 어디까지나 개인적인 공부 목적으로 시작한 번역 프로젝트인지라, 영어실력 부족에 따른 가독성이 낮은 번역본이 될 수 있습니다. 이 점은 참고해주세요. (하지만 최선을 다하겠습니다!)
이 기술적인 deep-dive에서, 우린 React Server Components(RSC)에 대한 매우 간단한 버전을 바닥에서부터 구현하게 될 것입니다.
이 deep-dive 시리즈는 다음과 같은 파트로 구성 될 예정입니다.
이 deep-dive 에선 RSC의 장점에 대해, 어떻게 RSC를 사용해서 앱을 만들지, 혹은 프레임워크에서 어떻게 RSC를 사용해야 하는지에 대해 설명하지 않습니다. 대신에, RSC를 바닥에서 부터 스스로 "발명"하는 일련의 절차를 같이 경험 하게 됩니다.
🔬 이 deep-dive는 바닥에서부터 직접 구현해보면서 새로운 기술을 배우는 걸 좋아하는 사람들을 위한 것입니다.
웹 개발에 대한 약간의 배경지식과, 리액트 숙련도를 요구합니다.
🚧 이 deep-dive는 리액트 서버 컴포넌트를 어떻게 써야 하는지에 대한 소개글이 아닙니다.
현재 리액트 웹사이트에 서버 컴포넌트에 대한 문서를 작업하고 있습니다.
그 사이에 만약 사용하는 프레임워크쪽에서 서버 컴포넌트를 지원한다면, 그쪽의 문서를 참고해 주세요.
😳 교육적인 이유로, 우리의 구현물은 실제 리액트에서 사용 되고 있는 것에 비해 비교할 수 없을 만큼 비효율적이게 될 것입니다.
훗날 최적화 할 수 있는 방법에 대해 살펴보겠지만, 효율성보단 개념을 이해하는데 우선순위를 둘 예정입니다.
어느날 아침에 일어나보니 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>
(HTML을 쉽게 읽기 위해 <nav>
, <article>
그리고 <footer>
같은 태그들이 그 당시에도 존재했다고 가정하겠습니다.)
역자)
<nav>
,<article>
,<footer>
의 경우 HTML5의 등장과 함께 사용한 태그입니다. HTML5는 2014년도에 공식 표준화 되었습니다. 따라서 2003년엔 존재하지 않았던 태그입니다.
http://locahost:3000/hello-world
를 브라우저에서 열게 된다면, 이 PHP 스크립트는 ./posts/hello-world.txt
로 부터 얻은 블로그 포스트와 함께 HTML 페이지를 리턴합니다.
오늘날의 Node.js API로 짠 동일한 기능을 하는 코드는 다음과 같습니다.
import { createServer } from 'http';
import { readFile } from 'fs/promises';
import escapeHtml from 'escape-html';
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년에 가져올 수 있었고, 서버에서 위의 Node.js 코드를 실행할 수 있다고 가정해봅시다.
만약 당신이 React스러운 패러다임을 그 세계에 도입하길 원한다면, 당신은 어떤 기능을 추가하고, 어떤 순서로 추가할 계획인가요?
위에서 본 코드가 이상적이지 않은 첫번째 이유는 직접적인 문자열 조작 때문입니다.
실수로 텍스트 파일에서 뽑아낸 컨텐츠를 HTML로 착각하지 않기 위해 escapeHtml(postContent)
를 사용해야 하는걸 코드에서 확인할 수 있습니다.
이 문제를 해결할 수 있는 한가지 방법은 "템플릿"으로 부터 로직을 분리하고, 텍스트와 특성들(attributes)의 동적으로 변하는 값들을 주입할 수 있고, 텍스트 컨텐츠를 안전하게 이스케이프 하며, 조건문, 반복문을 위한 도메인별 문법을 제공하는 템플릿 언어를 도입하는 것 입니다.
이 방식은 2000년대 가장 유명했던 서버 중심 프레임워크들에서 취했던 방식입니다.
역지) 텍스트 이스케이프란?
그러나, 당신의 머리속에 존재하는 리액트 지식은 이렇게 코드를 짜고 싶다고 말할 겁니다.
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);
겉보기엔 이전 코드와 비슷해 보입니다만, 우리의 "템플릿"은 더이상 문자열이 아닙니다.
문자열 보간 코드를 작성하는 것 대신, XML 부분집합들을 Javascript에 집어 넣고 있습니다.
즉, 우린 JSX를 "발명"해낸 것입니다.
JSX는 마크업을 관련 된 렌더링 로직에 근접 할 수 있도록 해줍니다, 그러나 문자열 보간과 다르게, JSX는 HTML 태그의 열고 닫는 괄호의 불일치나 텍스트 컨텐츠의 이스케이프를 잊어버리는 것과 같은 실수를 막아줍니다.
역자) 문자열 보간이란?
좀 더 기술적으로 파고들자면, JSX는 이런 모양의 트리 객체를 생성합니다.
// 간단하게 표현함
{
$$typeof: Symbol.for("react.element"), // 리액트에게 JSX라고 보내는 신호 (예. <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: {
/* ...계속... */
}
}
]
}
}
]
}
}
그러나, 결국에 브라우저로 보내야할 것은 HTML이지 JSON 트리가 아닙니다(적어도 지금까진 말이죠!)
우리의 JSX 코드를 HTML 문자열로 바꿔주는 함수를 만들어 봅시다.
만들기 위해선, 얼마나 다양한 종류의 노드(string, number, array, children을 가지고 있는 JSX 노드)들이 HTML의 일부분이 되어야 하는지 나열해볼 필요가 있습니다.
function renderJSXToHTML(jsx) {
if (typeof jsx === "string" || typeof jsx === "number") {
// 이건 문자열입니다. 이스케이프 처리하고 HTML에 바로 집어 넣으면 됩니다.
return escapeHtml(jsx);
} else if (jsx == null || typeof jsx === "boolean") {
// 이건 비어있는 노드입니다. HTML에 아무것도 보낼 필요가 없습니다.
return "";
} else if (Array.isArray(jsx)) {
// 이건 배열 타입의 노드입니다. 배열 내 아이템들을 HTML로 변환하고 합치면 됩니다.
return jsx.map((child) => renderJSXToHTML(child)).join("");
} else if (typeof jsx === "object") {
// 객체가 React JSX 엘리먼트인지 확인합니다(e.g. <div/>)
if (jsx.$$typeof === Symbol.for("react.element")) {
// HTML 태그로 변환합니다.
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.");
}
위의 sandbox 링크를 눌러 직접 실행해 보고 HTML이 렌더링 되어 서버에서 제공되는 걸 확인해 보세요.
JSX를 HTML 문자열로 바꾸는건 보통 "Server-Side Rendering(SSR)" 이라고 알려져 있습니다.
여기선 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 트리를 <BlogPostPage postContent={postContent} author={author} />
로 교체합시다.
createServer(async (req, res) => {
const author = "Jae Doe";
const postContent = await readFile("./posts/hello-world.txt", "utf8");
sendHTML(
res,
<BlogPostPage
postContent={postContent}
author={author}
/>
);
}).listen(8080);
만약 이 코드를 renderJSXToHTML
에 아무런 변화를 주지 않고 실행한다면, 결과로 뽑힌 HTML은 망가진것 처럼 보이게 됩니다.
<!-- 이건 전혀 정상적인 HTML로 보이지 않습니다... -->
<function BlogPostPage({postContent,author}) {...}>
</function BlogPostPage({postContent,author}) {...}>
문제의 원인은 우리의 renderJSXToHTML
함수의(JSX를 HTML로 바꿔주는 역할) jsx.type이 항상 HTML 태그 이름이 담긴 문자열로 생각하기 때문입니다("html"
, "footer"
, "p"
같은)
if (jsx.$$typeof === Symbol.for("react.element")) {
// HTML 태그를 처리하는 코드.(<p>같은)
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
}
그러나 BlogPostPage
는 함수입니다. 그러므로 "<" + jsx.type + ">"
가 동작하면 위와 같은 사태가 발생하게 됩니다. 우린 함수 자체를 HTML 태그로 내보내고 싶지 않습니다.
대신, 이 함수를 실행하고, HTML으로 처리 될 JSX를 직렬화 시킵시다.
if (jsx.$$typeof === Symbol.for("react.element")) {
if (typeof jsx.type === "string") { // 이게 <div> 같은 태그인가요?
// HTML 태그를 처리하는 기존 코드(<p> 같은).
let html = "<" + jsx.type;
// ...
html += "</" + jsx.type + ">";
return html;
} else if (typeof jsx.type === "function") { // 이게 <BlogPostPage> 같은 컴포넌트인가요?
// 컴포넌트를 컴포넌트가 가지고 있는 props와 함께 실행시키고, 반환 되는 JSX를 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" />
을 마주치게 된다면, 우린 BlogPostPage
함수에 { author: "Jae Doe" }
를 전달하여 실행시킵니다. 그리고 이 함수는 추가적인 JSX를 리턴하게 됩니다.
그리고 우린 이미 이 JSX들을 어떻게 처리해야 할지 알고 있습니다.
HTML을 만들어 내고 있는 renderJSXToHTML
함수에 다시 전달하면 됩니다.
이 변경 사항은 컴포넌트와 props 전달에 대한 지원을 추가하기에 충분합니다.
확인해보세요:
이제 컴포넌트로 작업할 수 있는 기본적인 설정을 마쳤습니다.
우리의 블로그에 약간의 페이지가 더 있다면 좋겠네요.
/hello-world
같은 URL에 접근하면, ./posts/hello-world.txt
에서 얻은 컨텐츠를 기반으로 하나의 블로그 게시물 페이지가 보여지고, 루트 /
URL로 접근하면 모든 블로그 게시물의 컨텐츠를 보여주는 긴 인덱스 페이지를 보여준다고 해봅시다.
이는 BlogPostPage
와 레이아웃은 공유하지만 내부 컨텐츠는 다른 BlogIndexPage
가 필요하다는 걸 의미합니다.
현재로선, BlogPostPage
컴포넌트는 <html>
부터 전체 페이지를 보여주고 있습니다.
BlogPostPage
에서 페이지 간에 공유 할 수 있는 UI 파트를 분리하여(header
, footer
같은) 재사용 가능한 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>
);
}
<BlogPostPage>
가 <BlogLayout>
내부에 들어간 상태는 이렇게 보여집니다.
이제 ./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>
);
}
이제 BlogIndexPage
도 동일한 헤더와 푸터를 가질 수 있도록 BlogLayout
내부에 넣을 수 있게 됐습니다.
마지막으로, 서버 핸들러를 변경하여 URL 기반으로 페이지를 선택하고, 데이터를 불러오고, 페이지를 렌더링 하여 레이아웃 내부로 집어 넣을 수 있도록 합시다.
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
// URL과 페이지를 매치시키고, 필요한 데이터를 로드합니다.
const page = await matchRoute(url);
// 매치 된 페이지를 공유 레이아웃으로 감싸줍니다.
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 === "/") {
// 모든 블로그 포스트를 보여주는 인덱스 루트에 도달했습니다.
// 포스트가 저장 된 파일들을 읽어서 컨텐츠들을 가져옵시다.
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 {
// 각각의 블로그 포스트를 보여줍시다.
// 블로그 포스트가 저장 된 폴더에서 알맞는 파일을 읽어옵니다.
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
컴포넌트로 만들어도, 우린 여전히 어떻게든 각각의 블로그 포스트에 필요한 컨텐츠를 "수직으로 내려 줘야 합니다".
function Post({ slug, content }) { // 누군가가 파일로 부터 'content' 프롭을 내려줘야 합니다. :-(
return (
<section>
<h2>
<a href={"/" + slug}>{slug}</a>
</h2>
<article>{content}</article>
</section>
)
}
현재로선, 포스트들의 컨텐츠를 로딩하는 로직이 여기와 여기에 중복 되어 있습니다.
우린 readFile
API가 비동기적으로 동작하기 때문에 컴포넌트 계층구조 밖에서 불러오고 있습니다.
그러므로 우린 곧바로 컴포넌트 트리에서 이를 사용할 수 없습니다. (fs
API에 동기적으로 불러올 수 있는 방법이 존재하지만, 여기선 무시합니다. 이 API는 데이터베이스를 읽거나, 서드파티 비동기 라이브러리를 실행하는데 사용 되곤 합니다.)
...과연 우린 이 문제를 해결하지 못할까요?
만약 당신이 client-side 리액트를 사용해 봤다면, fs.readFile
같은 API를 컴포넌트에서 호출하지 못한다는 아이디어는 굉장히 익숙할 거라고 생각합니다.
그렇기에 구식의 리액트 SSR(Server Rendering) 환경에서도(역자 : 지금 우리가 만들고 있는 프로젝트), 당신의 남아있는 직관은 당신에게 각각의 컴포넌트들은 서버에서 동작하는 것 만큼이나 브라우저에서도 동일하게 동작할 수 있어야 한다고 말할거라고 생각합니다.
그래서 fs.readFile
같은 서버에서만 동작하는 API는 동작하지 않을꺼라고 생각하게 됩니다.
그러나 당신이 이걸 2003년의 누군가에게 설명하려고 한다면, 그들은 이런 제한 사항이 이상하다고 생각할 것 입니다.
fs.readFile
을 사용하지 못한다고? 진심?
우리가 첫번째 원칙에서 모든 걸 접근하고 있다는 걸 기억하세요.
지금으로선, 우린 서버 환경에만 집중하고 있습니다.
그렇기 때문에 우린 우리의 컴포넌트들이 브라우저에서 동작하는 코드로 작성 되어야 한다는 한계를 세울 필요가 없습니다.
서버는 데이터가 로드 되고, 화면에 보여줄 수 있는 준비가 될 때까지 HTML을 내보내며 기다릴 수 있기 때문에, 컴포넌트가 비동기적으로 작성 되는 것도 완벽하게 괜찮습니다.
content
프롭을 제거하고, 대신에 Post
컴포넌트를 async
함수로 변경하여 await readFile()
호출을 통해 파일에 있는 컨텐츠를 로드할 수 있도록 합시다.
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
컴포넌트를 async
함수로 변경하고, 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
를 <Router>
컴포넌트로 교체할 수 있습니다.
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); // <--- 여기가 우리가 컴포넌트 함수들을 호출하던 곳입니다.
return renderJSXToHTML(returnedJsx);
} else throw new Error("Not implemented.");
이제 컴포넌트 함수들은 비동기 함수 일수 있기 때문에, await
을 붙여줍시다.
// ...
const returnedJsx = await Component(props);
// ...
이는 renderJSXToHTML
함수 자체가 async
함수가 되어야 한다는 의미입니다. 그리고 난 뒤 위의 코드를 호출한다면, 함수의 종료를 기다릴 수 있게 됩니다.
async function renderJSXToHTML(jsx) {
// ...
}
이번 업데이트로 인해 트리에 존재하는 어떤 컴포넌트도 비동기 컴포넌트가 될 수 있게 되었습니다. 그리고 결과로 나올 HTML은 이들이 완료 될 때 까지 "기다릴" 수 있게 되었습니다.
이 새로운 코드에서 BlogIndexPage
의 모든 파일 내용을 루프에서 "준비" 하는 특별한 로직이 없음을 주목하시길 바랍니다.
우리의 BlogIndexPage
컴포넌트는 여전히 Post
컴포넌트들이 담겨 있는 배열을 렌더링 합니다. 그러나 이제 각각의 Post
컴포넌트는 자신에게 할당 된 파일을 읽는 법을 알고 있습니다.
각각의
await
은 실행을 "막기" 때문에 현재 구현한 코드가 이상적이지 않다는 점은 주목해야 합니다.
예를 들어, 우린 모든 데이터가 생성되기 전에는 서버에서 HTML을 보내는걸 시작하지도 못합니다.
이상적으론, 서버에서 생성 된 결과물(payload)을 생성 되자 마자 보내고 싶습니다.(streaming)
이 방식은 더 복잡합니다, 그리고 우린 이 방식을 이번 게시물에선 다루지 않을 것입니다. 대신 데이터 흐름에 좀 더 집중할 것 입니다.
훗날 우린 컴포넌트에 어떤 변경도 주지 않은 채, 스트리밍 기능을 추가할 수 있게 될 것입니다.
각각의 컴포넌트들은await
을 사용하여 각자의 데이터를 기다릴 수 있습니다(이는 피할 수 없습니다.), 그러나 부모 컴포넌트는 자식 컴포넌트가 비동기 컴포넌트일지라도 자식 컴포넌트를 기다릴 필요가 없습니다.
이것이 왜 리액트가 자식 컴포넌트의 렌더링이 종료 되기 전에 부모 컴포넌트를 스트리밍 할 수 있는지에 대한 이유입니다.
지금까지 우리의 서버는 HTML 문자열에 대한 경로(route)만 렌더링할 수 있습니다.
async function sendHTML(res, jsx) {
const html = await renderJSXToHTML(jsx);
res.setHeader("Content-Type", "text/html");
res.end(html);
}
이 방식은 클라이언트에서 서버로 요청을 시작할 때에는 좋은 방법입니다.
브라우저는 HTML을 최대한 빠르게 보여주도록 최적화 되어 있습니다.
그러나 네비게이션에선 그렇게 이상적이진 않습니다.
우리는 "변화 하는 부분"만 업데이트 할 수 있으면서, 동시에 그것 내외부에 존재하는 client-side 상태도 유지하고 싶습니다.(e.g. 유저 입력 값, 비디오, 팝업 등등)
이는 또한 변화가 유동적으로 이루어질 수 있도록 합니다.(e.g. 블로그 포스트에 댓글을 다는 행동)
문제를 설명하기 위해, BlogLayout
에 있는 <nav>
태그에 <input />
을 붙여 봅시다.
<nav>
<a href="/">Home</a>
<hr />
<input />
<hr />
</nav>
우리가 블로그 웹 사이트를 돌아다닐 때 마다, input에 담긴 상태가 매번 "날라가는" 걸 확인해 보세요!
컨텐츠만 보여주면 되는 간단한 블로그라면 이런 문제는 크게 중요하지 않습니다.
그러나 만약 좀 더 인터랙티브한 앱을 만들고 싶다면, 어느 순간 이 문제는 크게 다가올 것입니다.
우린 유저가 지역 상태(local state)를 계속 잃지 않고, 앱을 이동할 수 있길 원합니다.
우린 3가지 단계로 이 문제를 해결하려고 합니다.
client-side 로직이 필요한 순간입니다. client.js
파일을 사용하기 위해 <script>
태그를 추가합시다. 이 파일 내에선, 사이트에 있는 네비게이션의 기본 행동을 덮어쓸 예정이고, 이후에 네비게이션은 우리의 함수인 navigate
를 호출하게 됩니다.
async function navigate(pathname) {
// TODO
}
window.addEventListener("click", (e) => {
// 링크 클릭만 신경씁니다.
if (e.target.tagName !== "A") {
return;
}
// "새창에서 열기"는 무시합니다.
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
// 외부 URL은 무시합니다.
const href = e.target.getAttribute("href");
if (!href.startsWith("/")) {
return;
}
// 브라우저의 URL은 업데이트 할 수 있도록 냅두고, 새로고침은 하지 못하도록 합니다.
e.preventDefault();
window.history.pushState(null, null, href);
// 우리의 함수를 실행 시킵니다.
navigate(href);
}, true);
window.addEventListener("popstate", () => {
// 유저가 뒤로가기/앞으로가기 를 누른다면, 마찬가지로 우리의 함수를 실행시킵니다.
navigate(window.location.pathname);
});
navigate
함수 내부에선, 다음 경로(route)의 HTML 리스폰스를 요청하고, 그 결과로 DOM을 업데이트 하게 됩니다.
let currentPathname = window.location.pathname;
async function navigate(pathname) {
currentPathname = pathname;
// 이동할 라우트에 해당하는 HTML을 요청합니다.
const response = await fetch(pathname);
const html = await response.text();
if (pathname === currentPathname) {
// 받아온 HTML에서 <body> 태그 부분만 가져옵니다.
const bodyStartIndex = html.indexOf("<body>") + "<body>".length;
const bodyEndIndex = html.lastIndexOf("</body>");
const bodyHTML = html.slice(bodyStartIndex, bodyEndIndex);
// 페이지의 컨텐츠를 바꿔줍니다.
document.body.innerHTML = bodyHTML;
}
}
지금 작성한 코드는 프로덕션을 위한(production-ready) 코드는 아닙니다.(예를 들어, 경로 변경이 일어나도, document.title
이 변하거나 경로 변경이 이루어졌다고 알려주지 않습니다.)
그러나 우리가 성공적으로 브라우저 네비게이션 기본 행동을 덮어쓰고 있음을 알 수 있습니다.
현재로선 다음 경로를 위한 HTML을 요청하고 있고, 이때문에 <input>
상태 값은 여전히 사라지고 있습니다.
다음 단계에서 우린 우리의 서버가 네비게이션을 위해 HTML이 아닌 JSX을 보내줄 수 있도록 바꿀 예정입니다.
JSX을 만들어 주는 트리 객체를 한번 다시 떠올려 봅시다.
{
$$typeof: Symbol.for("react.element"),
type: 'html',
props: {
children: [
{
$$typeof: Symbol.for("react.element"),
type: 'head',
props: {
// ... 그리고 계속 ...
서버에 새로운 모드를 추가해 봅시다.
요청이 ?jsx
로 끝나는 경우라면, HTML 대신 위와 같은 트리를 보내려고 합니다.
이렇게 하면 클라이언트쪽에선 어떤 부분이 변경 되었는지 찾기 쉬울 것이고, 변경이 필요한 DOM 부분만 업데이트 할 수 있습니다.
이 방식은 <input>
의 상태가 매순간의 경로 이동마다 유실 되는 문제를 해결해줄 것입니다만, 이 문제만을 해결하기 위한 해결책은 아닙니다.
당장은 아니지만, 다음 파트에선 우린 이 방식이 HTML 뿐만 아니라 서버에서 클라이언트로 새로운 정보를 전달 할 수 있는 방법을 확인할 수 있습니다.
시작하기 전에, 서버에 ?jsx
서치 파라미터가 들어온 순간 새로운 함수 sendJSX
를 실행할 수 있도록 서버 코드를 변경해 봅시다.
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === "/client.js") {
// ...
} else if (url.searchParams.has("jsx")) {
url.searchParams.delete("jsx"); // <Router>로 보내는 URL은 항상 깔끔하게 처리하여 보내줍니다.
await sendJSX(res, <Router url={url} />);
} else {
await sendHTML(res, <Router url={url} />);
}
// ...
sendJSX
함수 내에선, JSON.stringify(jsx)
를 사용하여 위에서 본 트리 객체를 네트워크를 통해 전달할 수 있는 형태인 JSON 문자열로 바꿔줍니다.
async function sendJSX(res, jsx) {
const jsxString = JSON.stringify(jsx, null, 2); // 들여쓰기는 2칸을 기준으로 합니다.
res.setHeader("Content-Type", "application/json");
res.end(jsxString);
}
우린 계속 이 행동을 "JSX를 보낸다" 라고 표현하겠지만, JSX 문법 자체를("<Foo/>"
같은) 보내는게 아닙니다.
우린 단지 JSX를 통해 생성 된 트리 객체를 전달하고, JSON 형식의 문자열로 바꿔준 것 뿐입니다.
그러나 시간이 지나면서 정확한 전송 형식은 변경 될 수 있습니다.(예를 들어, 실제 RSC 구현에선 이 시리즈의 뒷편에서 보게 될 다양한 형식을 사용하고 있습니다.)
client 코드를 바꿔 네트워크를 통해 뭐가 전달 되고 있는지 확인해 봅시다.
async function navigate(pathname) {
currentPathname = pathname;
const response = await fetch(pathname + "?jsx");
const jsonString = await response.text();
if (pathname === currentPathname) {
alert(jsonString);
}
}
직접 실행시켜 보세요. 만약 인덱스("/"
) 페이지에 접속하고, 페이지 이동 링크를 누르면, 이렇게 생긴 객체를 팝업으로 볼 수 있습니다.
{
"key": null,
"ref": null,
"props": {
"url": "http://localhost:3000/hello-world"
},
// ...
}
이건 그다지 유용하지 않습니다. JSX 트리가 <html>...</html>
과 같은 형식이었으면 좋을것 같습니다. 무엇이 잘못 된 걸까요?
원래 우리의 JSX는 이렇게 생겼습니다.
<Router url="http://localhost:3000/hello-world" />
// {
// $$typeof: Symbol.for('react.element'),
// type: Router,
// props: { url: "http://localhost:3000/hello-world" } },
// ...
// }
이 JSX를 JSON으로 바꾸어 클라이언트에 보내는건 "너무 이른" 것 같습니다.
왜냐면 우린 Router
컴포넌트가 렌더링 하고 싶은 JSX가 무엇인지 모르고, Router
컴포넌트는 서버에만 존재하기 때문입니다.
우리는 어떤 JSX를 client에 보내고 싶은지 찾기 위해 Router
컴포넌트를 실행시킬 필요가 있습니다.
만약 우리가 Router
함수를 { url: "http://localhost:3000/hello-world" }
라는 props와 함께 실행시킨다면, 우린 아래와 같은 JSX 조각들을 얻어낼 수 있게 됩니다.
<BlogLayout>
<BlogIndexPage />
</BlogLayout>
다시 한번 더 말하자면, 지금의 JSX를 JSON으로 변환하여 client에 보내는건 "너무 이른" 상태입니다.
왜냐면 우린 아직 BlogLayout
컴포넌트가 어떻게 렌더링 되는지 모르고 서버에만 존재하기 때문입니다.
우린 BlogLayout
역시 실행시켜야 합니다, 그리고 client에 어떤 JSX를 보내고 싶은지 파악하고, 그 다음으로 넘어가야 합니다.
(숙련 된 리액트 유저라면 이의를 제기할 수 있습니다: "클라이언트로 필요한 코드를 보내서, 클라이언트에서 실행시키면 안돼나?"
라고 말이죠.
그 생각을 이 시리즈의 다음 파트에 도달하기 전까지 잠시 접어 두세요! 그리고 그렇게 한다고 하더라도 BlogIndexPage
컴포넌트는 fs.readdir
를 호출하기 때문에 이 방법은 BlogLayout
에서만 작동 하게 됩니다.)
프로세스의 마지막 단계에선, 우린 서버에서만 동작할 수 있는 코드에 대한 참조가 없는 JSX 트리를 얻게 될 것입니다. 예를 들면 이런 형태로 말이죠.
<html>
<head>...</head>
<body>
<nav>
<a href="/">Home</a>
<hr />
</nav>
<main>
<section>
<h1>Welcome to my blog</h1>
<div>
...
</div>
</main>
<footer>
<hr />
<p>
<i>
(c) Jae Doe 2003
</i>
</p>
</footer>
</body>
</html>
위와 같은 형태가 JSON.stringify
를 실행시켜 client로 보낼 수 있는 트리라고 할 수 있습니다.
renderJSXToClientJSX
라는 이름의 함수를 만들어 봅시다.
이 함수는 JSX를 함수 인자로 받아, client에서 이해할 수 있는 JSX만 남을때 까지, 해당하는 컴포넌트들을 실행 시켜서 서버에서만 동작하는 코드를 "해결" 하려고 시도할 겁니다.
구조적으로, 이 함수는 renderJSXToHTML
와 비슷한 형태입니다. 하지만 HTML 대신에, JSX를 탐색하며 객체들을 리턴하게 됩니다.
async function renderJSXToClientJSX(jsx) {
if (
typeof jsx === "string" ||
typeof jsx === "number" ||
typeof jsx === "boolean" ||
jsx == null
) {
// 이 타입들에 대해선 특별한 행동을 취할 필요가 없습니다.
return jsx;
} else if (Array.isArray(jsx)) {
// 배열에 있는 각각의 아이템을 처리합니다.
return Promise.all(jsx.map((child) => renderJSXToClientJSX(child)));
} else if (jsx != null && typeof jsx === "object") {
if (jsx.$$typeof === Symbol.for("react.element")) {
if (typeof jsx.type === "string") {
// <div/ > 같은 컴포넌트를 의미합니다.
// 프롭들을 검사하여 JSON 형식으로 변환 될 수 있도록 합니다.
return {
...jsx,
props: await renderJSXToClientJSX(jsx.props),
};
} else if (typeof jsx.type === "function") {
// <Footer /> 같은 커스텀 리액트 컴포넌트를 의미합니다.
// 컴포넌트 함수를 실행시키고, 반환 되는 JSX에 대해 절차를 반복합니다.
const Component = jsx.type;
const props = jsx.props;
const returnedJsx = await Component(props);
return renderJSXToClientJSX(returnedJsx);
} else throw new Error("Not implemented.");
} else {
// 임의의 객체를 의미합니다(예를 들어, props, props 내부에 있는 무언가)
// 내부에 있는 값들을 확인하면서, JSX가 존재하는 경우 처리합니다.
return Object.fromEntries(
await Promise.all(
Object.entries(jsx).map(async ([propName, value]) => [
propName,
await renderJSXToClientJSX(value),
])
)
);
}
} else throw new Error("Not implemented");
}
다음으로 sendJSX를 편집하여 <Router />
와 같은 JSX를 "client JSX"로 변환한 뒤 문자열로 바꿔줍니다.
async function sendJSX(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
const clientJSXString = JSON.stringify(clientJSX, null, 2); // 들여쓰기는 2칸으로 합니다.
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
}
이제 링크를 클릭하면, HTML과 비슷한 모습의 트리 값이 팝업 되는 것을 확인할 수 있습니다.
드디어 트리를 변경할 수 있게 되었습니다!
당장 우리의 목표는 기능이 동작하도록 만드는 것입니다, 그러나 구현한 기능에 많은 아쉬움이 남습니다.
우선 포맷 자체가 굉장히 장황하고 중복 되는 부분이 있습니다.(실제 RSC 에선 더 작고 컴팩트한 포맷을 사용합니다.)
이전 HTML를 서버에서 생성하는 코드를 작성할 때 말했지만, 전체 응답(response)이 한꺼번에 "대기"되는 건 좋지 않습니다.
이상적으로, 우린 JSX가 전송 가능해졌을 때, 바로 JSX 조각(chunk)으로 서버에서 스트리밍하고, client 에서 합쳐지길 바랍니다.
또한 공용으로 사용 되는 레이아웃(<html>, <nav> 같은
) 이 변하지 않는다는걸 인지하고 있음에도 매번 재전송 하는 부분이 아쉽습니다.
알맞게 화면 전체를 새로고침 하는 능력을 가지고 있다는 건 좋지만, 단일 레이아웃 형태의 웹사이트 네비게이션에서 매번 레이아웃을 다시 요청하는 건 이상적이지 않습니다.
프로덕션을 위한(production-ready) RSC 에선 이런 문제를 겪지 않지만, 우리는 쉽게 이해할 수 있는 코드를 유지하기 위해 코드를 수정하지 않고 일단 넘어가겠습니다.
구조적으로 보자면, JSX 변경을 위해 리액트를 사용할 필요는 없습니다.
지금까지 우리의 JSX 노드들은 <nav>, <footer> 와 같은
내장(built-in) 브라우저 컴포넌트들만 포함하고 있습니다.
우리는 client-side 컴포넌트에 대한 개념이 전혀 없는 라이브러리를 사용하여 JSX를 변경하고 업데이트해도 전혀 무방합니다.
그러나 나중에 우린 풍부한 상호작용 능력을 원하기 때문에 처음부터 리액트를 사용하겠습니다.
우리의 앱은 HTML로 서버 렌더링 됩니다.
리액트가 브라우저에서 리액트에서 생성하지 않은 DOM 노드를 관리할 수 있도록 하기 위해선(HTML을 통해 만들어진 DOM 노드 같은), 그 DOM 노드에 상응하는 초기 JSX를 리액트에게 제공해야 합니다.
비유를 들자면, 집 수리를 하기 전에 집 설계도를 보고 싶어 하는 계약자가 있다고 상상해 봅시다.
그들은 미래에 안전하게 집을 수리하기 위해 원본 설계도를 요구할겁니다.
비슷하게, 리액트는 특정 DOM을 구성하는 JSX가 무엇인지 확인하기 위해 DOM을 순회합니다.
이는 리액트가 DOM 노드들에 이벤트 핸들러를 붙여주고, 인터랙티브하게 만들어 주거나 나중에 업데이트 할 수 있도록 해줍니다.
이제 DOM 노드들은 마치 식물들이 물을 먹고 생기가 살아나는 것 처럼 전부 hydrated 상태가 됩니다.
일반적으로 서버 렌더링 된 마크업을 hydrate 하기 위해선, 리액트에서 관리하고 싶은 DOM 노드와 서버에서 부터 만들어 진 초기 JSX 와 함께 hydrateRoot
를 실행하게 됩니다.
해당하는 코드는 이렇게 생겼을겁니다.
// 일반적으로 이런식으로 hydrate 하게 됩니다.
hydrateRoot(document, <App />);
문제는 우리의 client 측엔 <App />
같은 루트 컴포넌트가 없다는 것입니다!
client의 입장에서 보자면, 현재 우리의 앱은 리액트 컴포넌트는 아예 없는 하나의 거대한 JSX 덩어리입니다.
그러나, 리액트가 진짜 원하는건 초기 HTML에 상응하는 JSX 트리입니다.
<html>...</html>
같은 서버에게 만들라고 시켰던 "client JSX" 트리는 다음과 같이 동작합니다.
import { hydrateRoot } from 'react-dom/client';
const root = hydrateRoot(document, getInitialClientJSX());
function getInitialClientJSX() {
// TODO: 초기 HTML에 상응하는 <html>...</html> 형식의 client 트리를 리턴
}
지금 당장 이 코드를 실행한다면, client JSX 트리에는 어떤 컴포넌트도 존재하지 않기 때문에, 굉장히 빠르게 동작할 것입니다.
리액트는 DOM 트리와 JSX 트리를 거의 동시에 탐색하면서, 훗날 트리를 업데이트 하기 위해 필수적으로 필요한 내부 데이터 구조를 만들어 냅니다.
그리고 언제든지 유저가 페이지 사이를 이동한다면, 다음 페이지를 위한 JSX를 가져와 root.render
를 사용하여 DOM을 업데이트 할 것입니다.
async function navigate(pathname) {
currentPathname = pathname;
const clientJSX = await fetchClientJSX(pathname);
if (pathname === currentPathname) {
root.render(clientJSX);
}
}
async function fetchClientJSX(pathname) {
// TODO: 다음 라우트 페이지를 위한 <html>...</html> client JSX 트리를 요청하고 리턴하기
}
이걸 통해 우린 우리가 원하는 리액트가 평소에 하는 것 처럼 상태를 망가뜨리지 않고 DOM을 업데이트 할 수 있는 기능을 얻어낼 수 있게 될 겁니다.
이제 이 두가지 함수를 어떻게 구현하면 좋을지 생각해 봅시다.
fetchClientJSX
함수의 구현이 훨씬 쉽기 때문에 이 함수부터 구현해 봅시다.
먼저 서버가 ?jsx
를 받게 되면 어떻게 동작 했었는지 기억해 봅시다.
async function sendJSX(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
const clientJSXString = JSON.stringify(clientJSX);
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
}
client 쪽에선 ?jsx
를 붙여 서버로 요청을 보낼 것이고, 요청에 대한 결과를 JSON.parse
함수에 넣어 다시 JSX로 변환 시키게 됩니다.
async function fetchClientJSX(pathname) {
const response = await fetch(pathname + "?jsx");
const clientJSXString = await response.text();
const clientJSX = JSON.parse(clientJSXString);
return clientJSX;
}
지금까지의 구현 코드를 실행시켜 본다면 , 링크를 클릭하여 JSX를 요청하여 렌더링을 하려고 해도 매번 에러를 마주치게 될 것입니다.
Objects are not valid as a React child (found: object with keys {type, key, ref, props, _owner, _store}).
이유는 우리가 JSON.stringify
를 통해 보내는 객체의 모습이 이러하기 때문입니다:
{
$$typeof: Symbol.for("react.element"),
type: 'html',
props: {
// ...
그러나 client 쪽에서 JSON.parse
를 사용하여 변환한 결과를 확인해보면, $$typeof
속성이 전송 과정 중에 사라진 것을 확인할 수 있습니다.
{
type: 'html',
props: {
// ...
$$typeof: Symbol.for("react.element")
없인 클라이언트에 있는 리액트는 이걸 올바른 JSX 노드로 인식하길 거부하게 됩니다.
이는 의도적인 보안 매커니즘 때문인데, 기본적으로 리액트는 네트워크에서 가져온 임의의 JSON 객체를 JSX 태그로 인식하길 거부합니다.
핵심은 Symbol.for('react.element')
같은 심볼 값은 JSON 직렬화 작업에서 "생존" 할 수 없고, JSON.stringify
동작 중에 날라가게 됩니다.
이 보안 매커니즘은 우리의 앱에서 작성하지 않은 JSX를 렌더링 하지 않도록 보호해 줍니다.
그러나, 우린 이 JSX 노드들을 우리의 서버에서 생성하였고 클라이언트에서 렌더링 하길 원합니다.
그러므로 우린 우리의 로직을 바꾸어 JSON 직렬화 할 수 없는 $$typeof: Symbol.for("react.element")
를 같이 보낼 수 있도록 처리해야 합니다.
다행히도 고치는데 어렵지는 않습니다.
JSON.stringify
함수는 JSON이 생성 되는 방식을 우리가 커스터마이즈 할 수 있도록 해주는 replacer 함수를 인자로 받습니다.
서버에서 우린 Symbol.for('react.element')
를 "$RE"
라는 특별한 문자열로 바꿔줄 것입니다.
async function sendJSX(res, jsx) {
// ...
const clientJSXString = JSON.stringify(clientJSX, stringifyJSX); // 두번째 인자를 주목하세요 (replacer 함수)
// ...
}
function stringifyJSX(key, value) {
if (value === Symbol.for("react.element")) {
// 우린 심볼 값을 전달할 수 없습니다. 대신에, 마법의 문자열을 전달합니다.
return "$RE"; // 어떤 문자열을 사용해도 무방합니다. 저는 "React Element"에서 따온 RE를 사용하겠습니다.
} else if (typeof value === "string" && value.startsWith("$")) {
// 충돌을 방지하기 위해, $로 시작하는 문자열들에는 $를 앞쪽에 추가적으로 붙여줍니다.
return "$" + value;
} else {
return value;
}
}
client 에선 JSON.parse
함수에 reviver 함수를 전달하여 "$RE"
를 Symbol.for('react.element')
로 교체할 수 있도록 합니다.
async function fetchClientJSX(pathname) {
// ...
const clientJSX = JSON.parse(clientJSXString, parseJSX); // 두번째 인자를 주목하세요(reviver 함수)
// ...
}
function parseJSX(key, value) {
if (value === "$RE") {
// 서버에서 붙여줬던 특별한 식별자입니다.
// 심볼 값을 복구시켜 리액트에게 올바른 JSX라고 알려줍시다
return Symbol.for("react.element");
} else if (typeof value === "string" && value.startsWith("$$")) {
// $로 시작하는 문자열이라면, 서버에서 붙여줬던 추가적인 $를 떼어줍니다
return value.slice(1);
} else {
return value;
}
}
이제 우리는 다시 페이지 간 이동이 가능해 졌습니다. 하지만 이번엔 변경 사항들이 JSX 타입으로 서버에서 전달 되고, client에 적용 되고 있습니다!
input
태그에 타이핑을 하고 링크를 눌러본다면, <input>
태크의 상태가 첫번째 시도를 제외 하곤 모든 네비게이션이 이루어지면서 잘 보존되는 걸 확인할 수 있을 겁니다.
이는 우리가 리액트에게 페이지의 초기 JSX 값이 무엇인지 알려주지 않아서 그렇습니다. 그렇기 때문에 서버에서 받은 HTML에 적절하게 적용 되지 못한 겁니다.
우린 여전히 다음과 같은 코드를 가지고 있습니다:
const root = hydrateRoot(document, getInitialClientJSX());
function getInitialClientJSX() {
return null; // TODO
}
우리는 초기 client JSX와 함께 root를 hydrate 시켜야 합니다.
그러나 client에서 그 값을 어디서 얻을 수 있을까요?
우리의 페이지는 HTML으로 서버에서 렌더링 됩니다.
그러나 추가적인 페이지 이동을 위해선 우린 리액트에게 그 페이지의 초기 JSX를 알려줘야 합니다.
경우에 따라선, HTML에서 부분적으로 재구성할 수 있을 겁니다.
그러나 항상 가능하지 않고, 특히나 이 시리즈의 다음 파트에서 진행할 인터랙티브 기능을 추가 할때는 더더욱 불가능해집니다.
그리고 이 방식은 불필요한 waterfall 을 만들어 내기 때문에 요청하고 싶지도 않습니다.
일반적인 SSR을 사용하는 리액트에서 우리는 비슷한 문제를 마주치지만 오직 데이터가 연관된 경우입니다.
우리는 컴포넌트들을 hydrate 할 수 있고, 컴포넌트들의 초기 JSX를 얻을 수 있기 때문에 페이지에 대한 데이터가 필요합니다.
우리의 경우엔, 아직까진 페이지에 어떤 컴포넌트도 존재하지 않습니다.(최소한 브라우저에서 동작하는건 없습니다)
그렇기 때문에 실행시킬 컴포넌트도 없습니다. 그러나 초기 JSX를 어떻게 만들지 알고 있는 코드 또한 client에 없습니다.
이를 해결하기 위해, 문자열로 이루어진 초기 JSX는 클라이언트에서 전역 변수로 존재할 수 있다고 가정하겠습니다.
const root = hydrateRoot(document, getInitialClientJSX());
function getInitialClientJSX() {
const clientJSX = JSON.parse(window.__INITIAL_CLIENT_JSX_STRING__, reviveJSX);
return clientJSX;
}
서버쪽에선, sendHTML
함수를 수정하여 우리의 앱을 client JSX로 렌더링하고, HTML의 마지막 부분에 포함시키겠습니다.
async function sendHTML(res, jsx) {
let html = await renderJSXToHTML(jsx);
// paint 작업을 막는걸 피하기 위해 HTML 생성 이후에 JSX를 직렬화 합니다.
const clientJSX = await renderJSXToClientJSX(jsx);
const clientJSXString = JSON.stringify(clientJSX, stringifyJSX);
html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
html += `</script>`;
// ...
마지막으로, 리액트가 hydrate 할수 있도록 어떻게 텍스트 노드들을 통해 HTML을 만들어 낼 수 있는지에 대한 약간의 조정 이 필요합니다.
이제 페이지간 이동시에 input
태그에 타이핑을 해도 상태 값을 잃지 않게 되었습니다.
우리의 목표를 달성했습니다!
물론, 이 input
태그의 상태를 보존 했다는게 중요한게 아닙니다.
중요한건 우리의 앱이 이제 새로고침하거나 어느 페이지를 이동해도 상태를 무너뜨리지 않을 수 있게 되었다는 겁니다.
실제 RSC 에선 HTML 페이로드에서 JSX를 인코딩하지만, 몇 가지 중요한 차이점이 있습니다.
production-ready RSC 설정에선 JSX 를 하나의 거대한 덩어리로 보내지 않고, JSX 조각들이 생성 되자 마자 보내게 됩니다.
리액트가 로드 되면, hydration 은 리액트가 전체 JSX 조각들이 다 전송 되길 기다리지 않고 이미 전송 가능 상태인 JSX 조각들을 가지고 트리 탐색을 시작 할때 바로 실행 됩니다.
RSC 는 또한 특정 컴포넌트들을 클라이언트 컴포넌트(client components) 라고 마크할 수 있는데, 이 컴포넌트는 SSR 처리 되어 HTML을 얻어낼 수 있지만 컴포넌트 관련 코드는 번들에 포함 되어 있는 컴포넌트 입니다.
클라이언트 컴포넌트를 위해서, 클라이언트 컴포넌트들의 props 에 해당하는 JSON 만이 직렬화 됩니다.
가까운 미래엔, 리액트는 HTML과 임베드 된 페이로드 사이의 컨텐츠의 중복을 제거할 수 있는 매커니즘을 추가할 수 있습니다.
우리의 코드가 정상 동작하니, 코드 구조를 실제 RSC와 약간 비슷하게 만들어 보겠습니다.
스트리밍 같은 복잡한 매커니즘을 아직 구현하진 않았지만, 몇가지 결점을 보안하고, 다음 기능을 위한 준비를 하려고 합니다.
초기 HTML을 어떻게 만들어 내고 있는지 다시 확인해 봅시다.
async function sendHTML(res, jsx) {
// <Router /> 를 "<html>...</html>" 로 바꿔야 했습니다.(문자열)
let html = await renderJSXToHTML(jsx);
// *또한* <Router /> 를 <html>...</html> 로 바꿔야 했습니다.(객체)
const clientJSX = await renderJSXToClientJSX(jsx);
함수의 인자로 들어오는 jsx를 <Router url="https://localhost:3000" />
라고 가정해봅시다.
먼저 Router
와 다른 컴포넌트들을 재귀적으로 호출하여 HTML 문자열을 만들어 내는 renderJSXToHTML
를 호출합니다.
초기 클라이언트 JSX도 필요하기 때문에, 다시 Router
와 다른 컴포넌트들을 호출하는 renderJSXToClientJSX
를 바로 호출합니다.
우린 모든 컴포넌트들을 두번씩 호출하고 있습니다! 이 방식은 느릴 뿐만 아니라, 부정확할 가능성도 있습니다.
예를 들어 Feed
컴포넌트를 렌더링 한다고 가정한다면, 이 두 함수에서 서로 다른 결과 값을 받을 수 있습니다.
우린 어떻게 데이터가 흘러가야 할지 다시 생각해 봐야 합니다.
클라이언트 JSX 트리를 먼저 만든다면 어떨까요?
async function sendHTML(res, jsx) {
// 1. <Router /> 를 먼저 <html>...</html> 형태로 바꿉니다 (객체)
const clientJSX = await renderJSXToClientJSX(jsx);
이 시점에서 우리의 컴포넌트들은 이미 호출 된 상태입니다. 그렇다면 해당 트리로 부터 HTML을 만들어 봅시다.
async function sendHTML(res, jsx) {
// 1. <Router /> 를 먼저 <html>...</html> 형태로 바꿉니다(객체)
const clientJSX = await renderJSXToClientJSX(jsx);
// 2. <html>...</html> 를 "<html>...</html>" 형태로 바꿉니다 (문자열)
let html = await renderJSXToHTML(clientJSX);
// ...
이제 컴포넌트들은 요청마다 한번씩만 호출 됩니다.
초창기엔 어떻게 우리의 컴포넌트들이 실행 되는지를 제어하기 위해 맞춤형 renderJSXToHTML
함수를 필요로 했습니다.
예를 들어 해당 함수에 async
함수를 지원할 수 있도록 기능을 추가했습니다.
그러나 지금 우린 이미 실행 된 클라이언트 JSX 트리를 넘겨주고 있기에 맞춤형으로 구현한 기능들을 유지할 필요가 없습니다.
필요없는 기능들을 지우고, 대신에 리액트의 내장 renderToString
를 사용합시다.
import { renderToString } from 'react-dom/server';
// ...
async function sendHTML(res, jsx) {
const clientJSX = await renderJSXToClientJSX(jsx);
let html = renderToString(clientJSX);
// ...
클라이언트 코드와 비슷하다는게 느껴지나요?
async
컴포넌트와 같은 새로운 기능을 구현하긴 했지만, 여전히 우린 renderToString
혹은 hydrateRoot
같은 리액트의 API들을 사용할 수 있습니다.
우리가 이걸 사용하는 방식이 달라졌을 뿐입니다.
일반적인 서버 렌더링 리액트 앱에선, renderToString
와 hydrateRoot
를 루트 컴포넌트 <App />
와 함께 호출합니다.
하지만 우리의 방식에선, renderJSXToClientJSX
를 사용하여 "서버" JSX 트리를 얻어내고, 결과 값을 리액트 API에게 전달합니다.
일반적인 서버 렌더링 리액트 앱에선, 컴포넌트들은 서버와 클라이언트 모두 동일한 방식으로 실행 됩니다.
그러나 우리의 접근 방식에선, Router
, BlogIndexPage
, Footer
같은 컴포넌트들은 적어도 당장은 서버에서만 효율적입니다
renderToString
와 hydrateRoot
에 관한 한, Router
, BlogIndexPage
, Footer
들은 애초에 존재하지 않았던 거나 다름 없습니다.
이쯤에서 그들은 이미 트리에서 "녹아 없어지고" 난 뒤며, 결과 값만이 남게 됩니다.
이전 단계에서, 실행 중인 컴포넌트가 HTML을 생성하지 않도록 분리했습니다.
renderJSXToClientJSX
는 컴포넌트들이 클라이언트 JSX를 생성하도록 합니다.renderToString
이 클라이언트 JSX를 HTML로 변경합니다.서로 독립적인 동작이기 때문에, 이들을 같은 절차, 혹은 같은 기계에서 완료 될 필요가 없습니다.
이를 증명하기 위해, server.js
를 2개의 파일로 나눕시다.
server/rsc.js
: 이 서버는 우리의 컴포넌트들을 실행시킵니다. 항상 JSX를 리턴하며 HTML을 리턴하지 않습니다. 만약 컴포넌트들이 데이터베이스에 접근한다면, 지연시간 감소를 위해 서버를 데이터 센터와 가까운데서 실행하는게 좋습니다.server/ssr.js
: 이 서버는 HTML을 만들어 냅니다. HTML을 만들고, 정적 파일들을 제공하기 위해 "edge" 에서 실행 시킬 수 있습니다.우리의 package.json
에서 둘을 동시에 실행시킵니다.
"scripts": {
"start": "concurrently \"npm run start:ssr\" \"npm run start:rsc\"",
"start:rsc": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/rsc.js",
"start:ssr": "nodemon -- --experimental-loader ./node-jsx-loader.js ./server/ssr.js"
},
이 예제에선, 둘은 하나의 같은 컴퓨터에서 동작하게 됩니다. 그러나 원한다면 서로 다른 컴퓨터에서 동작시켜도 괜찮습니다.
RSC 서버는 컴포넌트들을 렌더링 합니다. JSX 결과물만 내보낼 수 있습니다:
// server/rsc.js
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
await sendJSX(res, <Router url={url} />);
} catch (err) {
console.error(err);
res.statusCode = err.statusCode ?? 500;
res.end();
}
}).listen(8081);
function Router({ url }) {
// ...
}
// ...
// ... 지금까지 만든 다른 컴포넌트들 ...
// ...
async function sendJSX(res, jsx) {
// ...
}
function stringifyJSX(key, value) {
// ...
}
async function renderJSXToClientJSX(jsx) {
// ...
}
또 다른 서버는 SSR 서버입니다.
SSR 서버는 유저들이 요청하는 서버입니다.
RSC 서버에게 JSX를 요청하고, 페이지 간 이동을 위해 JSX를 문자열로 제공하거나, 초기 로드를 위해 JSX를 HTML로 만듭니다.
// server/ssr.js
createServer(async (req, res) => {
try {
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname === "/client.js") {
// ...
}
// RSC 서버로 부터 직렬화 된 JSX를 얻습니다.
const response = await fetch("http://127.0.0.1:8081" + url.pathname);
if (!response.ok) {
res.statusCode = response.status;
res.end();
return;
}
const clientJSXString = await response.text();
if (url.searchParams.has("jsx")) {
// 유저가 페이지 간 이동중이라면, 직렬화 된 JSX를 그대로 보내줍니다.
res.setHeader("Content-Type", "application/json");
res.end(clientJSXString);
} else {
// 초기 페이지 로드 상황이라면, 트리를 복구하고 HTML로 만듭니다.
const clientJSX = JSON.parse(clientJSXString, parseJSX);
let html = renderToString(clientJSX);
html += `<script>window.__INITIAL_CLIENT_JSX_STRING__ = `;
html += JSON.stringify(clientJSXString).replace(/</g, "\\u003c");
html += `</script>`;
// ...
res.setHeader("Content-Type", "text/html");
res.end(html);
}
} catch (err) {
// ...
}
}).listen(8080);
우린 시리즈 내내 RSC와 SSR, 유저 처리 서버와 같은 "나머지 세계" 를 분리하여 이어나가고자 합니다.
그 중요성은 다음 부분에서 우리가 이 두 세계에 기능을 추가하고 그것들을 연결하기 시작할 때 더 분명해질 것입니다.
(엄밀히 말하자면, 기술적으로 RSC와 SSR을 하나의 프로세스에서 실행하는건 가능합니다. 다만 서로의 모둘 환경이 독립적이어야 합니다. 이는 고급 주제이고, 이번 게시물에선 벗어난 주제입니다.)
오늘은 여기까지입니다!
많은 양의 코드를 적은 것 같지만, 실제로 그렇진 않습니다.
이들을 쭉 읽어보세요. 우리의 머리속에 데이터 흐름을 "정착" 시키기 위해 몇가지 도표를 그려봅시다.
이 도표는 첫 페이지 로드 동안 발생하는 일들을 표현하고 있습니다:
그리고 이건 우리가 페이지 간 이동을 할 때 어떤 일이 일어나는지를 표현한 도표입니다:
마침내 몇가지 용어들을 정리해봅시다.
역자)
SSR은 리액트 클라이언트 이며
의 링크에 달린 원본을 번역해두었습니다. 아래 링크에서 확인해 보세요.
[번역] Why do Client Components get SSR'd to HTML?
이 게시물을 읽는게 당신의 호기심을 충족시키지 못했다면, 최종 코드를 가지고 놀아보는건 어떤가요?
여기 몇가지 시도해볼만한 아이디어입니다.
<body>
에 랜덤 배경색을 넣어보고, 배경색의 트랜지션을 추가해보세요. 페이지를 이동할 때, 배경색이 변경 되는 애니메이션을 볼 수 있을 겁니다.<>
) 지원 기능을 추가해보세요. 이 기능은 몇줄의 코드로 구현할 수 있지만, 어느 부분에 코드를 작성해야 하고, 그 코드가 무슨 일을 할지에 대해 알아내야 합니다.react-markdown
라이브러리의 <Markdown>
컴포넌트를 사용하여 블로그 포스트 형식을 마크다운으로 바꾸어보세요. 네, 지금 우리의 코드는 이를 처리할 수 있습니다!react-markdown
컴포넌트는 서로 다른 태그들에 맞춤형 구현을 할 수 있도록 지원하고 있습니다. 예를 들어, 우린 우리만의 Image
컴포넌트를 만들고 <Markdown components={{ img: Image }}>
형태로 내보낼 수 있습니다. 이미지 치수를 재고(npm 패키지를 사용해도 좋습니다), 자동으로 width
, height
를 만들어 내는 Image
컴포넌트를 만들어 보세요.<form>
이 필요할겁니다. 추가적인 챌린지로, client.js
에 로직을 확장하여 form 제출을 가로채서 페이지 새로고침을 방지해보세요. form 제출 대신에 페이지 JSX를 다시 불러와 댓글 리스트를 업데이트 시키세요.client.js
를 수정하여 뒤로가기/앞으로가기
에서 이전에 캐시 된 결과 값들을 사용할 수 있게 해보세요. 대신에 링크를 클릭 하는건 항상 새로운 결과를 받아올 수 있어야 합니다. 이 방식은 뒤로가기/앞으로가기
가 바로바로 동작하는 것 처럼 느껴지게 합니다. 이는 마치 브라우저가 전체 페이지 이동을 위해 처리하는 방식과 비슷합니다.{page}
를 무언가로 감싸면 "Router" 컴포넌트를 URL이 다른 페이지를 다른 컴포넌트로 취급하도록 가르치는 것이 좋습니다. 그 뒤에 우린 이 "무언가"가 네트워크 상에서 사라지지 않도록 해야 합니다.) 재미있게 보내세요!
이번년도 1월에 우연히 원티드를 통해 프론트엔드 챌린지를 진행하게 되었고, 챌린지를 위해 디스코드 채널에 가입하여 2주동안 챌린지를 진행했던 순간이 있었습니다.
챌린지 종료 이후에도 멘토님과 다른 개발자분들께서 꾸준히 양질의 개발 관련 컨텐츠들을 공유해주셨고, 지금도 간간히 양질의 개발 관련 글들이 올라오고 있습니다.
Dan abramov 가 작성한 이 글도 여기 디스코드 채널을 통해서 공유 받았던 글이었습니다.
react와 next.js에서 점차 서버 컴포넌트를 강조하고, RSC에 대한 관심이 오르고 있던 지금, react의 유명 contributor 인 Dan이 직접 작성한 RSC 관련 컨텐츠는 구미가 당기는 컨텐츠였습니다.
그러나 원본 글 링크를 누르고 내용을 확인한 순간, 엄청난 양의 볼륨과 영어라는 언어의 장벽으로 인해 쉬운 도전은 아니었습니다.
그러나 Dan
, RSC
, from scratch
, deep-dive
라는 키워드들은 이 글을 읽을만한 가치가 있음을 증명하고 있었고, 시간을 들여서라도 읽는다면 분명 +가 될 수 있으리라 판단하여 천천히 읽게 되었습니다.
아직 전체 시리즈가 완성 되지 않았고, 기나긴 여정의 첫 발걸음을 땐 상태이기 때문에 글을 다 읽더라도 시원하지 않은 구석이 있으리라 생각하고 있습니다.
그리고 모든 내용이 다 이해가 안될 수도 있다고 생각합니다. 사실 저도 번역 후기를 쓰고 있는 지금도 완벽하게 모든 내용을 이해한 상태는 아니라고 고백할 수 있습니다.
그러나 Dan 과 함께 정말 바닥에서부터 RSC를 만들기 위해 코딩하는 이 일련의 경험을 같이 해본다면 적어도 SSR과 서버 컴포넌트가 어떻게 동작하는지 원리를 파악하고, 이전엔 "마법처럼" 여겨졌던 부분들을 코드로 이해할 수 있으리라 생각합니다.
이 글을 읽는 독자분들께도 저와 비슷한 경험이 전달 되길 바라며 마치겠습니다.
긴 글 읽어주셔서 감사합니다! 고생하셨습니다!
좋은 글 감사합니다. ^^