Eric Park
Eric Park/devs

React Server Component와 Streaming으로 웹 성능 극대화하기: Next.js 실전 가이드

June 3, 2025

16 min read

안녕하세요, 뤼이드 Software Engineer 박준열입니다.

최근 사내 Web 기술 교류 행사에서 React Server ComponentsStreaming의 중요성, 그리고 새로운 Data Fetch 패턴에 대해 발표했습니다.

발표가 구성원 분들께 유익했다는 평가를 받아 외부에도 공유하고자 본 블로그 글을 작성하게 되었습니다.

많은 분께 도움이 되길 바랍니다.

App Router의 출시

말도 많고 탈도 많았던 NextJS의 App Router가 도입이 된지도 벌써 2년이 넘게 지났습니다.

처음에는 Pages Router가 있는데 굳이 App Router를 사용해야 하는지에 대한 의문들이 많았습니다. 그러나 NextJS 팀의 지속적인 지원과 노력 덕분에, 이제는 신규 프로젝트 대부분이 Pages Router 대신 App Router를 사용하여 시작되고 있습니다.

하지만 여전히 App Router를 사용해야 하는 이유와 React Server Components의 올바른 사용법을 잘 이해하고 있는 사람은 많지 않습니다.

이 글을 통해 많은 분들이 React Server Components를 사용하는 이유와 성능을 극대화하는 올바른 사용 방법을 명확히 이해할 수 있기를 바랍니다.

본 글은 React Server Components가 무엇인지를 설명하기보단, React Server Components을 활용한 성능 개선과 새로운 Data Fetch 패턴에 대해 설명합니다.

React Server Components? Server Side Rendering이랑 뭐가 다른데?

React Server Components가 처음 발표됐을 때, 많은 사람이 가장 혼란스러워한 부분은 기존의 **Server Side Rendering (SSR)**과 무엇이 다른지였습니다.

React Server ComponentsServer Side Rendering은 비슷하지만 분명한 차이가 있습니다.

공통점은 두 기술 모두 **BFF(Backend For Frontend)**에서 컴포넌트를 생성하는 함수가 실행된다는 점입니다.

차이점은 다음과 같습니다.

  • Server Side RenderingBFF와 Client 두 곳에서 함수를 실행합니다.

  • React Server Components는 오직 BFF에서만 함수를 실행합니다.

여기서 React Server Components의 중요한 장점 중 하나가 나타납니다. 이 컴포넌트의 코드는 오직 BFF에서만 실행되므로, 클라이언트로 전달되는 JS 번들에서 이 코드를 완전히 제거할 수 있습니다.

즉, 번들 사이즈를 줄일 수 있습니다!

하지만 장점만 있는 것은 아닙니다. Client로 전송되는 JS 번들 크기는 줄어들지만, React Server Components의 동작 방식으로 인해 HTML의 크기는 오히려 늘어나게 됩니다.

NextJS App Router를 사용하는 웹페이지의 Elements

App Router를 사용하는 웹 페이지의 Elements를 확인하면 다음과 같은 태그가 나타납니다.

html
<script>self.__next_f.push(...)</script>

이 태그들은 React Server Components의 Payload로, React Server Components가 동작하는 데 필요한 정보들을 담고 있습니다.

예를 들면, 다음과 같은 정보들이 모두 <script>self.__next_f.push(...)</script> 형태로 HTML 끝 부분에 추가됩니다.

  • 서버에서 생성된 서버 컴포넌트의 렌더링 결과물

  • CSS나 폰트 등 서버 컴포넌트에서 사용하는 리소스들의 연결 정보

  • 서버 컴포넌트에서 클라이언트 컴포넌트로 전달하는 props

React Server ComponentsPayload에 대해 더 깊이 알아보면 좋겠지만, 이번 글에서는 React Server Components를 잘 활용하는 법을 중점적으로 다루기에, 기회가 된다면 다음에 더 깊게 다뤄보겠습니다.

Data Fetch, 어디서 하는게 좋을까?

오늘의 주요 주제는 Data Fetch는 어디서 하는게 좋은가에 대한 얘기입니다.

NextJS와 같은 BFF를 지원하는 프레임워크 덕분에 BFF에서 Data Fetch를 수행하는 선택지가 생겼습니다. 물론, 인증 관리가 복잡해지거나 필요성을 느끼지 못해 이전처럼 Client에서 Data Fetch를 하는 경우도 여전히 많습니다.

그러나 Data Fetch를 어디서 어떻게 하느냐는 사용자 경험에 큰 영향을 줍니다.

따라서 상황에 맞는 올바른 패턴을 선택하는 것이 매우 중요합니다.

다음은 세가지 주요 Data Fetch 패턴들입니다.

  1. Client Data Fetch

  2. Server Data Fetch without Streaming

  3. Server Data Fetch with Streaming

각각의 Data Fetch 패턴을 차근차근 살펴보면서, 어떻게 사용자 경험을 극대화할 수 있는지 알아보겠습니다.

모든 예시는 유저가 Main Page에 접속하고 이미 로딩이 다 된 상황에서, Detail Page로 라우팅을 하는 상황이라고 가정합니다.

1. Client Data Fetch

첫 번째 패턴은 가장 익숙한 방식인 Client에서 Data Fetch를 수행하는 패턴입니다.

보통은 useEffect를 사용하거나, Tanstack Query 또는 SWR 같은 라이브러리를 통해 데이터를 가져오는 방식입니다.

세부 동작 방식

  1. 사용자가 상세 페이지로 이동을 클릭하면 곧바로 페이지로 이동합니다. (라우팅)

  2. 페이지가 우선적으로 렌더링됩니다.

  3. 마운트 후, useEffect가 실행되면서 Data Fetch를 수행합니다.

  4. 응답을 받은 후, 실제 데이터를 기반으로 페이지가 다시 렌더링됩니다.

이 중에서 사용자가 상세 페이지의 유의미한 정보를 확인할 수 있는 시점은 4단계응답을 받아온 뒤 실제 데이터를 기반으로 페이지가 재렌더링될 때입니다.

이는 Data Fetch를 통해 필요한 정보를 가져와야 사용자가 기대하는 내용을 담은 UI를 제공할 수 있기 때문입니다.

각 단계에 소요되는 시간을 시각적으로 확인하면

1번 과정과 2번 과정초록색 박스만큼의 시간이 걸리고, 3번 과정파란색 박스만큼의 시간이, 4번 과정까지 전부 마무리 되는데에는 노란색 박스만큼의 시간이 걸립니다.

여기서 주의 깊게 봐야하는 두 지표는 초록색 박스와 노란색 박스의 크기입니다.

초록색 박스는 유저가 라우팅 클릭을 하고 실제로 이동이 되기까지 걸리는 시간이고, 노란색 박스는 유저가 라우팅 클릭을 하고 유의미한 정보를 확인하기까지 걸리는 시간입니다.

초록색 박스의 크기가 클수록 사용자는 서비스가 빠르게 반응하지 않는다고 느끼며 답답함을 경험하게 됩니다.

노란색 박스의 크기가 클수록 사용자가 실제로 데이터를 기다리는 시간이 늘어나 앱이 느리다는 인상을 받게 됩니다.

Client Data Fetch의 장점

  • 초록색 박스의 크기가 매우 작기 때문에 사용자의 액션에 대해 즉각적인 반응이 발생하여 빠릿빠릿한 사용자 경험을 제공합니다.

  • Loading Spinner 또는 Skeleton을 통해 사용자는 현재의 상태를 명확히 이해할 수 있습니다.

Client Data Fetch의 단점

  • 노란색 박스의 크기가 비교적 크므로 사용자가 실제로 유의미한 정보를 확인하기까지 걸리는 시간이 상대적으로 길어질 수 있습니다.

  • Backend 서버와의 거리를 직접 조절할 수 없으므로 데이터 요청 속도가 일정하지 않고 변동성이 큽니다.

2. Server Data Fetch without Streaming

두 번째 패턴은 사용자가 상세 페이지로 라우팅을 하면 BFF에서 페이지를 생성하여 Client에 한 번에 완성된 페이지를 반환하는 방식입니다.

이 패턴은 다음 상황에서 나타납니다.

  • NextJS Pages Router에서 Server Data Fetch를 할 때

  • NextJS App Router에서 잘못된 방식의 Server Data Fetch를 수행할 때

즉, NextJS 기반 웹사이트에서 클릭 후 페이지 이동까지 시간이 오래 걸리는 경우 이 패턴을 사용하고 있다고 볼 수 있습니다.

NextJS Pages Router를 사용하는 서비스의 예시

위 이미지를 자세히 보면, 사용자가 페이지 이동을 위해 클릭을 한 후, 약 500ms 정도의 딜레이가 발생한 뒤 실제 페이지로 이동되는 것을 확인할 수 있습니다.

다음은 두 번째 패턴의 상세 페이지 로딩 과정입니다.

  1. 사용자가 상세 페이지로의 라우팅을 클릭하면 BFF로 요청이 전달됩니다.

  2. BFF는 필요한 데이터를 얻기 위해 Backend 서버에 API 요청을 보냅니다.

  3. Backend 서버의 API 응답을 받은 후 렌더링이 시작됩니다.

  4. 렌더링이 완료되면 결과물을 Client로 전송합니다.

  5. Client가 렌더링 결과물을 받아서 비로소 상세 페이지로 이동합니다.

이 과정을 이번에도 시각적으로 그려보면,

Client Data Fetch와 비교했을 때의 주요 차이점은 다음 세 가지입니다.

  • Backend 서버로부터 데이터를 가져오는 시간이 더 짧습니다.

  • 사용자가 유의미한 정보를 보기까지 걸리는 시간이 더 짧습니다.

  • 사용자가 페이지 이동 요청 후 실제로 페이지가 로드되기까지 걸리는 시간이 더 깁니다.

특히 중요한 점은, 이 방식에서는 파란색 박스의 크기Client Data Fetch 방식에 비해 더 작다는 점입니다.

Data Fetch를 수행하는 BFF는 결국 일종의 서버이므로 다음과 같은 이점이 있습니다.

  • BFF의 물리적 위치를 조정하여 Backend 서버와의 거리를 줄일 수 있습니다.

  • BFF의 컴퓨팅 리소스를 선택할 수 있으므로, 컴포넌트 렌더링 속도를 더 빠르게 만들 수 있습니다.

주목할 만한 점은 사용자가 실제 유의미한 정보를 확인하기까지 걸리는 시간(노란색 박스의 크기)이 Client Data Fetch보다 작다는 점입니다.

이는 다음과 같은 이유에서 가능합니다.

  • BFF의 위치와 컴퓨팅 리소스를 유연하게 조정하여, BFF와 Backend 서버 간 데이터 요청·응답의 지연(latency)을 최소화할 수 있습니다.

  • **Client와 BFF 간 통신이 한 번의 왕복(Round Trip)**만으로도 완성된 HTML을 전달하므로, 사용자가 최종적으로 완성된 페이지를 보는 데까지 걸리는 시간이 크게 단축됩니다.

하지만 가장 중요하다고 생각하는 인터랙션 응답 속도(초록색 박스)가 Client Data Fetch에 비해 현저히 크다는 것이 Server Data Fetch without Streaming의 가장 큰 단점입니다.

사용자가 유의미한 정보를 더 빨리 받을 수는 있지만, 클릭 후 실제 페이지로 라우팅되기까지의 시간이 길어져 사용자에게 큰 답답함을 유발하게 됩니다.

일부에서는 다음과 같은 질문을 할 수 있습니다.

“결국 Client Fetch나 Server Fetch 모두 API 요청을 기다리는 건 마찬가지 아닌가요? 그렇다면 유의미한 정보를 더 빨리 볼 수 있는 방식이 더 좋은 것 아닌가요?”

하지만 실제 사용자 경험 관점에서는 이러한 단순 비교가 성립하지 않습니다.

이와 유사한 유명한 사례로, 느린 엘리베이터와 거울 설치에 관한 일화가 있습니다.

예전에 어떤 건물에서 엘리베이터의 속도가 느리다는 사용자들의 불만이 지속적으로 제기되었습니다. 그러나 기술적으로 엘리베이터의 속도를 개선하기 어려운 상황이었습니다.

이에 관리자는 엘리베이터 내부에 대형 거울을 설치했습니다. 그러자 이후 사용자들의 불만이 현저히 감소했습니다.

그 이유는, 사용자들은 실제 대기 시간이 아니라 체감하는 대기 시간에 더 민감하기 때문입니다. 이전에는 엘리베이터 내부에서 단순히 기다리고만 있다 보니 대기 시간이 더욱 길게 느껴졌지만, 거울을 설치한 후 사용자가 자신의 모습을 보느라 대기 시간이 덜 지루하게 느껴져, 자연스럽게 속도에 대한 불만도 감소한 것입니다.

웹 애플리케이션에서도 정확히 같은 원리가 적용됩니다.

API 응답 속도 자체를 획기적으로 빠르게 만드는 데는 기술적 한계가 존재합니다. 실제 사용자 입장에서는 데이터를 받아오는 물리적인 시간보다, 대기하는 동안 제공받는 시각적 피드백(로딩 스켈레톤, 애니메이션, 재미 요소 등)에 따라 전반적인 앱의 체감 속도를 다르게 느끼게 됩니다.

따라서 UX 관점에서 가장 중요한 것은 사용자의 체감 대기 시간을 줄일 수 있는 적절한 피드백 UI와 스켈레톤 처리를 제공하는 것입니다.

오히려 SSR(Server Side Rendering)을 잘못 활용하면 사용자는 서비스가 더 느리다고 느낄 수도 있습니다.

3. Server Data Fetch with Streaming

지금까지 저희는 Client Data FetchServer Data Fetch without Streaming 두 가지를 살펴보며 각각의 장단점을 파악했습니다.

  • Client Data Fetch는 사용자가 유의미한 정보를 확인하기까지의 전체 시간이 길다는 단점이 있지만, 빠른 인터랙션 응답 속도와 로딩 스피너, 스켈레톤과 같은 다양한 기법을 활용하여 사용자의 체감 대기 시간을 줄일 수 있다는 장점이 있습니다.

  • 반면 Server Data Fetch without Streaming은 사용자가 유의미한 정보를 확인하기까지의 전체 시간이 짧다는 장점이 있지만, 인터랙션 응답 속도가 느리고, 로딩 스피너나 스켈레톤을 통한 UX 개선이 어려워 사용자 경험 관점에서는 단점이 있습니다.

두 가지 패턴의 장점만 결합한 마법 같은 패턴이 있다면 어떨까요?

Client Data Fetch의 장점인 빠른 반응 속도와 Skeleton UI, Server Data Fetch without Streaming의 장점인 짧은 로딩 시간을 합친 것이 바로 Server Data Fetch with Streaming입니다.

놀랍게도, 이 두 가지 장점만을 취하기 위한 작업은 단 하나, 바로 loading.tsx** 파일을 추가하는 것**입니다!

NextJS의 App Router에서 loading.tsx를 추가하는 것은 내부적으로 페이지 전체를 Suspense로 감싸는 것과 동일한 효과를 냅니다.

Suspense로 감싸진 자식 컴포넌트가 렌더링 중 suspend 상태에 들어가면, 완료될 때까지 가장 가까운 Suspense Boundary의 fallback 컴포넌트를 보여주게 됩니다.

컴포넌트가 suspend 상태가 되는 대표적인 상황은 다음과 같습니다.

  • Dynamic Import된 컴포넌트를 렌더링할 때

  • Dynamic한 React Server Component를 렌더링할 때

이러한 상황에서는 렌더링이 완료될 때까지 Suspense Boundary의 fallback을 보여줍니다.

즉,

  • Suspense Boundary에서 fallback을 표시하는 것은 Client Data Fetch의 장점Skeleton UI와 동일한 사용자 경험을 제공하고,

  • Data Fetch를 BFF에서 진행하고 컴포넌트를 완성하여 클라이언트에 전달하는 것은 Server Data Fetch without Streaming의 장점과 동일한 효과를 줍니다.

결국, Server Data Fetch with Streaming은 이 두 가지 장점을 자연스럽게 결합한 이상적인 패턴입니다.

결과적으로 Suspense 덕분에 다음과 같은 장점이 가능합니다.

  • 사용자는 상세 페이지 라우팅 요청 시 즉시 페이지로 이동하므로 서비스가 빠르게 반응한다고 느낍니다. (인터랙션 응답 속도 향상)

  • 실제 Data Fetch가 BFF에서 수행되므로 latency가 작아지고, 사용자가 대기하는 시간이 감소합니다. (로딩 시간 단축)

  • 기다리는 동안 사용자에게 Skeleton UI를 보여줘 사용자 체감 대기 시간을 최소화할 수 있습니다. (체감 속도 향상)

즉, 앞서 설명한 두 패턴(Client Data FetchServer Data Fetch without Streaming)의 장점만을 결합한 패턴이 바로 Server Data Fetch with Streaming입니다.

앞서 살펴본 내용을 시각화하여 정리하면 다음과 같습니다.

  • **페이지 라우팅까지 걸리는 시간(초록색 박스)**은 Client Data Fetch와 동일하여 빠르게 반응합니다.

  • **유저가 실제 유의미한 정보를 보기까지 걸리는 시간(노란색 박스)**은 Server Data Fetch without Streaming과 동일하게 짧습니다.

따라서, 만약 여러분이 NextJS App Router를 사용하고 있다면, 반드시 loading.tsx** 파일**을 추가하여 Server Data Fetch with Streaming의 장점을 활용하시기 바랍니다!

App Router가 처음 공개되었을 때, 사람들은 그동안 축적된 NextJS의 크고 잦은 변경들에 대한 불만이 폭발했고, App Router의 장점보다는 단점과 불편함에 더 집중했다고 생각합니다.

하지만 App Router를 깊이 학습할수록, NextJS 팀이 기존 Pages Router의 한계와 문제점들을 극복하기 위해 정말 많은 연구와 노력을 했다는 점을 알 수 있었습니다.

그중 대표적인 기능이 바로 StreamingReact Server Components 호환이라고 생각합니다.

앞서 설명한 내용을 통해 저희는 Streaming이 사용자 경험에 매우 중요하다는 사실을 확인할 수 있었습니다.

기존의 Pages Router에서는 Streaming을 지원하지 않아 속도가 느렸습니다. 또한, Static Site Generation(SSG) 또는 **Incremental Static Regeneration(ISR)**을 적절히 활용하지 않고 오직 **Server Side Rendering(SSR)**만을 사용하면 오히려 사용자 경험을 저하시킬 수도 있었습니다.

반면 App Router에서는 React Server Components가 처음으로 도입되면서, 기존 Pages Router에서 페이지 단위로만 가능했던 SSR이 이제는 컴포넌트 단위로 가능해졌고, 이를 통해 Streaming 기능의 효과가 더욱 강화되었습니다.

Streaming을 통해 사용자는 미리 보여줄 수 있는 부분부터 보고, 나중에 준비되는 부분은 이후에 볼 수 있게 되었습니다.

기존의 Pages Router에서는 컴포넌트 단위로 Server Side Rendering(SSR)을 하는 것이 불가능했기 때문에 Streaming 기능의 실질적 이점이 제한적이었습니다.

그러나 React Server Components가 도입되면서 컴포넌트 단위로 SSR을 할 수 있게 되었고, 이로 인해 Streaming이 본격적으로 빛을 발할 수 있게 되었습니다.

예를 들어 어떤 페이지의 90%가 Static하고, 10%만 Dynamic하다고 가정해봅시다.

기존 Pages Router에서는 페이지의 대부분이 Static하더라도 일부 Dynamic한 부분 때문에 페이지 전체가 suspend되어 사용자는 모든 내용을 기다려야만 했습니다.

반면, App Router컴포넌트 단위로 Server Side RenderingStreaming이 가능해졌기 때문에, 일부 컴포넌트가 느리게 준비되더라도 이미 준비된 나머지 컴포넌트를 먼저 렌더링하여 사용자에게 빠르게 보여줄 수 있습니다.

즉, 사용자는 더 빠르게 페이지를 경험할 수 있게 되는 것입니다.

출처: https://www.smashingmagazine.com/2024/05/forensics-react-server-components/

위 스크린샷은 동일한 페이지Pages RouterApp Router의 차이만 두고 구현한 결과입니다.

같은 페이지, 같은 데이터임에도 Streaming 여부에 따라 페이지 리소스들의 로딩 타이밍이 달라지며, 결과적으로 Web Vitals 수치와 사용자 경험에 큰 영향을 주게 됩니다. 이는 최종적으로 매출에도 직접적인 영향을 미칠 수 있습니다.

따라서 이 글을 통해 단 한 가지를 기억해야 한다면,

App Router를 사용하고 있다면 반드시 loading.tsx** 파일을 잊지 말고 생성합시다!**

4. (번외이지만 본 글의 가장 큰 목적) 기다려야하는 범위를 좁히는 꿀 패턴

위 내용을 통해 저희는 Data Fetch는 BFF 서버에서 수행하고, Streaming을 통해 페이지를 Client로 전달하는 방식이 가장 이상적이라는 것을 알게 되었습니다.

그러나 실제 개발 과정에서는 상태 관리나 인터랙션 처리, 혹은 React Hooks 사용이 필요해, 어쩔 수 없이 Server Components가 아니라 Client Components를 사용해야 하는 상황도 적지 않게 발생합니다.

이런 상황에서 “어쩔 수 없이 Client Data Fetch만 가능하다”라고 생각해 앞서 설명한 장점을 포기하시는 경우도 있을 수 있습니다.

바로 이런 분들을 위해 유용한 패턴 하나를 소개해드리겠습니다!

다음과 같은 페이지 상황을 가정해 봅시다.

  • 페이지 자체는 Server Component로 구성되어 있지만,

  • 이 페이지의 모든 자식 컴포넌트들은 Client Component입니다.

되게 전형적인 페이지의 구조이고, 이걸 트리의 형태로 보면

이렇게 볼 수 있습니다.

기능이 새로 추가되면서, Main Content 1 컴포넌트가 API를 호출해 가져온 데이터를 사용해야 한다고 가정해봅시다.

이때, 저희는 앞서 배운 Server Data Fetch의 이점을 활용하기 위해 다음과 같은 방법을 사용할 수 있습니다.

  • 페이지에서 유일한 Server ComponentMain Page에서 미리 Data Fetch를 수행합니다.

  • 이후 받아온 데이터를 prop으로 전달하여 **Main Content 1 컴포넌트(Client Component)**에서 사용할 수 있도록 합니다.

이렇게 하면, Client Component에서 직접 데이터를 가져오는 방식보다 더 빠르게 유저에게 유의미한 정보를 전달할 수 있습니다.

tsx
export default async function Home() {
  const data = await getData();

  return (
    <div className={cn('h-screen w-screen bg-white flex flex-col')}>
      <TopBar />
      <div className={cn('flex-1 w-full flex')}>
        <SideBar />
        <div className={cn('flex flex-col w-full')}>
          <ContentOne data={data}/>
          <ContentTwo />
          <ContentThree />
        </div>
      </div>
    </div>
  )
}

하지만 이 방식에는 아주 큰 문제가 있는데,

Main Page에서 Data Fetch를 해온다는 의미는,

  1. Main Page의 렌더링이 Suspend가 된다는 의미이고,
  2. Main Page를 감싸는 Suspense Boundary(loading.tsx)가 발동이 되면서,
  3. Main Page 전체가 Fallback을 보여주게 됩니다.

이 상황에서 실제 데이터를 필요로 하는 컴포넌트는 Main Content 1뿐임에도, Top Bar, Sidebar, Main Content 2, Main Content 3와 같은 다른 컴포넌트들도 불필요하게 Fallback에 가려지게 됩니다.

오직 Server Data Fetch의 이점을 누리기 위해, 보여줄 수 있는 정보들까지 숨겨서 사용자 경험을 저하시키는 것은 바람직하지 않습니다. 가능하다면 사용자에게 미리 보여줄 수 있는 정보는 즉시 보여주는 편이 좋습니다.

이때 많은 분들은 자연스럽게 다음과 같이 생각합니다.

“Main Content 1은 Client Component니까 어쩔 수 없이 Client Data Fetch를 해야겠구나.”

물론 이 방법도 한 가지 해결책이 될 수 있습니다. 하지만 정말 이 방법밖에 없을까요?

만약 새로운 Server Component를 하나 생성할 수 있다면, 더 좋은 접근 방법이 가능하지 않을까요?

바로 다음과 같은 방법을 사용할 수 있습니다.

  • Main Content 1을 감싸는 새로운 Server Component를 생성합니다.

  • 이 새로 만든 Server Component 내부에서 Data Fetch를 수행합니다.

  • 받아온 데이터를 **Client Component(Main Content 1)**에 prop으로 전달합니다.

tsx
export default async function Home() {
  return (
    <div className={cn('h-screen w-screen bg-white flex flex-col')}>
      <TopBar />
      <div className={cn('flex-1 w-full flex')}>
        <SideBar />
        <div className={cn('flex flex-col w-full')}>
          <Suspense fallback={<div>Loading...</div>}>
            <ContentOneWrapper />
          </Suspense>
          <ContentTwo />
          <ContentThree />
        </div>
      </div>
    </div>
  )
}
tsx
export const ContentOneWrapper = async () => {
  const data = await getData();

  return <ContentOne data={data}/>
}

이렇게 하면 새로운 Suspense Boundary를 Main Content 1 바깥에 추가를 해서 Suspend되는 범위를 좁힐 수 있기 때문에,

해당 이미지처럼 데이터가 필요한 Main Content 1 컴포넌트만 Loading State를 보여주고, 바로 보여줄 수 있는 컴포넌트들은 즉시 유저에게 보여지게 됩니다. 유저 경험이 훨씬 개선된 것이죠.

히지만 실제 개발을 하다 보면 기획이 변경되거나 기능이 추가되는 경우가 많습니다.

예를 들어, 추가로 Top Bar 컴포넌트에서도 동일한 데이터를 받아와야 하는 상황이 생겼다고 가정해 봅시다.

이 경우, 가장 빠르고 직관적인 방법은 다음과 같습니다.

  • Top Bar 컴포넌트를 감싸는 새로운 Server Component를 하나 더 생성합니다.

  • Server Component에서 동일한 데이터를 fetch하고, 해당 데이터를 prop으로 **Top Bar(Client Component)**에 전달합니다.

tsx
export default async function Home() {
  return (
    <div className={cn('h-screen w-screen bg-white flex flex-col')}>
      <Suspense fallback={<div>Loading...</div>}>
        <TopBarWrapper />
      </Suspense>
      <div className={cn('flex-1 w-full flex')}>
        <SideBar />
        <div className={cn('flex flex-col w-full')}>
          <Suspense fallback={<div>Loading...</div>}>
            <ContentOneWrapper />
          </Suspense>
          <ContentTwo />
          <ContentThree />
        </div>
      </div>
    </div>
  )
}
tsx
export const TopBarWrapper = async () => {
  const data = await getData();

  return <TopBar data={data}/>
}

하지만 이 방식은 적절하지 않습니다.

만약 Sidebar, Main Content 2에서도 동일한 데이터를 사용해야 하는 경우를 생각해봅시다. 더 나아가, 페이지 내부에 100개의 컴포넌트가 있고, 그중** 99개의 컴포넌트**가 동일한 데이터를 필요로 한다면 어떨까요?

이러한 상황에서 각각의 컴포넌트마다 Wrapper 컴포넌트를 생성해 데이터를 전달하는 방식은 비효율적이며 유지보수에 어려움을 줍니다. 특히 모든 Wrapper 컴포넌트가 비슷한 로직을 반복하게 되므로, 코드 관리가 쉽지 않습니다.

더 나은 접근법이 없을까요?

다시 한 번 요구사항을 정리해보면,

  • Data FetchServer에서 진행합니다.

  • Client Component는 서버에서 받아온 데이터를 사용합니다.

  • 데이터를 필요로 하지 않는 컴포넌트는 블로킹되지 않습니다.

  • 코드의 중복은 최소화합니다.

이를 고려할 때, 코드 중복을 최소화하려면 Main Page에서 Data Fetch를 진행하고, 받아온 데이터를 각 컴포넌트에 prop으로 전달하는 방식이 적합합니다.

하지만 이 방식은 데이터가 필요하지 않은 컴포넌트도 불필요하게 블로킹된다는 단점이 있었습니다.

저희는 이 문제를 React Server Components의 PayloadReact의 use 을 활용하여 해결해보고자 합니다.

본 글의 처음에 설명했던 React Server Components Payload에는 다음과 같은 값이 포함됩니다.

  • 서버 컴포넌트에서 클라이언트 컴포넌트로 전달하는 prop들

여기서 주목할 만한 사실은 이 prop들에 Promise도 포함될 수 있다는 점입니다.

이 내용을 코드로 표현하면 다음과 같습니다.

tsx
export default async function Home() {
  const dataPromise = getData();

  return (
    <div className={cn('h-screen w-screen bg-white flex flex-col')}>
      <Suspense fallback={<div className='w-full h-[120px] bg-red-300 flex items-center justify-center text-6xl'>Loading...</div>}>
        <TopBarWrapper />
      </Suspense>
      <div className={cn('flex-1 w-full flex')}>
        <SideBar />
        <div className={cn('flex flex-col w-full')}>
          <Suspense fallback={<div className='w-full flex-1 bg-fuchsia-300 flex items-center justify-center text-6xl overflow-hidden'>Loading...</div>}>
            <ContentOneWrapper />
          </Suspense>
          <ContentTwo />
          <Suspense fallback={<div className='w-full flex-1 bg-yellow-300 flex items-center justify-center text-6xl'>Loading...</div>}>
            <ContentThree dataPromise={dataPromise}/>
          </Suspense>
        </div>
      </div>
    </div>
  )
}

즉, 위 코드처럼 getData()에서 반환된 Promise를 await 하지 않고 바로 클라이언트 컴포넌트로 전달할 수 있습니다.

그러면 Client Component 내부에서는 React use를 사용해서

tsx
export default function ContentThree({ dataPromise }: { dataPromise: Promise<Data[]> }) {
  const data = use(dataPromise);

  return (
    <div className={cn('w-full flex-1 bg-yellow-300 flex items-center justify-center text-6xl')}>
      Content 3
      <div className='flex flex-col'>
        {data.map((item) => (
          <div key={item.id}>{item.name}</div>
        ))}
      </div>
    </div>
  )
}

전달받은 Promise에 담겨있는 값을 받아볼 수 있게 됩니다.

이렇게 저희는 기존의 불편하고 중복적인 Wrapper 컴포넌트를 생성하지 않고도

Main Page에서 Data Fetch를 하지만,** 데이터가 필요한 Client Component들만 Suspend**가 되도록 할 수 있습니다.

그럼 기존 코드도 리팩토링하여 불필요한 Wrapper 컴포넌트들을 제거해줍시다.

tsx
export default async function Home() {
  const dataPromise = getData();

  return (
    <div className={cn('h-screen w-screen bg-white flex flex-col')}>
      <Suspense fallback={<div className='w-full h-[120px] bg-red-300 flex items-center justify-center text-6xl'>Loading...</div>}>
        <TopBar dataPromise={dataPromise}/>
      </Suspense>
      <div className={cn('flex-1 w-full flex')}>
        <SideBar />
        <div className={cn('flex flex-col w-full')}>
          <Suspense fallback={<div className='w-full flex-1 bg-fuchsia-300 flex items-center justify-center text-6xl overflow-hidden'>Loading...</div>}>
            <ContentOne dataPromise={dataPromise}/>
          </Suspense>
          <ContentTwo />
          <Suspense fallback={<div className='w-full flex-1 bg-yellow-300 flex items-center justify-center text-6xl'>Loading...</div>}>
            <ContentThree dataPromise={dataPromise}/>
          </Suspense>
        </div>
      </div>
    </div>
  )
}

이제 Server Component에서는 Promise를 생성만 하고, 데이터가 필요한 Client Component에서만 React의 use 을 통해 데이터를 받아오는 매우 실용적인 패턴을 활용할 수 있게 되었습니다.

Deep Dive: Promise는 어떻게 전달될까?

위에서 설명한 패턴을 보고 resolve 되지 않은 Promise를 서버에서 클라이언트로 전송하는 것이 어떻게 가능한지, 그리고 Data Fetch가 서버와 클라이언트 중 어디에서 실행되는지, 혹은 양쪽에 걸쳐 실행되는지 명확히 이해하기 어려울 수 있습니다.

우선 결론부터 말씀드리면, 실제 Promise는 서버에서 관리됩니다.

그렇다면 클라이언트는 어떻게 해당 Promise의 값을 받아오며, Promise가 resolve 될 때까지 어떻게 기다리는지에 대한 의문이 생길 수 있습니다.

React Server Components의 Payload는 JSON을 문자열로 변환한(Stringify) 형태로 클라이언트에 전송됩니다. 즉, React Server Components의 Payload는 JSON 직렬화 과정을 거치게 되는데, 그 과정에서 만약 직렬화하려는 값이 Promise(또는 Thenable)인 경우, 해당 Promise를 그대로 넣지 않고 새로운 ID를 발급하여 Promise에 할당한 채로 JSON 직렬화를 수행합니다.

이해를 돕기 위해 코드로 과정을 살펴보겠습니다. 모든 코드는 React 공식 Github에서 찾을 수 있습니다.

1. Json 직렬화 과정에서 Pending 상태의 Promise 발견

https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFlightServer.js#L2927

해당 코드는 JSON 직렬화 코드의 일부분으로, JSON 직렬화 과정에서 Thenable이 발견될 경우, serializeThenable 함수로 해당 Thenable의 ID 값을 발급받습니다.

serializeThenable 함수의 로직은 복잡하지만, 해당 Promise의 상태(fulfilled, rejected, pending)에 따라 추가적인 로직을 실행하고, 항상 결과적으로는 해당 Promise의 ID 값을 반환한다는 점만 이해하면 됩니다. https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFlightServer.js#L674

https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFlightServer.js#L2199

그리고 해당 promiseId 값을 갖고 serializePromiseID 함수를 호출하여, JSON을 파싱하는 클라이언트 측에서 해당 ID가 Promise임을 알리기 위해 '$@' 접두사(prefix)를 추가합니다.

실제로 저희가 위에서 보던 프로젝트를 살펴보면,

{"dataPromise":"$@11"} 이런 Payload를 받았다는 것을 알 수 있습니다. 그럼 클라이언트는 이 정보를 토대로 현재 dataPromise라는 prop에는 id가 11인 현재 pending 상태의 promise가 전달이 될 것이라는 사실을 알 수 있습니다.

2. Client에서는 전달받은 Promise의 Id에 맞는 Pending Chunk 생성

https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js#L1418

클라이언트는 Payload를 파싱하는 과정에서 '$@'를 발견할 경우, 이 값이 Promise의 ID 값이라는 것을 알고, 이 ID 값을 갖고 새로운 Pending chunk를 생성합니다.

https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js#L379

해당 Pending Chunk는 내부적으로 Pending 상태인 Promise를 생성하는 것과 동일하며, React use에서는 이 Promise를 기다리게 됩니다.

실제로는 Promise가 아니라, 전달받는 chunk를 감싸고 Promise의 프로토타입을 재정의한 ReactPromise라는 새로운 객체이지만, 이해를 위해 Promise로 표현했습니다. https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js#L233

3. Promise가 Resolve되어서 원하는 데이터를 받아옴

BFF에서 관리하던 실제 Promiseresolve되어 저희가 원하는 데이터가 준비되면, 해당 데이터를 기존 Promise의 ID 값과 함께 클라이언트로 전송하게 됩니다. 이 데이터 역시 스트리밍을 통해 클라이언트로 전달되고, 형태는 동일하게 <script>self.__next_f.push([])</script>가 됩니다.

실제로 위 프로젝트의 element를 살펴보면,

이런 데이터를 발견할 수 있는데, 이.push([1, "11:[{\\"id\\":1,...}"] 라는 데이터를 조금 분석해보면,

[1, …] 는 현재 들어온 데이터는 React Server Components의 Payload라는 것을 의미하고, "11:[{\\"id\\":1,...}" 는 Id가 11인 Promise의 데이터는 …이다 라는 것을 의미합니다.

그럼 실제 코드에서는 어떻게 이 데이터를 이해하고, 기존에 suspend 되어있던 컴포넌트를 어떻게 이어서 렌더링을 하게 되는지를 살펴보겠습니다.

4. Client에서 Payload를 파싱하고 컴포넌트 렌더링까지

받아온 Payload를 파싱하고 suspend된 컴포넌트를 이어서 렌더링하기 위해, 우선 받아온 데이터를 아래 resolveModel 함수를 통해서 기존에 만들어놓은 pending chunk를 찾고, resolveModelChunk 함수를 호출합니다.

https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js#L1764

기존 pending chunk와 데이터를 갖고, resolveModelChunk 함수를 호출하면,

https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js#L543

initializeModelChunk 함수를 통해서 기존 Pending상태의 Promise에 받아온 데이터를 추가하고 ,

wakeChunkIfInitialized 함수를 통해서 Pending상태의 Promise를 기다리고 있던 컴포넌트를 “깨워서" 렌더링을 재개합니다.

이러한 과정을 통해 React는 서버에서 관리하는 Promise를 직접 클라이언트로 전달하지 않더라도, 고유한 ID와 스트리밍된 데이터를 활용해 클라이언트가 해당 Promise의 상태와 결과를 인지하고 적절하게 렌더링할 수 있도록 합니다.

이번 글에서는 다양한 Data Fetch 패턴React Server Components의 장점을 살린 새로운 패턴에 대해 알아봤습니다.

긴 글 읽어주셔서 감사합니다!