프레임워크 없는 바닐라 리액트 서버 컴포넌트
원문: https://krasimirtsonev.com/blog/article/vanilla-react-server-components-with-no-framework
저는 지난 6월 ReactSummit에 참석했고, Vercel 사람들과 Next.js와 프레임워크 밖에서의 RSC 지원에 대해 이야기했습니다. 강력한 기능들을 마음껏 써보고 싶었지만, 거대한 애플리케이션들을 Next.js로 마이그레이션 할 여유가 없었습니다. 결론은 명확했습니다. 프레임워크 없이 RSC 사용을 시작하는 것은 어렵다는 것이었습니다.
“내가 직접 해보자”는 마음으로 탐구를 시작했습니다. 솔직히 쉽지 않았습니다. 하지만 3개월 후, 저는 해결책을 가지게 되었습니다. 이 시점에 Forket을 여러분께 소개합니다. 이 도구는 코드를 클라이언트와 서버 버전으로 분리하여, 프레임워크 없이 리액트 서버 컴포넌트로 실행할 수 있게 해 줍니다.
목차:
- 멘탈 모델
- Forket이 정확히 하는 일
- 사용 방법
- 마지막 말
멘탈 모델
연구를 시작했을 때, Next.js 외부에도 해결책들이 있다는 것을 발견했지만, 이들은 불완전하거나 Vite나 esbuild와 같은 특정 도구에 종속되어 있었습니다. 더 파고들수록, 사실 우리가 가진 건 올바르게 구현되지 않은 단순한 패턴일 뿐이라는 걸 알게 되었습니다.
이는 당시의 Flux를 떠올리게 했습니다. 새로운 아이디어를 도입했지만, 그 아이디어가 기존 애플리케이션에 어떻게 맞춰져야 하는지에 대한 명확한 방향이 부족했을 때의 패턴이었습니다. 이는 우리(개발자들)에게 달린 일이므로, 저는 라이브러리에 구애받지 않는 도구를 설계하기로 결정했습니다. 기존 툴체인에 묶이는 대신, 그것들보다 먼저, 또는 병렬로 작동합니다.
제가 계속 돌아오게 된 핵심 아이디어 중 하나는 제 코드의 두 가지 버전을 갖는 것이었습니다. 이 패턴의 핵심은 프런트엔드와 백엔드 사이의 경계를 모호하게 만드는 것입니다. 하지만 기술적으로, 그 경계는 여전히 존재하며 매우 엄격합니다. 초기에는 어딘가에 정확히 그런 일을 하는 도구가 있을 것이라고 생각했지만, 불행히도 그렇지 않았습니다. 그래서 직접 만들었습니다.
그래서 다른 모든 것보다 먼저 실행되는 도구가 필요했습니다. 이것은 제 코드의 서버와 클라이언트 버전을 생성할 것입니다. 그 후 제 빌드 도구는 client 경로의 소스를 기반으로 클라이언트 번들을 준비하고, 제 Node 서버는 server 경로의 파일들을 사용하여 HTTP 서버를 가동할 것입니다.
이러한 접근 방식을 취함으로써, 저는 다른 도구들의 내부 구조를 방해하지 않고 분리된 상태로 글루 코드(glue code)를 구현할 자유를 얻게 됩니다.
Forket이 정확히 하는 일
(이 세부사항들을 실제로 알 필요는 없습니다. 해결책을 시도해 보고 싶다면 사용 방법 섹션으로 이동하세요.)
Forket이 실행되면 소스 파일을 하나씩 읽어 처리하기 시작합니다. 자바스크립트나 타입스크립트가 아닌 파일은 그대로 복사합니다. 나머지에 대해서는 일련의 작업들을 실행합니다.
1. 그래프 구축
먼저, 컴포넌트 트리와 그 종속성을 나타내는 그래프가 필요합니다. 라이브러리는 말 그대로 파일 내용을 읽고, 텍스트를 AST(추상 구문 트리)로 변환하고, 코드를 분석합니다. 그 분석의 일부는 import 문을 찾아내는 것입니다. 그다음 각 파일에 역할을 설정합니다. 저는 이 부분이 특히 자랑스러운데, 그래프를 콘솔에서 볼 수 있도록 만드는 데 시간을 투자했기 때문입니다. 결과는 다음과 같이 보입니다.
그래프에 (server)와 (client) 파일이 모두 포함되어 있는 것을 주목하세요. (client)로 표시된 것들은 번들되어 브라우저로 전송되며, (server)로 표시된 것들은 백엔드에 남아있습니다. 서버 액션들도 강조표시됩니다. 예를 들어 /src/server-actions/auth.js에서 (SAs: login, logout)가 이에 해당합니다.
2. 코드의 “서버” 버전 생성. 클라이언트 경계 찾기
여기서 주요 목표는 클라이언트 경계를 식별하고 클라이언트에서의 하이드레이션을 위해 준비하는 것입니다. 그 준비 과정은 다음을 포함합니다.
- 컴포넌트 props 직렬화 — 원시 값들만 전송됩니다. 함수들은 서버 액션이 아닌 이상 건너뛰며, 서버 액션인 경우 특정 문자열 ID로 교체됩니다. 프로미스(promise)를 전달할 때도 마찬가지입니다.
- children 추출 — 렌더링 된
children은 하이드레이션 중에 재사용될 수 있도록<template>태그 안에 배치됩니다. - 글루 코드(glue code) — 추가 로직이 클라이언트 경계의 하이드레이션을 트리거합니다.
다음은 서버에서 note를 로드하고 클라이언트에서 comment를 로드하는 서버 컴포넌트 예시입니다.
export default async function Page({ example }) {
const note = await db.notes.get(42);
const commentsPromise = db.comments.get(note.id);
return (
<div className="container">
<div>
{note.content}
<Comments commentsPromise={commentsPromise} />
</div>
</div>
);
}우리는 노트를 await하고(db.notes.get을 통해) 그 내용을 렌더링 하지만, 댓글에 대해서는 그리 신경 쓰지 않습니다. 대신, db.comments.get은 클라이언트 <Comments> 컴포넌트에 전달하는 프로미스를 반환합니다. 이것은 흥미로운 혼합을 만들어냅니다. 일부 로직은 순전히 백엔드에서 실행되지만 프런트엔드로 흘러갑니다.
Forket이 서버용으로 준비한 후 컴포넌트 코드입니다. 즉, 우리의 Node 서버가 렌더링 하여 브라우저로 스트리밍 할 것입니다.
export default async function Page({ example }) {
const note = await db.notes.get(42);
const commentsPromise = db.comments.get(note.id);
return (
<div className="container">
<div>
{note.content}
<CommentsBoundary commentsPromise={commentsPromise} />
</div>
</div>
);
}
function CommentsBoundary(props) {
const serializedProps = JSON.stringify(
forketSerializeProps(props, "Comments", "f_43")
);
const children = props.children;
return (
<>
{children && (
<template type="forket/children" id="f_43" data-c="Comments">
{children}
</template>
)}
<template type="forket/start/f_43" data-c="Comments"></template>
<Comments {...props} children={children} />
<template type="forket/end/f_43" data-c="Comments"></template>
<script
id="forket/init/f_43"
dangerouslySetInnerHTML={{
__html: `$F_booter(document.currentScript, "f_43", "Comments", ${JSON.stringify(
serializedProps
)});`,
}}
></script>
</>
);
}컴포넌트들을 렌더링 한 후, 노드 서버는 다음을 브라우저로 전송합니다.
<div>
Note 42
<template type="forket/start/f_43" data-c="Comments"></template>
<p>Loading comments...</p>
<template type="forket/end/f_43" data-c="Comments"></template>
<script id="forket/init/f_43">
$F_booter(
document.currentScript,
"f_43",
"Comments",
'{"commentsPromise":"$FLP_f_0"}'
);
</script>
</div>Note 42는 노트의 내용입니다. <p>Loading comments...</p>는 프로미스가 해결되기 전에 <Comments> 컴포넌트가 기본적으로 반환하는 것입니다. $F_booter 함수의 마지막 인수를 주목하세요. 이것은 클라이언트 경계 props의 직렬화된 버전입니다. 프로미스는 Forket이 클라이언트에서 파싱 하여 실제 프로미스로 변환할 단순한 문자열입니다.
3. 코드의 “클라이언트” 버전 생성. 서버 액션 찾기.
여기서 주요 도전 과제는 서버 액션(서버 함수)이 사용되는 곳을 감지하고, 실제 구현을 생략하는 무언가로 교체하는 것입니다. 목표는 코드가 오직 서버에만 존재하도록 보장하는 것입니다. 따라서 번들되거나 브라우저로 전송되지 않습니다.
다음은 서버 액션을 사용하는 예시입니다.
"use client";
import { createNote } from "./actions.js";
export default function EmptyNote() {
return <button onClick={() => createNote()}>Create note</button>;
}actions.js의 내용은 다음과 같습니다.
"use server";
import db from "./db.js";
export async function createNote() {
return await db.notes.create();
}Forket이 파일을 처리한 후 우리가 얻는 결과입니다.
"use client";
const createNote = function (...args) {
return window.FSA_call("$FSA_createNote", "createNote")(...args);
};
export default function EmptyNote() {
return (
<button onClick={() => createNote().then(console.log)}>Create note</button>
);
}따라서 실제 createNote 대신, 우리의 클라이언트 코드는 FSA_call이라는 전역적으로 사용 가능한 함수를 트리거할 것입니다. 이것은 우리의 Node 서버에 요청을 만들고 올바른 코드 조각을 실행할 것입니다.
4. 클라이언트 진입점 주석 추가
서버 컴포넌트를 사용할 계획이라면, 단일 페이지 애플리케이션에 대한 생각을 조정해야 할 것입니다. 이 모델에서는 서버가 주도권을 잡고, 브라우저는 소위 아일랜드(island)들을 하이드레이션 합니다.
Forket은 이러한 아일랜드 컴포넌트들을 전역 스코프로 내보내는 단계를 포함하여, 다른 유틸리티들이 그것들을 찾아 적절한 곳에서 하이드레이션 할 수 있게 합니다. 유일한 요구사항은 루트 경로에 상단에 "use client"가 있는 파일이 최소 하나 있어야 한다는 것입니다.
다음은 클라이언트 진입점의 예시입니다.
"use client";
import ReactDomClient from "react-dom/client";
import React from "react";
import f_6 from "./components/Feed.tsx";
window.$f_6 = f_6;
/* FORKET CLIENT */
// @ts-ignore
(()=>{(function(){let y=new Map,w=window.$F_renderers={},...Forket은 리액트가 있는지와 <Feed> 컴포넌트가 있는지 확인합니다.
사용 방법
작동 방식을 이해했으니, 실제 설정에서 Forket을 사용하는 데 얼마나 많은 노력이 필요한지 살펴보겠습니다. 먼저, Forket 작업 시 두 가지 관점이 있다는 것을 알아두는 것이 중요합니다.
- 빌드 타임에서 — 라이브러리가 코드를 두 부분(클라이언트와 서버)으로 분리합니다.
- 런타임 글루 코드(glue code) — 컴포넌트들을 스트리밍 하고 클라이언트에서 하이드레이션 합니다.
둘 다 여기에서 찾을 수 있는 공유 구성을 사용합니다. 본질적으로, 두 개의 필수 옵션이 있는 forket.config.js 파일입니다.
// forket.config.js
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const config = {
sourceDir: path.normalize(path.join(__dirname, "src")),
buildDir: path.normalize(path.join(__dirname, "build")),
};
export default config;우리는 기본적으로 소스 코드가 어디에 있는지와 변환 후 파일들을 어디에 저장할지를 말합니다.
빌드 타임에서
라이브러리를 설치한 후(npm install forket을 통해) 다음을 실행할 수 있습니다.
> npx forket이 명령은 근처에서 forket.config.js를 찾고 마법을 시작할 것입니다.
다른 방법은 Forket 자바스크립트 API를 통하는 것입니다.
import Forket from "forket";
const forket = await Forket({
watch: true, // src 경로의 변경사항 감시
printGraph: true,
});
await forket.process();런타임에서
위에서 말했듯이 서버와 클라이언트가 함께 작동하도록 하는 데 필요한 약간의 글루 코드(glue code)가 있습니다.
HTTP 서버 계측
express와 같은 어떤 종류의 HTTP 서버 라이브러리가 있다고 가정해 보겠습니다.
import express from "express";
import Forket from "forket";
const port = 8087;
const app = express();
const server = http.createServer(app);
Forket().then((forket) => {
app.use("/@forket", forket.forketServerActions());
app.get(
"/",
forket.serveApp({
factory: (req) => <App request={req} />,
})
);
});
server.listen(port, () => {
console.log(`App listening on port ${port}.`);
});우리는 서버 함수들을 위한 엔드포인트를 정의하고 Forket이 우리의 메인 페이지 컴포넌트를 서빙하도록 보장하고 있습니다. 내부적으로 라이브러리는 renderToPipeableStream을 사용하여 리액트 컴포넌트들을 스트리밍 합니다. /@forket 경로는 다른 것을 원한다면 구성 가능하다는 것을 주목하세요.
최소 하나의 클라이언트 진입점
그리고 루트 경로에 "use client"가 있는 파일을 최소 하나 생성하는 것을 잊지 말아야 합니다.
"use client";이것으로 준비가 끝났습니다. 빌드 타임에 우리의 소스 코드는 build 경로로 변환될 것입니다. 그 후 우리의 일반적인 파이프라인이 클라이언트 번들을 생성하고 HTTP 서버를 가동할 것입니다.
다음은 예시 중 하나가 터미널에서 어떻게 보이는지입니다.
마지막으로
Forket을 구축하는 3개월은 정말 멋진 시간이었고, 그 과정에서 많은 즐거움을 느꼈습니다. 저는 분명히 계속 사용하면서 발전시킬 예정입니다.
다른 사람들이 시도해 보고 피드백을 공유하기를 기대합니다 — 여러분의 설정에서 원활하게 작동하는지 아닌지 상관없이 말입니다. Vite, Webpack, 또는 다른 도구들을 사용하는 프로젝트에 이것을 가져오는 것을 탐구하는 것도 기꺼이 하겠습니다.
