
회사에서는 인터랙션이 많은 편집 툴을 주로 개발하다 보니, 서버 사이드 렌더링보다는 클라이언트 사이드 렌더링을 다룰 일이 많았고, SSR이나 RSC를 깊게 파볼 기회는 적었다.
그러던 중 최근 Waku라는 RSC 프레임워크를 접하며 RSC의 내부 동작 원리에 대해 깊이 파헤쳐 보고 싶은 욕심이 생겼다. 공부하며 정리한 RSC의 빌드부터 런타임 과정, 그리고 SSR과의 결정적인 차이점을 정리해보겠다.
RSC(React Server Component)는 말 그대로 서버에서만 실행되는 리액트 컴포넌트를 의미한다.
기존의 리액트 컴포넌트가 브라우저에서 실행되어 UI를 그렸다면, RSC는 서버 환경에서 실행되어 그 결과물(데이터)을 브라우저에 전달한다. 이를 통해 클라이언트가 다운로드해야 할 자바스크립트 번들 크기를 줄이고 데이터 페칭 성능을 최적화할 수 있다.
RSC 프로젝트를 빌드하면 번들러는 클라이언트와 서버라는 두 가지 환경을 위해 분주하게 움직인다.
RSC를 지원하는 주요 번들러로는 Webpack, Turbopack, Vite가 있다.
- Turbopack은 Rust 기반 번들러로, Webpack 대비 빌드/HMR 속도가 매우 빠르다.
- Vite는 Tanstack Start, Waku 등 신생 프레임워크들이 대부분 채택하고 있는 번들러다.
먼저 번들러는 프로젝트 전체를 스캔하며 use client 지시어가 붙은 파일들을 찾아낸다. 이 파일들은 브라우저에서 실행될 클라이언트 컴포넌트들이다. 번들러는 이 컴포넌트들과 그에 필요한 의존성들을 묶어 브라우저용 자바스크립트 번들을 생성한다.
이때 중요한 파일이 하나 만들어지는데, 바로 react-client-manifest.json이다. 이 매니페스트 파일은 어떤 컴포넌트가 어떤 자바스크립트 번들 파일에 위치해 있는지 기록한 지도와 같다. 서버가 브라우저에게 "이 컴포넌트가 필요해"라고 말할 때 참조하는 근거가 된다.
이어서 서버 컴포넌트 코드를 빌드한다. 특이한 점은 서버 빌드 결과물에는 클라이언트 컴포넌트의 실제 코드가 포함되지 않는다는 것이다. 대신 앞서 만든 매니페스트의 참조 정보로 그 자리를 대체한다. "여기는 나중에 브라우저가 실행할 Counter 컴포넌트가 들어갈 자리야"라는 표식만 남겨두는 셈이다.
다시 정리해보면,
번들러가 use client 파일들을 모아 브라우저용 번들과 매니페스트(react-client-manifest.json)를 만들고, 서버 빌드에서는 그 매니페스트 참조로 클라이언트 컴포넌트 자리를 대신 채워 넣는다.
사용자가 브라우저를 통해 처음 접속하면 다음과 같은 순서로 화면이 그려진다.
사용자가 링크를 클릭해 다른 페이지로 이동할 때는 HTML을 다시 받아오지 않는다.
이를 통해 싱글 페이지 애플리케이션(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를 넘길 때 함수를 넘길 수 없다는 중요한 제약이 생긴다. (단, 'use server'로 선언된 서버 액션(Server Action) 함수는 예외로, 직렬화 가능한 참조로 변환되어 클라이언트에 전달될 수 있다.)
// 불가능! 함수는 직렬화할 수 없음.
<ClientComponent onClick={() => console.log('서버에서 정의한 함수')} />왜 함수는 직렬화할 수 없는데?
함수는 코드를 통째로 문자열로 바꿀 경우 보안 이슈가 생길 수 있고, 브라우저에서 이를 다시 실행하는 과정도 까다롭기 때문이다. 따라서 서버에서 클라이언트로 데이터를 보낼 때는 기본적으로 숫자, 문자열, 객체처럼 JSON으로 표현 가능한 데이터를 사용하는 것이 안전하다. (스트리밍되는 Promise나 프레임워크별 확장, 그리고 앞서 언급한 서버 액션 참조는 예외다.)
지금까지 설명한 개념이 실제 프레임워크에서 어떻게 나타나는지 살펴보자. 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은 "HTML이 언제 그려지는가"의 문제다. 이 둘은 혼동하기 쉬우므로 차이를 짚고 넘어가자. RSC와 SSR는 서로 '보완 관계'이지 대체 관계가 아니다.
브라우저가 Fiber 노드를 생성하기 위해서 자바스크립트 번들에 모든 컴포넌트 코드가 포함되어야 하므로 번들이 무거워진다. 또한 컴포넌트 함수가 서버와 클라이언트에서 두 번 실행되는 비효율이 발생한다.
두 방식의 차이를 표로 정리하면 다음과 같다.
| 항목 | 전통적인 SSR (RSC 미사용) | RSC + SSR |
|---|---|---|
| 번들 크기 | 모든 컴포넌트의 소스 코드 포함 | 클라이언트 컴포넌트의 코드만 포함 |
| 컴포넌트 함수 실행 횟수 | 서버 1회 + 클라이언트 1회 (총 2회) | 서버 컴포넌트는 서버에서 1회만 실행 |
| Fiber 트리 구성 | 모든 노드를 소스 코드 기반으로 재구성 | 클라이언트 노드(소스 코드) + 서버 노드(결과값)가 공존 |
정리하자면 RSC는 컴포넌트가 어디서 실행될 것인가에 대한 문제이고, SSR은 초기 HTML을 어떻게 빠르게 보여줄 것인가에 대한 문제다.
그렇다면 실무에서는 어떤 기준으로 서버/클라이언트 경계를 나눠야 할까? 상태나 이벤트 핸들러가 필요 없는 컴포넌트는 최대한 서버 컴포넌트로 남겨두고, 인터랙션이 꼭 필요한 최소 단위만 use client로 감싸는 것이 번들 크기와 성능을 동시에 챙기는 방법이다.