뒤로가기
React Server Components, 대체 왜 필요한 건지

October 24, 2023

ReactRSCNext.jsfrontend

3개월 전 사내 기술 공유 시간에 동료가 물었다. "Server Components가 SSR이랑 뭐가 다른 거예요?" 나는 Next.js App Router를 쓰고 있었고, RSC 위에서 코드를 작성하고 있었고, 그런데 그 질문에 깔끔하게 대답을 못 했다. "음... SSR은 HTML을 서버에서 만들어서 보내는 거고, RSC는... 컴포넌트를 서버에서 실행하는 거..." 말하면서도 이게 뭔 차이인지 나도 모르겠다는 게 느껴졌다.

그날 저녁에 집에 가서 React 공식 RFC부터 다시 읽었다. 그리고 알게 됐다. 내가 RSC를 "쓰고" 있었지 "이해하고" 있지는 않았다는 걸.

SSR과 RSC는 해결하는 문제가 다르다#

이게 핵심이다. SSR과 RSC를 같은 선상에 놓고 비교하려니까 헷갈리는 거다. 둘은 해결하려는 문제 자체가 다르다.

SSR (Server-Side Rendering):

서버에서 React 컴포넌트를 실행해서 HTML 문자열을 만든다. 그 HTML을 브라우저로 보낸다. 브라우저는 그 HTML을 일단 화면에 보여준다. 그 다음 JavaScript 번들을 다운로드하고, hydration이라는 과정을 거쳐서 그 HTML에 이벤트 핸들러를 붙인다. 모든 컴포넌트의 JavaScript 코드가 클라이언트에 전송된다.

text
서버: 컴포넌트 실행 → HTML 생성 → 브라우저로 전송
브라우저: HTML 표시 → JS 번들 다운로드 → hydration → 인터랙티브

RSC (React Server Components):

서버에서 컴포넌트를 실행하고, 그 결과를 특수한 직렬화 포맷(RSC Payload)으로 만들어서 보낸다. 이 컴포넌트의 JavaScript 코드는 클라이언트로 전송되지 않는다. 클라이언트에서 실행할 필요가 있는 컴포넌트("use client")만 JavaScript가 전송된다.

text
서버: Server Component 실행 → RSC Payload 생성
      Client Component JS 번들만 클라이언트로 전송
브라우저: RSC Payload로 UI 구성 + Client Component hydration

차이가 보이는가? SSR은 언제 렌더링하느냐의 문제고, RSC는 어디서 컴포넌트가 실행되느냐의 문제다. SSR을 써도 모든 컴포넌트의 JS 코드가 브라우저로 간다. RSC를 쓰면 서버 전용 컴포넌트의 JS 코드는 브라우저에 안 간다.

그리고 이 둘은 배타적이지 않다. Next.js App Router는 SSR과 RSC를 동시에 사용한다. Server Component도 초기 요청 시 서버에서 HTML로 렌더링된다(SSR). 하지만 그 컴포넌트의 JS는 클라이언트로 전송되지 않는다(RSC).

번들 사이즈, 실제로 얼마나 줄어드나#

이론은 그렇다 치고, 실제로 체감이 되냐가 중요하다.

내가 작업하던 프로젝트에 마크다운 렌더링 기능이 있었다. marked 라이브러리를 써서 사용자가 작성한 마크다운을 HTML로 변환해 보여주는 기능. marked의 minified 사이즈가 약 40KB다. 여기에 코드 하이라이팅을 위해 highlight.js를 쓰면 기본 번들만 80KB 이상이다. 날짜 포맷팅에 date-fns를 쓰면 tree-shaking을 해도 사용하는 함수들이 누적되면서 번들에 올라간다.

Pages Router에서는 이 라이브러리들이 전부 클라이언트 번들에 포함됐다. getServerSideProps에서 데이터를 가져오더라도, 컴포넌트 안에서 marked(content)를 호출하면 marked 라이브러리는 클라이언트 번들에 들어간다. 컴포넌트 코드가 통째로 클라이언트에 전송되니까.

tsx
// Pages Router - marked가 클라이언트 번들에 포함됨
import { marked } from 'marked';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';

export default function PostPage({ post }) {
  return (
    <article>
      <time>{format(new Date(post.publishedAt), 'yyyy년 M월 d일', { locale: ko })}</time>
      <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
    </article>
  );
}

export async function getServerSideProps({ params }) {
  const post = await getPost(params.slug);
  return { props: { post } };
}

App Router의 Server Component에서는 다르다.

tsx
// App Router - Server Component (기본값)
// marked, date-fns 모두 클라이언트 번들에 포함되지 않음
import { marked } from 'marked';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';

export default async function PostPage({ params }) {
  const post = await getPost(params.slug);

  return (
    <article>
      <time>{format(new Date(post.publishedAt), 'yyyy년 M월 d일', { locale: ko })}</time>
      <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
    </article>
  );
}

코드가 거의 똑같다. 하지만 이 컴포넌트는 서버에서만 실행된다. markeddate-fns의 JavaScript 코드가 클라이언트 번들에 포함되지 않는다. 서버에서 이미 실행이 끝났으니까. 클라이언트로는 실행 결과(렌더링된 HTML 구조)만 전달된다.

내가 작업하던 프로젝트에서 이런 식으로 서버 전용 라이브러리를 정리하고 나니 클라이언트 번들 First Load JS가 약 120KB 줄었다. 모바일에서 LCP가 눈에 띄게 개선됐다.

직접 DB에 접근한다는 것#

RSC의 또 다른 큰 변화는 컴포넌트 안에서 직접 데이터베이스나 파일 시스템에 접근할 수 있다는 점이다.

Pages Router에서는 이런 흐름이었다.

tsx
// 1. API Route 만들기
// pages/api/posts/[slug].ts
export default async function handler(req, res) {
  const post = await db.post.findUnique({ where: { slug: req.query.slug } });
  res.json(post);
}

// 2. 컴포넌트에서 fetch
// pages/posts/[slug].tsx
export async function getServerSideProps({ params }) {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/posts/${params.slug}`);
  const post = await res.json();
  return { props: { post } };
}

물론 getServerSideProps에서 직접 DB에 접근할 수도 있었다. 하지만 컴포넌트 단위로 데이터를 가져오는 게 아니라 페이지 단위로 모든 데이터를 한 번에 가져와서 props로 내려줘야 했다. 컴포넌트 트리가 깊어지면 prop drilling 지옥이 펼쳐진다.

Server Component에서는 각 컴포넌트가 자기가 필요한 데이터를 직접 가져온다.

tsx
// app/posts/[slug]/page.tsx
import { db } from '@/lib/db';

export default async function PostPage({ params }) {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
  });

  return (
    <article>
      <h1>{post.title}</h1>
      <PostContent content={post.content} />
      <CommentSection postId={post.id} />
    </article>
  );
}

// CommentSection도 Server Component
async function CommentSection({ postId }) {
  const comments = await db.comment.findMany({
    where: { postId },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <section>
      <h2>댓글 {comments.length}</h2>
      {comments.map(comment => (
        <Comment key={comment.id} comment={comment} />
      ))}
      {/* 입력 폼은 인터랙션이 필요하니까 Client Component */}
      <CommentForm postId={postId} />
    </section>
  );
}

PostPage가 게시글 데이터를 가져오고, CommentSection이 댓글 데이터를 가져온다. 각 컴포넌트가 자기 데이터에 책임을 진다. Request deduplication 덕분에 같은 요청이 중복으로 나가지도 않는다.

이게 왜 좋냐면, 컴포넌트를 다른 페이지로 옮기거나 삭제할 때 데이터 fetching 로직도 같이 움직이기 때문이다. 페이지 최상단의 getServerSideProps를 수정할 필요가 없다.

"use client" 경계를 어디에 긋느냐#

여기서부터가 실전에서 가장 혼란스러운 부분이다.

App Router에서 모든 컴포넌트는 기본적으로 Server Component다. "use client" 지시어를 파일 맨 위에 붙이면 그 컴포넌트와 그 컴포넌트가 import하는 모든 컴포넌트가 Client Component가 된다.

내가 처음에 한 실수가 이거다.

tsx
// ❌ 이렇게 하면 안 된다
'use client';

// 페이지 전체를 Client Component로 만들어버림
import { marked } from 'marked';

export default function PostPage({ post }) {
  const [showComments, setShowComments] = useState(false);

  return (
    <article>
      <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
      <button onClick={()=> setShowComments(!showComments)}>
        댓글 보기
      </button>
      {showComments && <CommentList postId={post.id} />}
    </article>
  );
}

댓글 토글 버튼 하나 때문에 useState가 필요했고, 그래서 전체 페이지에 "use client"를 붙였다. 그 순간 marked 라이브러리가 클라이언트 번들로 들어간다. RSC의 장점이 사라진다.

올바른 패턴은 인터랙티브한 부분만 분리하는 것이다.

tsx
// app/posts/[slug]/page.tsx (Server Component)
import { marked } from 'marked';
import { db } from '@/lib/db';
import { CommentToggle } from './comment-toggle';

export default async function PostPage({ params }) {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
  });

  return (
    <article>
      {/* marked는 서버에서만 실행됨 */}
      <div dangerouslySetInnerHTML={{ __html: marked(post.content) }} />
      {/* 인터랙션이 필요한 부분만 Client Component */}
      <CommentToggle postId={post.id} />
    </article>
  );
}
tsx
// app/posts/[slug]/comment-toggle.tsx
'use client';

import { useState } from 'react';

export function CommentToggle({ postId }) {
  const [showComments, setShowComments] = useState(false);

  return (
    <>
      <button onClick={()=> setShowComments(!showComments)}>
        댓글 보기
      </button>
      {showComments && <CommentList postId={postId} />}
    </>
  );
}

핵심 원칙은 간단하다. "use client" 경계를 가능한 한 트리의 아래쪽(leaf)에 둬라.

실수하기 쉬운 패턴을 몇 가지 더 정리하면 이렇다.

tsx
// ❌ Layout에 "use client" 붙이기
// → 그 Layout 아래 모든 page가 Client Component가 됨
'use client';
export default function DashboardLayout({ children }) { ... }

// ❌ Context Provider 때문에 전체를 Client Component로 만들기
// → Provider만 Client Component로 분리하면 됨
'use client';
export function ThemeProvider({ children }) {
  return <ThemeContext.Provider value={...}>{children}</ThemeContext.Provider>;
}
// ✅ children으로 Server Component를 넘길 수 있다!
// layout.tsx (Server Component)
export default function Layout({ children }) {
  return <ThemeProvider>{children}</ThemeProvider>;
}

마지막 예시가 의외로 중요하다. Client Component의 children prop으로 Server Component를 넘길 수 있다. ThemeProvider가 Client Component여도, {children}으로 들어오는 page는 Server Component로 유지된다. 이 패턴을 모르면 불필요하게 "use client" 범위가 넓어진다.

현실적인 한계들#

RSC가 장밋빛만 있는 건 아니다. 지금 시점에서 부딪히는 문제들이 있다.

Pages Router에서는 쓸 수 없다. RSC는 App Router에서만 동작한다. Pages Router를 쓰고 있는 프로젝트라면 App Router로 마이그레이션해야 RSC를 쓸 수 있다. 그리고 이 마이그레이션이 간단하지 않다. 라우팅 구조, 데이터 페칭 패턴, 레이아웃 시스템이 전부 바뀐다.

라이브러리 호환성 문제가 아직 있다. Server Component에서는 useState, useEffect 같은 Hook을 쓸 수 없다. 당연히 이 Hook들에 의존하는 라이브러리들도 Server Component에서 사용 불가다. CSS-in-JS 라이브러리 중 일부(특히 runtime CSS-in-JS)는 RSC와 잘 안 맞는다. styled-components가 대표적이다. 서버에서 실행되는 컴포넌트에 runtime 스타일 주입이 필요한 라이브러리를 쓰려면 "use client"를 붙여야 하고, 그러면 RSC의 이점이 줄어든다.

디버깅이 어렵다. Server Component에서 에러가 나면 브라우저 DevTools에서 바로 확인이 안 된다. 서버 로그를 봐야 한다. 개발 모드에서는 에러 오버레이가 뜨지만, 복잡한 버그를 추적할 때는 서버 쪽과 클라이언트 쪽을 왔다 갔다 하면서 봐야 한다.

캐싱 동작이 직관적이지 않다. Next.js의 App Router 캐싱은 여러 레이어가 있다. Request Memoization, Data Cache, Full Route Cache, Router Cache. 이 중 일부는 기본적으로 활성화되어 있고, 특히 fetch의 기본 캐싱 동작이 Next.js 버전마다 바뀌어서 혼란을 준 적이 있다. 최근 버전에서는 fetch의 기본 캐싱이 no-store로 변경됐지만, 프로젝트의 Next.js 버전에 따라 다를 수 있다.

그래서 지금 배워야 하나#

이건 상황에 따라 다르다.

새 프로젝트를 시작한다면 — App Router + RSC를 기본으로 가라. React의 방향성이 이쪽이다. React 팀이 공식적으로 프레임워크 사용을 권장하고 있고, Next.js App Router가 RSC를 가장 먼저 지원한 프레임워크다. 새 프로젝트에서 Pages Router를 고를 이유가 없다.

기존 Pages Router 프로젝트가 있다면 — 잘 돌아가고 있으면 굳이 마이그레이션하지 마라. "RSC 쓰고 싶어서" 마이그레이션하는 건 비용 대비 효과가 낮다. 마이그레이션은 비즈니스적으로 의미가 있을 때 하는 거다. 번들 사이즈가 심각하게 크거나, 새로운 기능 추가 시 App Router의 이점이 명확할 때 점진적으로 전환하면 된다. Next.js는 Pages Router와 App Router를 한 프로젝트에서 공존시킬 수 있으니까, 새로 만드는 페이지부터 App Router로 작성하는 것도 방법이다.

RSC의 개념은 지금 알아둬야 한다. 당장 프로젝트에서 쓰지 않더라도, "컴포넌트가 서버에서만 실행된다"는 개념, "use client" 경계의 의미, 서버와 클라이언트 사이에서 데이터가 어떻게 흐르는지 — 이건 React 생태계의 방향이다. 나중에 쓸 때 바닥부터 공부하는 것보다, 지금 개념을 잡아두고 실제 코드를 작성하면서 감을 익히는 게 낫다.

내가 기술 공유 시간에서 대답을 못 했던 이유는, App Router 프로젝트를 하면서도 RSC가 뭘 해주는 건지 정확히 모르고 있었기 때문이다. "use client"를 언제 붙이는지는 알았지만 왜 붙이는지는 몰랐다. 프레임워크가 해주는 것과 내가 이해하는 것은 다르다. 도구가 알아서 잘 해주는 시대지만, 도구가 뭘 해주는 건지는 알고 써야 문제가 생겼을 때 방향을 잡을 수 있다.