Suspense 의 구조
https://velog.io/@leejpsd/React-Custom-Suspense
React Working Group(reactwg)에서 React Core Team의 Dan Abramov는 React 18의 주요 Feature 중 하나인 Suspense가 SSR Architecture의 구조적인 성능 개선을 위해 어떤 역할을 하게 될지에 대한 내용을 설명했습니다. [discussion link] 이번 포스팅은 해당 Discussion을 번역해서 현재 SSR이 어떻게 이루어지고 있는지, React 18의 Suspense가 이를 어떻게 구조적으로 개선했는지에 대해 살펴보려고 합니다. (원문 링크는 discussion link나 아래 Reference를 참고해주세요)
이 글은 새로운 서버사이드 렌더링 아키텍처와 디자인, 그리고 문제 해결 방법에 대한 High-Level Overview입니다. 자세한 구현 사항은 이후 포스팅을 통해 살펴보도록 하겠습니다.
유저가 애플리케이션을 로드했을 때, 우리는 가능한 한 빠르게 fully-interactive 한 페이지를 보여주고 싶을 것입니다. 아래 일러스트레이션에서 초록색으로 칠해진 영역은 페이지 상에서 상호작용이 가능하다(interactive)는 것을 나타냅니다. 다른 말로 하면, 해당 영역에 Javascript 이벤트 핸들러가 붙어있는 상태이고, 버튼을 눌렀을 때, 상태를 업데이트하는 등의 Reactive 한 동작들을 수행할 수 있다는 것을 의미합니다.
하지만, 자바스크립트가 “모두” 로드되기 전까지 사용자는 페이지에 대한 상호작용을 할 수 없습니다. 애플리케이션의 규모가 작지 않다면(non-trivial), 로딩 시간의 대부분은 애플리케이션 코드를 다운로드하는 데 사용될 것입니다. 만약 SSR을 사용하지 않는다면 자바스크립트가 로딩되는 동안 유저는 빈 페이지(blank page)를 보게 될 것입니다.
이것은 좋은 사용자 경험이 아니며(특히 자바스크립트 로딩 시간이 오래 걸리는 저사양 디바이스에서 그렇습니다) 이러한 이유로 SSR(서버 사이드 렌더링)이 권장됩니다. SSR은 React Component를 서버에서 HTML로 렌더링해서 유저에게 내려주는 방식으로 동작합니다. 이렇게 내려온 HTML은 브라우저에서 기본적으로 제공하는 link, form input 등과 같은 상호작용 요소를 제외하고는 interactive 하지는 않지만, 유저들로 하여금 자바스크립트가 로딩되는 동안 무언가를 볼 수 있게 해줍니다.
아래 일러스트레이션에서 회색 영역은 아직 상호작용을 할 수 없다(not fully interactive yet)는 것을 의미합니다. 애플리케이션의 자바스크립트가 아직 로드되지 않았기 때문에 해당 영역에 버튼이 있고, 해당 버튼을 눌렀을 때 State가 업데이트되는 것이 기대되지만, 실제로는 아무 일도 일어나지 않습니다. 하지만, 콘텐츠가 많은 웹사이트의 경우 자바스크립트가 로드되는 동안에 유저는 콘텐츠를 읽을 수 있으므로 SSR을 사용하는 것이 매우 유용하며, 권장됩니다.
React와 애플리케이션 코드가 모두 로드되고 나면, 이미 존재하는 회색 영역의 HTML을 상호작용이 가능하도록 만들어야 합니다.
You tell React: “Here’s the App component that generated this HTML on the server.
Attach event handlers to that HTML!”
리액트는 메모리상에 컴포넌트를 렌더링하지만, DOM 노드를 생성하지 않고(이미 노드가 문서에 존재하므로) 이미 존재하는 HTML에 이벤트 핸들러와 같은 로직을 붙여줍니다. 이렇게 React가 로드되고 나서 컴포넌트를 메모리에 렌더링하고 이벤트 핸들러를 붙이는 일련의 과정을 Hydration이라고 합니다. 이렇게 Hydration이 끝나고 나면 나머지는 “평소의 리액트”(React as usual)입니다. 컴포넌트는 상태 값을 변경할 수도 있고, 클릭에 반응할 수도 있습니다.
SSR는 “마법”처럼 보일 수 있겠지만, 이 과정 자체로는 애플리케이션의 상호작용을 더 빠르게 만들지는 않습니다. 대신, 애플리케이션에서 상호작용이 없는(non-interactive version) 부분을 더 빠르게 볼 수 있게 해서 유저들이 자바스크립트가 로드되는 동안 정적 콘텐츠를 볼 수 있도록 해줍니다. SSR은 특히 네트워크 상태가 좋지 않은 유저들에게 큰 차이를 가져오고 유저가 인식하는 성능(perceived performance) 향상을 가져옵니다. 또한 쉬운 인덱싱과 빠른 속도로 Search Engine Optimization에도 도움을 줍니다.
이러한 SSR은 많은 장점들을 가져왔지만, 근본적인 측면에서의 문제를 해결하지 못합니다.
SSR이 가진 문제점 중 하나는 컴포넌트가 “데이터를 기다리도록” 하지 않는다는 것입니다. 현재의 SSR 관련 API를 사용하면 HTML을 렌더하는 시점에 서버에서 컴포넌트 렌더를 위해 필요한 모든 데이터가 준비되어야 하고, 이 방법은 매우 비효율적입니다.
예를 들어 댓글이 있는 포스트를 렌더링하고 싶다고 가정해보겠습니다. 댓글을 빠르게 보여주는 것은 중요하기 때문에 서버사이드 렌더링에 포함시켜서 출력하고 싶지만, 프론트엔드 개발자가 제어할 수 없는 DB나 API 레이어에서의 속도가 느려서 이 부분은 건드릴 수 없는 상황이라고 한다면 이런 경우 힘든 결정을 내려야 합니다. 댓글 컴포넌트를 최초 HTML 렌더링에서 제외한다면 유저는 자바스크립트가 완전히 로드가 되고, React가 클라이언트 쪽에서 해당 컴포넌트를 다시 DOM에 그릴 때까지 컴포넌트를 볼 수 없게 될 것입니다. 댓글 컴포넌트를 최초 HTML 렌더링에 포함시킨다면 전자의 경우보다는 댓글 컴포넌트를 빠르게 보여줄 수는 있겠지만, 댓글 데이터를 가져오고 전체 트리를 렌더하기 전까지 나머지 HTML을 전송하는 것을 지연시켜야 할 것입니다. (이는 네비게이션바, 사이드바, 포스팅 본문 등의 내용을 포함합니다.)
자바스크립트 코드가 로드된 다음 리액트는 HTML을 “Hydrate”하고, 이를 통해 페이지는 상호작용이 가능한 상태가 됩니다. 리액트는 서버에서 생성된 HTML을 순회(walk)하며 여기에 이벤트 핸들러를 부착시킬 것입니다. 이 과정이 제대로 동작하기 위해서는 “브라우저에서 컴포넌트를 기반으로 생성된 트리”와 “서버에서 생성된 트리”가 일치해야 합니다. 그렇지 않으면 리액트는 두 트리를 “일치시킬 수 없게” 됩니다. 이러한 과정의 가장 안타까운 점은 Hydration이 시작하기 전에 클라이언트에 모든 컴포넌트에 대한 자바스크립트가 로드되어야 한다는 것입니다.
예를 들어, 댓글 위젯이 많은 양의 복잡한 상호작용 로직을 가지고 있고, 자바스크립트를 로드하기 위해 꽤 오랜 시간이 걸린다고 가정해보겠습니다. 이러한 경우에 우리는 또다시 힘든 결정을 내려야 합니다. 유저에게 댓글을 빨리 보여주기 위해 SSR을 사용하는 것이 좋을 것입니다. 하지만 오늘날의 Hydration 로직 하에서는 네비게이션바, 사이드바, 그리고 포스트 본문들 모두가 댓글 위젯에 대한 코드가 로드될 때까지 Hydration을 할 수 없게 됩니다.
물론 Code Splitting을 통해 컴포넌트를 따로따로 로드할 수 있지만, 이렇게 분리된 컴포넌트는 최초 Hydration에 포함되지 않으며 리액트는 Hydration 단계에서 해당 컴포넌트를 삭제할 것입니다.
Hydration 자체에도 비슷한 문제가 있습니다. 리액트는 Hydration을 처리할 때, 도중에 멈추지 않고 모든 작업을 한 번에 처리합니다. (React hydrates the tree in a single pass) 이는 서버사이드 렌더 된 컴포넌트 중 어느 하나라도 상호작용이 가능한 상태가 되기 위해서는 모든 컴포넌트가 Hydration 되어야 한다는 것을 의미합니다.
예를 들어, 댓글 위젯 쪽에 굉장히 시간이 오래 걸리는 렌더링 로직이 들어있다고 가정해보겠습니다. 한번 Hydration이 시작되면, 전체 컴포넌트 트리가 완전히 Hydration 되기 전까지 유저는 댓글 위젯뿐 아니라 네비게이션바, 사이드바, 포스팅 본문들 모두와 상호작용할 수 없습니다. 특히 네비게이션바의 경우 유저가 이 페이지 자체에서 떠나고 싶지만, Hydration이 진행되는 동안에는 해당 컴포넌트와 상호작용을 할 수 없으므로 더 이상 보고 싶지 않은 페이지에 남아 있어야 하는 경우가 생기게 됩니다.
위에서 얘기한 3가지 문제점들에는 공통점이 하나 있는데, 그것은 어떤 동작을 “이른 시기부터 수행하거나”(다른 작업들을 모두 블로킹하기 때문에 사용자 경험을 훼손하는 경우), “나중에 수행하거나”(시간을 낭비하기 때문에 사용자 경험을 훼손하는 경우)의 둘 중 하나를 선택하도록 강요한다는 것입니다.
이러한 문제가 발생하는 이유는 이 모든 과정들이 “Concurrent” 하게 이루어지지 못하기 때문입니다. (Waterfall) 서버에서 데이터를 가져오는 과정이 모두 끝나야 서버에서 HTML로 렌더링할 수 있고 클라이언트에 모든 자바스크립트 코드가 렌더링 된 후에야 Hydration을 수행할 수 있고, 모든 Hydration이 끝나야 임의의 컴포넌트가 상호작용이 가능한 상태가 될 수 있다는 것입니다. 그리고 이 Waterfall 방식의 SSR 동작이 이 모든 것을 비효율적으로 만드는 이유입니다. 이에 대해 React Core Team이 제시하는 해결책은 이 모든 과정을 “Concurrent” 하게 만드는 것입니다. 즉, 앞에서 언급한 작업을 쪼개, 전체 애플리케이션이 아닌 각각의 부분들에 대해 이 단계들을 수행할 수 있게 하는 것입니다. 그리고 이 문제를 React 18의 Suspense를 사용해서 해결할 수 있습니다.
Reat18에서는 Suspense를 통해 다음 2가지 SSR Feature를 사용할 수 있습니다.
해당 기능들이 어떤 역할을 하고, 더 나아가 어떤 문제를 해결하는지 확인하기 위해 예제를 살펴보도록 하겠습니다. (예제는 앞에서도 계속 사용되었던 댓글 위젯에 대한 내용을 사용합니다.)
React 18은 페이지의 부분을 Suspense로 감싸서 특정 컴포넌트가 준비되기 전까지 fallback UI를 보여주도록 할 수 있습니다. 아래의 예시에서 “Comments” 컴포넌트를 “Suspense”로 감싸줌으로써, 리액트에게 댓글 컴포넌트를 기다리지 말고 우선 나머지 페이지에 대해서 HTML을 Streaming 할 수 있게 도와줍니다
<Layout>
<NavBar />
<Sidebar />
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
이렇게 최초로 서버에서 렌더된 HTML에는 댓글 컴포넌트 대신에 fallback ui인 spinner가 들어가 있게 됩니다.
<main>
<nav>
<!--NavBar -->
<a href="/">Home</a>
</nav>
<aside>
<!-- Sidebar -->
<a href="/profile">Profile</a>
</aside>
<article>
<!-- Post -->
<p>Hello world</p>
</article>
<section id="comments-spinner">
<!-- Spinner -->
<img width=400 src="spinner.gif" alt="Loading..." />
</section>
</main>
이 상황에서 서버 쪽에서 댓글 데이터가 준비되면, 리액트는 동일한 Stream에 추가되는 HTML과, 해당 HTML을 올바른 “위치”에 주입하기 위한 작은 inline “script” 태그를 보내줍니다.
<div hidden id="comments">
<!-- Comments -->
<p>First comment</p>
<p>Second comment</p>
</div>
<script>
// This implementation is slightly simplified
document.getElementById('sections-spinner').replaceChildren(
document.getElementById('comments')
);
</script>
결과적으로 클라이언트에서 리액트 자체를 로드하기도 전에 늦게 도착한 댓글 부분의 HTML을 보여줄 수 있게 됩니다.
이러한 방법은 위에서 이야기한 “첫 번째 문제(You have to fetch everything before you can show anything)”를 해결합니다. 이제 무언가를 보여주기 위해 서버에서 모든 데이터가 준비될 필요가 없습니다. 화면의 일부가 최초 HTML을 보내는 작업을 지연시키면, 해당 파트를 HTML에서 제외할 것인지를 선택할 필요 없이 해당 부분만 HTML 스트리밍 상에 나중에 들어오게 할 수 있습니다. 어디에 로딩 스피너가 나타날지 정해주면, 리액트가 해당 위치에 Stram으로 script와 함께 컴포넌트를 넣어주기 때문에 데이터가 특별한 순서에 맞춰서 로딩될 필요도 없습니다.
React 18에서 Suspense는 댓글 위젯이 로드되기 전에 애플리케이션을 Hydration 할 수 있게 해줍니다. 유저의 관점에서 최초에는 HTML로 스트리밍된 상호작용할 수 없는 콘텐츠를 보게 됩니다.
이제 리액트는 Hydration을 수행하고, 이때 아직 댓글에 해당하는 코드는 로드되지 않았지만, 리액트는 Selective Hydration을 통해 댓글에 해당하는 코드를 제외하고 Hydration을 수행할 수 있습니다. (Suspense로 감싸져 있기 때문에 그렇습니다.)
Comments를 Suspense로 묶음으로써 리액트가 Streaming과 Hydration이 렌더링을 Block 하는 것을 막아줍니다. 이로 인해 “두 번째 문제(You have to load everything before you can hydrate anything)”를 해결할 수 있습니다. Hydration을 시작하기 위해 모든 코드가 로드되는 것을 기다릴 필요가 없으며 리액트 코드의 부분부분이 로드될 때마다 Hydration을 진행할 수 있습니다. 리액트는 댓글 섹션 코드가 모두 로드된 뒤에 “이 부분만” Hydration을 시작하게 됩니다. Selective-Hydration 덕분에 무거운 자바스크립트 코드의 일부가 나머지 페이지의 상호작용을 막지 않게 됩니다.
리액트가 가장 중요하게 생각하는 “선언적(Declarative)”인 속성은 Suspense와 Hydration에도 적용됩니다. 즉, Suspense와 non-blocking Hydration에 대한 것을 리액트에서 알아서 처리한다는 것입니다. 이로 인해 작업이 예상하지 못한 순서로 진행되는 것에 대해 걱정할 필요가 없습니다. HTML을 스트리밍하는 것 자체도 여러 상황들로 인해 시간이 지연될 수 있는데, 만약 Straming HTML보다도 나머지 부분의 자바스크립트 코드가 더 빨리 로드되었다면 리액트는 나머지 페이지를 먼저 Hydration 합니다. Straming HTML이 먼저 도착하든, 자바스크립트 코드가 먼저 도착하든 Suspense는 일관적으로 non-blocking 하게 동작하며 리액트는 그저 먼저 도착한 것을 먼저 처리할 뿐입니다.
위의 과정 이후 HTML이 도착하면 해당 부분만 상호작용이 불가능한 상태로 그려지고, 이후 자바스크립트가 로드되면 해당 부분까지도 상호작용이 가능한 상태로 바뀌게 됩니다.
댓글 부분을 Suspense로 감쌌을 때, 드러나지 않는 개선점이 하나 더 있습니다. 즉, Hydration 과정 자체가 더 이상 브라우저를 “완전히” 점유하지 않는다는 것을 의미합니다. 예를 들어, 댓글 부분의 Hydration이 진행되는 동안 유저가 사이드바를 클릭했다고 가정해보겠습니다. React 18의 Suspense boundary 내부에서 발생하는 Hydration 과정에는 브라우저가 이벤트를 핸들링할 수 있도록 작은 여지(tiny gaps)들이 포함됩니다. (여기서는 tiny-gaps라고 다소 추상적으로 묘사했지만, Hydration 자체가 Fiber의 Rendering Phase에서 특정 우선순위를 가진 상태로 Concurrent하게 일어나기 때문에 Mouse Click, Input과 같은 더 중요한 이벤트가 발생했을 때, 이를 먼저 처리하는 여지를 준 것으로 해석할 수 있습니다.)
이 방법을 통해 클릭은 즉시(immediatly) 처리되고 브라우저는 저사양 디바이스에서 발생하는 긴 Hydration 구간에 갇히지 않아도 됩니다. 예를 들어, 유저는 이제 더 이상 보고 싶지 않은 페이지에서 Hydration이 진행되는 동안에도 네이게이션 바를 통해 다른 페이지로 이동할 수 있게 됩니다.
앞선 예제에서 Suspense를 댓글 컴포넌트에서만 사용했지만, 아래와 같이 더 많은 위치에 배치함으로써, 나머지 구성 요소들에 대한 Hydration 작업도 분리할 수 있습니다. 예를 들어 사이드바에도 Suspense를 적용해볼 수 있습니다.
<Layout>
<NavBar />
<Suspense fallback={<Spinner />}>
<Sidebar />
</Suspense>
<RightPane>
<Post />
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>
</RightPane>
</Layout>
이제 네비게이션과 포스트를 가지고 있는 최초의 HTML이 전송된 뒤에도 서버로부터 사이드바와 댓글 컴포넌트가 스트리밍될 수 있습니다. 그리고 이 경우에는 Hydration에도 영향을 줍니다. 예를 들어 두 항목의 HTML이 모두 로드되었지만 아직 코드는 로드되지 않은 경우, 아래와 같이 보일 것입니다.
이 상황에서 사이드바와 댓글 코드를 가지고 있는 번들이 로드되면, 리액트는 이 둘 모두를 Hydration 하는데, 트리 상에서 더 먼저 발견되는 Suspense boundary의 컴포넌트부터 시작합니다. (이 경우 Sidbar가 해당될 것입니다.)
이렇게 Hydration이 진행되고 있는 상황에서 유저가 댓글 쪽을 먼저 클릭하는 경우를 가정해보겠습니다. 리액트는 이러한 경우에 대해 해당 클릭을 기록하고, 이것이 더 급하기 때문에 댓글에 대한 Hydration에 우선순위를 부여합니다. 댓글 코드가 Hydration을 마치면 리액트는 기록된 클릭 이벤트를 먼저 실행하고, 다시 사이드바를 Hydration하기 시작할 것입니다.
이 과정은 우리의 “세 번째 문제(You have to hydrate everything before you can interact with anything)”를 해결합니다. Selective-Hydration 덕분에, 리액트는 최대한 빨리 모든 것을 Hydration 하면서 유저의 상호작용을 기반으로 화면 상에서 가장 급한 부분에 우선순위를 부여하게 될 것입니다. Selective Hydration의 장점은 애플리케이션에 Suspense를 적용하고, 각각의 영역이 더 작아지게 되면 더 명확해질 것입니다.
위 예제에서 유저는 Hydration이 시작된 후 첫 번째 댓글을 클릭합니다. 리액트는 모든 부모 Suspense 영역에 대한 Hydration을 우선시하지만, 관련 없는 sibiling 컴포넌트에 대한 Hydration은 우선 skip 할 것입니다. 이 방법은 마치 Hydration이 “즉각적으로” 이루어진다는 착각을 불러오는데, 이는 상호작용에 해당하는 컴포넌트가 가장 먼저 Hydration되기 때문입니다.
React 18이 등장하기 전까지 Suspense는 그저 “로딩 UI를 조금 더 예쁘게 보여주는 방식” 정도로 여겨졌던 것 같습니다. 하지만 브라우저를 점유하지 않고 작업을 Concurrent하게 끊어서 처리할 수 있는 기반인 Fiber Architecture, 여러 Task들 중 더 높은 우선순위가 높은 작업들을 우선 처리할 수 있게 만드는 Lane Model, 그리고 SSR에서 조금 더 우선순위가 높은 작업들을 먼저 Hydration할 수 있도록 도와주는 Suspense를 이어 붙여보니 Concurrency를 지원하기 위해 거의 모든 것을 “다시” 만들어야 했던 React 18이 새삼 대단하게 느껴지는 것 같습니다.
이러한 과정을 지원하면서도 여전히 리액트는 이 과정을 “선언적”으로 사용할 수 있게 만들어서 리액트 개발자들이 이러한 Background를 다 이해하지 못해도 이를 편하게 사용할 수 있도록 만들어주었고, 이러한 부분들이 React를 Modern Frontend Scene을 움직이는 핵심 라이브러리로 만들어주는 게 아닐까 하는 생각이 듭니다.