[Next.js] 리스트 형태의 데이터를 스트리밍으로 렌더링

Keonwoo Kim·2024년 1월 6일
0
post-thumbnail

React Server Components를 이용하여 리스트 형태의 데이터를 클라이언트로 스트리밍해서 보내는 방식을 고민해 봤습니다.

서버에서 데이터나 시간의 부족으로 불완전하게 계산된 결과를 클라이언트에 바로 보내면 유저는 유의미한 컨텐츠를 즉시 볼 수 있게 되며, 서버는 그 후 빠르게 다음 데이터를 보내 유저가 로딩 화면을 볼 필요가 없게 할 수 있습니다.

데모

아래 데모 페이지는 두 페이지를 병렬로 로드합니다.

  1. 왼쪽: 카운트다운 페이지입니다. Generator function과 Suspense를 이용하여 0이 될 때까지 1초마다 수를 하나씩 적어 내려가며 서버에서 클라이언트에 스트리밍합니다.
  2. 오른쪽: Bell number를 계산하는 컴포넌트입니다. 각 행을 렌더한 후 클라이언트에 스트리밍하며, 각 행은 worker_threads를 이용해 별도 스레드에서 WebAssembly 바이너리를 실행하여 얻은 결과로 렌더됩니다.

링크

Layout

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>
  );
}

Counter (w/ generator functions)

유튜브 어디선가 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을 렌더하는 컴포넌트입니다. (promisePromise.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>
  );
}

Counter 재구현

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>
  );
}

Worker + WASM + <Batch />

서버에서 순차적으로 데이터를 보내야 할 만한 상황 중 하나로 CPU-intensive한 작업을 워커 스레드에서 실행시킨 후 보내는 상황을 생각할 수 있겠습니다. 보통 시간이 오래 걸리는 경우가 많기 때문에 작업이 완료되는 대로 즉시 보내는 것이 사용자 입장에서는 일부 데이터라도 빨리 받을 수 있기에 좋기 때문입니다.

이 예시에서는 WebAssembly 바이너리를 워커에서 실행하고 있습니다. 굳이 WebAssembly일 필요는 없지만 워커에서 돌릴 만한 CPU-intensive한 작업은 보통 native module이나 WebAssembly로 작성되기 때문입니다.

아래는 n번째 Bell number를 Dobinski's formula를 이용해 계산하는 zig 프로그램입니다. (부동소수점 이슈로 인해 n=22n = 22까지만 정확한 값이 나옵니다.)

Bn=1ek=0K1knk!providedKnK!1, K>1{\displaystyle B_{n}=\left\lceil {1 \over e}\sum _{k=0}^{K-1}{\frac {k^{n}}{k!}}\right\rceil } \quad \text{provided}\quad {\displaystyle {\frac {K^{n}}{K!}}\leq 1},\ K>1
// 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 등으로 데이터를 받아오는 편이 좋을 것 같습니다.

0개의 댓글