RSC: 빌드 및 런타임 과정과 SSR와의 차이
안녕하세요. 최근에 동료에게 리액트 서버 컴포넌트(이하 RSC)에 대해 설명할 일이 있었는데요.
설명하다보니, 저도 내부 동작 매커니즘에 있어서 헷갈리는 부분들이 생기면서.. 관련해서 다시 공부하고 정리해보았습니다.
RSC의 빌드 과정
RSC를 도입한 프로젝트는 빌드 시 클라이언트와 서버, 두 가지 환경을 위한 준비 과정을 거칩니다.
1. 클라이언트 빌드
-
먼저 번들러(Webpack, Vite 등)가 프로젝트 전체를 스캔하며
use client지시어가 명시된 파일(클라이언트 컴포넌트 코드)를 찾습니다. -
이 파일들과 그 의존성들을 묶어 브라우저용 자바스크립트 번들을 생성합니다.
-
이때 매니페스트 파일(
react-client-manifest.json)이 생성됩니다. 이 파일은 "어떤 컴포넌트가 어떤 JS 번들 파일에 위치하는지"를 기록한 지도 역할을 합니다.
2. 서버 빌드
- 이후 서버 컴포넌트 코드를 빌드합니다. 서버는 클라이언트 컴포넌트의 실제 코드를 포함하지 않습니다.
- 대신 앞서 생성된 매니페스트의 참조 정보(ID, 파일 경로 등)로 해당 부분을 대체합니다. 즉, "여기에는 나중에 브라우저가 실행할 특정 클라이언트 컴포넌트가 들어갈 자리야"라고 표시만 해두는 셈입니다.
RSC 런타임 과정
초기 진입 시
사용자가 브라우저를 통해 처음 접속하면 아래와 같은 과정이 일어납니다.
- RSC Payload 생성: 서버는 서버 컴포넌트를 실행하여 RSC Payload라는 데이터를 생성합니다. 이 과정에서 직렬화(Serialization)가 일어납니다.
- HTML 생성 (SSR): 사용자가 빈 화면을 보지 않도록, 생성된 RSC Payload를 바탕으로 리액트 엔진이 HTML을 미리 만들어둡니다.
- 스트리밍 전송: 서버는 HTML + 클라이언트 JS 번들 + RSC Payload를 브라우저로 전송합니다. 보통
Transfer-Encoding: chunked를 통해 Chunk 단위로 스트리밍합니다. - Hydration: 브라우저는 먼저 HTML을 보여주고, JS 번들을 실행해 이벤트 리스너를 부착합니다. 그리고 RSC Payload를 해석하여 서버 컴포넌트와 클라이언트 컴포넌트가 결합된 리액트 컴포넌트 트리를 완성합니다.
페이지 이동 (Navigation)
사용자가 Link 등을 통해 페이지를 이동할 때는 HTML을 다시 받아오지 않습니다.
- 브라우저는 서버에 해당 페이지의 RSC Payload만 요청합니다.
- 서버는 새 페이지의 서버 컴포넌트만 실행해 결과값을 직렬화하여 응답합니다.
- 브라우저(리액트 런타임)는 현재 메모리에 있는 State를 유지한 채, 서버에서 받은 Payload로 **UI 트리만 교체(Reconciliation)**합니다.
직렬화(Serialization)와 제약 사항
서버 컴포넌트의 실행 결과는 네트워크를 타고 브라우저로 넘어가야 합니다. 이를 위해 리액트는 컴포넌트 트리(객체)를 문자열 형태인 RSC Payload로 변환하는데, 이를 직렬화라고 합니다.
RSC Payload의 형태
Payload는 JSON과 유사하지만 리액트만의 특수한 기호가 섞인 텍스트 데이터입니다.
M1:{"id":"./src/Counter.client.js","chunks":["client0"],"name":"Counter"} // 클라이언트 컴포넌트 정보
J0:["$","div",null,{"children":[
["$","h1",null,{"children":"방명록"}],
["$","@$M1",null,{"initialCount":0}] // M1(클라이언트 컴포넌트)을 여기에 끼워넣어라!
]}]
이렇게 RSC Payload로 변환하기 위해 직렬화를 거치기 떄문에 생기는 중요한 제약이 있는데, 바로 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때 '함수'를 넘기면 안된다는 것입니다.
// 불가능! 함수는 직렬화할 수 없음.
<ClientComponent onClick={() => console.log('서버에서 정의한 함수')} />
함수를 직렬화하려면 코드를 통째로 문자열로 변환해야 하는데, 이는 보안 문제(서버 로직 노출)와 실행 문제(브라우저에서 eval 사용 필요)를 야기하기 때문입니다. 따라서 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때는 반드시 직렬화 가능한 데이터(JSON 형태: 문자열, 숫자, 배열, 객체 등)만 가능합니다.
Waku 프레임워크로 본 실제 빌드 결과물
RSC 전용 프레임워크인 Waku를 사용해서 간단한 예시 프로젝트를 만들고, 이를 빌드해서 빌드 결과물이 어떻게 나오는지 확인해보았습니다.
dist/
├── public/ # 브라우저가 접근하는 정적 자원 (Client-side)
│ ├── assets/
│ ├── RSC/
│ └── [page]/index.html
└── server/ # Node.js 환경에서 실행되는 서버 로직 (Server-side)
├── vite_rsc_assets_manifest.js
├── server-node.js
└── index.js
dist/server/server-node.js: 서버 엔트리 포인트입니다.dist/server/__vite_rsc_assets_manifest.js: 서버 컴포넌트가 참조하는 클라이언트 컴포넌트 ID(6d786e...)가 실제 어떤 JS 파일(/assets/client-BwapN45v.js)에 있는지 맵핑되어 있습니다.dist/public/RSC/: 빌드 타임에 미리 생성 가능한 정적 RSC Payload 조각들이 저장됩니다.
RSC와 SSR
사용자가 첫 화면을 가장 빨리 보게 하려면 HTML을 미리 렌더링해 띄우는 SSR이 필요하고, 이후 앱처럼 부드럽게 동작하며 번들 크기를 줄이려면 RSC가 필요합니다.
- RSC 사용 O, SSR 사용 O → 현대적인 SSR 방식. 브라우저가 전달 받은 HTML을 즉시 화면에 그리고, 이후 RSC Payload 가지고 컴포넌트 트리를 만들고 JS 번들 이용해 컴포넌트에 로직 입힘.
- RSC 사용 X, SSR 사용 O → 전통적인 SSR 방식. 브라우저가 전달 받은 HTML을 즉시 화면에 그리고, 이후 JS 번들 이용해 DOM 요소들에 이벤트 리스너를 붙이고 상태 초기화함.
- RSC 사용 O, SSR 사용 X → 브라우저가 텅 빈 HTML을 받아서 빈 화면을 그리고, 이후에 서버에서 RSC Payload 받아와 화면 그림.
전통적인 SSR 방식 (RSC X)
- 서버에서 모든 컴포넌트 함수들을 실행해서 HTML을 만듭니다.
- 브라우저는 이 HTML 받아 화면을 그리고, 리액트 런타임이 모든 컴포넌트 함수들을 다시 실행하면서 각 컴포넌트에 대응하는 Fiber Node를 메모리에 생성합니다.
- 이렇게 생성한 Fiber Tree와 이미 그려진 HTML의 DOM을 비교하면서 이벤트 리스너를 붙입니다. -> Hydration
→ JS 번들에 모든 컴포넌트 함수들이 다 포함되어 있어서 번들 크기가 무겁고, 컴포넌트 함수가 서버/클라이언트에서 두번씩 실행(Double Invocation)되어 비효율적입니다. Fiber Tree도 모든 노드의 소스 코드를 포함하고 있으니 무거워집니다.
현대적인 방식 (RSC + SSR)
- 서버에서 서버 컴포넌트 함수들을 실행해서 RSC Payload를 만드는데, 이게 사실상 Fiber Tree의 일부 조각입니다.
- 브라우저에서는 HTML 받아 화면 그린 후, 리액트 런타임이 RSC Payload 읽으면서 Fiber Tree를 구축합니다. 이때 서버 컴포넌트들이 이미 결과값(div, h1 등)으로 만들어진 상태이니, 화면을 그리는데 결과값을 그대로 사용합니다.
- 클라이언트 컴포넌트들만 기존 방식처럼 컴포넌트 함수 실행해서 결과값 만들고, Hydration을 거칩니다.
→ JS 번들에는 클라이언트 컴포넌트의 소스 코드만 포함되어 있어 번들 크기가 훨씬 가볍습니다. Fiber Tree에는 소스 코드 갖고 있는 노드(클라이언트)와 소스 코드 없이 결과값만 들고 있는 노드(서버)가 공존하고 있습니다.