2025-09-22 23:17

  • 리액트 서버 컴포넌트]는 서버에서만 렌더링되어 클라이언트로 HTML을 전송, 초기 로딩 속도와 SEO를 개선하고 번들 크기를 줄인다.
  • 서버 컴포넌트는 데이터 fetching, 파일 시스템 접근 등 백엔드 작업을 직접 수행하며, ‘use client’ 지시어로 클라이언트 컴포넌트와 명확히 분리된다.
  • 서버 컴포넌트와 클라이언트 컴포넌트의 조합은 인터랙티브하면서도 성능이 뛰어난 웹 애플리케이션 구축을 가능하게 하는 새로운 패러다임이다.

리액트 서버 컴포넌트 완벽 정복 핸드북

과거 웹 개발의 패러다임이 클라이언트 사이드 렌더링(CSR)으로 전환되면서, 우리는 풍부한 인터랙션과 동적인 사용자 경험을 얻었다. 하지만 이는 거대한 자바스크립트 번들, 느린 초기 로딩 속도, 그리고 검색 엔진 최적화(SEO)의 어려움이라는 새로운 숙제를 안겨주었다. 수많은 개발자들이 이 문제를 해결하기 위해 서버 사이드 렌더링(SSR), 정적 사이트 생성(SSG) 등 다양한 방법을 시도했지만, 각각의 장단점 속에서 완벽한 해결책을 찾기란 어려웠다.

바로 이 지점에서, 리액트 팀은 웹 개발의 근본적인 문제에 대한 새로운 해답을 제시했다. 그것이 바로 **리액트 서버 컴포넌트(React Server Components, RSC)**다. 서버 컴포넌트는 단순히 서버에서 렌더링하는 것을 넘어, 서버의 힘을 적극적으로 활용하여 웹 애플리케이션의 성능과 사용자 경험을 한 단계 끌어올리는 혁신적인 개념이다. 이 핸드북은 리액트 서버 컴포넌트의 탄생 배경부터 구조, 사용법, 그리고 심화 내용까지 모든 것을 담아, 당신을 서버 컴포넌트 전문가로 만들어 줄 것이다.


1. 왜 리액트 서버 컴포넌트가 만들어졌나

서버 컴포넌트의 탄생 배경을 이해하기 위해서는 기존 렌더링 방식의 한계를 먼저 짚어봐야 한다.

클라이언트 사이드 렌더링 (CSR)의 명과 암

초기 웹은 서버가 완성된 HTML 페이지를 보내주는 방식이었다. 사용자가 링크를 클릭할 때마다 서버로부터 새로운 페이지를 받아왔다. 이후 Ajax의 등장과 함께 SPA(Single Page Application)가 유행하며, 자바스크립트가 브라우저에서 UI를 동적으로 그리는 **클라이언트 사이드 렌더링(CSR)**이 대세가 되었다.

  • 장점: 한번 로딩된 후에는 페이지 전환이 부드럽고, 사용자 인터랙션에 즉각적으로 반응하여 앱과 유사한 경험을 제공한다.
  • 단점:
    • 느린 초기 로딩: 사용자는 빈 HTML 페이지를 먼저 받고, 그 후에 거대한 자바스크립트 파일을 다운로드하고 실행해야 비로소 컨텐츠를 볼 수 있다. 이를 TTV(Time To View)가 길다고 표현한다.
    • SEO의 어려움: 검색 엔진 봇이 자바스크립트를 실행하지 못하면 빈 페이지만 보게 되어 컨텐츠를 제대로 수집하기 어렵다.
    • 비대한 번들 사이즈: 애플리케이션의 기능이 많아질수록 자바스크립트 번들 크기가 무한정 커져 성능 저하의 주범이 된다.

서버 사이드 렌더링 (SSR)과 정적 사이트 생성 (SSG)의 등장

이러한 CSR의 단점을 보완하기 위해 **서버 사이드 렌더링(SSR)**과 **정적 사이트 생성(SSG)**이 등장했다.

  • SSR: 서버에서 리액트 컴포넌트를 렌더링하여 완성된 HTML을 클라이언트에 보낸다. 초기 로딩 속도가 빠르고 SEO에 유리하지만, 서버에 부하가 가중되고 페이지 이동 시마다 서버 렌더링을 거쳐야 한다는 단점이 있다.
  • SSG: 빌드 타임에 모든 페이지를 미리 HTML로 만들어둔다. 매우 빠르지만, 동적인 데이터가 자주 변경되는 페이지에는 적합하지 않다.

이처럼 각 렌더링 방식은 명확한 트레이드오프 관계를 가지고 있었다. 리액트 팀은 이 문제에 대해 “왜 우리는 서버와 클라이언트 중 하나만 선택해야 하는가?”라는 근본적인 질문을 던졌다. 그리고 그에 대한 해답이 바로 서버와 클라이언트의 장점만을 결합한 ‘리액트 서버 컴포넌트’다.

서버 컴포넌트의 핵심 철학은 “컴포넌트가 자신의 코드가 실행될 위치를 스스로 결정하게 하자”는 것이다. 어떤 코드는 서버의 강력한 성능과 데이터 접근 능력이 필요하고, 어떤 코드는 사용자와의 상호작용을 위해 클라이언트에 있어야 한다. 서버 컴포넌트는 이 둘을 자연스럽게 조합할 수 있는 길을 열어주었다.


2. 리액트 서버 컴포넌트의 구조와 작동 원리

서버 컴포넌트는 기존 리액트 컴포넌트와는 다른 새로운 종류의 컴포넌트다. 그 핵심적인 차이를 이해하는 것이 중요하다.

서버 컴포넌트 vs 클라이언트 컴포넌트

리액트 서버 컴포넌트 환경에서는 모든 컴포넌트가 기본적으로 서버 컴포넌트로 간주된다. 우리가 특별히 클라이언트 컴포넌트라고 명시하지 않는 한, 모든 컴포넌트는 서버에서만 실행된다.

특징서버 컴포넌트 (Server Components)클라이언트 컴포넌트 (Client Components)
실행 환경서버클라이언트 (브라우저)
상태 관리 (useState)불가능가능
생명주기 (useEffect)불가능가능
브라우저 API 접근불가능 (e.g., window, document)가능
서버 자원 접근가능 (e.g., 파일 시스템, 데이터베이스 직접 접근)불가능
자바스크립트 번들 포함포함되지 않음포함됨
표시 방법파일 최상단에 지시어 없음파일 최상단에 'use client'; 지시어 필요

이 둘을 구분하는 가장 중요한 기준은 바로 상태(State)와 인터랙션의 유무다. useState, useEffect와 같이 상태를 가지거나 사용자의 클릭, 입력 등 브라우저 이벤트에 반응해야 하는 컴포넌트는 반드시 클라이언트 컴포넌트여야 한다. 반면, 단순히 데이터를 화면에 표시하거나 서버의 자원을 활용하는 컴포넌트는 서버 컴포넌트로 남겨두는 것이 좋다.

렌더링 프로세스: 어떻게 서버에서 클라이언트로 전달되는가?

서버 컴포넌트의 렌더링 과정은 기존과는 사뭇 다르다.

  1. 서버 렌더링: 리액트는 서버에서 서버 컴포넌트들을 렌더링한다. 이 과정에서 데이터베이스 조회, 파일 읽기 등의 작업이 수행된다.
  2. 직렬화 (Serialization): 렌더링된 결과물은 HTML이 아닌, 리액트가 이해할 수 있는 특별한 포맷(RSC Payload)으로 직렬화된다. 이는 단순한 HTML 문자열이 아니라, 렌더링된 UI 구조와 클라이언트 컴포넌트의 참조 정보를 담고 있는 데이터 스트림이다.
  3. 클라이언트 전송: 이 RSC Payload가 클라이언트로 스트리밍된다.
  4. 클라이언트 재구성 (Reconciliation): 클라이언트의 리액트는 이 스트림을 받아 점진적으로 UI를 재구성한다. 이 과정에서 ‘여기는 클라이언트 컴포넌트가 들어갈 자리’라고 표시된 부분을 실제 클라이언트 컴포넌트 코드로 채워 넣고, 필요한 자바스크립트를 다운로드하여 실행한다.

이 과정의 핵심은 서버 컴포넌트의 코드는 절대 클라이언트로 전송되지 않는다는 점이다. 오직 렌더링된 결과물만 전달된다. 이는 자바스크립트 번들 크기를 획기적으로 줄여주는 결정적인 역할을 한다. 예를 들어, 날짜 포맷팅을 위해 date-fns 라이브러리를 사용하거나 마크다운을 렌더링하기 위해 marked 라이브러리를 사용하는 서버 컴포넌트가 있다면, 이 라이브러리들은 클라이언트의 자바스크립트 번들에 포함되지 않는다. 서버에서만 사용되고 그 결과물만 클라이언트에 전달될 뿐이다.


3. 리액트 서버 컴포넌트 사용법

백문이 불여일견. 실제 코드를 통해 서버 컴포넌트와 클라이언트 컴포넌트를 어떻게 작성하고 조합하는지 알아보자. (Next.js App Router 기준)

서버 컴포넌트 작성하기

app 디렉토리 안에 있는 모든 컴포넌트는 기본적으로 서버 컴포넌트다. 특별한 지시어가 필요 없다.

// app/page.js
 
// 서버에서만 실행되는 비동기 함수
async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}
 
// 이 컴포넌트는 서버 컴포넌트다.
export default async function HomePage() {
  // 서버 컴포넌트에서는 직접 await를 사용할 수 있다.
  const posts = await getPosts();
 
  return (
    <div>
      <h1>블로그 포스트</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

위 코드의 놀라운 점을 발견했는가?

  • HomePage 컴포넌트는 async 함수다. 컴포넌트 자체를 비동기로 선언하고 내부에서 await를 사용하여 데이터를 가져올 수 있다. 더 이상 useEffect와 로딩 상태 관리에 얽매일 필요가 없다.
  • getPosts 함수는 서버 환경에서 실행되므로, 데이터베이스에 직접 접속하거나 내부 API를 호출하는 등 민감한 작업을 안전하게 처리할 수 있다.

클라이언트 컴포넌트 작성하기

사용자와의 상호작용이 필요한 컴포넌트는 파일 최상단에 'use client'; 지시어를 추가하여 클라이언트 컴포넌트로 만들어야 한다.

// app/components/Counter.js
 
'use client'; // 이 파일의 모든 컴포넌트는 클라이언트 컴포넌트가 된다.
 
import { useState } from 'react';
 
export default function Counter() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>증가</button>
    </div>
  );
}
  • 'use client';는 반드시 파일의 가장 위에 위치해야 한다.
  • 이 컴포넌트는 useState를 사용하므로 클라이언트 컴포넌트여야 한다.
  • Counter 컴포넌트의 코드는 클라이언트 자바스크립트 번들에 포함되어 브라우저로 전송된다.

서버 컴포넌트와 클라이언트 컴포넌트 조합하기

이제 이 둘을 조합해보자. 서버 컴포넌트 안에서 클라이언트 컴포넌트를 import하여 사용할 수 있다.

// app/page.js
 
import Counter from './components/Counter'; // 클라이언트 컴포넌트를 import
 
async function getPosts() {
  // ... (데이터 fetching 로직)
}
 
// 서버 컴포넌트
export default async function HomePage() {
  const posts = await getPosts();
 
  return (
    <div>
      <h1>블로그 포스트</h1>
      {/* 서버 컴포넌트가 렌더링하는 정적 컨텐츠 */}
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      
      <hr />
      
      <h2>카운터</h2>
      {/* 클라이언트 컴포넌트가 렌더링하는 동적 컨텐츠 */}
      <Counter />
    </div>
  );
}

이 코드가 실행되면,

  1. 서버에서 HomePage가 렌더링된다. getPosts가 실행되고 <ul> 리스트가 생성된다.
  2. <Counter />를 만나는 순간, 리액트는 “이 자리는 Counter라는 클라이언트 컴포넌트가 들어갈 곳”이라고 표시해둔다.
  3. 이 결과물(RSC Payload)이 클라이언트로 전달된다.
  4. 클라이언트는 Counter 컴포넌트의 자바스크립트 코드를 다운로드하고 실행하여 표시된 자리에 렌더링하고, 인터랙션을 활성화(Hydration)한다.

중요한 규칙:

  • 서버 컴포넌트는 클라이언트 컴포넌트를 import 할 수 있다.
  • 클라이언트 컴포넌트는 서버 컴포넌트를 직접 import 할 수 없다. 이는 기술적으로 불가능하다. 클라이언트 코드는 서버에서만 실행될 수 있는 코드를 포함할 수 없기 때문이다. 대신, 서버 컴포넌트를 자식(children)으로 받아 렌더링하는 패턴을 사용한다.
// app/components/ClientLayout.js
'use client';
 
export default function ClientLayout({ children }) {
  // 이 컴포넌트는 클라이언트 컴포넌트다.
  // 하지만 children으로 서버 컴포넌트를 받을 수 있다.
  return (
    <div>
      <header>클라이언트 레이아웃 헤더</header>
      <main>{children}</main>
    </div>
  );
}
 
// app/page.js
import ClientLayout from './components/ClientLayout';
import MyServerComponent from './components/MyServerComponent';
 
export default function Page() {
  return (
    <ClientLayout>
      {/* MyServerComponent는 서버에서 렌더링된 후 그 결과가 ClientLayout의 children으로 전달된다. */}
      <MyServerComponent /> 
    </ClientLayout>
  );
}

4. 심화 내용: 서버 컴포넌트 제대로 활용하기

서버 컴포넌트의 기본 개념을 이해했다면, 이제 더 깊이 들어가 보자.

데이터 Fetching 패턴의 변화

서버 컴포넌트는 데이터 fetching 방식을 근본적으로 바꾼다. 기존에는 클라이언트에서 데이터를 가져오기 위해 useEffect와 로딩, 에러 상태를 수동으로 관리해야 했다.

// 기존 클라이언트 컴포넌트 방식
function OldPosts() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch('/api/posts')
      .then(res => res.json())
      .then(data => {
        setPosts(data);
        setLoading(false);
      });
  }, []);
 
  if (loading) return <p>로딩 중...</p>;
  return <ul>{/* ... */}</ul>;
}

서버 컴포넌트를 사용하면 이 모든 것이 극도로 단순해진다.

// 서버 컴포넌트 방식
async function NewPosts() {
  const res = await fetch('https://api.example.com/posts', { cache: 'no-store' });
  const posts = await res.json();
  
  // 로딩 상태가 필요 없다. 데이터가 준비될 때까지 렌더링이 지연될 뿐이다.
  return <ul>{posts.map(post => <li key={post.id}>{post.title}</li>)}</ul>;
}

Next.js는 fetch API를 확장하여 컴포넌트 수준에서 데이터 캐싱과 재검증(revalidation)을 제어할 수 있는 강력한 기능을 제공한다. 이를 통해 SSG와 SSR의 장점을 모두 취할 수 있다.

서버 액션 (Server Actions)

서버 액션은 서버 컴포넌트 패러다임의 화룡점정이다. 이는 클라이언트에서 서버의 함수를 직접 호출할 수 있게 해주는 기능이다. 더 이상 데이터 변경을 위해 API 라우트를 만들고, 클라이언트에서 fetch 요청을 보낼 필요가 없다.

// app/components/AddToCartButton.js
'use client';
 
import { addToCart } from '../actions'; // 서버 액션을 import
 
export default function AddToCartButton({ productId }) {
  return (
    <button onClick={async () => {
      await addToCart(productId);
      alert('장바구니에 추가되었습니다.');
    }}>
      장바구니 담기
    </button>
  );
}
 
// app/actions.js
'use server'; // 이 파일의 모든 함수는 서버 액션이 된다.
 
export async function addToCart(productId) {
  // 이 코드는 서버에서만 실행된다.
  // 데이터베이스에 접근하여 장바구니 데이터를 업데이트할 수 있다.
  console.log(`서버: ${productId} 상품을 장바구니에 추가합니다.`);
  // db.cart.add({ productId });
}

'use server'; 지시어를 사용하면, addToCart 함수는 클라이언트에서 호출되지만 실제 실행은 서버에서 이루어진다. 이를 통해 클라이언트와 서버 간의 데이터 흐름이 매우 직관적이고 단순해진다.

서버 컴포넌트 시대의 새로운 고민거리

서버 컴포넌트는 수많은 장점을 제공하지만, 새로운 사고방식을 요구하기도 한다.

  • 컴포넌트 경계 설계: 어떤 컴포넌트를 서버로 남기고, 어떤 컴포넌트를 클라이언트로 전환할지 결정하는 것이 중요해졌다. 인터랙션이 필요한 가장 작은 단위로 클라이언트 컴포넌트를 분리하는 것이 좋다.
  • 네트워크 폭포수(Waterfall) 방지: 서버 컴포넌트 내에서 데이터를 순차적으로 await하면 렌더링이 지연될 수 있다. Promise.all 등을 활용하여 데이터를 병렬로 가져오는 것이 중요하다.
  • 서버-클라이언트 간 데이터 전달: 서버 컴포넌트에서 클라이언트 컴포넌트로 props를 전달할 때, 함수나 Date 객체 등 직렬화할 수 없는 값은 전달할 수 없다는 제약을 이해해야 한다.

결론: 웹 개발의 새로운 패러다임을 맞이하며

리액트 서버 컴포넌트는 단순히 새로운 기능 추가가 아니다. 이는 우리가 웹 애플리케이션을 구축하는 방식을 근본적으로 바꾸는 패러다임의 전환이다. 서버의 강력한 성능과 클라이언트의 풍부한 인터랙션을 매끄럽게 결합함으로써, 더 빠르고, 더 가볍고, 더 나은 사용자 경험을 제공하는 웹을 만들 수 있는 가능성을 열었다.

물론 새로운 개념인 만큼 학습 곡선이 존재하고, 프로젝트에 적용하기 전에 충분한 이해가 필요하다. 하지만 서버 컴포넌트가 제시하는 미래는 명확하다. 개발자는 인프라에 대한 고민을 줄이고 비즈니스 로직에 더 집중할 수 있게 되며, 사용자는 그 결과물인 빠르고 쾌적한 웹을 경험하게 될 것이다. 이 핸드북이 당신이 리액트 서버 컴포넌트라는 새로운 시대의 흐름에 성공적으로 합류하는 데 훌륭한 길잡이가 되기를 바란다.