왜 RSC는 번들러와 통합될까?

TMT

https://overreacted.io/why-does-rsc-integrate-with-a-bundler/ - Dan Abramov

공개 경고—이 글은 개발자들을 위한 내용입니다.

React Server Components(RSC)는 모듈 시스템을 확장하여 서버/클라이언트 애플리케이션을 두 런타임에 걸친 하나의 프로그램으로 표현하는 프로그래밍 패러다임입니다. 내부적으로 RSC 구현은 두 가지 주요 부분으로 구성됩니다:

‎⁠react-server⁠react-client 패키지는 React 저장소 내부에 있습니다.

이들은 완전히 오픈 소스이지만, npm에 원본 형태로 배포되지는 않습니다. 그 이유는 중요한 요소—모듈 시스템 통합—이 빠져 있기 때문입니다. 많은 (역)직렬화 도구와 달리, RSC는 데이터 뿐만 아니라 _코드_도 전송하는 것에 신경을 씁니다. 예를 들어, 다음과 같은 트리를 생각해봅시다:

<p>Hello, world</p>

이 ‎⁠<p>⁠ 태그를 JSON으로 변환하고 싶다면 이렇게 할 수 있습니다:

{
  type: 'p',
  props: {
    children: 'Hello world'
  }
}

하지만 ‎⁠<Counter>⁠ 태그는 어떻게 직렬화할까요?

import { Counter } from './client';
 
<Counter initialCount={10} />

...

'use client';

import { useState, useEffect } from 'react';
export function Counter({ initialCount }) {
  const [count, setCount] = useState(initialCount);
  // ...
}

모듈을 어떻게 직렬화할 수 있을까요?

모듈 직렬화

실제로는 ‎⁠<Counter>⁠의 스냅샷만이 아니라, 반대편에서 실제 ‎⁠<Counter> ⁠를 복원하고 싶습니다—즉, 상호작용을 위한 전체 로직이 필요합니다!

한 가지 방법은 ‎⁠Counter⁠ 코드를 JSON에 문자 그대로 삽입하는 것입니다:

{
  type: `
    import { useState, useEffect } from 'react';
 
    export function Counter({ initialCount }) {
      const [count, setCount] = useState(initialCount);
      // ...
    }
  `,
  props: {
    initialCount: 10
  }
}

하지만 이건 별로 좋지 않죠? 코드를 문자열로 클라이언트에 보내서 ‎⁠eval⁠하는 건 원하지 않고, 같은 컴포넌트 코드를 여러 번 보내는 것도 원하지 않습니다. 대신, 해당 코드가 정적 JS asset으로 앱에서 제공된다고 가정하는 것이 합리적입니다—이렇게 하면 JSON에서 참조할 수 있습니다. 거의 <script> 태그와 비슷하죠:

{
  type: '/src/client.js#Counter', // "Load src/client.js and grab Counter"
  props: {
    initialCount: 10
  }
}

실제로 클라이언트에서는 <script> 태그를 생성해서 불러올 수도 있습니다.

하지만 소스 파일에서 하나씩 임포트해서 네트워크로 불러오는 것은 비효율적입니다. 한 파일이 다른 파일을 임포트할 수 있고, 클라이언트는 임포트 트리를 미리 알지 못합니다. 워터폴(연쇄적 요청)을 만들고 싶지 않죠. 우리는 이미 20년간 클라이언트 사이드 앱을 만들면서 이 문제를 해결해왔습니다: 바로 번들링입니다.

RSC 번들러 바인딩

이런 이유로 RSC는 번들러와 통합됩니다. RSC가 반드시 번들러를 요구하는 것은 아닙니다: 번들러 없이 동작하는 RSC ESM 프로토타입도 있습니다. 하지만 최적화 없이 순진하게 구현하면 비효율적이기 때문에, 실제로는 번들러와의 통합이 필요합니다.

실제 RSC 통합은 번들러별로 다릅니다. Parcel, Webpack, (그리고 앞으로는) Vite용 바인딩이 React 저장소에 있으며, 이들은 모듈을 어떻게 전송하고 불러올지 지정합니다:

  • 빌드 시: ‘use client’가 있는 파일을 찾아 해당 엔트리 포인트에 대한 번들 청크를 생성합니다—Astro Islands와 비슷합니다.
  • 서버에서: 이 바인딩은 React에 모듈을 클라이언트로 어떻게 전송할지 알려줍니다. 예를 들어, 번들러는 ‘chunk123.js#Counter’와 같이 모듈을 참조할 수 있습니다.
  • 클라이언트에서: React가 번들러 런타임에 해당 모듈을 어떻게 요청할지 알려줍니다. 예를 들어, Parcel 바인딩은 Parcel 전용 함수를 호출합니다.

이 세 가지 덕분에 React Server는 모듈을 직렬화하는 방법을 알게 되고, React Client는 역직렬화하는 방법을 알게 됩니다.

React Server로 트리를 직렬화하는 API는 번들러 바인딩을 통해 노출됩니다:

import { serialize } from 'react-server-dom-yourbundler'; // 번들러별 패키지
const reactTree = <Counter initialCount={10} />;
const outputString = serialize(reactTree); // 위의 JSON과 비슷한 결과

이제 ‎⁠outputString⁠을 디스크에 저장하거나, 네트워크로 전송하거나, 캐시할 수 있고—결국 React Client에 전달할 수 있습니다. React Client는 전체 트리를 역직렬화하며, 필요에 따라 참조된 모듈에서 코드를 불러옵니다:

import { deserialize } from 'react-server-dom-yourbundler/client';  // 번들러별 패키지
const outputString = // ... 네트워크로 받거나, 디스크에서 읽거나 등
const reactTree = deserialize(outputString); // <Counter initialCount={10} />

모든 것이 제대로 동작했다면, 마치 클라이언트에서 <Counter initialCount={10} /> 를 직접 쓴 것처럼 정상적인 JSX 트리를 얻게 됩니다. 이 트리로 무엇이든 할 수 있습니다—렌더링, 상태로 보관, HTML로 변환 등.

const outputString = // ... 네트워크로 받거나, 디스크에서 읽거나 등
const reactTree = deserialize(outputString); // <Counter initialCount={10} />
// 일반적인 JSX 트리와 똑같이 사용할 수 있습니다. 예를 들어:
const root = createRoot(domNode);
root.render(reactTree);

이것이 Next.js 같은 RSC 프레임워크가 내부적으로 사용하는 API입니다.

이러한 저수준 API로 RSC를 직접 실험해보고 싶다면, Parcel RSC 구현체가 좋은 출발점입니다.

(위의 ‎⁠serialize⁠‎⁠deserialize⁠는 예시용 이름입니다. 실제 이름은 바인딩에 따라 다르며, 오버로드도 있을 수 있습니다. 예를 들어, ‎⁠@parcel/rsc⁠ 패키지는 내부적으로 ‎⁠react-server-dom-parcel⁠ 바인딩을 감싸며, 직렬화는 ‎⁠renderRSC⁠, 역직렬화는 ‎⁠fetchRSC⁠로 노출합니다. 또한 실제 구현은 논블로킹이며, 양쪽 모두 스트리밍을 지원합니다.)

Edit this page

On this Page