출처 (RSC 1편): https://edspencer.net/2024/6/18/understanding-react-server-components-and-suspense

웹이 초창기었을 때는 HTML 텍스트 응답을 화면의 렌더링된 픽셀로 변환하는 웹 브라우저 소프트웨어를 실행하는 클라이언트에게 HTML 페이지가 제공되었습니다. 처음에는 정적 HTML 파일이었지만, PHP와 다른 것들이 등장하여 서버가 각 클라이언트에 전송되는 HTML을 사용자 정의할 수 있게 되었습니다.

CSS는 렌더링된 내용의 모양을 변경하기 위해 등장했습니다. JavaScript는 페이지를 대화형으로 만들기 위해 등장했습니다. 갑자기 페이지는 더 이상 웹 경험의 원자 단위가 아니었습니다. 서버가 전혀 루프에 있지 않고도 브라우저 내부에서 페이지가 스스로 수정될 수 있었습니다.

네트워크가 느리고 100% 신뢰할 수 없기 때문에 이것은 좋았습니다. 이는 웹의 새로운 황금기를 예고했습니다. 점차 HTML 콘텐츠가 클라이언트에 사전 렌더링된 HTML로 전송되는 경우가 줄어들고, 클라이언트가 JavaScript를 사용하여 HTML로 렌더링할 JSON 데이터로 전송되는 경우가 늘어났습니다.

하지만 이 모든 것은 클라이언트에서 훨씬 더 많은 작업을 필요로 했고, 이는 클라이언트가 훨씬 더 많은 JavaScript를 다운로드해야 한다는 것을 의미했습니다. 얼마 지나지 않아 우리는 웹 브라우저로 메가바이트의 JavaScript를 전송했고, 전체 페이지를 항상 다시 로드하지 않음으로써 얻은 속도를 잃었습니다. 페이지 전환은 빠르지만 초기 로드는 느렸습니다. 브라우저로 전송된 메가바이트의 코드는 소모되는 디바이스 메모리의 수백 메가바이트로 늘어날 수 있으며, 모든 디바이스가 최첨단 Macbook Pro인 것은 아닙니다.

싱글 페이지 애플리케이션에서 실제로 렌더링된 출력은 종종 몇 킬로바이트의 일반 텍스트 HTML이지만, 우리는 그 몇 킬로바이트의 HTML을 생성하기 위해 메가바이트의 JavaScript를 다운로드, 구문 분석 및 실행했습니다. SPA의 상호 작용을 유지하면서 클라이언트에 렌더링해야 하는 HTML만 보낼 수 있는 방법이 있다면 어떨까요?

Enter React Server Components

React Server Components는 수년간 React에서 가장 큰 개발 중 하나였으며, 이러한 문제 중 많은 부분을 해결할 잠재력이 있습니다. RSC를 사용하면 페이지 렌더링을 두 개의 버킷으로 나눌 수 있습니다. 클라이언트에서 렌더링된 컴포넌트(기존 React 스타일)와 서버에서 렌더링된 컴포넌트(기존 웹 스타일).

기기를 관리하는 데 도움이 되는 애플리케이션을 빌드한다고 가정해 보겠습니다. 그래서 CRUD가 필요합니다. 아마도 기기 목록을 볼 수 있는 기기 인덱스 페이지가 있을 것이고, 그런 다음 하나를 클릭하여 세부 정보를 보거나 버튼을 클릭하여 새 기기를 만들 수 있습니다. 기기를 편집하거나 삭제할 수도 있습니다.

기존 React 클라이언트 측 동작은 브라우저에서 렌더링되는 페이지를 스스로 빌드할 것입니다. 이 페이지는 백엔드에서 Devices 데이터를 페치하고, 응답이 돌아올 때까지 기다리고, 모든 오류를 처리한 다음, 디바이스 목록을 렌더링해야 합니다. SWR과 같은 라이브러리를 사용하여 데이터 페치 및 캐싱을 처리할 수 있으며, React Query와 같은 라이브러리를 사용하여 데이터 변형을 처리할 수 있습니다.

우리는 다음과 같은 결과를 얻을 수 있을 것입니다.

클라이언트 측에서 이를 수행하는 코드 useState, useEffect, fetch, try/catch 및 기타 보일러플레이트와 함께 수천 번이나 보셨을 겁니다. 이 코드에서 버그를 만들고, 엣지 케이스를 처리하는 것을 잊고, 예상대로 작동하지 않는 페이지로 만드는 것은 쉽습니다. 대신 이렇게 작성할 수 있다면 어떨까요?

import { getDevices } from "@/models/device";

import { Heading } from "@/components/common/heading";
import { Button } from "@/components/common/button";
import DevicesTable from "@/components/device/table";

export default async function DevicesPage() {
  const devices = await getDevices();

  return (
    <div>
      <div className="flex w-full flex-wrap items-end justify-between gap-4 pb-6">
        <Heading>Devices</Heading>
        <div className="flex gap-4">
          <Button href="/devices/create">Add Device</Button>
        </div>
      </div>
      <DevicesTable devices={devices} />
    </div>
  );
}

이것은 React Server Component입니다. 파일이 'use client'로 시작하지 않기 때문에 서버 구성 요소라고 말할 수 있습니다. RSC는 아직 꽤 새롭고 서버 측 렌더링 기능이 있는 NextJS와 같은 프레임워크에서만 지원됩니다.

이 구성 요소가 하는 주요 작업은 getDevices함수를 통해 장치 데이터를 가져오는 것인데, 이는 모두 서버 측에서 실행되고 아마도 데이터베이스에서 읽을 것입니다. 서버에서 이 작업을 수행함으로써 a) React 구성 요소에서 별도로 데이터를 가져오기 위한 추가 HTTP 왕복과 b) 이를 작동시키는 데 필요한 모든 클라이언트 측 로직을 피할 수 있습니다. 우리의 코드는 깔끔하고 간단하며, async/await의 마법으로 동기식인 것처럼 읽히므로 인간의 뇌에 더 쉽습니다.

이 구성 요소가 렌더링되는 layout.tsx 파일을 간단히 살펴보겠습니다.

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        {children}
      </body>
    </html>
  );
}

좋아요, 이게 가장 기본적인 내용입니다. RootLayout 컴포넌트도 React Server 컴포넌트입니다. 서버에서 렌더링되고 결과 HTML이 클라이언트로 전송됩니다. /devices URL을 방문하면 서버가 컴포넌트를 렌더링하여 layout.tsx 파일에 {children}에 app/devices/page.tsx 를 밀어넣습니다.

라이브 데모) https://rsc-suspense-patterns.edspencer.net/ 에서 이러한 다양한 접근 방식이 어떤 느낌인지 보여주는 라이브, 실제 Next JS 애플리케이션을 볼 수 있습니다 . 코드는 매우 간단하며 https://github.com/edspencer/rsc-suspense-patterns 에서 사용할 수 있습니다 .

하지만 여기에는 한 가지 문제가 있습니다. 우리의 DevicesPage 컴포넌트는 async 함수로 정의되어 있습니다. 이 경우 페이지를 렌더링하는 데 필요한 데이터를 가져오기 위해 비동기 호출을 해야 하기 때문입니다. 따라서 물론 비동기여야 하지만, 이것이 레이아웃의 동기 렌더링과 클라이언트에 대한 응답 반환과 어떻게 맞물릴까요?

글쎄요, 기본적으로 서버는 비동기 함수가 완료될 때까지 기다려야 DevicesPage 페이지를 렌더링하고 클라이언트로 보낼 수 있습니다. 데이터베이스 조회가 느리다면, 이는 사용자가 잠시 동안 완전히 빈 화면을 바라보고 앉아 있다는 것을 의미합니다. 좋은 사용자 경험이 아닙니다.

이것을 확신시키기 위해, 저는 라이브 데모에서 스켈레톤 Next JS 애플리케이션을 만들었습니다. 5페이지로 구성되어 있으며, 모두 React Server 구성 요소이며, 모두 비동기 데이터 가져오기에 대한 처리 방식이 다릅니다.

Vanilla async Server Component rendering

제 스켈레톤 앱의 첫 번째 페이지는 https://rsc-suspense-patterns.edspencer.net/slow/no-suspense 에 있습니다. 가장 좋은 방법은 새 창에서 열어서 로드되는 것을 보는 것입니다. 3초 동안 아무 일도 일어나지 않다가 갑자기 전체 페이지가 한꺼번에 나타납니다. 그 이유는 위 코드 블록에서 보여드린 것과 정확히 같기 때문입니다. 즉, 일부 데이터를 페치한 다음 반환하는 비동기 함수입니다. 그 안에 호출은 가짜 데이터의 배열을 반환하기 전에 임의로 3초만 기다립니다.

이 페이지가 고정난 거 같지 않나요? 3초 동안 아무 일도 일어나지 않습니다. 이는 사용자가 페이지가 고장났다고 생각하고 떠나기에 충분한 시간입니다. 하지만 React Suspense를 사용하면 이보다 더 나은 작업을 할 수 있습니다.

Page-level Suspense boundaries with loading.tsx

Next.JS는 React Server Component 페이지를 포함하여 페이지 수준 Suspense 동작을 제공하기 위한 멋진 작은 컨벤션을 제공합니다. Suspense는 React 애플리케이션이 가능한 모든 것을 렌더링하여 사용자에게 보여주고 페이지의 나머지 구성 요소를 렌더링할 준비가 되면 브라우저로 스트리밍할 수 있는 방법입니다.

Next.JS를 사용하면 page.tsx 파일과 같은 디렉토리에 loading.tsx 파일을 만들면 되고, 페이지가 로딩되는 동안 fallback 으로 사용됩니다. 이것은 페이지가 로딩되는 동안 사용자에게 로딩 스피너나 다른 로딩 표시기를 보여주는 좋은 방법입니다. 이것이 얼마나 간단한지 보여드리겠습니다.

export default function Loading() {
  return (
    <div className="flex justify-center items-center h-64">
      <div className="animate-spin rounded-full h-16 w-16 border-b-2 border-gray-900"></div>
    </div>
  );
}

이 파일을 정의하는 것만으로도 Next.js는 내부적으로 약간의 작업을 수행하여 다음과 같은 동작을 하게 됩니다.

  • 페이지가 처음 로드될 때 page.tsx 구성 요소 렌더링이 시작되지만 즉시 렌더링되지 않습니다.
  • 비동기 함수가 렌더링하기 전에 데이터를 가져오거나 다른 작업을 수행하는 동안 loading.tsx 구성 요소가 대신 렌더링됩니다.
  • 비동기 함수가 완료되면 page.tsx 구성 요소가 렌더링되고 loading.tsx 구성 요소가 대체됩니다.

https://rsc-suspense-patterns.edspencer.net/slow/suspense 에서 이것이 실제로 어떻게 되는지 볼 수 있습니다. 이번에는 페이지 헤더 메뉴가 즉시 렌더링됩니다. 이것은 layout.tsx의 일부이며 loading.tsx를 통해 3초 동안 로딩 스피너 렌더링을 봅니다. 3초 후에 page.tsx 구성 요소가 렌더링되어 스피너를 대체합니다.

Component-level suspense boundaries

페이지 수준 서스펜스 경계는 바닐라 버전보다 개선된 점인데, 적어도 애플리케이션의 일부를 즉시 렌더링하고, 로딩 스피너를 통해 사용자에게 무언가가 일어나고 있음을 보여주기 때문입니다. 또한 loading.tsx 구성 요소 파일을 디렉터리에 끌어다 놓고 작동시키는 것도 매우 쉽습니다.

하지만 그보다 더 나은 방법이 있습니다. 구성 요소 수준에서 Suspense 경계를 사용하여 사용자에게 더 세부적인 수준에서 무언가가 발생하고 있음을 보여줄 수 있습니다. 데모에서 세 번째이자 마지막 느린 로딩 RSC 페이지는 https://rsc-suspense-patterns.edspencer.net/slow/component-suspense 에서 볼 수 있습니다.

import { getDevices } from "@/models/device";

import Heading from "@/components/common/heading";
import DevicesTable from "@/components/device/table";
import AddDeviceButton from "@/components/device/button";

import Loading from "@/components/common/loading";
import { Suspense } from "react";

export default function DevicesPage() {
  return (
    <>
      <div className="flex w-full flex-wrap items-end justify-between gap-4 pb-6">
        <Heading>Devices (3000ms database call, Component-level Suspense)</Heading>
        <div className="flex gap-4">
          <AddDeviceButton />
        </div>
      </div>
      <Suspense fallback={<Loading />}>
        <LoadedDevicesTable />
      </Suspense>
      <p>
        On this screen, we get all of the page contents rendered instantly (including this paragraph),
        but see a loading spinner while the table is loaded, rendered, and streamed back to the client.
      </p>
    </>
  );
}

async function LoadedDevicesTable() {
  const devices = await getDevices();

  return <DevicesTable devices={devices} />;
}

여기서 우리는 세 가지 일을 했습니다.

  1. <DevicesTable>의 로딩과 렌더링을 <LoadedDevicesTable>이라는 별도의 (비동기화된) 구성 요소로 나누었습니다
  2. DevicesPage 구성 요소를 동기식으로 만들었으므로 즉시 렌더링됩니다.
  3. LoadedDevicesTable 구성 요소를 Suspense 로 감싸며, 스피너를 만드는 fallback prop 을 사용했습니다

헤더와 푸터, 그리고 무슨 일이 일어나고 있는지 설명하는 문단을 포함하여 전체 페이지가 즉시 렌더링되는 것을 볼 수 있습니다. 즉시 렌더링되지 않는 유일한 것은 데이터 테이블인데, 데이터를 가져오고 테이블이 렌더링될 때까지 로딩 스피너를 표시합니다.

이것은 vanilla 버전, 심지어 페이지 레벨 Suspense 버전보다 훨씬 더 나은 사용자 경험입니다. 사용자에게 무언가가 일어나고 있고 페이지가 깨지지 않았다는 것을 보여주는 좋은 방법이며, 가능한 한 많은 페이지를 즉시 렌더링합니다. Suspense 래퍼를 추가하는 것은 loading.tsx 파일을 추가하는 것만큼 쉽고, 종종 더 나은 사용자 경험을 제공합니다.

이제 귀하의 애플리케이션은 React Server Components를 사용하여 서버 측에서 약 90% 렌더링되고, 대화형 부분만 클라이언트 측에서 렌더링됩니다. 이는 서버 측 렌더링의 속도와 안정성, 클라이언트 측 렌더링의 대화형성이라는 두 가지 장점을 모두 얻을 수 있는 좋은 방법입니다.

Implications for React Server Components

일반적으로, 페이지에 데이터를 로드하기 위해 여러 데이터베이스/RPC 호출이 필요한 경우, 일반적으로 클라이언트 측보다 서버 측에서 해당 페이지를 렌더링하는 것이 훨씬 빠릅니다. 이는 서버가 일반적으로 데이터베이스에 대해 빠르고 지연 시간이 짧은 연결을 가지고 있고, 단일 패스로 페이지를 렌더링할 수 있기 때문입니다.

하지만 이것은 만병통치약이 아닙니다. 빠르게 시작한 데이터베이스는 시간이 지나면서 느려지는 경우가 많습니다. 10ms 데이터 페치로 완벽하게 의미가 있었던 UX 패턴(예: Suspense를 사용하지 않는 것)은 페치가 3000ms 이상 걸리면 문제가 될 수 있습니다. 페이지에서 하나 이상의 느린 데이터 페치를 시작하면 페이지 수준에서 비동기 React Server Components를 사용하더라도 사용자에게 훌륭한 경험을 제공할 수 없습니다.

Consider making page-level RSCs synchronous

이를 해결하는 한 가지 방법으로, 비동기 코드를 페이지 구성 요소에서 분리합니다. 페이지 수준에서 동기 구성 요소만 렌더링하도록 제한함으로써 페이지를 즉시 렌더링한 다음 비동기 구성 요소가 준비되면 스트리밍할 수 있습니다. 이는 사용자에게 진행 상황을 제공하고 페이지에 계속 참여하게 하는 좋은 방법입니다.

import Loading from "@/components/common/loading";
import { Suspense } from "react";

//synchronous - fast!
export default function FastRSCPage() {
  return (
    <>
      <h2>My lovely page</h2>
      <Suspense fallback={<Loading />}>
        <SlowLoadingComponent />
      </Suspense>
    </>
  );
}

//async - can be slow but doesn't matter as it's not at the page level
async function SlowLoadingComponent() {
  const devices = await getDevices();

  return <DevicesTable devices={devices} />;
}

이 접근 방식에서 우리의 컴포넌트 FastRSCPage와 SlowLoadingComponent 컴포넌트는 여전히 React Server 컴포넌트입니다. 심지어 같은 파일에 있을 수도 있지만, 반드시 그럴 필요는 없습니다. 비동기 코드를 최상위 컴포넌트("페이지")에서 분리하면 가능한 한 많은 UI를 본질적으로 즉시 렌더링할 수 있다는 의미일 뿐입니다.

결론 및 추가 자료

React Server Components는 React의 강력한 새로운 기능으로, 올바르게 구현하면 애플리케이션의 UX를 바꿀 수 있습니다. 또한, 오래된 방식으로 작동하는 React 앱에 수천 시간을 투자했다면 성가신 것처럼 보일 수 있는 Big Rewrite 함정이기도 합니다. 하지만 새로운 프로젝트를 시작하거나 원하는 대로 잘 작동하지 않는 프로젝트가 있다면, 꼭 살펴볼 가치가 있습니다.

저는 이 주제에 대한 이해를 위한 나만의 여정을 시작하면서 훌륭한 사람들이 쓴 훌륭한 게시물을 읽었습니다. RSC에 관한 세 가지 기사를 읽어보세요.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글

Powered by GraphCDN, the GraphQL CDN