HTML의 발전을 상상하며 함수, 객체, 서버/클라이언트 참조로 확장한 새로운 시각을 탐구합니다. React Server Components의 원리를 근본부터 쉽게 풀어 설명합니다.
여기 HTML 한 조각이 있습니다:
<html>
<body>
<p>Hello, world</p>
</body>
</html>
만약 여러분이 평생 이것만 본 적 있다면, HTML에 어떤 기능을 어떤 순서로 추가하고 싶으신가요?
어디서 시작할까요?
개인적으로, 저는 HTML 태그를 직접 정의할 수 있게 하는 기능부터 추가하고 싶습니다.
복잡할 필요는 없습니다. 그냥 자바스크립트 함수를 쓰면 됩니다:
<html>
<body>
<Greeting />
</body>
</html>
function Greeting() {
return <p>Hello, world</p>
}
이게 동작하려면, HTML이 네트워크로 전송(즉 "직렬화")될 때, 서버는 이러한 태그를 그 반환값으로 대체해야 한다고 명시합시다:
<html>
<body>
<p>Hello, world</p>
</body>
</html>
더 이상 호출할 태그 함수가 없으면 HTML은 전송 준비 완료입니다.
깔끔한 기능이죠?
초기에 추가했다니 다행입니다.
이 경험이 앞으로의 방향에 영향을 미칠 수도 있습니다.
이제 태그에 속성을 전달하고 값을 삽입(interpolate)하는 것도 지원해 봅시다.
<html>
<body>
<Greeting name="Alice" />
<Greeting name="Bob" />
</body>
</html>
function Greeting({ name }) {
return <p>Hello, {name}</p>
}
물론, 저 아규먼트가 꼭 문자열일 필요는 없습니다.
Greeting
에 객체를 넘겨보는 것도 좋겠죠:
<html>
<body>
<Greeting person={{ name: 'Alice', favoriteColor: 'purple' }} />
<Greeting person={{ name: 'Bob', favoriteColor: 'pink' }} />
</body>
</html>
function Greeting({ person }) {
return (
<p style={{ color: person.favoriteColor }}>
Hello, {person.name}
</p>
);
}
객체는 관련된 정보를 묶는 데 도움이 됩니다.
지금까지의 명세에 따라, 위 HTML을 직렬화하면:
<html>
<body>
<p style={{ color: 'purple' }}>Hello, Alice</p>
<p style={{ color: 'pink' }}>Hello, Bob</p>
</body>
</html>
여전히 객체는 완전히 제거되지 않았습니다.
{ color: '...' } 같은 객체를 보내도 괜찮을까요?
객체는 어떻게 처리할까요?
우리가 아는 “진짜” HTML은 객체라는 개념이 없습니다. 진짜 HTML을 출력하려면 style
을 문자열로 포맷팅해야 합니다:
<html>
<body>
<p style="color: purple">Hello, Alice</p>
<p style="color: pink">Hello, Bob</p>
</body>
</html>
하지만 HTML을 새롭게 상상한다면, 기존 한계를 지킬 필요가 없습니다. 예를 들어, 서버가 우리 상상 속 HTML을 JSON 트리로 직렬화하도록 명세해 봅시다:
["html", {
children: ["body", {
children: [
["p", {
children: "Hello, Alice",
style: { color: "purple" }
}],
["p", {
children: "Hello, Bob",
style: { color: "pink" }
}]
]
}]
}]
네? 어리둥절하신가요?
이 이상한 JSON 표현이 아직은 그리 흥미롭지도 유용하지도 않습니다. 하지만 앞으로는 이것을 기본 출력 포맷으로 삼겠습니다. "진짜" HTML보다 더 풍부하고 표현력 있는 포맷이니까요. HTML과 객체 모두를 담을 수 있습니다. 이로써 style
객체든, 원하면 어떤 객체든 그대로 유지할 수 있습니다.
다시 곱씹어 보면 이걸 나중에 진짜 HTML로 변환하는 건 어렵지 않겠구나 싶을 겁니다.
정말 필요하다면요.
이전에는 HTML에 객체를 하드코딩했습니다:
<html>
<body>
<Greeting person={{ name: 'Alice', favoriteColor: 'purple' }} />
<Greeting person={{ name: 'Bob', favoriteColor: 'pink' }} />
</body>
</html>
function Greeting({ person }) {
// ...
}
하지만 다른 곳에서 가져올 수도 있습니다.
파일 시스템에서 데이터를 읽어오죠:
<html>
<body>
<Greeting person={JSON.parse(await readFile('./alice123.json', 'utf8'))} />
<Greeting person={JSON.parse(await readFile('./bob456.json', 'utf8'))} />
</body>
</html>
function Greeting({ person }) {
// ...
}
여기 있는 await
에 주목하세요. 데이터 읽기는 보통 비동기입니다!
사실 약간 반복적인 느낌이 드는데, 이번엔 await
를 Greeting
함수 안으로 옮겨봅시다:
<html>
<body>
<Greeting username="alice123" />
<Greeting username="bob456" />
</body>
</html>
async function Greeting({ username }) {
const filename = `./${username.replace(/\W/g, '')}.json`;
const person = JSON.parse(await readFile(filename, 'utf8'));
return (
<p style={{ color: person.favoriteColor }}>
Hello, {person.name}
</p>
);
}
명세를 약간 수정해야 하겠네요. 이제 서버가 상상 속 HTML을 직렬화할 때, 등장하는 async
함수 태그는 반드시 await
해야 합니다.
최종 결과는 여전히 같은 JSON:
["html", {
children: ["body", {
children: [
["p", {
children: "Hello, Alice",
style: { color: "purple" }
}],
["p", {
children: "Hello, Bob",
style: { color: "pink" }
}]
]
}]
}]
그리고 원한다면 이 JSON을 진짜 HTML로 바꿀 수 있습니다:
<html>
<body>
<p style="color: purple">Hello, Alice</p>
<p style="color: pink">Hello, Bob</p>
</body>
</html>
상상 속 "HTML" 덕분에 사용자의 언어로 말하기가 가능해집니다:
<html>
<body>
<Greeting username="alice123" />
<Greeting username="bob456" />
</body>
</html>
async function Greeting({ username }) {
// ...
}
이건 특정 username에 대한 _인사말_이죠. 단순한 "문단"이 아닙니다.
데이터를 어디서 불러오든, 뭘 출력하든지는 Greeting
의 구현 디테일입니다.
멋지죠!
함수 태그를 만나면 뭘 해야 할까요?
<html>
<body>
<Greeting />
</body>
</html>
function Greeting() {
return <p>Hello, world</p>
}
그 함수를 호출하고, 그 반환값으로 태그를 대체합니다.
<html>
<body>
<p>Hello, world</p>
</body>
</html>
이로써 모든 함수를 전송 시점에 없앨 수 있으니, 함수 전송에 대해 신경 쓸 일이 없습니다.
그런데, 지금 _실행_하는 것이 아니라 나중에 함수를 실행해야 한다면 어떨까요?
예를 들어, 클릭 시?
<button>
Like
</button>
함수를 네트워크를 통해 전달해야 합니다.
코드를 문자열로 넘길 수도 있습니다:
<button onClick="addLike()">
Like
</button>
하지만 이건 유지보수성이 너무 떨어집니다.
onLike
을 제대로 된 함수로 쓰고 싶을 경우:
<button onClick={onLike}>
Like
</button>
function onLike() {
addLike();
}
문자열 없이 쓰면 어디서 실행되는지 애매합니다. onLike
이 서버에서 실행되나요? 그럼 writeFile
같은 것도 쓸 수 있나요? 아니면 브라우저에서 실행? 그때는 alert
도 되죠?
이 애매함은 JSON.stringify
가 함수를 누락시키는 것에서도 나타납니다:
["button", {
children: "Like"
// onClick 빠짐 :(
}]
기본적으로 JSON.stringify
는 함수를 모르니까요. (아예 명세에, 태그로 쓰지 않은 위치에 함수가 등장하면 에러를 내도록 합시다. 그래야 명확한 선택을 강요할 수 있겠죠.)
자, 이제 _진짜_로 뭘 원하는지 생각해 봅시다.
onClick
코드를 <script>
태그로 클라이언트에 보내고 싶다고 해 봅시다.
그러려면 어느 <script>
파일의 어떤 함수인지 알아야 합니다. 다음과 같이 인코딩할 수 있을 겁니다:
["button", {
children: "Like",
onClick: "/src/bundle.js#onLike"
}]
'/src/bundle.js#onLike'
를 _클라이언트 참조(Client Reference)_라고 합시다. 서버에서 클라이언트 코드의 "주소"로 쓰는 방식이죠.
브라우저가 이 형식을 직접 이해한다면 해당 <script>
를 불러와 onClick 핸들러를 붙일 것입니다. 그렇지 않다고 해도 이 JSON만 있으면 "진짜" HTML로 바꾸는 것은 충분히 가능합니다:
<button id="btn">Like</button>
<script src="/src/bundle.js"></script>
<script>btn.onclick = onLike;</script>
이 코드를 어떻게 작성하고 싶을까요?
당연히 onLike
를 정식 함수로 쓰고 싶겠죠:
<button onClick={onLike}>
Like
</button>
function onLike() {
addLike();
}
이건 현실적이지 않습니다. 서버와 클라이언트 코드를 한 파일에 섞는 일은 너무 혼란스러우니까요. 한 환경에서만 작동하는 코드를 어떻게 구분할까요? 의존성이 어디서 실행되는지 어떻게 아나요?
HTML을 반으로 나눠봅시다. 첫 번째 절반은 서버에서 시작합니다:
<button onClick={onLike}>
Like
</button>
두 번째 절반은 클라이언트로 보낼 코드입니다:
function onLike() {
addLike();
}
이제 실제로 "분할"을 선언하는 문법이 필요합니다. 이 모듈을 import할 때, 클라이언트 참조만 받고 진짜 함수 본문은 받지 않는다는 뜻이죠. 다행히 이미 존재하는 문법이 있습니다:
import { onLike } from './onLike';
<button onClick={onLike}>
Like
</button>
'use client'; // 클라이언트 참조로 직렬화해라
export function onLike() {
alert('You liked this.');
}
'use client'
지시어는 "import할 때 진짜 함수를 반환하지 않고, 참조(주소)만 준다"는 의미입니다.
["button", {
children: "Like",
onClick: "/src/bundle.js#onLike"
}]
이 JSON은 나중에 다양한 방식으로 해석 가능하겠습니다. <button>
과 함께 <script>
로 붙여 둘 수도 있고, HTML 생성 없이 클라이언트에서 직접 document.createElement('button')
으로 만들어 클릭 핸들러를 달 수도 있습니다.
중요한 점은, 이제 코드를 클라이언트로 보낼 _1급 구문_이 있다는 것입니다. 상상 속 HTML의 <script>
태그는 필수가 아닙니다. 대신 'use client'
가 <script>
역할을 모듈 시스템의 일부로 대체합니다.
함수를 직렬화하는 한 방법이 <script>
태그로 보내는 것이죠.
하지만 그게 유일한 방식은 아닙니다.
다른 일반적인 패턴은 onLike
을 서버에 두고 클라이언트가 _호출 가능_하게 만드는 것입니다(예: POST fetch()
호출). 다음처럼 인코딩 가능하죠:
["button", {
children: "Like",
onClick: "/api?fn=onLike"
}]
'/api?fn=onLike'
를 _서버 참조(Server Reference)_라 부릅시다. 클라이언트에서 서버 코드의 "주소"로 참조하는 방식입니다.
"진짜" HTML로 변환한다면, 여러 방법이 있습니다. <button>
의 onclick
에서 fetch로 호출하거나, HTML <form action="/api?fn=onLike">
와 같이 더 들이밀 수도 있습니다. 아니면 HTML 생성 자체를 건너뛰고 클라이언트에서 모두 처리할 수도 있죠.
이 코드를 어떻게 쓰고 싶을까요?
이 경우에는 코드를 분리할 필요가 없습니다. 둘 다 서버에서 동작하니까요. 애매함이 없습니다:
<button onClick={onLike}>
Like
</button>
async function onLike() {
const likes = Number(await readFile('./likes.txt', 'utf8'));
await writeFile('./likes.txt', likes + 1, 'utf8');
}
여전히 함수가 API 엔드포인트로 노출될 수 있도록 _어떤 신호_가 필요합니다.
마침 이미 만들어둔 문법이 있습니다:
<button onClick={onLike}>
Like
</button>
async function onLike() {
'use server'; // Server Reference로 직렬화해라
const likes = Number(await readFile('./likes.txt', 'utf8'));
await writeFile('./likes.txt', likes + 1, 'utf8');
}
'use server'
지시어는 "이 함수를 직렬화할 때 Server Reference(주소)로 변환해라, 클라이언트가 API처럼 호출할 수 있게"란 뜻입니다.
["button", {
children: "Like",
onClick: "/api?fn=onLike"
}]
이후 HTML로 바꿀 수도 있고(경우에 따라 progressive enhancement 동작도 가능), 순수하게 자바스크립트로 클라이언트에서 해석해도 됩니다.
중요한 점은, 이제 서버 함수를 클라이언트에 _직접 전달_할 수 있는 방법이 마련됐다는 것입니다. 즉, API 호출 자체가 _모듈 시스템_의 일부가 된 셈입니다.
지금까지 몇 가지 새로운 프리미티브로 HTML을 확장해왔습니다:
<script>
)fetch
)이 설계에서 자동으로 나오는 흥미로운 결과가 있습니다.
클라이언트 참조를 import하여 _태그로 써본다_고 가정해 봅시다:
import { LikeButton } from './LikeButton';
<LikeButton color="purple" />
'use client'; // 클라이언트 참조로 직렬화해라
export function LikeButton({ color }) {
function onLike() {
alert('You liked this.');
}
return (
<button onClick={onLike} style={{ color }}>
Like
</button>
);
}
이전 규칙에 따르면, 직렬화 과정에서 태그로 등장하는 함수는 모두 실행해야 하지만, 클라이언트 참조는 함수가 아니죠:
["/src/bundle.js#LikeButton", {
color: "purple"
}]
즉, JSON을 직렬화하는 서버는 더 이상 할 일이 없습니다. 다시 말해, 일부 태그의 실행을 _나중에 미룰 수 있다_는 얘기입니다.
이런 태그를 클라이언트 태그(Client Tag)라 부릅시다.
이 JSON을 "진짜" HTML로 바꾸는 방법은 여러 가지입니다. 클라이언트(브라우저)에서만 동작하도록 렌더링한다면 <script>
로 번들 자바스크립트와 data만 넣으면 됩니다:
<script src="bundle.js"></script>
<script>
const output = LikeButton({ color: 'purple' });
render(document.body, output);
</script>
또는, 빠른 첫 화면을 위해 미리 클라이언트 태그를 "진짜" HTML로 렌더링(Prefetch)하는 것도 가능합니다:
<!-- 초기 HTML (선택사항) -->
<button>
Like
</button>
<!-- 인터랙티브 -->
<script src="bundle.js"></script>
<script>
const output = LikeButton({ color: 'purple' });
render(document.body, output);
</script>
아니면 아예 HTML은 안 만들고 클라이언트에서 코드만 로드할 수도 있죠.
어떤 전략을 쓰든, 필요한 정보는 모두 JSON에 들어 있습니다:
["/src/bundle.js#LikeButton", {
color: "purple"
}]
이제 서버/클라이언트 양쪽 모두에서 직접 만든 태그를 조합할 수 있게 됐습니다:
import { LikeButton } from './LikeButton';
<>
<PersonalizedLikeButton username="alice123" />
<PersonalizedLikeButton username="bob456" />
</>
async function PersonalizedLikeButton({ username }) {
const filename = `./${username.replace(/\W/g, '')}.txt`;
const color = await readFile(filename);
return <LikeButton color={color} />;
}
'use client';
export function LikeButton({ color }) {
function onLike() {
alert('You liked this.');
}
return (
<button onClick={onLike} style={{ color }}>
Like
</button>
);
}
직렬화 과정에서 PersonalizedLikeButton
같은 서버 태그는 실행되어서 결과값만 남깁니다. 역직렬화시에는 LikeButton
같은 클라이언트 태그가 실제 동작, HTML·DOM·그 외 무엇이든 만들어냅니다.
이로써 불가능했던 컴포넌트—양쪽 모두의 영속적인 상태와 데이터 로딩을 캡슐화한 추상 컴포넌트—를 만들 수 있습니다.
또한 클라이언트와 서버의 행위를 조합할 수 있습니다. 예를 들어, onLike
의 일부 로직을 addLike
로 서버로 넘겨봅시다:
'use server';
import { readFile, writeFile } from 'fs/promises';
export async function addLike() {
const likes = Number(await readFile('./likes.txt', 'utf8'));
await writeFile('./likes.txt', likes + 1, 'utf8');
}
'use client';
import { addLike } from './actions';
export function LikeButton({ color }) {
async function onLike() {
await addLike();
alert('You liked this.');
}
return (
<button onClick={onLike} style={{ color }}>
Like
</button>
);
}
이제 LikeButton
이 백엔드의 일부와 직접 연결되어 있습니다.
클라이언트와 서버 코드를 한 파일에 섞지 않으면서도, 'use client'
와 'use server'
덕분에 양뱡향 참조가 가능합니다. 이를 통해 서버와 클라이언트의 결합 관계를 타입 및 모듈 단위로 안전하게 표현할 수 있습니다. (예전엔 stringly 스타일로 <script>
태그·API 라우트 등 문자 조작에 의존했죠.)
초기 렌더에서는 HTML을 완전히 생성하는 것이 이롭습니다(필수는 아니지만요). 이렇게 하면 페이지에서 클라이언트 참조 관련 <script>
들이 로드되는 동안 사용자에겐 미리 내용을 보여줄 수 있습니다. 심지어 HTML로 미리 렌더링하면 <script>
를 일찍부터 로딩할 수 있어 더 좋습니다.
그런데 우리 주 출력 포맷은 HTML이 아니라 JSON입니다. 그래서 <Router>
클라이언트 태그를 만들어 내비게이션 시마다 다음 화면의 JSON을 받아서, 기존 DOM 위에 부드럽게 반영할 수도 있습니다. 모든 데이터 가져오기는 단 1회 왕복만으로 끝납니다(서버 태그는 직렬화 시 이미 모두 실행함). 클라이언트에선 JSON을 DOM에 "그레이스풀"하게 적용할 충분한 자유가 있습니다. 그리고 이 JSON에는 모든 클라이언트 태그의 최신 속성이 들어가 있으니, 객체든 뭐든 문제가 없습니다.
나중엔 <Router>
클라이언트 태그를 중첩시켜 각 경로 세그먼트별로 더 세밀한 부분 갱신도 할 수 있습니다. 이런 <Router>
는 서버 참조로 새 JSON 트리를 fetch해서 사용합니다.
이 접근법의 한 가지 약점은, 모든 비동기 서버 태그가 resolve될 때까지 전체 페이지 렌더링이 블록된다는 점입니다. 이를 해결할 방법을 생각해 보죠.
비동기 태그 여러 개를 렌더링한다고 해 봅시다.
function Page() {
return (
<Layout>
<PostContent />
<PostComments />
</Layout>
);
}
function Layout({ children }) {
return (
<article>
<header>Welcome to Overreacted</header>
{children}
<footer>Thanks for reading</footer>
</article>
);
}
async function PostContent() {
// ...
}
async function PostComments() {
// ...
}
서버가 직렬화 시, 바깥쪽부터 JSON을 만들어가면서 미완성 부분은 "구멍"으로 남겨둘 수 있습니다:
["article", {
children: [
["header", {
children: 'Welcome to Overreacted'
}],
/* HOLE_1 */,
/* HOLE_2 */,
["footer", {
children: 'Thanks for reading'
}]
]
}]
구멍 부분은 서버에서 resolve될 때마다 _스트림_으로 채워 넣습니다:
["article", {
children: [
["header", {
children: 'Welcome to Overreacted'
}],
/* HOLE_1 */,
/* HOLE_2 */,
["footer", {
children: 'Thanks for reading'
}]
]
}]
/* HOLE_1: */["article", { children: [["p", "Here is a piece of HTML:", ...]]}]
/* HOLE_2: */["ul", { className: "comments", children: [["li", { children: "Server rendering sucks, you should only do things on the client" }], ["li", { children: "Client rendering sucks, you should only do things on the server" }]] }]
심지어 같은 방식으로 Promise 직렬화도 가능합니다. (진짜?)
"팝핑(pop) 효과"와 같은 UX는 피하고 싶습니다. 즉, _계산_은 최대한 스트리밍으로 하더라도, 사용자가 인지하는 로딩 상태는 의도적으로 만들고 싶다는 뜻이죠. 사실 진짜 HTML에는 선언적 로딩 상태를 위한 프리미티브가 없기 때문에 페이지가 로딩되면서 "튀는" 현상이 있습니다.
우리 버전의 HTML에는 이런 로딩 상태를 위한 프리미티브가 있습니다.
그 이름을 <Placeholder>
라 하죠:
function Page() {
return (
<Layout>
<PostContent />
<Placeholder fallback={<CommentsGlimmer />}>
<PostComments />
</Placeholder>
</Layout>
);
}
function CommentsGlimmer() {
return <div className="glimmer" />;
}
직렬화에는 직접 영향은 없습니다. JSON에서 클라이언트 태그로 보입니다:
["article", {
children: [
["header", {
children: 'Welcome to Overreacted'
}],
/* HOLE_1 */,
["Placeholder", {
fallback: ["div", { className: "glimmer" }],
children: /* HOLE_2 */
}],
["footer", {
children: 'Thanks for reading'
}]
]
}]
/* HOLE_1: */["article", { children: [["p", "Here is a piece of HTML:", ...]]}]
/* HOLE_2: */["ul", { className: "comments", children: [["li", { children: "Server rendering sucks, you should only do things on the client" }], ["li", { children: "Client rendering sucks, you should only do things on the server" }]] }]
역직렬화하는 쪽은 "구멍"이 다 채워질 때까지 기다릴지, 또는 fallback
을 먼저 표시할지 전략을 정할 수 있습니다.
예를 들어, 100% 정적 HTML로 블로그를 생성한다면 "구멍" 다 채워질 때까지 기다리는 것이 최선입니다. fallback을 보여주는 이점이 없습니다.
반대로, 서버에서 실시간으로 페이지를 만든다면 fallback
으로 나오는 HTML을 곧바로 보여주고, 완성된 나머지 HTML을 인라인 <script>
로 동적으로 붙여 넣을 수도 있습니다. 이로써 사용자는 의도적인 로딩 단계를 경험하게 됩니다:
포스트 본문 같은 영역은 반드시 화면을 가로막을 수 있도록 하고 싶습니다. 블로그 본문은 본질적이기 때문이죠. 그런데 만약 포스트 영역도 글리머로 로딩시키고 싶다면, <Placeholder>
위치만 바꾸면 됩니다:
function Page() {
return (
<Layout>
<Placeholder fallback={<PostGlimmer />}>
<PostContent />
<PostComments />
</Placeholder>
</Layout>
);
}
혹은 nested Placeholder도 가능합니다:
function Page() {
return (
<Layout>
<Placeholder fallback={<PostGlimmer />}>
<PostContent />
<Placeholder fallback={<CommentsGlimmer />}>
<PostComments />
</Placeholder>
</Placeholder>
</Layout>
);
}
<Placeholder>
는 일종의 loading 상태 try / catch
문과 같습니다. 중요한 점은, 이것이 직렬화에 특정 의미를 강요하지 않으므로, 클라이언트는 원하는 방식으로 해석할 수 있습니다. (예: 중첩 fallback의 표시 타이밍을 조절하거나, 전체 버퍼가 끝날 때까지 기다리는 등)
(실제로 HTML을 생성해도 아키텍처상 "클라이언트"입니다. "클라이언트"란 이 JSON 같은 출력을 해석(interprete)하는 무엇이든 가리킵니다.)
“진짜” HTML과 달리, 앞서 설명한 JSON 구조는 100% 조합(Composable)됩니다. 예를 들어, 클라이언트 <Counter />
태그를 서로 다른 데이터로 3번 렌더링하고 싶다면, JSON으로 아주 간단하게 표현할 수 있습니다:
["div", {
children: [
["/src/chunk123.js#Counter", { initialCount: 0, color: "pink" }],
["/src/chunk123.js#Counter", { initialCount: 10, color: "purple" }],
["/src/chunk123.js#Counter", { initialCount: 100, color: "blue" }],
]
}]
<script>
태그로 같은 걸 만들고 3번 반복하면, 스크립트 중복되고 충돌이 납니다:
<button id="counter">0</button>
<script src="/src/chunk123.js"></script>
<script>Counter('#counter', { initialCount: 0, color: "pink" })</script>
<button id="counter">10</button>
<script src="/src/chunk123.js"></script>
<script>Counter('#counter', { initialCount: 10, color: "purple" })</script>
<button id="counter">100</button>
<script src="/src/chunk123.js"></script>
<script>Counter('#counter', { initialCount: 100, color: "blue" })</script>
풀 수는 있지만, 마지막 단계에서 처리하는 게 훨씬 쉽습니다.
좀 더 복잡한 예로, 블로그 포스트을 정렬 가능한 리스트로 만드는 서버 태그가 있다고 해 봅시다:
서버 태그 결과 모든 내부 태그가 평가되고 나면 다음과 같은 JSON이 나옵니다:
["div", {
className: "mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans",
children: ["/chunk123.js#SortableList", {
items: [
["/chunk456.js#ExpandingSection", {
extraContent: ["p", {children: "I wrote a bit of JSX in my editor: [...]"}],
children: [
["a", { href: "/a-chain-reaction", children: "A Chain Reaction" }],
["i", { children: "2,452 words" }]
]
}],
["/chunk456.js#ExpandingSection", {
extraContent: ["p", {children: "You wrote a few components with Hooks [...]"}],
children: [
["a", { href: "/a-complete-guide-to-useeffect", children: "A Complete Guide to useEfffct" }],
["i", { children: "9,913 words" }]
]
}],
/* ... */
]
}]
}]
이 JSON은 _초기 정적 결과 뿐만 아니라 전체 동작_을 기술합니다. 클라이언트에서 interactive한 SortableList
, ExpandingSection
의 동작 코드와 위치까지 모두 알려주죠.
이는 "진짜" HTML로도 바꿀 수 있지만, 훨씬 더 구조화된 포맷입니다.
이 구조는 서버 캐싱에도 특히 유리합니다. 서버 사이드 렌더링 시, 화면의 일부만 여러 요청에서 재활용하려면 과거에는 HTML "partial"의 캐싱이 많았습니다. 특히 구멍(hole)이 있는 partial을 캐싱하면 바뀌기 쉬운 부분만 나중에 채울 수 있어 이상적이죠. 불행히도 HTML+<script> 태그 조합은 코드와 데이터를 분리하기가 힘들어 이 방식이 어렵습니다.
하지만 JSON 구조는 데이터와 코드를 명확히 분리해 줍니다. "여기가 태그, 여기에 props와 데이터" 같이 딱 구분되어 있죠. 정적, 동적 내용이 똑같은 방식으로 표현되어 있기 때문에, 이런 JSON 조각은 독립적으로 캐시되고, 구멍(hole)이 있는 상태로 보관해 자주 바뀌는 내용을 나중에 깔끔하게 채워 넣을 수 있습니다.
이 글에서는 React Server Components를 처음부터 다시 발명해보았습니다. 우리가 이미 봤던 대로, React Server Components는 아래처럼 생각할 수 있습니다:
이 글에서 저는, React Server Components를 함수형이고, 프로그래머블하며, 조합 가능한 HTML의 "재해석"—양쪽에 태그가 존재하는—으로 볼 수 있다는 점을 보여드리고자 했습니다.