출처 (3편): https://edspencer.net/2024/7/12/promises-across-the-void-react-server-components
지난 시간에 우리는 React Server Component Payloads가 내부적으로 어떻게 작동하는지 살펴보았습니다. 그 기사의 끝부분에서 RSC로 할 수 있는 흥미로운 일을 언급했습니다. 서버에서 클라이언트로 해결되지 않은 약속을 보내는 것 입니다. 처음 읽었을 때 설명서 버그인 줄 알았지만 실제로는 꽤 현실적이었습니다(약간의 제한이 있긴 하지만요).
서버에서 클라이언트로 Promise 을 보내는 간단한 예는 다음과 같습니다. 먼저, 이 경우 SuspensePage 라고 하는 서버 렌더링 구성 요소가 있습니다.
import { Suspense } from "react";
import Table from "./table";
import { getData } from "./data";
export default function SuspensePage() {
return (
<div>
<h1>Server Component</h1>
<Suspense fallback={<div>Loading...</div>}>
<Table dataPromise={getData(1000)} />
</Suspense>
</div>
);
}
우리는 1초 후에 해결되는 Promise 을 반환하는 getData 함수를 가져왔습니다. 이것은 데이터베이스나 다른 비동기 액션에 대한 호출을 시뮬레이션합니다. 여기 우리의 가짜 getData 함수가 있습니다.
const fakeData = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]
export async function getData(delay: number): Promise<any> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(fakeData)
}, delay)
})
}
우리는 그 Promise을 Table 라고 불리는 컴포넌트에 props로 전달합니다. 여기 그 컴포넌트가 있습니다. (use client는 컴파일러에게 이 컴포넌트가 클라이언트에서 실행될 것이라고 알려줍니다)
"use client";
import { use } from "react";
export default function Table({ dataPromise }: { dataPromise: Promise<any> }) {
const data = use(dataPromise)
return (
<table className="max-w-5xl table-auto text-left">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.map((row: any) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.name}</td>
</tr>
))}
</tbody>
</table>
)
}
dataPromise는 실제로 prop에 적합한 이름은 아니지만, 여기서는 이것이 데이터 자체가 아니라 Promise라는 것을 명확히 하기 위해 그렇게 부릅니다. Promise가 해결될 때까지 실제 데이터를 얻을 수 없습니다.
React Server Components는 async 함수가 될 수 있지만, 실제로는 async를 사용하여 서버 렌더링 SuspensePage 컴포넌트를 작성하지 않고 있으며 클라이언트 측 Table 함수도 async로 만들지 않습니다. (이는 부분적으로는 async 컴포넌트가 아직 클라이언트 측에서 지원되지 않기 때문이지만, 그럴 필요가 없기 때문이기도 합니다.)
이 구성 요소는 새로운 React use Hook을 사용하여 Promise가 해결될 때까지 기다립니다. use는 Promise를 인수로 수락하고 몇 가지 영리한 작업을 수행합니다.
커버 아래에서 두 번째 시나리오(Promise가 아직 해결되지 않음)에서는 실제로 리액트가 Promise을 throw하고, 리액트가 이를 포착하여 구성 요소를 일시 정지시키는 데 사용됩니다. 이렇게 리액트는 구성 요소를 렌더링하기 전에 Promise가 해결될 때까지 기다려야 합니다. throw된 Promise는 가장 가까운 Suspense 경계에 의해 포착되며, Promise가 해결될 때까지 fallback이 표시됩니다.
Promise가 결국 해결되면 React는 해결된 값으로 구성 요소를 다시 렌더링합니다. use()를 여러 번 사용하면 모든 Promise가 해결되고 모든 구성 요소가 렌더링될 때까지(또는 일부 Promise가 거부되고 가장 가까운 오류 경계가 렌더링될 때까지) 패턴이 반복됩니다.
참고. React 렌더링의 기본 변경
use 도입은 React 렌더링이 작동하는 방식에 근본적인 변화를 동반합니다. 이전에 React는 구성 요소를 동기적으로만 렌더링할 수 있었고, 구성 요소가 무언가가 일어날 때까지 기다려야 하는 경우(예: Promise가 해결될 때까지) 이를 관리하기 위해 useEffect와 같은 것을 사용해야 했고, 결국 꽤 많은 보일러플레이트가 생겼습니다.
이제 React는 use를 통해 Promise가 해결될 때까지 컴포넌트 렌더링을 일시 중단할 수 있습니다. 이는 비동기 렌더링을 단순화하는 측면에서 큰 진전입니다. 이는 컴포넌트가 일시 중단되면 해당 지점까지 수행된 모든 작업이 버려지지만 컴포넌트가 많은 양의 무거운 처리를 수행하지 않는 한(그럴 리가 없습니다) 큰 문제는 아닙니다.
서버에서 클라이언트로 Promise를 보내는 핵심은 서버에서 Promise를 기다리지 않는 것입니다. 서버에서 Promise를 기다리는 경우 Promise 자체가 아니라 해결된 값을 클라이언트로 보내게 되지만 Promise를 해결하는 데 시간이 걸릴 수 있으며, 그 동안 UI 렌더링이 차단되고 사용자는 대기 상태로 남게 됩니다.
이것이 실제로 내부적으로 어떻게 작동하는지 알고 싶다면 React Server Component Payloads가 작동하는 방식에 대한 지난 게시글을 읽어보세요.
상위 레벨의 흐름은 다음과 같습니다.
이 모든 작업의 핵심은 서버가 HTML 응답을 클라이언트에 스트리밍하고, 해결된 Promise를 포함하여 모든 것을 렌더링하는 것을 마칠 때까지 실제로 해당 스트림을 닫지 않는다는 것입니다. 따라서 위의 경우 서버는 몇 밀리초 안에 기본적인 SuspensePage 구성 요소를 렌더링할 가능성이 높지만, getData() Promise가 해결될 때까지 기다리는 동안 스트림을 잠시 더 열어 둡니다.
그 시점에서 HTML의 스트리밍 가능한 특성 덕분에 서버는 script 태그 형태로 조금 더 많은 응답 HTML을 보낼 수 있으며, 이를 통해 next.js(이 경우) 콜백이 트리거되어 클라이언트 측 구성 요소가 해결된 값으로 업데이트됩니다.
RSC 예제 사이트) 최근에 이런 RSC 예제를 여러 개 만들었기 때문에, 모든 예제를 https://rsc-examples.edspencer.net/ 의 예제 라이브러리로 통합하기로 했습니다. 지금까지 만든 모든 예제를 여기에서 볼 수 있고, 진행하면서 더 추가할 것입니다.
저는 RSC 예제 사이트에서 이것에 대한 간단한 라이브 호스팅 예제를 만들었습니다. https://rsc-examples.edspencer.net/promises/resolved 에서 이 예제를 볼 수 있습니다. 혹은 이 curl 명령을 실행하면 실시간으로 스트리밍되는 서버 응답을 볼 수 있습니다.
curl -D - --raw https://rsc-examples.edspencer.net/examples/promises/resolved
거기서 볼 수 있는 것은 서버가 대부분의 HTML 응답을 보내고 1초간 멈춘 다음, 이와 비슷한 최종 script 태그를 내보내는 것입니다. 그리고 잠시 후에 살펴볼 div 태그도 있습니다.
<script>self.__next_f.push([1,"9:[{\"id\":1,\"name\":\"Alice\"},{\"id\":2,\"name\":\"Bob\"},{\"id\":3,\"name\":\"Charlie\"}]\n"])</script>
self.__next_f. push
는 __next_f
배열에서 푸시 메서드를 재정의하는 next.js 함수로, 내부적으로 서버가 전송하는 모든 것을 처리하기 위해 여러 논리를 실행합니다. React가 우리의 dataPromise Promise를 위해 생성한 ID는 ID=9였기 때문에, 이 script 태그가 실행되면 내부적으로 next.js는 ID=9로 해결된 Promise가 다시 Table 구성 요소의 dataPromise prop으로 돌아가야 한다는 것을 알게 될 것입니다.
이제 Promise가 서버에서 해결되고 클라이언트에게 스트리밍된 후 다시 Promise로 재구성되었으므로, 클라이언트 구성 요소는 이번에는 데이터 약속이 해결된 상태로 다시 렌더링되어 완전히 렌더링할 수 있게 됩니다. 우리는 실제로 두 개의 Promise를 가지게 되었습니다. 하나는 서버에 있고, 다른 하나는 클라이언트에 있는 Promise의 재구성된 버전이지만, 우리의 코드에서는 이를 동일하게 취급할 수 있습니다.
이제 서버에서 보낸 div 태그도 살펴봅시다 (여기에는 두 번째 script 태그도 붙어 있습니다). 보통 한 줄로 내려가기 때문에 더 읽기 쉽도록 약간 포맷했습니다:
<div hidden id="S:0">
<table class="max-w-5xl table-auto text-left">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>Alice</td>
</tr>
<tr>
<td>2</td>
<td>Bob</td>
</tr>
<tr>
<td>3</td>
<td>Charlie</td>
</tr>
</tbody>
</table>
</div>
<script>
$RC=function(b,c,e){c=document.getElementById(c);c.parentNode.removeChild(c);var a=document.getElementById(b);if(a){b=a.previousSibling;if(e)b.data="$!",a.setAttribute("data-dgst",e);else{e=b.parentNode;a=b.nextSibling;var f=0;do{if(a&&8===a.nodeType){var d=a.data;if("/$"===d)if(0===f)break;else f--;else"$"!==d&&"$?"!==d&&"$!"!==d||f++}d=a.nextSibling;e.removeChild(a);a=d}while(a);for(;c.firstChild;)e.insertBefore(c.firstChild,a);b.data="$"}b._reactRetry&&b._reactRetry()}};
$RC("B:0","S:0")
</script>
따라서 서버가 해결된 Promise와 함께 script 태그를 보내는 동시에 완전히 렌더링된 테이블과 함께 이 div 태그도 보냈습니다. 이는 next.js가 페이지 로드 속도를 높이기 위해 하는 깔끔한 트릭입니다: 서버가 렌더링한 HTML과 Promise을 함께 전송하여 클라이언트가 서버가 렌더링한 HTML로 즉시 페이지를 렌더링한 다음, 도착하면 해결된 Promise로 하이드레이션 할 수 있도록 합니다.
두 번째 script 태그는 페이지의 기존 요소를 ID B:0으로 대체하는 함수를 정의합니다. S:0 요소는 방금 스트리밍된 서버 렌더링 테이블이고, B:0 요소는 Suspense로 렌더링된 placeholder로, React가 지연된 콘텐츠를 DOM의 올바른 위치에 드롭할 수 있도록 합니다. Table이 처음에 서버에서 렌더링을 시도했지만 해결되지 않은 Promise으로 인해 일시 중지되었을 때, 실제 Table 대신 placeholder를 생성하여 ID가 B:0입니다.
💡 서버가 Promise에 대한 데이터를 보내 Table 클라이언트 측을 렌더링할 수 있게 한 다음, 서버 측도 Table를 렌더링하고 반환한 이유가 궁금하다면, 여기에 이에 대한 논의가 있습니다. 데이터를 두 번 보내는 것은 반직관적으로 보입니다. 한 번은 원시 데이터로, 한 번은 렌더링 데이터로 보내는 것입니다. 하지만 현재로서는 그럴 필요가 있는 복잡성이 있습니다.
하지만 서버에서 클라이언트로 아무 Promise나 보낼 수는 없습니다. Promise가 궁극적으로 해결하는 값은 문자열, 숫자 또는 부동 소수점과 같은 간단한 네이티브 데이터 유형이거나 일반 JS 객체/배열이거나 렌더링된 React 구성 요소여야 합니다. Promise가 다른 것으로 해결되면 React가 렌더링하려고 할 때 클라이언트 측에서 오류가 발생합니다.
저는 https://rsc-examples.edspencer.net/promises/various-datatypes 에서 다양한 데이터 유형을 로드하는 기능을 보여주는 두 번째 예를 만들었습니다.
여기서 우리는 문자열, 숫자, 부동 소수점, 일반 객체 및 배열이 void를 통해 전송되는 것을 볼 수 있으며, React 구성 요소도 볼 수 있습니다. 이는 멋진 일입니다. React 구성 요소는 문자열을 렌더링하는 간단한 구성 요소이지만 원하는 대로 할 수 있습니다.
❗️ 복합 데이터 유형은 지원되지 않습니다. 위에 언급된 것과 같은 간단하고 쉽게 직렬화할 수 있는 것들을 보낼 수는 있지만 함수, 클래스 또는 순환 참조가 있는 것과 같은 복잡한 데이터 유형은 보낼 수 없다는 점에 유의하세요. 이러한 데이터 유형 중 하나를 보내려고 하면 React가 렌더링하려고 할 때 클라이언트 측에서 오류가 발생합니다. 이 문제를 해결하는 방법은 있지만, 이는 또 다른 게시물에서 다루겠습니다.
https://rsc-examples.edspencer.net/promises/rendering-components 에 다른 유형은 모두 제외하고 React 구성 요소만 렌더링하는 데 초점을 맞춘 별도의 예제가 있어서 이것이 격리되어서 어떻게 작동하는지 확인할 수 있습니다.