리액트 서버 컴포넌트 톺아보기 (번역)

원문: The Forensics of React Server Components (RSC)

간단 요약: 클라이언트 사이드 렌더링은 서버의 무거운 연산 부담을 덜어줍니다. 그 대신 초기 페이지 로드 시 빈 HTML 페이지가 보이는 점에서 사용자 경험에 좋지 않습니다. 반면, 서버 사이드 렌더링은 빠른 CDN을 통해 정적 자산(static assets)을 제공함으로써 초기 페이지 로드 시 충분한 정보를 제공할 수 있게 해줍니다. 그러나 동적 콘텐츠가 많은 대규모 프로젝트에는 서버 사이드 렌더링이 적합하지 않습니다. 리액트 서버 컴포넌트는 두 방식의 장점을 결합한 기술로, 저자 Lazar Nikolov은 RSC가 페이지 로드 타임라인에 미치는 영향을 깊이 있게 살펴봅니다.

이 글에서는 리액트 서버 컴포넌트(React Server Components, 이하 RSC)를 깊이 있게 살펴봅니다. RSC는 리액트 생태계의 혁신적인 최신 기술로, 서버 사이드 렌더링과 클라이언트 사이드 렌더링, 스트리밍 HTML을 활용하여 가능한 한 빠르게 콘텐츠를 전달합니다.

RSC가 리액트 구조 속에서 어떻게 자리 잡는지, 컴포넌트 렌더링 라이프사이클에 대해 어느 정도의 제어권을 제공하는지, 그리고 RSC가 적용된 페이지 로드는 어떤 모습인지 완벽하게 이해하기 위해 세세하게 파고들 것입니다.

그 전에, RSC가 필요하게 된 원인에 대한 맥락을 이해하기 위해, 기존에 리액트가 웹사이트를 렌더링 하던 방식을 되짚어보면 좋겠습니다.

1. 초기: 클라이언트 사이드 렌더링

최초의 리액트 앱은 클라이언트, 즉 브라우저에서 렌더링했습니다(Client-Side Rendering, CSR). 개발자들은 자바스크립트 클래스로 컴포넌트를 작성하고, 웹팩과 같은 번들러를 사용해 모든 코드를 컴파일 및 트리쉐이킹한 상태로 상용 환경에 배포할 수 있는 코드로 패키징했습니다.

서버에서 전달한 HTML에는 다음과 같은 몇 가지 요소가 포함되었습니다.

  • HTML 문서. <head>에는 메타데이터가 포함되었으며, <body>에는 빈 <div>가 포함되어 있습니다. 빈 <div>는 DOM에 리액트 앱을 주입하기 위한 요소로 사용됩니다.
  • 리액트 핵심 코드와 실제 웹 앱 코드가 포함된 자바스크립트 리소스. 이를 통해 빈 <div> 안에 리액트 앱을 채워 넣고 사용자 인터페이스를 생성할 것입니다.
그림 1. 클라이언트 사이드 렌더링 과정
그림 1. 클라이언트 사이드 렌더링 과정 (크게 보기)

이 방식을 따르는 웹 앱은 자바스크립트의 작업이 모두 완료된 이후에야 비로소 사용자와 상호작용을 할 수 있습니다. 이는 개발자 경험(DX)에는 좋지만, 사용자 경험(UX)에는 부정적인 영향을 주게 됩니다.

사실, 리액트의 CSR에는 장단점이 존재합니다. 장점은 다음과 같습니다. 화면 전체를 새로고침 하지 않고 업데이트되는 반응형 컴포넌트 덕분에 매끄럽고 빠른 페이지 전환을 제공합니다. 페이지 로딩에 걸리는 시간을 단축합니다. 또한 서버 부담을 줄이고, 빠른 CDN(content delivery network)을 통해 사용자가 지리적으로 가까운 서버에서 콘텐츠를 제공받을 수 있게 해줍니다.

그러나 CSR에는 단점도 있습니다. 예컨대 각 컴포넌트가 독립적으로 데이터를 가져올 수 있어서, 워터폴 네트워크 요청이 발생하면 전체 속도가 현저히 느려질 수 있습니다. 이는 UX 측면에서 단순한 불편함 정도로 여길 수도 있지만, 실은 사용자에게 상당한 손해를 끼칠 수 있는 문제입니다. Eric Bailey의 “Modern Health, frameworks, performance, and harm”은 모든 CSR 작업에 경고하는 이야기라고 할 것입니다.

또 다른 부정적인 CSR 결과로 검색 엔진 크롤러를 들 수 있습니다. 크롤러는 CSR 페이지에 대해 메타데이터와 빈 <div>만 있는 HTML 문서를 접할 수 있을 뿐, 렌더링이 완료된 페이지 전체 정보를 수집하지 못합니다. 최근에는 이 문제가 해결되었지만, 당시 검색 엔진 트래픽에 의존하는 회사 사이트들에는 심각한 문제였습니다.

2. 전환: 서버 사이드 렌더링

변화가 필요했습니다. CSR은 개발자에게 빠르고 인터랙티브한 인터페이스를 구축할 수 있는 강력한 접근 방식을 제공했지만, 전 세계 사용자는 빈 화면과 로딩 인디케이터에 시달려야 했습니다. 해결책은 렌더링 경험을 클라이언트에서 서버로 이전하는 것이었습니다. 개선하기 위해 기존 방식으로 돌아간다는 점이 아이러니해 보일 수도 있겠네요.

그래서 리액트는 서버 사이드 렌더링(Server-Side Rendering, SSR) 기능을 도입했습니다. SSR은 리액트 커뮤니티 내에서 큰 논쟁거리가 되었으며, 한 때 큰 주목을 받기도 했습니다. SSR 도입은 앱 개발 방식에 중대한 변화를 불러왔습니다. 리액트의 동작 방식이 크게 달라졌고, 브라우저 대신 서버를 통해 콘텐츠를 전달할 수 있게 되었습니다.

그림 2. 서버 사이드 렌더링 과정
그림 2. 서버 사이드 렌더링 과정 (크게 보기)

2.1. CSR 한계 해결

SSR에서는 빈 HTML 문서를 보내는 대신, 서버에서 초기 HTML을 렌더링 하여 브라우저로 전송합니다. 브라우저는 로딩 인디케이터를 보여줄 필요 없이 즉시 콘텐츠를 표시할 수 있습니다. 이는 웹 성능(Web Vitals) 중 First Contentful Paint (FCP) 성능 지표를 크게 개선합니다.

서버 사이드 렌더링은 CSR에서 발생한 SEO 문제도 해결했습니다. 크롤러가 웹사이트의 콘텐츠를 직접 받아 인덱싱할 수 있게 되었기 때문입니다. 또한 클라이언트보다 데이터 소스에 가까운 서버에서 초기 데이터 페칭을 수행하므로, 오류 없이 잘 수행된다면 데이터 요청의 워터폴 현상을 없앨 수 있게 되었습니다.

2.2. 하이드레이션

SSR은 복잡한 과정을 거칩니다. 리액트가 서버에서 받은 정적 HTML을 인터랙티브하게 만들기 위해서는 하이드레이션(Hydration)이 필요합니다. 하이드레이션은 초기 HTML의 DOM을 기반으로 클라이언트 측에서 가상 문서 객체 모델(Virtual DOM)을 재구성하는 과정입니다.

참고: 리액트는 실제 DOM보다 가상 DOM에서 업데이트를 파악하는 것이 빠르기 때문에 자체 Virtual DOM을 사용합니다. UI 업데이트가 필요할 때 가상 DOM과 실제 DOM을 동기화하지만, 변경 사항을 파악하는 알고리즘은 가상 DOM에서 수행됩니다.

이제 리액트에는 두 가지 버전이 존재합니다.

  1. 컴포넌트 트리로부터 정적 HTML을 렌더링 하는 서버 사이드 버전
  1. 페이지를 인터랙티브하게 만드는 클라이언트 사이드 버전

초기 HTML을 하이드레이트하기 위해서는 리액트와 앱 코드를 브라우저로 전송해야 합니다. 하이드레이션 과정에서 리액트는 서버에서 렌더링 된 DOM과 클라이언트에서 렌더링 된 DOM을 비교하여 차이점을 찾아내는 재조정(reconciliation) 작업을 수행합니다. 만약 두 DOM 사이에 차이가 있다면, 리액트는 컴포넌트 트리를 다시 하이드레이트하고, 컴포넌트 계층 구조를 서버에서 렌더링 된 구조에 맞추어 업데이트하려 합니다. 그래도 해결되지 않는 불일치가 있다면, 리액트는 문제를 알리기 위해 오류를 발생시킵니다. 일반적으로 이를 ‘하이드레이션 오류’라고 합니다.

2.3. SSR의 단점

SSR은 CSR의 한계를 해결하는 만능 해결책은 아닙니다. SSR 역시 단점이 있습니다. 초기 HTML 렌더링과 데이터 페칭을 서버로 옮겼기 때문에, 해당 서버들은 클라이언트에서 모든 것을 로드할 때보다 훨씬 더 큰 부하를 겪게 됩니다.

일반적으로 SSR 도입은 FCP 성능 지표를 개선한다고 합니다. 이는 대체로 맞지만, Time to First Byte (TTFB) 성능 지표 만큼은 그렇지 않습니다. 브라우저는 서버가 필요한 데이터를 페칭하고 초기 HTML을 생성하여 첫 바이트를 전송할 때까지 마냥 기다려야 합니다. TTFB 자체는 핵심 웹 성능에 해당하지 않지만, 다른 성능 지표에 영향을 줍니다. 즉, TTFB가 나쁘면 핵심 웹 성능 지표가 하락합니다.

SSR의 또 다른 단점은 클라이언트 측의 리액트가 하이드레이션을 완료하기 전까지는 전체 페이지가 제대로 동작하지 않는다는 점입니다. 예를 들어, 리액트가 이벤트 리스너를 부착하는 과정을 수행하기 전까지는 인터랙티브 요소들이 사용자 상호작용에 대해 “반응”하지 않습니다. 하이드레이션 과정은 일반적으로 빠르게 이뤄지지만, 사용 중인 기기의 인터넷 연결 상태나 하드웨어 성능에 따라 속도가 눈에 띄게 느려질 수도 있습니다.

3. 현재: 하이브리드 접근 방식

지금까지 리액트 렌더링의 두 가지 형태, 즉 CSR과 SSR에 대해 살펴보았습니다. 이 두 가지 방식이 서로의 한계를 보완하려는 시도였다면, 이제는 CSR과 SSR의 한계를 줄이기 위해 SSR을 다시 세 가지 방식으로 분화한 하이브리드 접근 방식을 취하고 있습니다.

먼저 정적 사이트 생성(Static Site Generation, SSG)증분 정적 재생성(Incremental Static Regeneration, ISR)에 대해 살펴본 후, 세 번째 유형인 RSC로 넘어가겠습니다.

3.1. 정적 사이트 생성 (SSG)

SSG는 요청마다 동일한 HTML 코드를 재생성하지 않습니다. 빌드 타임에 전체 앱을 컴파일하고 구축하여 정적인 순수 HTML과 CSS 파일을 생성하고, 이를 빠른 CDN에 호스팅합니다.

예상할 수 있듯이, 이 접근 방식은 콘텐츠가 크게 변하지 않는 소규모 프로젝트(예: 마케팅 사이트나 개인 블로그)에는 적합하지만, 사용자 상호작용에 따라 콘텐츠가 자주 변경되는 대규모 프로젝트(예: 전자상거래 사이트)에는 적합하지 않습니다.

SSG는 서버의 부담을 줄이는 동시에 서버가 페이지를 다시 렌더링 하기 위해 무거운 작업을 수행할 필요가 없으므로 TTFB와 관련된 성능 지표를 개선합니다.

3.2. 증분 정적 재생성 (ISR)

SSG는 콘텐츠 변경이 필요할 때 앱의 모든 코드를 다시 빌드해야 한다는 단점이 있습니다. 정적이라는 특성상 콘텐츠가 고정되어 있어서 전체를 다시 빌드하지 않고 일부만 변경할 수는 없습니다.

Next.js 팀은 전체를 빌드해야 한다는 SSG의 단점을 해결하기 위해 증분 정적 재생성(ISR)이라는 리액트의 두 번째 하이브리드 방식을 만들었습니다. 이름에서 알 수 있듯이, ISR은 전체를 다시 빌드하는 대신 필요한 부분만 빌드합니다. 초기 빌드 시기에는 (SSG와 마찬가지로) 페이지의 “초기 버전”을 정적으로 생성합니다. 이후 사용자가 어떤 페이지에 접근하여 서버 요청이 발생하면 데이터를 확인하여, 오래된 데이터가 포함된 경우에는 해당 페이지를 다시 빌드할 수 있습니다.

이후부터 서버는 필요한 때에만 점진적으로 해당 페이지의 새로운 버전을 정적으로 제공하게 됩니다. 즉, ISR은 SSG와 전통적인 SSR을 잘 섞은 하이브리드 접근 방식입니다.

그렇지만 ISR을 적용하더라도 문제가 있습니다. 사용자가 새 버전의 콘텐츠가 생성되기 전에 페이지를 방문하면 여전히 ‘오래된 콘텐츠’를 보게 됩니다. 또한 ISR은 SSG와 달리 개별 페이지를 재생성하기 위해 실제 서버가 필요합니다. 이는 최적화된 자산 전달을 위해 CDN에 앱을 배포한 의미를 퇴색시킵니다.

4. 미래: 리액트 서버 컴포넌트 (RSC)

지금까지는 필요에 따라 CSR, SSR, SSG, ISR 방식을 선택하여 사용해 왔습니다. 각각은 성능, 개발의 복잡성, 사용자 경험에 부정적인 영향을 미치는 일종의 트레이드오프가 있었습니다. 새로 도입된 RSC는 개발자가 개별 리액트 컴포넌트마다 올바른 렌더링 전략을 선택할 수 있도록 하여 위의 단점 대부분을 해결하고자 합니다.

RSC를 사용하면 서버에서 정적으로 처리할 컴포넌트와 클라이언트에서 렌더링할 컴포넌트를 선택적으로 결정할 수 있어 클라이언트에 전송되는 자바스크립트 양을 크게 줄일 수 있습니다. 이를 통해 각 프로젝트의 특성에 맞게 최적의 균형을 맞추는 데 더 많은 자유도와 유연성을 확보할 수 있습니다.

참고: RSC와 같은 고급 아키텍처를 도입할 때 모니터링은 매우 중요합니다. Sentry는 RSC 기반 애플리케이션의 실제 성능을 모니터링하고, 릴리즈의 성능 및 안정성에 대한 통찰력을 제공하는 강력한 성능 모니터링 및 오류 추적 기능을 제공합니다. Next.js와 같은 RSC를 지원하는 프레임워크에 Sentry를 구현하는 것은 단 한 줄의 터미널 명령으로도 충분합니다.

그렇다면 RSC란 정확히 무엇일까요? 이제 그 내부 작동 방식을 살펴보겠습니다.

5. RSC의 구성 요소

RSC는 컴포넌트를 서버 컴포넌트클라이언트 컴포넌트의 두 가지 유형으로 구분하고자 합니다. 둘의 차이는 작동 방식이 아니라, 어디에서 실행되는지, 또한 어떤 환경을 위해 설계되었는지에 있습니다. 이 글을 쓰는 시점에서 RSC는 RSC를 지원하는 프레임워크를 통해서만 사용할 수 있습니다. 현재까지는 Next.js, Gatsby, RedwoodJS의 세 프레임워크에서만 지원합니다.

그림 3. 서버 컴포넌트와 클라이언트 컴포넌트로 구성된 아키텍처 예시
그림 3. 서버 컴포넌트와 클라이언트 컴포넌트로 구성된 아키텍처 예시(크게 보기)

5.1. 서버 컴포넌트

서버 컴포넌트는 서버에서 실행되도록 설계되었으며, 그 코드가 브라우저로는 절대 전송되지 않습니다. 서버 컴포넌트는 오직 HTML 출력물과 컴포넌트가 받을 props만을 제공합니다. 이 접근 방식은 여러 가지 성능상 이점과 향상된 사용자 경험을 제공합니다.

  • 서버 컴포넌트는 용량이 큰 의존성 정보를 서버에 남겨둘 수 있습니다.
    어떤 컴포넌트에 용량이 큰 라이브러리를 사용한다고 가정해 봅시다. 클라이언트 측에서 컴포넌트를 실행하면 해당 라이브러리 전체를 브라우저로 전달해야 합니다. 하지만 서버 컴포넌트를 사용하면 정적인 HTML 출력물만 전달하고, 자바스크립트는 브라우저로 보내지 않아도 됩니다. 이런 경우 서버 컴포넌트는 문자 그대로 정적이어서, 하이드레이션 과정 자체를 생략하게 됩니다.
  • 서버 컴포넌트는 코드 생성에 필요한 데이터 소스(예: 데이터베이스나 파일 시스템)에 훨씬 가까이 위치합니다.
    서버의 연산 능력을 활용하여 연산을 많이 필요로 하는 렌더링 작업을 빠르게 수행하고, 생성된 결과만 클라이언트에 전달합니다. 단일 패스로 생성되기 때문에 워터폴 요청과 HTTP 왕복 횟수를 줄일 수 있습니다.
  • 서버 컴포넌트는 민감한 데이터와 로직을 브라우저로부터 안전하게 격리합니다.
    이는 개인 토큰이나 API 키가 클라이언트가 아닌 안전한 서버에서 실행되기 때문입니다.
  • 렌더링 결과는 캐시 되어 이후 요청이나 다른 세션 간에 재사용될 수 있습니다.
    이에 따라 렌더링 시간이 크게 단축되고, 요청마다 페칭되는 전체 데이터양이 줄어듭니다.

또한 서버 컴포넌트는 HTML 스트리밍을 활용합니다. 서버가 특정 컴포넌트에 대한 HTML 생성을 지연하고, 그동안 그 자리에 폴백(fallback) 값을 렌더링했다가, 이후 생성된 HTML을 스트리밍 방식으로 전달하는 것을 의미합니다. 스트리밍 서버 컴포넌트는 <Suspense> 태그로 컴포넌트를 감싸서 폴백을 제공합니다. 초기에는 폴백이 표시되다가 새로운 콘텐츠가 준비되면 스트리밍됩니다.

스트리밍에 대해서는 뒤에 더 자세히 알아봅시다. 그 전에 우선 클라이언트 컴포넌트와 서버 컴포넌트를 비교해 보죠.

5.2. 클라이언트 컴포넌트

클라이언트 컴포넌트는 우리가 이미 잘 알고 있는 컴포넌트입니다. 이들은 클라이언트 측에서 실행되며, 사용자 상호작용을 처리하고 localStoragegeolocation 같은 브라우저 API에 접근할 수 있습니다.

“클라이언트 컴포넌트”라는 용어는 새로운 개념을 설명하는 것이 아닙니다. 단지 “기존”의 CSR 컴포넌트와 서버 컴포넌트를 구분하기 위해 사용됩니다. 클라이언트 컴포넌트는 파일 상단에 "use client" 지시어로 정의합니다.

1
2
3
4
5
6
7
8
9
"use client"
export default function LikeButton() {
const likePost = () => {
// ...
}
return (
<button onClick={likePost}>Like</button>
)
}

Next.js는 기본적으로 모든 컴포넌트를 서버 컴포넌트로 취급합니다. 따라서 클라이언트 컴포넌트는 명시적으로 "use client"로 정의해야 합니다. 물론 "use server" 지시어도 존재하지만, 이는 서버 액션-클라이언트에서 호출하지만, 실행은 서버에서 이뤄지는 RPC(Remote Procedure Call)와 유사한 액션-을 위해 사용하며, 서버 컴포넌트를 정의하는 데 사용하지는 않습니다.

클라이언트 컴포넌트는 오직 클라이언트에서 렌더링 된다고 생각할 수 있습니다. 그러나 Next.js는 초기 HTML 생성을 위해 서버에서 클라이언트 컴포넌트를 렌더링 합니다. 그 결과로 브라우저는 이를 즉시 렌더링할 수 있으며, 이후 하이드레이션 과정을 거칩니다.

5.3. 서버 컴포넌트와 클라이언트 컴포넌트의 관계

클라이언트 컴포넌트는 오직 다른 클라이언트 컴포넌트만 명시적으로 임포트 할 수 있습니다. 즉, 클라이언트 컴포넌트는 재 렌더링 문제 때문에 서버 컴포넌트를 직접 임포트 할 수 없습니다. 다만 children prop을 통해 클라이언트 컴포넌트의 하위에 서버 컴포넌트를 전달할 수는 있습니다. 클라이언트 컴포넌트는 브라우저에 존재하며 사용자 상호작용을 처리하거나 자체 상태를 정의하기 때문에, 재 렌더링이 빈번하게 발생합니다. 클라이언트 컴포넌트가 다시 렌더링 되면 서브 트리도 함께 렌더링 되는데, 그 서브 트리에 서버 컴포넌트가 포함되어 있다면 어떻게 렌더링할 수 있을까요? 서버 컴포넌트는 클라이언트에 존재하지 않습니다. 그래서 리액트 팀은 이러한 제약을 두게 된 것입니다.

역자주: Next.js는 최초 서버에서 클라이언트 컴포넌트를 렌더링 합니다. 이때는 클라이언트 컴포넌트에서 서버 컴포넌트를 임포트 하더라도 문제가 되지 않습니다. 그러나 브라우저로 넘어간 이후에는 상황이 다릅니다. 브라우저로 넘어갈 때 서버 컴포넌트는 하이드레이션 과정에서 제외되어 존재하지 않게 됩니다. 이후 클라이언트 컴포넌트를 다시 렌더링 하라는 요청이 발생하면, 리액트는 서브 트리 중 일부(서버 컴포넌트)가 존재하지 않는다는 사실을 비로소 인지하게 되고, 렌더링 과정이 정상적으로 수행되지 않는 등의 문제가 발생할 것입니다. 리액트 팀은 이런 오류를 예방하기 위해 “클라이언트 컴포넌트는 서버 컴포넌트를 직접 임포트 할 수 없다”라는 제약을 만들었다는 설명입니다.

그런데 잠깐! 실제로는 클라이언트 컴포넌트에서 서버 컴포넌트를 임포트 할 수 있긴 합니다. 단, 직접적으로 임포트 되는 것은 아니며, 서버 컴포넌트가 클라이언트 컴포넌트로 전환되어 전달됩니다. 이때, 만약 브라우저에서 사용할 수 없는 서버 API를 사용한다면 에러가 발생합니다. 그렇지 않다면 서버 컴포넌트의 코드가 브라우저로 “누출”된 형태로 처리됩니다.

이 점은 RSC로 작업할 때 매우 중요한 개념이므로 유념해야 합니다.

6. 렌더링 생명주기

Next.js가 콘텐츠를 스트리밍하는 순서는 다음과 같습니다.

  1. 앱 라우터가 페이지의 URL과 서버 컴포넌트를 매칭하여 컴포넌트 트리를 구성하고, 서버 측 리액트에 해당 서버 컴포넌트와 그 하위 컴포넌트 전부를 렌더링 하도록 지시합니다.

  2. 렌더링 하는 동안 리액트는 “RSC 페이로드”를 생성합니다. RSC 페이로드는 페이지 정보와 예상 결과물, <Suspense> 상태(보류 중)일 때 보여줄 폴백 등을 Next.js에 알려줍니다.

  3. 리액트가 보류된 컴포넌트를 만나면, 해당 서브 트리의 렌더링을 일시 중지하고, 보류된 컴포넌트의 폴백을 사용합니다.

  4. 마지막 정적 컴포넌트까지 순회를 마치고 나면, Next.js는 생성된 HTML과 RSC 페이로드를 준비하여, 하나 이상의 청크(chunk)로 나누어 클라이언트에 스트리밍합니다.

  5. 클라이언트 측 리액트는 전달받은 RSC 페이로드와 클라이언트 컴포넌트에 대한 지침에 따라 UI를 렌더링 합니다. 그리고 로드되는 각 클라이언트 컴포넌트를 하이드레이트합니다.

  6. 서버는 보류 중인 서버 컴포넌트가 준비되는 대로 RSC 페이로드 형태로 스트리밍합니다. 보류된 컴포넌트에 클라이언트 컴포넌트의 자식이 포함된 경우, 그들도 이 시점에 함께 하이드레이트합니다.

아래 그림은 앞서 설명한 단계들을 도식화한 것입니다. (브라우저 관점에서의 RSC 렌더링 생명주기는 잠시 후에 살펴보겠습니다.)

그림 4. RSC 렌더링 라이프사이클 다이어그램
그림 4. RSC 렌더링 라이프사이클 다이어그램 (크게 보기)

다음 장에서는 이 작업 흐름을 브라우저 관점에서 조금 더 살펴보겠습니다.

7. RSC 페이로드

RSC 페이로드는 서버가 컴포넌트 트리를 렌더링 하면서 생성하는 특별한 데이터 형식으로, 다음과 같은 내용을 포함합니다.

  • 렌더링 된 HTML

  • 클라이언트 컴포넌트가 렌더링 되어야 할 자리를 표시하는 플레이스홀더(placeholder)

  • 클라이언트 컴포넌트의 자바스크립트 파일에 대한 참조 정보

  • 어떤 자바스크립트 파일을 실행해야 하는지에 대한 지시 사항

  • 서버 컴포넌트에서 클라이언트 컴포넌트로 전달된 모든 props

물론 너무 깊게 파고들 필요는 없지만, RSC 페이로드에 어떤 내용이 담기는지를 파악하는 것은 도움이 될 것입니다. 다음은 저자가 만든 데모 앱에서 간략하게 일부만 잘라낸 예시입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1:HL["/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
2:HL["/_next/static/css/app/layout.css?v=1711137019097","style"]
0:"$L3"
4:HL["/_next/static/css/app/page.css?v=1711137019097","style"]
5:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
8:"$Sreact.suspense"
a:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
b:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
d:I["(app-pages-browser)/./src/app/global-error.jsx",["app/global-error","static/chunks/app/global-error.js"],""]
f:I["(app-pages-browser)/./src/components/clearCart.js",["app/page","static/chunks/app/page.js"],"ClearCart"]
7:["$","main",null,{"className":"page_main__GlU4n","children":[["$","$Lf",null,{}],["$","$8",null,{"fallback":["$","p",null,{"children":"🌀 loading products..."}],"children":"$L10"}]]}]
c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]...
9:["$","p",null,{"children":["🛍️ ",3]}]
11:I["(app-pages-browser)/./src/components/addToCart.js",["app/page","static/chunks/app/page.js"],"AddToCart"]
10:["$","ul",null,{"children":[["$","li","1",{"children":["Gloves"," - $",20,["$...

데모 앱에서 이 코드를 확인하려면, 브라우저 개발자 도구의 Elements 탭에서 페이지 하단의 <script> 태그들을 살펴보세요. 해당 태그들은 다음과 같은 형태의 라인을 포함합니다.

1
self.__next_f.push([1,"PAYLOAD_STRING_HERE"]).

위 스니펫의 각 라인은 개별적인 RSC 페이로드입니다. 각 라인은 숫자나 문자로 시작하고, 콜론 뒤에 배열이 옵니다. 배열에는 때때로 접두사가 붙는데, 간단히 설명하면 다음과 같습니다.

  • HL 페이로드: “힌트”라고 하며, CSS나 폰트 같은 특정 리소스와 연결됩니다.

  • I 페이로드: “모듈”이라고 부르며, 특정 스크립트를 호출합니다. 클라이언트 컴포넌트도 이와 같은 방식으로 로드됩니다. 클라이언트 컴포넌트가 메인 번들의 일부인 경우에는 바로 실행됩니다. 그렇지 않다면(지연 로드의 경우) 해당 컴포넌트의 CSS와 자바스크립트 파일을 가져오는 명령이 담긴 페처(fetcher) 스크립트가 메인 번들에 추가됩니다. 그리고 필요할 때 페처 스크립트를 호출하는 I 페이로드가 서버에서 전송됩니다.

  • "$" 페이로드: 특정 서버 컴포넌트에 대해 생성된 DOM 정의입니다. 일반적으로 서버에서 스트리밍된 실제 정적 HTML과 함께 제공됩니다. 보류된 컴포넌트가 렌더링될 준비가 되면, 서버는 해당 컴포넌트의 정적 HTML과 RSC 페이로드를 생성하여 이들을 모두 브라우저로 스트리밍합니다.

8. 스트리밍

스트리밍(Streaming)은 서버로부터 UI를 점진적으로 렌더링할 수 있도록 해줍니다. RSC를 사용하면 각 컴포넌트가 자체적으로 데이터를 페칭할 수 있습니다. 어떤 컴포넌트는 완전히 정적이어서 즉시 클라이언트로 전송할 수 있지만, 다른 컴포넌트는 로드 전에 추가 작업이 필요할 수 있습니다. Next.js는 각 컴포넌트의 성격에 따라 여러 개의 청크로 분할하고, 각 청크가 준비되는 대로 브라우저로 스트리밍합니다. 즉, 사용자가 페이지를 방문하면 서버는 모든 서버 컴포넌트를 호출하여 페이지의 초기 HTML(페이지 껍질)을 생성하고, “보류된” 컴포넌트의 콘텐츠를 폴백으로 대체한 다음, 이를 하나 이상의 청크로 나누어 클라이언트에 스트리밍합니다.

서버는 브라우저에 HTML 스트리밍을 전송할 것이라는 신호로 Transfer-Encoding: chunked 헤더를 반환합니다. 브라우저는 이 헤더에 따라 여러 개의 청크를 수신할 준비를 하고, 수신하는 대로 렌더링 합니다. 개발자 도구의 Network 탭에서 문서 요청을 클릭하고 새로고침 하면 이 헤더를 확인할 수 있습니다.

그림 5. HTML 스트리밍을 준비하도록 브라우저에 신호 제공
그림 5. HTML 스트리밍을 준비하도록 브라우저에 신호 제공 (크게 보기)

터미널에서 curl 명령어를 사용해 Next.js가 청크를 전송하는 방식을 디버깅할 수도 있습니다.

1
curl -D - --raw localhost:3000 > chunked-response.txt
그림 6. 청크 응답
그림 6. 청크 응답 (크게 보기)

출력을 보면 패턴이 보입니다. 각 청크마다 서버가 청크의 크기를 먼저 응답한 다음에 청크의 내용을 전송하고 있습니다. 출력 결과를 보면 서버가 전체 페이지를 16개의 청크로 나누어 스트리밍했음을 확인할 수 있습니다. 마지막에는 크기가 0인 청크를 보내 스트리밍의 종료를 알리고 있습니다.

첫 번째 청크는 <!DOCTYPE html> 선언으로 시작합니다. 마지막에서 두 번째 청크에는 닫는 </body></html> 태그가 포함되어 있습니다. 즉, 서버가 문서를 상단부터 하단까지 스트리밍하고, 보류된 컴포넌트를 기다린 후, 마지막에 body와 HTML을 닫고 스트리밍을 종료하는 것을 볼 수 있습니다.

서버가 문서 전체 스트리밍을 완전히 마치지 않았더라도, 브라우저의 내결함성(fault tolerance) 기능 덕분에 닫는 </body></html> 태그를 기다리지 않고도 현재 가지고 있는 내용을 그릴 수 있습니다.

8.1. 보류 중인 컴포넌트

렌더링 생명주기에서 살펴보았듯이, 페이지를 방문하면 Next.js는 해당 페이지의 RSC 컴포넌트를 매칭하고, 리액트에 해당 서브 트리를 HTML로 렌더링 하도록 요청합니다. 리액트가 보류된 컴포넌트(비동기 컴포넌트)를 만나면, <Suspense> 컴포넌트(또는 Next.js 라우트의 경우 loading.js 파일)에서 해당 폴백을 가져와 대신 렌더링 하고, 나머지 컴포넌트 로딩을 이어서 진행합니다. 그와 동시에 RSC는 백그라운드에서 비동기 컴포넌트를 호출합니다. 이는 나중에 로드가 완료되면 스트리밍될 것입니다.

이 시점에서 Next.js는 전체 페이지에 대한 정적 HTML을 반환합니다. 반환되는 HTML은 (정적 HTML로 렌더링 된) 컴포넌트들을 포함하는데, 보류된 컴포넌트의 경우에는 대신 그에 대응하는 폴백 값을 포함합니다. Next.js는 이 정적 HTML과 RSC 페이로드를 하나 이상의 청크로 나누어 브라우저로 스트리밍합니다.

그림 7. 콜백과 보류된 컴포넌트
그림 7. 콜백과 보류된 컴포넌트 (크게 보기)

보류된 컴포넌트가 로딩을 마치면, 리액트는 재귀적으로 HTML을 생성하며 다른 중첩된 <Suspense> 경계를 찾고, 그에 해당하는 RSC 페이로드를 생성한 다음, Next.js가 새로운 청크로 HTML과 RSC 페이로드를 브라우저에 스트리밍하도록 합니다. 브라우저는 새로운 청크를 수신하면 필요한 HTML과 RSC 페이로드를 갖게 되어, DOM 상의 폴백 요소를 새롭게 스트리밍된 HTML로 대체할 준비를 마칩니다. 이런 과정이 반복됩니다.

그림 8. 보류된 컴포넌트 HTML
그림 8. 보류된 컴포넌트 HTML (크게 보기)

그림 7과 8에서 보듯, 폴백 요소들은 B:0, B:1 등의 고유한 ID를 갖고 있으며, 실제 컴포넌트들도 유사한 형태의 ID인 S:0, S:1 등을 가집니다.

첫 번째 청크에 보류된 컴포넌트의 HTML이 포함되어 있으면, 서버는 $RC 함수(리액트 소스 코드의 completeBoundary 함수)도 함께 전송합니다. 이 함수는 DOM 내의 B:0 폴백 요소를 찾아 서버로부터 받은 S:0 템플릿으로 대체하는 역할을 합니다. 이것이 바로 컴포넌트의 내용이 브라우저에 도착하면 이를 교체하는 “교체자” 함수입니다.

청크 단위로 로딩을 계속하여 결국에는 전체 페이지에 대한 로딩을 마치게 됩니다.

8.2. 지연 로딩 컴포넌트

보류된 서버 컴포넌트가 지연 로딩(lazy-loading)된 클라이언트 컴포넌트를 포함하는 경우, Next.js는 해당 클라이언트 컴포넌트의 코드 페칭 및 로드를 위한 지시 사항을 포함한 RSC 페이로드 청크를 함께 전송합니다. 이는 세션 중에 로드되지 않을 수 있는 자바스크립트에 의해 페이지 로드가 지연되지 않도록 하여, 성능을 크게 개선하는 효과를 가져옵니다.

그림 9. 지연 로딩 스크립트 페칭
그림 9. 지연 로딩 스크립트 페칭 (크게 보기)

이 글을 쓰는 시점에서는 Next.js 내에서 서버 컴포넌트 안에 있는 클라이언트 컴포넌트를 지연 로딩하는 동적 방식이 예상한 대로 작동하지 않고 있습니다. 효과적으로 클라이언트 컴포넌트를 지연 로딩하려면, 해당 컴포넌트를 감싸는 “래퍼” 클라이언트 컴포넌트를 만들고, 그 래퍼가 해당 컴포넌트를 dynamic 메서드를 사용해 불러오도록 해야 합니다. 이 래퍼는 필요한 시점에 클라이언트 컴포넌트의 자바스크립트와 CSS 파일을 페칭하고 로드하는 스크립트로 전환될 것입니다.

8.3. 요약

너무 많은 정보가 쏟아져 혼란스러울 수 있다고 생각합니다. 요약하자면, 페이지 방문 시 Next.js는 가능한 많은 HTML을 렌더링 하고, 보류된 컴포넌트에 대해서는 폴백을 사용하여 HTML을 생성한 뒤 이를 브라우저에 전송합니다. 한편, Next.js는 보류된 비동기 컴포넌트를 호출해 HTML과 함께 RSC 페이로드에 담아 청크 단위로 스트리밍하고, $RC 스크립트를 함께 보내어 교체 작업을 수행하도록 합니다.

9. 페이지 로드 타임라인

이제 RSC가 어떻게 작동하는지, Next.js가 이를 어떻게 렌더링 하는지, 그리고 모든 요소가 어떻게 맞물려 동작하는지 확실히 이해하게 되었으리라 생각합니다. 이 섹션에서는 브라우저에서 RSC 페이지를 방문할 때 정확히 어떤 일이 발생하는지 자세히 살펴보겠습니다.

9.1. 초기 로드

위 요약 섹션에서 언급했듯, 페이지를 방문하면 Next.js는 보류된 컴포넌트를 제외한 초기 HTML을 렌더링 하여 첫 번째 스트리밍 청크의 일부로 브라우저에 전송합니다.

페이지 로드 중에 발생하는 모든 과정을 확인하려면, Chrome DevTools의 “Performance” 탭을 열고 “reload” 버튼을 누릅니다. 그러면 페이지를 새로고침 한 다음 프로파일을 캡처할 것입니다. 아래 그림은 그 결과에 대한 예시 화면입니다.

그림 10. 첫 번째 청크가 스트리밍되는 모습
그림 10. 첫 번째 청크가 스트리밍되는 모습 (크게 보기)

앞부분을 확대해 보면 첫 “Parse HTML” 영역이 보입니다. 이는 서버가 문서의 첫 번째 청크를 브라우저에 스트리밍하기 시작했음을 의미합니다. 브라우저는 페이지 껍데기 및 폰트, CSS 파일, 자바스크립트 등 몇몇 리소스에 대한 링크를 포함한 초기 HTML을 받았고, 스크립트를 실행하기 시작합니다.

그림 11. 첫 프레임들
그림 11. (크게 보기)

잠시 후 페이지의 첫 번째 프레임들이 나타나고, 초기 자바스크립트 스크립트들이 로드되며 하이드레이션이 진행됩니다. 프레임을 자세히 보면, 전체 페이지 껍데기가 렌더링 되고, 보류된 서버 컴포넌트 자리에 “loading” 컴포넌트들이 적용된 것을 확인할 수 있습니다. 이 과정은 약 800ms 정도 소요되었는데, 브라우저는 100ms에 첫 HTML을 받기 시작한 이후 700ms 동안 계속해서 서버로부터 청크를 수신하고 있습니다.

참고로, 이는 로컬 개발 모드에서 실행 중인 Next.js 데모 앱이기 때문에, 프로덕션 모드에서 실행될 때보다 느릴 수 있습니다.

9.2. 보류된 컴포넌트

몇 초 뒤로 가보면 타임라인에 또 다른 “Parse HTML” 영역이 보입니다. 이는 보류된 서버 컴포넌트가 로딩을 마치고 브라우저로 스트리밍되고 있음을 나타냅니다.

그림 12. 보류된 컴포넌트
그림 12. 보류된 컴포넌트 (크게 보기)

같은 시간대에 지연 로딩된 클라이언트 컴포넌트와 해당 컴포넌트에 필요한 CSS 및 자바스크립트 파일들이 보입니다. 이 파일들은 초기 번들에는 포함되지 않았습니다. 필요한 시점에 비로소 로드될 수 있도록 하기 위함입니다.

이러한 코드 분할 방식은 초기 페이지 로드 성능을 확실히 개선합니다. 또한 클라이언트 컴포넌트의 코드가 실제로 필요할 때만 전달되도록 보장합니다. 만약 클라이언트 컴포넌트의 부모 컴포넌트 역할을 하는 서버 컴포넌트가 오류를 발생시키면, 하위의 클라이언트 컴포넌트는 로드되지 않습니다. 로드될지 여부를 알 수 없는 상황에서 미리 모든 코드를 로드하는 것은 불필요한 낭비인 셈이니까요.

그림 12에서는 DOMContentLoaded(DCL) 이벤트가 페이지 로드 타임라인의 끝부분에 기록되는 것을 볼 수 있습니다. 그리고 그 직전에는 localhost에 대한 HTTP 요청이 종료되는 것을 확인할 수 있습니다. 이는 서버가 마지막 0바이트 크기의 청크를 보내 데이터 전송이 완료되었음을 클라이언트에 알렸다는 의미입니다.

9.3. 최종 결과

localhost HTTP 요청은 약 5초 정도 걸렸지만, 스트리밍 덕분에 그보다 훨씬 이전부터 페이지 내용이 보이기 시작했습니다. 만약 전통적인 SSR 방식이었다면, 5초 동안 빈 화면만 보고 있어야 했을 것입니다. 또한 전통적인 CSR 방식이었다면, 훨씬 더 많은 자바스크립트를 불러오도록 하여 브라우저와 네트워크에 부담을 주었을 것입니다.

반면 RSC 방식은 5초가 지나기 전부터 이미 앱이 완전히 인터랙티브한 상태를 유지합니다. 클라이언트 컴포넌트가 초기 메인 번들에 포함된 덕분에 페이지 간 내비게이션과 상호작용을 원활하게 수행할 수 있습니다. 사용자 경험 측면에서 명백한 승리입니다.

10. 결론

RSC는 리액트 생태계의 중요한 진화를 의미합니다. RSC는 서버 사이드와 클라이언트 사이드 렌더링의 장점을 모두 활용하면서 HTML 스트리밍을 통해 콘텐츠 전달 속도를 높입니다. 이 접근 방식은 CSR에서 경험하는 SEO 및 로딩 시간 문제를 해결할 뿐만 아니라, SSR의 서버 부하를 줄여 성능을 끌어올립니다.

앞서 공유했던 RSC 앱을 Next.js Page 라우터와 SSR을 사용하도록 바꿔보았더니, 확실히 RSC의 개선 사항이 두드러집니다.

그림 13. SSR vs RSCs
그림 13. SSR vs RSC (크게 보기)

Sentry에서 추출한 두 보고서를 비교해 보면, 스트리밍 덕분에 실제 요청이 완료되기 전에 페이지의 리소스 로드가 시작된다는 것을 알 수 있습니다. 그래서 보고서상의 웹 성능 지표도 크게 개선되었습니다.

결론: 사용자는 RSC에 기반한 아키텍처 덕에 더 빠르고 반응성이 뛰어난 인터페이스를 경험하게 됩니다.

RSC 아키텍처는 서버 컴포넌트와 클라이언트 컴포넌트라는 두 가지 새로운 컴포넌트 유형을 도입하였습니다. 이 구분은 리액트와 이를 기반으로 한 프레임워크(예: Next.js)가 콘텐츠를 전달하는 동안에도 상호작용성을 유지할 수 있도록 합니다.

물론 이러한 설정은 상태 관리, 인증, 컴포넌트 아키텍처 등의 분야에 새로운 과제를 야기하기도 합니다. 이 과제들을 탐구하는 것은 또 다른 블로그 글로 다룰 수 있는 좋은 주제가 될 것입니다!

이러한 어려움에도 불구하고 RSC의 장점은 이를 도입해야 하는 강력한 이유가 됩니다. RSC가 발전함에 따라 앞서 언급한 과제들을 해결하는 방법에 대한 다양한 가이드가 제시될 것입니다. 제 생각에 RSC는 이미 현대 웹 개발 렌더링 관행의 미래로 보입니다.