리액트 19 서버 컴포넌트 (RSC)

React SSR에서 클라이언트 번들과 하이드레이션의 경계를 나누는 방식

React Server Components, 줄여서 RSC는 React 19에서 본격적으로 다루기 좋아진 기능이다. 처음에는 이름 그대로 “서버에서 렌더링되는 컴포넌트”라고 이해했는데, 그렇게만 보면 기존 SSR과 뭐가 다른지 헷갈리기 쉽다.

Next.js에서 SSR을 써본 입장에서는 RSC가 완전히 낯선 개념처럼 느껴지지는 않았다. 서버에서 먼저 결과를 만들고, 브라우저에서 React가 이어받는다는 큰 흐름은 익숙하다. 다만 RSC에서는 모든 컴포넌트를 클라이언트에서 다시 살리는 대신, 브라우저에서 정말 살아나야 하는 부분만 'use client' 경계 아래로 내려보낸다.

그러니까 RSC는 SSR을 대체하는 신비한 기술이라기보다, React SSR에서 클라이언트 번들과 하이드레이션의 책임 범위를 줄이기 위한 경계 모델에 가깝다고 생각한다.


기존 SSR의 문제점


기존 컴포넌트를 서버 사이드 렌더링(이하 SSR) 할 때 어떤 문제들이 있었을까?

1. 하이드레이션

사용자는 서버에서 렌더링된 HTML과 다운로드된 자바스크립트를 활용해서 React Virtual DOM을 구성하고 이벤트 바인딩을 처리해야 웹 사이트를 정상적으로 사용할 수 있게 된다. 이 과정을 하이드레이션이라고 한다.

하이드레이션의 문제는 상호작용이 필요한 컴포넌트의 코드가 결국 자바스크립트 번들에 포함되어야 한다는 점이다. 페이지의 규모가 커질수록 하이드레이션 비용과 번들 사이즈가 함께 커질 수 있다.

2. 클라이언트 기반 컴포넌트

기존 React 컴포넌트는 대체로 클라이언트에서 동작하는 것을 기본으로 생각하기 쉽다. 하지만 SSR을 처리하려면 같은 컴포넌트가 서버 환경에서도 실행될 수 있는지 고려해야 한다.

컴포넌트에서 브라우저 API를 직접 사용하거나, 브라우저 API에 의존적인 라이브러리를 사용한다면 서버에서 실행되지 않도록 별도의 처리를 해줘야 한다.

3. Props Drilling

페이지 단위로 SSR을 구성했거나, 페이지 라우터 기반의 Next.js를 사용할 경우 페이지에 props로 렌더링될 데이터를 전달하는 방식을 주로 취한다. 이때 컴포넌트의 깊이가 깊어지고 하위 컴포넌트에 데이터 전달이 필요하다면 불필요한 Props Drilling이 발생할 수 있다.


서버 컴포넌트


서버 컴포넌트의 핵심은 “서버에서 HTML을 렌더링한다”보다 “클라이언트에서 React로 살아날 필요가 있는 컴포넌트인지 구분한다”에 가깝다고 생각한다.

서버 컴포넌트는 서버에서 데이터를 가져오고, 클라이언트로 보낼 UI 결과를 만든다. 사용자와 직접 상호작용하지 않으며, 브라우저에서 실행될 필요가 없는 컴포넌트라면 클라이언트 번들에 포함하지 않을 수 있다. 그래서 서버 컴포넌트는 하이드레이션 대상이 아니며, 번들 사이즈를 줄이는 데 도움이 될 수 있다.

예를 들어 아래와 같은 구조를 생각해볼 수 있다.

// 서버 컴포넌트
export default async function Page() {
  const posts = await getPosts()

  return (
    <>
      <PostList posts={posts} />
      <LikeButton />
    </>
  )
}
// 클라이언트 컴포넌트
'use client'

import { useState } from 'react'

export function LikeButton() {
  const [liked, setLiked] = useState(false)

  return (
    <button onClick={() => setLiked(true)}>
      {liked ? '좋아요!' : '좋아요'}
    </button>
  )
}

이 구조에서 PagePostList는 서버에서 실행될 수 있다. 반면 LikeButton은 클릭 이벤트와 상태가 필요하므로 브라우저에서 React로 살아나야 한다. 그래서 'use client' 경계 아래에 두고 클라이언트 번들에 포함시킨다.

이렇게 보면 RSC는 모든 컴포넌트를 하이드레이션 대상으로 보는 대신, 실제로 브라우저에서 상호작용해야 하는 부분만 클라이언트 컴포넌트로 남기는 방식에 가깝다.

다만 “서버 컴포넌트니까 무조건 안전하다”라고 이해하면 곤란하다. 서버 컴포넌트의 코드 자체는 클라이언트로 전달되지 않지만, 서버에서 가져온 데이터를 클라이언트 컴포넌트의 props로 넘기면 그 값은 클라이언트로 전달될 수 있다. 키나 토큰 같은 민감한 값은 여전히 경계해서 다뤄야 한다.

또한 RSC는 React만 import한다고 모든 환경에서 자동으로 사용할 수 있는 기능이라기보다, 이를 지원하는 프레임워크와 번들러 환경 위에서 사용하는 모델에 가깝다. Next.js App Router처럼 RSC를 지원하는 환경에서는 서버 컴포넌트와 클라이언트 컴포넌트의 경계를 기준으로 애플리케이션을 구성할 수 있다.


서버 컴포넌트와 클라이언트 컴포넌트의 경계


서버 컴포넌트와 클라이언트 컴포넌트를 구분하는 중요한 기준은 'use client' 디렉티브다. 파일 상단에 'use client'를 선언하면 해당 모듈은 클라이언트 컴포넌트의 진입점이 된다.

'use client'가 선언된 컴포넌트 아래에서는 브라우저에서 실행되는 클라이언트 환경을 전제로 생각할 수 있다. 이 컴포넌트와 그 하위 의존성은 클라이언트 번들에 포함될 수 있고, useState, useEffect 같은 클라이언트 상태와 이펙트 Hook을 사용할 수 있다.

반대로 서버 컴포넌트는 서버에서 실행되므로 데이터베이스나 파일 시스템처럼 서버에서만 접근 가능한 자원에 접근할 수 있다. 대신 브라우저 API를 직접 사용할 수 없고, 사용자 이벤트를 직접 처리하는 상호작용 컴포넌트가 될 수 없다.

주의할 점은 “클라이언트 컴포넌트는 서버 컴포넌트를 절대 포함할 수 없다”는 표현이다. 클라이언트 컴포넌트가 서버 컴포넌트를 직접 import해서 실행할 수는 없다. 하지만 서버 컴포넌트가 클라이언트 컴포넌트를 렌더링하면서, 서버에서 만든 UI를 children이나 props 형태로 넘기는 구조는 가능하다.

즉, 중요한 것은 화면의 포함 관계보다 어떤 코드가 어디에서 실행되는가다.

각각의 서버 컴포넌트에서 필요한 데이터는 해당 서버 컴포넌트에서 직접 가져와서 사용하거나 내려줄 수 있기 때문에, 페이지 최상단에서 모든 데이터를 받아 깊은 곳까지 전달하는 방식보다 Props Drilling을 줄일 수 있다.

RSC vs Client Component

구분

서버 컴포넌트

클라이언트 컴포넌트

실행 위치

서버

클라이언트

클라이언트 번들 포함

하이드레이션 대상

서버 자원 접근

사용자 상호작용

Browser API 사용

클라이언트 상태/이펙트 Hook

서버 컴포넌트 직접 import

클라이언트 컴포넌트 렌더링

정리하면 RSC는 기존 SSR과 완전히 동떨어진 개념이라기보다, React가 서버와 클라이언트의 경계를 더 세밀하게 나누기 위한 방식에 가깝다. 서버에서 끝낼 수 있는 일은 서버에 남기고, 사용자와 상호작용해야 하는 부분만 클라이언트로 넘긴다.

그래서 RSC를 이해할 때 중요한 질문은 “서버에서 렌더링하느냐, 클라이언트에서 렌더링하느냐” 하나만은 아니다. 더 중요한 질문은 이것에 가깝다.

이 컴포넌트의 코드가 정말 브라우저에서 React로 살아나야 하는가?