React Server Components를 이용하여 리스트 형태의 데이터를 클라이언트로 스트리밍해서 보내는 방식을 고민해 봤습니다.
서버에서 데이터나 시간의 부족으로 불완전하게 계산된 결과를 클라이언트에 바로 보내면 유저는 유의미한 컨텐츠를 즉시 볼 수 있게 되며, 서버는 그 후 빠르게 다음 데이터를 보내 유저가 로딩 화면을 볼 필요가 없게 할 수 있습니다.
아래 데모 페이지는 두 페이지를 병렬로 로드합니다.
Suspense
를 이용하여 0이 될 때까지 1초마다 수를 하나씩 적어 내려가며 서버에서 클라이언트에 스트리밍합니다.worker_threads
를 이용해 별도 스레드에서 WebAssembly 바이너리를 실행하여 얻은 결과로 렌더됩니다.Next.js 14의 Parallel Routes를 이용하여, 양쪽 섹션 코드를 분리해서 작성할 수 있게 했습니다.
// @/app/(content)/layout.tsx
export default function Layout({ counter, async }: { counter: React.ReactNode; async: React.ReactNode }) {
return (
<main className="grid grid-cols-2 h-[100dvh] overflow-y-auto">
{counter}
{async}
</main>
);
}
유튜브 어디선가 React component로 generator function을 쓸 수 있다는 이야기를 들은 적이 있어서 만들어 봤습니다. 원래는 async generator function을 써보려 했는데 이는 Next.js에서 오류가 나서 generator function은 async RSC랑 잘 호환이 안 되는 것 같고, 대신 Suspense
로 감싸서 해결했습니다.
// @/app/(content)/@counter/Counter.tsx
import { unstable_noStore } from "next/cache";
import { Suspense } from "react";
export default function* Counter({
count,
batch = 1,
delay = 1000,
}: {
count: number;
batch?: number;
delay?: number;
}) {
unstable_noStore();
if (count < 0) return;
yield (
<>
{Array.from(
{ length: Math.min(batch, count + 1) },
(_, i) => count - i,
).map((number) => (
<span key={number}>
{number}
{number > 0 && ", "}
</span>
))}
</>
);
if (count < batch) return;
yield (
<Suspense fallback="...">
<AsyncCounter count={count - batch} batch={batch} delay={delay} />
</Suspense>
);
}
async function AsyncCounter({
count,
batch,
delay,
}: {
count: number;
batch: number;
delay: number;
}) {
await new Promise((r) => setTimeout(r, delay));
return <Counter count={count} batch={batch} delay={delay} />;
}
참고: Next.js 14 Prerendering 시에 Maximum call stack size exceeded 에러가 발생하여 이를 비활성화하는
noStore();
을 호출합니다.
<Async />
/ <Batch />
Generator functions 없이도 구현이 가능합니다. 오히려 이 경우에는 generator functions를 사용하지 않는 게 여러모로 좋은 것 같습니다.
<Async promise={promise}>{children}</Async>
는 promise
가 fulfill 되고 난 후 children
을 렌더하는 컴포넌트입니다. (promise
는 Promise.allSettled
의 리턴값입니다.)
type Node = JSX.Element | string | number | null;
async function Async({
promise,
children,
}: {
promise: Promise<PromiseSettledResult<Node>[]>;
children?: Node;
}) {
const elements = await promise;
return (
<>
{elements.map((element, i) => {
if (element.status === "fulfilled") {
return <Fragment key={i}>{element.value}</Fragment>;
}
return null;
})}
{children}
</>
);
}
이 Async
를 이용하여, 주어진 리스트에 대해 async function을 순차적으로 실행하는 Batch
컴포넌트를 만들 수 있습니다.
export default function Batch<T>({
args,
batchSize = 1,
fallback,
children: render,
}: {
args: T[];
batchSize?: number;
fallback?: Node;
children: (arg: T) => Promise<Node>;
}) {
if (args.length === 0) return null;
const promise = Promise.allSettled(args.slice(0, batchSize).map(render));
return (
<Suspense fallback={fallback}>
<Async promise={promise}>
<Batch
args={args.slice(batchSize)}
batchSize={batchSize}
fallback={fallback}
>
{render}
</Batch>
</Async>
</Suspense>
);
}
위 Batch
를 이용하면 Counter
컴포넌트를 간단히 재구현할 수 있습니다. 이 경우에는 noStore()
call도 필요 없어 보입니다.
// @/app/(content)/@counter/AltCounter.tsx
import Batch from "../Batch";
export default function AltCounter({
count,
batch = 1,
delay = 1000,
}: {
count: number;
batch?: number;
delay?: number;
}) {
return (
<Batch
args={Array.from({ length: count + 1 }, (_, n) => count - n)}
batchSize={batch}
fallback="..."
>
{async (n) => {
if (n !== count) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
return (
<span>
{n}
{n > 0 && ", "}
</span>
);
}}
</Batch>
);
}
<Batch />
서버에서 순차적으로 데이터를 보내야 할 만한 상황 중 하나로 CPU-intensive한 작업을 워커 스레드에서 실행시킨 후 보내는 상황을 생각할 수 있겠습니다. 보통 시간이 오래 걸리는 경우가 많기 때문에 작업이 완료되는 대로 즉시 보내는 것이 사용자 입장에서는 일부 데이터라도 빨리 받을 수 있기에 좋기 때문입니다.
이 예시에서는 WebAssembly 바이너리를 워커에서 실행하고 있습니다. 굳이 WebAssembly일 필요는 없지만 워커에서 돌릴 만한 CPU-intensive한 작업은 보통 native module이나 WebAssembly로 작성되기 때문입니다.
아래는 n
번째 Bell number를 Dobinski's formula를 이용해 계산하는 zig 프로그램입니다. (부동소수점 이슈로 인해 까지만 정확한 값이 나옵니다.)
// get_bell_number.zig
const std = @import("std");
export fn get_bell_number(n: usize) f64 {
var sum: f64 = 0.0;
var k: usize = 1;
while (true) {
var term: f64 = get_term(k, n);
sum += term;
if (term < 1.0 and k > 1) break;
k += 1;
}
sum /= std.math.e;
return std.math.ceil(sum);
}
// Returns k^n / k!
// If k <= n, k^n / k! == (k/1) * ... * (k/(k-1)) * k^(n-k)
// If k > n, k^n / k! == (k/1) * ... * (k/(n-1)) * (k/n) * (1/(n+1)) * ... * (1/n)
fn get_term(k: usize, n: usize) f64 {
var result: f64 = 1.0;
var n_float = @as(f64, @floatFromInt(n));
var k_float = @as(f64, @floatFromInt(k));
var i_float: f64 = 1.0;
for (1..k) |i| {
result /= i_float;
if (i < n) {
result *= k_float;
}
i_float += 1.0;
}
if (k < n) {
result *= std.math.pow(f64, k_float, n_float - k_float);
}
return result;
}
다음 명령어로 ReleaseSmall
모드의 WebAssembly 바이너리로 컴파일할 수 있습니다.
zig build-lib <path-to>/get_bell_number.zig -target wasm32-freestanding -dynamic -rdynamic -O ReleaseSmall
그렇게 나온 get_bell_number.wasm
을 @/app/(content)/@async/
아래에 넣고, 이를 순차적으로 호출하는 서버 컴포넌트를 구현할 수 있습니다.
먼저 @/app/(content)/@async/worker.js
에 워커 파일을 만들고,
const { parentPort, workerData } = require("worker_threads");
const { readFile } = require("fs/promises");
readFile(`${__dirname}/get_bell_number.wasm`)
.then((source) => {
const typedArray = new Uint8Array(source);
return WebAssembly.instantiate(typedArray);
})
.then((wasm) => {
const { get_bell_number } = wasm.instance.exports;
parentPort.postMessage(get_bell_number(workerData));
});
이 워커와 데이터를 주고 받는 page.tsx
를 만듭니다.
// @/app/(content)/@async/page.tsx
import { Worker } from "worker_threads";
import Batch from "../Batch";
function getBellNumber(n: number): Promise<number> {
return new Promise((resolve, reject) => {
const worker = new Worker("./app/(content)/@async/worker.js", {
workerData: n,
});
worker.on("message", resolve);
worker.on("error", reject);
worker.on("exit", (code) => {
if (code !== 0)
reject(new Error(`Worker stopped with exit code ${code}`));
});
});
}
export default async function AsyncSection() {
return (
<section>
<ol>
<Batch args={Array.from({ length: 23 }, (_, n) => n)}>
{async (n) => {
return (
<li>
B<sub>{n}</sub> = {await getBellNumber(n)}
</li>
);
}}
</Batch>
</ol>
</section>
);
}
Generator function을 이용한 방법은 array가 아닌 iterator가 데이터인 상황에는 쓸 수 있을 것 같지만 그 외의 상황에서라면 위처럼 Batch
같은 구현을 이용하는 것이 나아 보입니다. Batch
구현에 에러 핸들링(즉 promise
가 rejected 상태가 되었을 때)이 빠져 있는데 구현은 어렵지 않겠습니다.
주의할 점은 현재 batch가 아닌 남은 데이터를 렌더하는 컴포넌트는 recursive하게 Suspense
로 감싸기 때문에, 그 수가 많아지면 "Maximum call stack size exceeded" 에러를 냅니다. 또한 결국 ReadableStream으로 구현되기 때문에 연결이 끊기거나 했을 때 에러 처리가 어렵다는 단점도 있습니다. 이런 경우에는 server-side events나 websocket 등으로 데이터를 받아오는 편이 좋을 것 같습니다.