Server Components
서버에서 실행되는 React 컴포넌트
Server Component란?
서버 컴포넌트란 서버에서 실행 및 렌더링되는 React 컴포넌트다.
어떤 문제를 해결하는가?
서버 컴포넌트를 사용하면 React 앱을 개발할 때 서버를 더 쉽게 레버리지 함으로써 다음과 같은 문제를 해결할 수 있다.
1) Zero-Bundle-Size Components
앱을 개발하다보면 여러 서드파티 라이브러리를 사용해 기능을 구현하는 것이 합리적인 선택일 때가 있다. 잘 작성된 오픈소스 라이브러리의 코드를 활용하면 직접 개발하는 것보다 적은 시간을 들여 구현할 수 있기 때문이다. 하지만 이 과정에서 개발 시간과 JS 번들 사이즈 간 트레이드 오프 관계가 형성된다. 서드파티 라이브러리를 사용하면 개발 시간은 줄일 수 있지만 번들 사이즈가 늘어난다. 반대로 라이브러리를 사용하지 않고 직접 개발하면 번들 사이즈는 줄일 수 있지만 개발 시간이 늘어난다.
RFC 내에서는 아래와 같이 markdown을 렌더링하는 예시를 통해 설명하고 있다. Markdown을 렌더링하는 라이브러리는 자그마치 206KB이다. 즉 유저의 브라우저에서 206KB, 압축한 경우에도 63.3KB의 JS 코드를 다운로드해야 한다는 뜻이다.
// NOTE: *before* Server Components
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)
function NoteWithMarkdown({text}) {
const html = sanitizeHtml(marked(text));
return (/* render */);
}
더 용량이 작은 라이브러리를 찾아서 우회할 수 있지만, 그마저도 유저의 브라우저가 번들을 다운로드 받아야하는 건 마찬가지다. 반면 서버 컴포넌트를 사용하면 코드가 아닌 서버에서 실행된 결과를 클라이언트에 전달함으로써 유저의 브라우저가 서드파티 라이브러리 코드를 다운로드하지 않아도 된다. 즉 번들 사이즈가 커서 파생되는 여러 문제를 근본적으로 해결한다.
2) Full Access to the Backend
React 앱에서 많이 수행되는 작업 중 하나는 데이터에 접근하는 방법과, 그 데이터를 저장하는 위치를 결정하는 것이다. 이를 위한 솔루션이 여러가지 있지만 크게 두 가지 문제점이 있었다. 첫째, 특정 UI만을 구성하기 위한 API를 만들어야 하거나. 둘째, 특정 UI를 구성하기 위해 만들어진 API가 아니지만 필요에 따라 가져와서 사용해야 했다. 이는 API와 UI간 결합을 강화했다.
서버 컴포넌트를 통해 아래와 같이 특정 컴포넌트에서 필요한 데이터에만 접근하도록 함으로서, 전체 API와 개별 컴포넌트 간 결합은 낮추고 개별 컴포넌트와 개별 API 간 응집도는 높일 수 있다. 아래 예제의 컴포넌트를 앱에서 더 이상 사용하지 않으면 컴포넌트를 통으로 드러내면 된다.
import db from "db";
async function Note({ id }) {
const note = await db.notes.get(id);
return <NoteWithMarkdown note={note} />;
}
3) Automatic Code Splitting
번들 사이즈를 줄이기 위한 노력 중 하나인 코드 스플리팅은 React 앱을 개발하는 개발자에게 익숙한 개념일 것이고, 이미 React.lazy
라는 해결책이 있는 문제다.
// PhotoRenderer.js
// NOTE: *before* Server Components
import { lazy } from "react";
// one of these will start loading *when rendered on the client*:
const OldPhotoRenderer = lazy(() => import("./OldPhotoRenderer.js"));
const NewPhotoRenderer = lazy(() => import("./NewPhotoRenderer.js"));
function Photo(props) {
// Switch on feature flags, logged in/out, type of content, etc:
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <OldPhotoRenderer {...props} />;
}
}
하지만 기존 접근법은 두 가지 문제가 있다. 첫째, 개발자가 모든 스플리팅 포인트를 기억하고 명시해야 한다. 둘째, lazy로 감싸면 렌더링해야하는(조건에 부합하는) 컴포넌트의 로드 시점도 지연되기 때문에 코드를 덜 로드하는 이점을 일부 상쇄한다.
서버 컴포넌트는 두 가지 방법으로 이 문제를 개선한다. 첫째, 서버 컴포넌트는 모든 자식 클라이언트 컴포넌트를 코드 스플리팅 포인트로 간주한다. 둘째, 어떤 컴포넌트를 렌더링 할지는 서버에서 미리 결정한다. 따라서 클라이언트에서는 결정된 컴포넌트를 렌더링 프로세스에 포함시킬 수 있다.
// PhotoRenderer.js - Server Component
// one of these will start loading *once rendered and streamed to the client*:
import NewPhotoRenderer from "./NewPhotoRenderer.js";
import OldPhotoRenderer from "./OldPhotoRenderer.js";
function Photo(props) {
// Switch on feature flags, logged in/out, type of content, etc:
if (FeatureFlags.useNewPhotoRenderer) {
return <NewPhotoRenderer {...props} />;
} else {
return <OldPhotoRenderer {...props} />;
}
}
서버 컴포넌트를 사용하면 개발자가 코드 스플리팅에 대한 고민과 일을 이전보다 내려놓고 앱 개발에 집중할 수 있다.
4) No Client-Server Waterfalls
React 앱에서 데이터를 fetching할 때 waterfall 렌더링이라고 불리는 병목이 생긴다. 주로 useEffect 내에서 데이터를 fetching하고 완료되기 전까지는 placeholder를 보여주는 패턴을 활용하기 때문에 해당 컴포넌트의 자식 컴포넌트는 데이터 fetching을 시작하지도 못한다.
// Note.js
// NOTE: *before* Server Components
function Note(props) {
const [note, setNote] = useState(null);
useEffect(() => {
// NOTE: loads *after* rendering, triggering waterfalls in children
fetchNote(props.id).then(noteData => {
setNote(noteData);
});
}, [props.id]);
if (note == null) {
return "Loading";
} else {
return (/* render note here... */);
}
}
이 방법에도 물론 이점이 있다. 렌더링 될 때만 데이터를 불러옴으로써 불필요한 데이터를 불러오지 않아도 된다. 하지만 이는 최적의 방법이 아니다. 연속적인 round trip이 클라이언트와 서버 간 이루어지기 때문이다.
서버 컴포넌트를 사용하면 클라이언트와 서버 간 round trip을 서버로 옮길 수 있다. 보통 서버와 데이터 소스는 가까이 위치해있기 때문에 클라이언트보다 서버에서 데이터 fetching을 수행하는 것이 응답 지연 속도 관점에서 더 유리하다. 컴포넌트가 렌더링 될 때 fetching하는 것은 유지함으로써 over-fetching도 방지할 수 있다.
// Note.js - Server Component
async function Note(props) {
// NOTE: loads *during* render, w low-latency data access on the server
const note = await db.notes.get(props.id);
if (note == null) {
// handle missing note
}
return (/* render note here... */);
}
하지만 서버 컴포넌트를 사용한다고 waterfall 렌더링이 완전히 해결된 것은 아니다. 클라이언트와 서버 간 round trip을 서버로 옮겨 네트워크 지연과 처리 속도를 개선했다는 것이 주요 개선 포인트다.
5) Avoiding the Abstraction Tax
React는 템플릿 언어가 아닌 Javascript를 사용하기 때문에 함수 합성 등 언어 차원에서 제공하는 기능을 활용해 UI 추상화를 구현할 수 있다. 하지만 이러한 추상화는 더 많은 코드를 작성하게 하고, 런타임 오버헤드를 초래할 수 있다.
정적 언어와 같이 AOT(ahead-of-time) 컴파일이 가능한 언어는 실행 전 컴파일 타임에 이러한 오버헤드를 줄일 수 있지만 Javascript에서는 그렇지 않기 때문에 아래 예제 컴포넌트와 같이 여러 추상화 레이어가 있을 때 오버헤드를 줄일 수 있는 방법이 제한적이다. 서버 컴포넌트를 사용하면 UI 추상화를 위한 코드는 서버에서 걷어내고, 코드의 평가 결과만을 클라이언트로 전달할 수 있다. 아래 예제에서 div
와 그 콘텐츠만을 전달하는 것이다.
// Note.js
// ...imports...
async function Note({ id }) {
const note = await db.notes.get(id);
return <NoteWithMarkdown note={note} />;
}
// NoteWithMarkdown.js
// ...imports...
function NoteWithMarkdown({ note }) {
const html = sanitizeHtml(marked(note.text));
return <div {/* ... */} />;
}
// client sees:
<div>{/* markdown output here */}</div>;
6) Distinct Challenges, Unified Solution
웹앱을 개발할 때, 서버만을 활용하거나 클라이언트만을 활용해서 앱을 구현하는 것은 최적의 선택지가 아닐 수 있다. 서버에서 데이터에 직접 접근하고, 빠르게 정적 콘텐츠를 전달할 수 있는 한편, 클라이언트에서는 유저와의 상호작용 경험을 끌어올릴 수 있기 때문에 각자 잘하는 일이 다르다. 보통은 서버와 클라이언트가 다른 언어, 다른 프레임워크로 개발되었다면, 서버 컴포넌트를 활용해 React와 Javascript 만으로 서버와 클라이언트 각각의 장점을 취할 수 있게 된다.
서버 컴포넌트의 렌더링
서버 컴포넌트가 어떤 문제를 해결하는지 살펴보았으니, 서버 컴포넌트가 어떻게 렌더링되는지 큰 틀에서 살펴보자.
첫 렌더링
Server-side
-
Next.js 등 프레임워크의 라우터가 사용자의 요청 URL에 따른 path, search param 등의 props를 서버 컴포넌트에 전달한다.
-
React는 주입받은 props를 기반으로 루트 서버 컴포넌트부터 모든 자식 서버 컴포넌트를 렌더링한다. 렌더링은 div, span과 같은 네이티브 요소를 만나거나 클라이언트 컴포넌트를 만날 때까지 수행된다.
-
렌더링 된 네이티브 요소는 UI에 대한 설명이 담긴 JSON의 형태로 스트리밍 된다.
-
렌더링을 건너뛴 클라이언트 컴포넌트는 직렬화 된 props와 함께 번들 내 클라이언트 컴포넌트의 주소가 스트리밍 된다.
💡네이티브 요소 페이로드는 서버 컴포넌트의 렌더링 결과. 클라이언트 컴포넌트 페이로드는 클라이언트 컴포넌트에 대한 placeholder라고 볼 수 있다.
-
프레임워크는 React가 각 UI 유닛을 렌더링한 결과를 클라이언트로 스트리밍한다.
💡불러온 데이터를 기존 클라이언트 컴포넌트와 병합하기 위해 React는 HTML이 아닌 JSON 형태로 데이터를 생성한다.
-
렌더링 과정에서 서버 컴포넌트가 비동기 작업에 의해 Suspend 되면 해당 컴포넌트 서브트리의 렌더링은 일시중지된다.
Client-side
- 프레임워크는 스트리밍 된 페이로드를 React와 함께 렌더링한다.
- React는 페이로드를 역직렬화하여 네이티브 요소와 클라이언트 컴포넌트를 렌더링한다. 렌더링은 모든 페이로드의 스트리밍이 완료되지 않아도 시작될 수 있다. Suspense로 서버 컴포넌트의 데이터 fetching 혹은 클라이언트 컴포넌트의 코드의 로딩을 기다리며 로딩 UI를 표시할 수 있다.
- 서버 컴포넌트와 클라이언트 컴포넌트가 모두 로드되면 최종 UI 상태를 유저에게 보여줄 수 있고, Suspense의 바운더리는 모두 최종 UI 컴포넌트로 대체된다.
첫 렌더링 과정은 위와 같이 진행된다. 업데이트 과정도 크게 다르지 않지만 React가 서버 컴포넌트를 HTML이 아닌 JSON like 데이터로 렌더링하는 이유를 알기 위해서는 업데이트 과정도 알아야 한다.
업데이트 (re-render)
Client-side
- 앱이 특정 단위의 UI에 대해 refetch를 요청한다. 예시) 라우팅
- 프레임워크는 앱의 요청이 적절한 엔드포인트에 요청되도록 조정한다.
Server-side
- 프레임워크는 엔드포인트와 서버 컴포넌트를 적절히 매칭하고, React가 서버 컴포넌트를 렌더링 할 수 있도록 props를 전달한다.
- React는 첫 렌더링과 마찬가지로 렌더링을 수행한다.
- 프레임워크는 React의 렌더링 결과를 클라이언트로 스트리밍한다.
Again Client-side
- 프레임워크는 스트리밍된 데이터를 받아 해당 UI 유닛의 리렌더링을 트리거한다.
- React는 새 렌더링 결과와 기존 화면에 렌더링된 컴포넌트 간 변화를 병합하고 조정한다.
- 서버 컴포넌트 및 자식 클라이언트 컴포넌트의 렌더링 결과가 HTML이 아닌 JSON like 데이터이기 때문에 React가 focus, typing, CSS 트랜지션 등의 주요 UI 상태를 유지한 채 컴포넌트를 병합하고 조정할 수 있다.
서버/클라이언트 컴포넌트 제약조건
서버 컴포넌트의 등장으로 서버 컴포넌트와 클라이언트 컴포넌트 사이에는 구분되는 특징과 일부 제약이 생겼다. 모두 외울 수는 없겠지만 서버 컴포넌트는 서버에서 한 번만 실행되기 때문에 제약이 생긴다는 사실은 알아두자.
서버 컴포넌트
서버 컴포넌트는 보통 요청당 한 번만 실행되며, 서버에서만 실행되기 때문에 아래와 같은 제약이 따른다.
useState()
,useReducer()
등의 state를 사용할 수 없다.useEffect()
useLayoutEffect()
와 같은 렌더링 라이프사이클에 따른 effect를 사용할 수 없다.- 폴리필을 구현하지 않는 이상 DOM과 같은 브라우저 전용 API를 사용할 수 없다.
- state나 effect에 의존하는 커스텀 훅을 사용할 수 없으며, 브라우저 전용 API에 의존하는 유틸리티 함수를 사용할 수 없다.
대신 다음과 같은 장점이 있다.
- async/await을 사용해 데이터베이스, 파일 시스템 등 서버 전용 데이터 소스에 접근할 수 있다.
- 다른 서버 컴포넌트와 네이티브 요소(div, span 등)를 렌더링 할 수 있다.
클라이언트 컴포넌트
클라이언트 컴포넌트는 보통의 React 컴포넌트라고 생각하면 된다. 하지만 서버 컴포넌트와 함께 사용할 때는 다음을 주의해야 한다.
- 클라이언트 컴포넌트에서 서버 컴포넌트를 import 하거나 서버 전용 훅/유틸리티 함수를 사용하지 않아야 한다. 이는 서버에서만 실행되기 때문이다.
- 하지만 서버 컴포넌트에서 클라이언트 컴포넌트의 자식 컴포넌트로 서버 컴포넌트를 전달하는 건 가능하다. 클라이언트 컴포넌트 입장에서 이 자식 서버 컴포넌트는 이미 렌더링 된 서브트리이기 때문이다.
- 서버 전용 데이터 소스에 접근하지 않아야 한다.
- 대신 기존처럼 state, 라이프사이클 이벤트에 따른 effect 등 기존 React 컴포넌트에서 사용하던 기능은 모두 사용할 수 있다.
SSR과의 차이
서버 컴포넌트라는 이름 때문에 서버 사이드 렌더링(SSR)과 혼동되기 쉽다. 하지만 서버 컴포넌트는 SSR과 다른 개념이며, 주요 차이점은 다음과 같다.
- 서버 컴포넌트의 코드는 클라이언트로 전송되지 않는다. 반면 기존 SSR은 JS Bundle에 컴포넌트 코드를 전달해야 했다.
- 서버 컴포넌트는 컴포넌트 레벨에서 데이터베이스 등의 데이터 소스에 접근할 수 있다. 기존 SSR에서는
getServerSideProps()
처럼 페이지 단위로만 접근할 수 있었다. - 서버 컴포넌트의 refetch는 클라이언트 상태를 보존한다. 불러오는 데이터의 형태가 HTML이 아닌 JSON like 형태이기 때문이다.
서버 컴포넌트 사용하기
2020-12-21에 RFC가 시작되었지만, 아직 서버 컴포넌트 도입은 극초기 단계다. 2023.12 기준 Next.js App router를 사용해야만 React 앱에 서버 컴포넌트를 적용할 수 있다. 서버 컴포넌트의 렌더링을 위해 라우팅, 번들링에 대한 추가 작업이 필요하기 때문에 앞으로도 Next.js 등의 프레임워크를 이용해야 할 가능성이 높다.
이 블로그도 Next.js의 App router로 개발되었다.
맺으며
서버 컴포넌트를 이용하면 번들 사이즈 최적화, 데이터 소스 직접 접근, 코드 스플리팅, 클라이언트-서버 waterfall 제거 등의 개선이 가능하다. 서버 컴포넌트는 클라이언트 중심의 React 앱이 서버를 더 레버리지 할 수 있는 솔루션이며, 데이터 의존성이 React 컴포넌트의 한 구성 요소로 취급되길 바라는 React 팀의 비전이 실현된 것이기도 하다.
서버 컴포넌트를 독립적인 기능으로 보는 것도 좋지만, Suspense와 스트리밍 등 최신 React 기능이 어떤 문제를 해결하는지 살펴보며 큰그림을 그려보는 것을 추천한다. Suspense와 서버 컴포넌트까지 살펴본 지금, 이 모든 기능이 "데이터 의존성을 더 React스럽고 효율적인 방법으로 처리할 수 없을까?"라는 고민에서 시작되었다는 것을 알게 되었다.
React 코어 팀의 Andrew Clark와 Sebastian Markbåge의 최근(2023.11.24 기준) talk과 Dan Abramov의 Server component 관련 talk, Shaundai Person의 Suspense 관련 talk을 보면 빠르게 큰그림을 이해하기 좋다. 그리고 좀 더 깊이 알고 싶다면 서버 컴포넌트 RFC와 Suspense RFC까지 살펴보자.
참고자료
Data Fetching with React Server Components
RFC: React Server Components
React Labs: What We've Been Working On – March 2023
React Roundtable: Server Components, Suspense, and Actions
Introducing Zero-Bundle-Size React Server Components
React 18: 리액트 서버 컴포넌트 준비하기
SSR과의 차이