RSC (1): 빌드부터 런타임까지 한번에 이해하기
회사에서 편집 툴 개발을 주로 담당하다 보니 SSR이나 RSC를 깊게 다룰 기회가 적었다.
그러던 중 최근 Waku라는 RSC 프레임워크를 접하며 RSC의 내부 동작 원리에 대해 깊이 파헤쳐 보고 싶은 욕심이 생겼다. 공부하며 정리한 RSC의 빌드부터 런타임 과정, 그리고 SSR과의 결정적인 차이점을 공유해본다.
RSC란?
RSC(React Server Component)는 말그대로 서버에서만 실행되는 리액트 컴포넌트를 의미한다.
기존의 리액트 컴포넌트가 브라우저에서 실행되어 UI를 그렸다면, RSC는 서버 환경에서 실행되어 그 결과물(데이터)을 브라우저에 전달한다. 이를 통해 클라이언트가 다운로드해야 할 자바스크립트 번들 크기를 줄이고 데이터 페칭 성능을 최적화할 수 있다.
RSC의 빌드 과정
RSC 프로젝트를 빌드하면 번들러는 클라이언트와 서버라는 두 가지 환경을 위해 분주하게 움직인다.
1. 클라이언트 빌드
먼저 번들러는 프로젝트 전체를 스캔하며 use client 지시어가 붙은 파일들을 찾아낸다. 이 파일들은 브라우저에서 실행될 클라이언트 컴포넌트들이다. 번들러는 이 컴포넌트들과 그에 필요한 의존성들을 묶어 브라우저용 자바스크립트 번들을 생성한다.
이때 중요한 파일이 하나 만들어지는데, 바로 react-client-manifest.json이다. 이 매니페스트 파일은 어떤 컴포넌트가 어떤 자바스크립트 번들 파일에 위치해 있는지 기록한 지도와 같다. 서버가 브라우저에게 이 컴포넌트가 필요해라고 말할 때 참조하는 근거가 된다.
2. 서버 빌드
이어서 서버 컴포넌트 코드를 빌드한다. 특이한 점은 서버 빌드 결과물에는 클라이언트 컴포넌트의 실제 코드가 포함되지 않는다는 것이다. 대신 앞서 만든 매니페스트의 참조 정보로 그 자리를 대체한다. 여기는 나중에 브라우저가 실행할 Counter 컴포넌트가 들어갈 자리야 라는 표식만 남겨두는 셈이다.
RSC 런타임 과정
초기 진입 시
사용자가 브라우저를 통해 처음 접속하면 다음과 같은 순서로 화면이 그려진다.
- RSC Payload 생성: 서버는 서버 컴포넌트를 실행하여 RSC Payload라는 특수한 형태의 데이터를 만든다. 이 과정에서 컴포넌트 트리를 텍스트로 바꾸는 직렬화가 일어난다.
- HTML 생성 (SSR): 사용자가 빈 화면을 보지 않도록, 생성된 RSC Payload를 바탕으로 리액트 엔진이 HTML을 미리 렌더링한다.
- 스트리밍 전송: 서버는 완성된 HTML과 클라이언트 자바스크립트 번들, 그리고 RSC Payload를 브라우저로 보낸다. 보통 모든 데이터가 준비될 때까지 기다리지 않고 조각 단위로 스트리밍한다.
- Hydration: 브라우저는 HTML을 먼저 띄워준 뒤, 자바스크립트 번들을 실행해 이벤트 리스너를 붙인다. 마지막으로 RSC Payload를 해석해 서버와 클라이언트 컴포넌트가 결합된 최종 리액트 트리를 완성한다.
페이지 이동 (Navigation)
사용자가 링크를 클릭해 다른 페이지로 이동할 때는 HTML을 다시 받아오지 않는다.
- 브라우저는 서버에 새 페이지의 RSC Payload만 요청한다.
- 서버는 해당 서버 컴포넌트만 실행해 결과값을 직렬화해서 응답한다.
- 브라우저는 현재 메모리에 있는 상태를 유지하면서 서버에서 받은 데이터로 UI 트리만 교체(Reconciliation)한다.
이를 통해 싱글 페이지 애플리케이션(SPA) 같은 부드러운 전환이 가능해진다.
직렬화와 제약 사항
서버 컴포넌트의 실행 결과는 네트워크를 타고 브라우저로 이동해야 하므로 문자열 형태인 RSC Payload로 변환되어야 한다. 이 과정을 직렬화(Serialization)라고 한다.
RSC Payload의 실제 형태를 예시로 살펴보면 다음과 같다. JSON과 유사하지만 리액트만의 특수한 기호가 섞인 텍스트 데이터이다.
M1:{"id":"./src/Counter.client.js","chunks":["client0"],"name":"Counter"} // 클라이언트 컴포넌트 정보
J0:["$","div",null,{"children":[
["$","h1",null,{"children":"방명록"}],
["$","@$M1",null,{"initialCount":0}] // M1(클라이언트 컴포넌트)을 여기에 끼워넣어라!
]}]
이러한 직렬화 과정 때문에 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 넘길 때 함수를 넘길 수 없다는 중요한 제약이 생긴다.
// 불가능! 함수는 직렬화할 수 없음.
<ClientComponent onClick={() => console.log('서버에서 정의한 함수')} />
함수는 코드를 통째로 문자열로 바꿀 경우 보안 이슈가 생길 수 있고, 브라우저에서 이를 다시 실행하는 과정도 까다롭기 때문이다. 따라서 서버에서 클라이언트로 데이터를 보낼 때는 반드시 숫자, 문자열, 객체처럼 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가 실제 어떤 자바스크립트 파일에 있는지 맵핑되어 있다.dist/public/RSC/: 빌드 타임에 미리 생성된 정적 RSC Payload 조각들이 저장되어 있다.
RSC와 SSR의 차이
RSC와 SSR는 서로 '보완 관계'이지 대체 관계가 아니다.
전통적인 SSR 방식 (RSC 미사용)
- 서버에서 모든 컴포넌트를 실행해 HTML을 만든다.
- 이후 브라우저는 HTML을 받고 나서 리액트 런타임이 모든 컴포넌트 함수를 다시 실행하며 메모리에 Fiber 노드를 생성한다.
- 이렇게 생성한 Fiber Tree와 이미 그려진 HTML의 DOM을 비교하면서 이벤트 리스너를 붙인다. (Hydration)
브라우저가 Fiber 노드를 생성하기 위해서 자바스크립트 번들에 모든 컴포넌트 코드가 포함되어야 하므로 번들이 무거워진다. 또한 컴포넌트 함수가 서버와 클라이언트에서 두 번 실행되는 비효율이 발생한다.
현대적인 방식 (RSC + SSR)
- 서버에서 서버 컴포넌트를 실행해 RSC Payload를 만든다. 브라우저는 이 Payload를 읽어 트리를 구축하는데, 서버 컴포넌트들은 이미 실행이 완료된 결과값 상태이므로 클라이언트에서 다시 함수를 실행할 필요가 없다.
- 결과적으로 자바스크립트 번들에는 클라이언트 컴포넌트의 코드만 담기게 되어 용량이 획기적으로 줄어든다. Fiber 트리 역시 소스 코드를 가진 클라이언트 노드와 결과값만 가진 서버 노드가 공존하는 가벼운 형태가 된다.
정리하자면 RSC는 컴포넌트가 어디서 실행될 것인가에 대한 문제이고, SSR은 초기 HTML을 어떻게 빠르게 보여줄 것인가에 대한 문제다. 이 두 기술이 결합되었을 때 비로소 최고의 사용자 경험을 제공할 수 있다.