React 공식 문서의 Server Components 문서를 번역하며 핵심을 정리하고, 서버 컴포넌트를 써야 하는 이유를 다른 관점의 글과 함께 정리했다.

React Server Component란?

서버 컴포넌트(Server Components)는 새로운 유형의 컴포넌트로, 번들링 전에 미리 렌더링되며, 클라이언트 애플리케이션이나 SSR 서버와는 별도의 환경에서 실행된다. 이 별도의 환경이 React 서버 컴포넌트에서 말하는 “서버”이다.

서버 컴포넌트는 빌드 시 CI 서버에서 한 번 실행되거나, 각 요청마다 웹 서버에서 실행될 수 있다.

더보기

여기서 CI 서버란?

CI 서버란 Continuous Integration(지속적 통합)의 약자로, 소프트웨어 개발에서 코드를 자동으로 빌드하고 테스트하기 위한 서버를 말한다. CI 서버는 개발자가 코드를 변경하고 이를 저장소에 푸시할 때, 변경된 코드를 자동으로

  1. 빌드 (코드를 컴파일하거나 번들링)
  2. 테스트 (유닛 테스트, 통합 테스트 등 실행)
  3. 배포 준비

같은 작업을 수행하여 코드 품질을 유지하고 문제를 조기에 발견할 수 있도록 도와준다.

React Server Components의 경우, CI 서버에서 빌드 타임에 서버 컴포넌트를 실행해 HTML을 미리 생성하거나, 정적 파일을 생성하는 작업을 처리할 수 있다. 이는 코드가 사용자 요청에 따라 실시간으로 실행되는 것이 아니라 빌드 단계에서 미리 처리된다는 점에서 효율적이다.

∴ CI 서버는 개발 자동화 도구로, React 서버 컴포넌트를 빌드 시 미리 렌더링하는 데 사용될 수 있는 환경이다.

공식 문서에서는 아래 4가지 챕터를 통해 React Server Component를 설명한다.

  • 웹 서버 없는 서버 컴포넌트
  • 웹 서버에서 실행되는 서버 컴포넌트
  • 서버 컴포넌트에 상호작용 추가하기
  • 서버 컴포넌트를 사용한 비동기 컴포넌트

내용이 너무 길고 복잡하기 때문에, 자세히 설명하기 전 먼저 핵심만 요약해보겠다.

  1. 서버에서 렌더링
    • 서버 컴포넌트는 브라우저로 전송되지 않고, 서버에서만 렌더링된다.
    • 서버에서 데이터를 처리하거나 파일 시스템에 접근 가능하며, 클라이언트로는 HTML만 전송된다.
  2. 클라이언트 컴포넌트와 조합
    • 상호작용이 필요한 경우, “use client” 지시어로 정의된 클라이언트 컴포넌트와 조합하여 사용한다.
    • 서버 컴포넌트는 클라이언트 컴포넌트로 데이터를 props로 전달한다.
  3. 비동기 지원
    • async/await 을 사용하여 데이터를 비동기로 처리할 수 있다.
    • 중요한 데이터는 서버에서 await하고, 덜 중요한 데이터는 클라이언트에서 use API로 기다린다.
  4. 성능 최적화
    • 서버에서 데이터 처리와 렌더링을 수행해 클라이언트 번들 크기를 줄이고, 초기 로딩 속도를 개선한다.
    • 스트리밍과 Suspense를 통해 중요한 콘텐츠를 우선적으로 렌더링하고, 나머지 데이터를 비동기로 처리한다.
  5. 유연한 아키텍처
    • 서버 중심 멀티 페이지 앱(MPA)과 클라이언트 중심 싱글 페이지 앱(SPA)의 장점을 결합한 아키텍처로, 동적이고 상호작용적인 앱 구현이 가능하다.

웹 서버 없는 서버 컴포넌트

서버 컴포넌트는 빌드 시 파일 시스템에서 데이터를 읽거나 정적 콘텐츠를 가져올 수 있으므로 웹 서버가 필요하지 않다.

예를 들어, 콘텐츠 관리 시스템(CMS)에서 정적 데이터를 읽고 싶을 때 사용할 수 있다.

서버 컴포넌트를 사용하지 않을 경우, 일반적으로 클라이언트에서 Effect를 사용해 정적 데이터를 가져온다:

// bundle.js
import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function Page({page}) {
  const [content, setContent] = useState('');
  // 첫 페이지 렌더링 이후에 데이터를 가져옵니다.
  useEffect(() => {
    fetch(`/api/content/${page}`).then((data) => {
      setContent(data.content);
    });
  }, [page]);
  
  return <div>{sanitizeHtml(marked(content))}</div>;
}

// api.js
app.get(`/api/content/:page`, async (req, res) => {
  const page = req.params.page;
  const content = await file.readFile(`${page}.md`);
  res.send({content});
});

이 패턴에서는 정적 콘텐츠를 렌더링하기 위해 사용자가 추가로 75KB(gzipped)의 라이브러리를 다운로드하고 파싱해야 하고,

페이지가 로드된 후에 데이터를 가져오기 위한 두 번째 요청을 기다려야 한다.

이 콘텐츠는 페이지의 수명 동안 변경되지 않는 정적 콘텐츠다.

서버 컴포넌트를 사용하면 빌드 시 한 번만 이러한 컴포넌트를 렌더링할 수 있다.

import marked from 'marked'; // 번들에 포함되지 않음
import sanitizeHtml from 'sanitize-html'; // 번들에 포함되지 않음

async function Page({page}) {
  // 앱이 빌드될 때 렌더링 중 데이터를 로드합니다.
  const content = await file.readFile(`${page}.md`);
  
  return <div>{sanitizeHtml(marked(content))}</div>;
}

렌더링된 출력은 서버 사이드 렌더링(SSR)을 통해 HTML로 변환되어 CDN에 업로드된다.

앱이 로드될 때, 클라이언트는 원래의 Page 컴포넌트나 마크다운을 렌더링하기 위한 무거운 라이브러리를 포함하지 않고, 렌더링된 출력만 보게 된다.

<div><!-- 마크다운의 HTML 출력 --></div>

따라서 서버 컴포넌트를 사용하게 되면 아래와 같은 이점이 있다.

  • 첫 페이지 로드 시 콘텐츠가 바로 표시된다.
  • 정적 콘텐츠를 렌더링하는 데 필요한 무거운 라이브러리들이 번들에 포함되지 않는다.

웹 서버에서 실행되는 서버 컴포넌트

서버 컴포넌트는 페이지 요청 시 웹 서버에서 실행될 수도 있다.

이를 통해 API를 따로 구축하지 않고 데이터 계층에 직접 액세스할 수 있다.

서버 컴포넌트는 애플리케이션이 번들링되기 전에 렌더링되며, 클라이언트 컴포넌트로 데이터와 JSX를 props로 전달할 수 있다.

서버 컴포넌트를 사용하지 않는 경우, 일반적으로 클라이언트에서 Effect를 사용해 동적 데이터를 가져온다:

// bundle.js
function Note({id}) {
  const [note, setNote] = useState('');
  // 첫 렌더링 후 데이터를 가져옵니다.
  useEffect(() => {
    fetch(`/api/notes/${id}`).then(data => {
      setNote(data.note);
    });
  }, [id]);
  
  return (
    <div>
      <Author id={note.authorId} />
      <p>{note}</p>
    </div>
  );
}

function Author({id}) {
  const [author, setAuthor] = useState('');
  // Note가 렌더링된 후 데이터를 가져옵니다.
  // 클라이언트-서버 간의 느린 워터폴(waterfall) 호출을 유발합니다.
  useEffect(() => {
    fetch(`/api/authors/${id}`).then(data => {
      setAuthor(data.author);
    });
  }, [id]);

  return <span>By: {author.name}</span>;
}

// api
import db from './database';

app.get(`/api/notes/:id`, async (req, res) => {
  const note = await db.notes.get(id);
  res.send({note});
});

app.get(`/api/authors/:id`, async (req, res) => {
  const author = await db.authors.get(id);
  res.send({author});
});

서버 컴포넌트를 사용하면, 데이터를 읽고 컴포넌트 내에서 렌더링할 수 있다.

import db from './database';

async function Note({id}) {
  // 렌더링 중 데이터를 가져옵니다.
  const note = await db.notes.get(id);
  return (
    <div>
      <Author id={note.authorId} />
      <p>{note}</p>
    </div>
  );
}

async function Author({id}) {
  // Note 이후 데이터를 가져옵니다.
  // 하지만 데이터가 근처에 있으면 빠르게 처리됩니다.
  const author = await db.authors.get(id);
  return <span>By: {author.name}</span>;
}

번들러는 데이터를 결합하고, 렌더링된 서버 컴포넌트와 동적 클라이언트 컴포넌트를 함께 번들로 묶는다.

선택적으로 이 번들은 서버 사이드 렌더링(SSR) 되어 페이지의 초기 HTML을 생성할 수 있다.

페이지가 로드될 때 브라우저는 원래의 Note와 Author 컴포넌트를 보지 않고, 렌더링된 출력만 클라이언트로 전달된다.

<div>
  <span>By: The React Team</span>
  <p>React 19 is...</p>
</div>

서버 컴포넌트는 서버에서 데이터를 다시 가져와 동적으로 렌더링될 수 있다.

이 새로운 애플리케이션 아키텍처는 서버 중심 다중 페이지 앱(Multi-Page Apps)의 간단한 “요청/응답” 모델과 클라이언트 중심 단일 페이지 앱(Single-Page Apps)의 매끄러운 상호작용을 결합하여 두 렌더링 방식의 장점을 모두 제공한다.


서버 컴포넌트에 상호작용 추가하기

서버 컴포넌트는 브라우저로 전송되지 않기 때문에, useState와 같은 상호작용 API를 사용할 수 없다.

서버 컴포넌트에 상호작용을 추가하려면 “use client” 지시어를 사용하여 클라이언트 컴포넌트와 조합해야 한다.

더보기

서버 컴포넌트에는 별도의 지시어가 없다.
“use server” 지시어는 서버 컴포넌트를 나타내는 것이 아니라 서버 함수(Server Functions) 에 사용된다.
서버 컴포넌트에 대한 별도 지시어는 없으므로 혼동하지 않도록 주의해야 한다.

자세한 내용은 Directives 문서에서 확인할 수 있다.

다음 예제에서는 Notes 서버 컴포넌트가 상태를 사용해 확장 여부를 toggle하는 Expandable 클라이언트 컴포넌트를 가져온다.

서버 컴포넌트

import Expandable from './Expandable';

async function Notes() {
  const notes = await db.notes.getAll();
  return (
    <div>
      {notes.map(note => (
        <Expandable key={note.id}>
          <p note={note} />
        </Expandable>
      ))}
    </div>
  );
}

클라이언트 컴포넌트

"use client";

export default function Expandable({children}) {
  const [expanded, setExpanded] = useState(false);
  return (
    <div>
      <button onClick={() => setExpanded(!expanded)}>
        Toggle
      </button>
      {expanded && children}
    </div>
  );
}

작동 방식

  1. Notes 컴포넌트는 서버 컴포넌트로 먼저 렌더링된다.
  2. 번들러는 클라이언트 컴포넌트인 Expandable을 위한 번들을 생성한다.

브라우저에서 클라이언트 컴포넌트는 서버 컴포넌트로부터 전달된 출력(props)을 보게 된다.

<head>
  <!-- 클라이언트 컴포넌트를 위한 번들 -->
  <script src="bundle.js"></script>
</head>
<body>
  <div>
    <Expandable key={1}>
      <p>this is the first note</p>
    </Expandable>
    <Expandable key={2}>
      <p>this is the second note</p>
    </Expandable>
    <!--...-->
  </div> 
</body>

이 구조를 통해 서버에서 데이터를 렌더링한 다음, 클라이언트 측에서 동적 상호작용을 추가할 수 있다.


서버 컴포넌트와 비동기 컴포넌트

서버 컴포넌트는 async/await 을 사용하는 새로운 방식의 컴포넌트 작성법을 제공한다.
비동기 컴포넌트에서 await를 사용하면 React는 일시 중단(suspend)하고, 프로미스가 해결될 때까지 기다린 뒤 렌더링을 재개한다.

이 방식은 서버와 클라이언트 경계를 넘나들며 Suspense를 위한 스트리밍 지원과 함께 작동한다.

심지어 서버에서 프로미스를 생성한 뒤, 클라이언트에서 이를 await할 수도 있다.

서버 컴포넌트

import db from './database';

async function Page({id}) {
  // 서버 컴포넌트를 일시 중단합니다.
  const note = await db.notes.get(id);
  
  // 여기서는 대기하지 않습니다. 프로미스는 서버에서 시작되고,
  // 클라이언트에서 기다립니다.
  const commentsPromise = db.comments.get(note.id);
  
  return (
    <div>
      {note}
      <Suspense fallback={<p>Loading Comments...</p>}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </div>
  );
}

클라이언트 컴포넌트

"use client";
import {use} from 'react';

function Comments({commentsPromise}) {
  // 서버에서 시작된 프로미스를 이어받습니다.
  // 데이터가 준비될 때까지 일시 중단(suspend)됩니다.
  const comments = use(commentsPromise);
  return comments.map(comment => <p>{comment}</p>);
}

작동 방식

  • note 콘텐츠는 페이지 렌더링에 중요한 데이터이므로, 서버에서 이를 await 한다.
  • 댓글은 페이지 하단 콘텐츠로 우선순위가 낮기 때문에, 서버에서 프로미스를 시작하고 클라이언트에서 use API를 사용해 기다린다.

클라이언트에서 이 작업은 일시 중단(suspend)되지만, 페이지의 note 콘텐츠 렌더링을 막지는 않는다.

더보기

클라이언트에서 비동기 컴포넌트 지원

  • 클라이언트에서는 비동기 컴포넌트를 직접 지원하지 않기 때문에, 클라이언트에서 프로미스를 기다릴 때는 use API를 사용해야 한다.

공식 문서를 번역하고 정리하면서 Server Component를 사용하면 어떤 이점이 있는지 파악할 수 있었다.

추가로 서버 컴포넌트를 사용할 때 어떤 장점을 제공하는지 다른 관점에서 설명하는 글을 요약해보겠다.


1. 데이터 페칭 및 데이터 처리의 용이성

React 컴포넌트에서 데이터 페칭을 하려면 보통 두 가지 방법이 있다:

  1. 외부 API에 직접 요청하기
  2. 서버에 내부 API 엔드포인트를 생성한 후 해당 엔드포인트에 요청하기

하지만 이 두 방법은 각각 단점이 있다:

  • 외부 API에 직접 요청 시:
    • CORS 문제 발생 가능
    • 데이터베이스와 같은 데이터 소스에는 접근할 수 없음
    • 데이터 요청 로직이 클라이언트에 노출될 수 있음
    • UI 컴포넌트와 에러 처리 로직이 결합됨
  • 내부 API 엔드포인트 사용 시:
    • 보안 문제는 해결되지만, 추가적인 서버 코드를 작성해야 함

React Server Component의 장점

React Server Component(RSC)는 이러한 문제를 해결해준다. 예를 들어:

async function getUserData() {
  // 데이터 페칭 로직 작성
}

async function MyComponent() {
  let data = await getUserData();

  if (!data) {
    return <div className="error-msg">User not found!</div>;
  }

  return (
    <div>
      {data.username}
      <DeleteUserButton id={data.id} />
    </div>
  );
}

이 예제의 특징은 다음과 같다:

  • 클라이언트 사이드 코드나 브라우저 API, 이벤트 핸들러 등을 사용하지 않음.
  • 상태 관리나 사이드 이펙트가 없으며, 단순히 데이터를 조회하고 렌더링만 함.
  • 결과가 없을 때는 다른 HTML을 반환.
  • DeleteUserButton과 같은 클라이언트 컴포넌트와도 자연스럽게 결합 가능.

RSC의 주요 이점

  • 내부 API 엔드포인트를 별도로 생성하지 않아도 됨.
  • 데이터 소스의 에러 처리를 UI 컴포넌트에 결합하지 않음.
  • CORS 문제가 발생하지 않음.
  • 데이터베이스, 외부 API, 로컬 파일 등 모든 데이터 소스에서 데이터를 가져올 수 있음.

따라서, RSC는 데이터 페칭을 더 간단하고 효율적으로 할 수 있는 방법을 제공한다.


2. 더 빠른 UI와 개선된 UX

  • 서버에서 렌더링되기 때문에, 브라우저가 해당 DOM을 그릴 때 별도의 자바스크립트 처리가 필요 없음.
    • 브라우저는 서버로부터 전달받은 HTML을 DOM에 추가하기만 하면 됨.
  • 클라이언트 측에서 자바스크립트로 HTML을 동적으로 생성하고 DOM을 업데이트하는 작업이 없기 때문에, 성능이 향상됨.
  • 특히 복잡한 애플리케이션에서 많은 컴포넌트를 서버 컴포넌트로 바꾸면 더 많은 처리 작업을 서버로 넘겨, 클라이언트의 부하를 줄일 수 있음.
  • 브라우저가 처리해야 하는 작업이 줄어 UI가 더 빨리 로드되며, 사용자에게 빠른 첫 화면 로드를 제공하여 UX 개선에 기여.

3. 더 간단한 UI 코드

  • RSC를 사용하면 클라이언트 컴포넌트가 UI 비즈니스 로직을 전부 관리할 필요가 없음.
    • 클라이언트 컴포넌트에서 데이터 페칭 및 데이터 처리를 서버 컴포넌트로 이동 가능.
  • 클라이언트 컴포넌트의 크기가 줄어들어 번들 크기가 감소, 애플리케이션의 로딩 시간이 단축됨.
  • 클라이언트 컴포넌트는 UI 렌더링에만 집중하고, 비 UI 관련 로직은 RSC로 분리 가능.
  • 동적 동작이 필요 없는 정적 UI 요소들(예: 푸터)도 서버에서 렌더링하여 클라이언트 코드베이스를 줄일 수 있음.

참고 자료