
React Server Component(RSC)는 서버에서 렌더링되는 리액트 컴포넌트를 의미한다.
❓ SSR과 RSC의 차이가 뭘까?
SSR은 페이지 단위의 HTML을 서버에서 미리 생성하는 방식이고, RSC는 컴포넌트 단위로 서버에서 실행하는 방식이다.
RSC 내부에서는 useState, useReducer, useEffect, useRef 등 상태나 사이클 관련 hook을 사용할 수 없다. RSC는 서버에서 실행되어 결과물(RSC Payload)를 만들고, 브라우저 런타임에는 실행되지 않는 함수이기 때문에 브라우저 메모리에 상주하며 상태를 유지하거나, DOM이 마운트된 후 실행되는 라이프사이클 개념 자체가 존재하지 않기 때문이다.
onClick, onChange, onSubmit, onKeyDown 같은 상호작용 이벤트 리스너를 React의 JSX에 직접 붙일 수 없다. 이벤트 함수(() => doSomething())는 자바스크립트 함수 객체이므로, 서버에서 클라이언트로 전송되는 데이터 형태(JSON과 유사한 형태)로 변환(Serialize)될 수 없기 때문이다.
window, document, navigator, localStorage, sessionStorage, location 등 브라우저 전용 객체에 접근하면 즉시 에러가 발생한다. RSC는 브라우저가 아닌 서버 환경에서 실행되기 때문이다.
async/await로 작성 가능기존 React에서는 Hook의 규칙을 위반하기 때문에 컴포넌트 함수 자체를 비동기로 만들 수 없었지만 RSC는 컴포넌트 함수 자체를 async 함수로 작성할 수 있다. 이는 서버에서 비동기로 데이터를 가져온 뒤 JSX를 반환하기 때문에 가능하다.
// ✅ 가능: RSC는 함수 자체를 async로 선언하여 직관적으로 비동기 처리가 가능
export default async function UserProfile() {
// useEffect 없이 최상위에서 바로 await 호출!
const res = await fetch('https://api.example.com/user');
const user = await res.json();
return <div>{user.name}</div>;
}
❓ Hook의 규칙이란?
https://ko.legacy.reactjs.org/docs/hooks-rules.html
- 최상위 레벨에서만 훅을 호출해야 한다.
- 오직 함수 컴포넌트나 Custom hook 내부에서만 훅을 호출해야 한다. 일반 자바스크립트 함수나 클래스 컴포넌트에서는 훅을 호출할 수 없다.
❓ 왜 기존 React 컴포넌트는
async로 작성할 수 없었을까?
async함수 내부에서await를 만나면 자바스크립트는 해당 지점에서 함수의 실행을 중단하고 비동기 작업이 끝날 때까지 기다린다. 이로 인해 훅의 호출 시점이 렌더링 주기를 벗어나 비동기로 밀리게 되고, 결과적으로 React가 훅의 호출 순서를 보장할 수 없게 되어 Hook의 규칙을 위반하게 된다.- 기존의 React 렌더러는 컴포넌트 함수가 JSX를 반환할 것으로 기대하지만
async함수는 JSX가 아닌Promise객체를 반환하므로 이를 UI 노드로 인식하지 못하고 에러를 발생시킨다.
props 데이터의 타입 제약서버 컴포넌트에서 자식으로 불러오는 클라이언트 컴포넌트로 props를 넘길 때 직렬화 가능한(Serializable) 데이터 타입만 전달할 수 있다. 서버 컴포넌트에서 클라이언트로 넘어가는 경계는 서버에서 브라우저로 데이터가 네트워크를 타고 넘어가는 지점이기 때문이다.
초기 React는 클라이언트 중심의 렌더링(CSR)만을 지원했지만, 이후 SEO와 초기 로딩 속도 문제를 해결하기 위해 SSR이 도입되었다. 하지만 애플리케이션의 규모가 커짐에 따라 기존 방식들로는 더 이상 해결할 수 없는 근본적인 한계에 부딪히게 되었다. React 팀은 이 문제들을 해결하기 위해 서버의 이점과 클라이언트의 이점을 모두 챙길 수 있는 React Server Component를 고안하게 된다.
서비스가 복잡해질수록 다양한 라이브러리를 설치하여 사용하게 된다. 이 때 SSR을 사용하더라도 클라이언트에서 hydration을 실행해야 했기 대문에 사용한 모든 라이브러리의 JS 코드가 클라이언트로 전송되어야 했다. 결과적으로 네트워크 다운로드 시간과 JS 해석 시간이 길어져 TTL(Time to Interactive)가 느려졌다.
서버 컴포넌트 내부에서 사용하는 라이브러리(예: date-fns, lodash 등)는 서버에서만 실행되고 클라이언트로 전송되지 않는다. 결과적으로 클라이언트 자바스크립트 번들 용량이 획기적으로 감소하게 된다.
기존 CSR/SSR 방식에서는 부모 컴포넌트의 데이터가 로드된 후 자식 컴포넌트의 데이터를 요청하는 Network Waterfall 현상이 자주 발생했다. 클라이언트와 서버 간의 왕복(Round-trip)이 연속으로 일어나 화면 전체가 보이는데 오랜 시간이 걸렸다.
RSC를 활용하면 데이터 요청이 클라이언트-서버 간을 오가는 것이 아니라, 대역폭이 매우 넓고 지연 시간이 거의 없는 환경인 서버 내부에서 데이터 요청이 발생하기 때문에 지연 시간이 줄어든다. 또한 서버 내부에서 다수의 데이터 요청을 병렬로 처리한 뒤 완료된 UI트리를 한 번에 응답할 수 있어 네트워크 요청 횟수가 대폭 감소한다.
기존에는 React에서 필요한 데이터를 가져오기 위해 REST API나 GraphQL 등의 endpoint를 따로 구축해야 했다. 이 과정에서 DTO 정의, API 라우터 작성, 클라이언트 데이터 패칭 라이브러리(Tanstack Query, SWR 등) 세팅 등 단순히 데이터를 화면에 보여주기 위해 거쳐야 하는 과도한 보일러플레이트 코드가 양산되었다.
API 엔드포인트를 따로 작성하지 않고 서버 컴포넌트 내부에서 DB, 파일 시스템, 내부 마이크로서비스 API에 직접 접근하는 방법을 사용할 수 있게 되었다. API 키와 같은 민감한 정보도 클라이언트에 노출될 위험이 없다.
❓ 백엔드 리소스와 직접 연동하는 방법은 프론트엔드 개발자 / 백엔드 개발자가 명확히 나뉘어있는 조직에서는 사용하기 어렵지 않을까?
그렇다고 한다…. 풀스택 개발자들이나 마음껏 사용할 수 있는 방법이었다ㅠ 실무에서는 RSC를 백엔드 Microservice API를 호출하는 BFF용으로 사용하는 것이 더 적합하고 웬만해서는 사용할 일이 없는 듯 하다.
React Server Component의 기본 제약과 동작 원리는 동일하지만, 프레임워크에 따라 설정 방식과 작성 패턴에 차이가 있다.
https://nextjs.org/docs/app/getting-started/server-and-client-components
Next.js(App Router)에서는 RSC를 핵심 아키텍처로 채택하고 있어 모든 컴포넌트가 기본적으로 Server Component로 동작한다.
별도의 지시어 없이 작성하며, 컴포넌트 함수 자체를 async로 선언하여 서버 리소스에 직접 접근할 수 있다.
❗ 내부에
await하는 비동기 작업(DB 접근,fetch등)이 없다면async함수가 아닌 일반 함수로 작성하는 것이 더 자연스럽고 좋은 방법이다.
// app/users/page.tsx (기본적으로 Server Component)
import db from '@/lib/db';
export default async function UsersPage() {
const users = await db.user.findMany();
return (
<main>
<h1>사용자 목록</h1>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</main>
);
}
useState, useEffect, DOM 이벤트 등이 필요한 파일 최상단에 'use client'를 명시하여 Client Boundary를 형성한다.
'use client'를 선언한 컴포넌트의 자식 컴포넌트는 별도의 'use client' 선언 없이도 Client Boundary 내부에 포함된다.
import 가능import할 수 없으며 대신 children prop으로 주입받아 사용https://reactrouter.com/how-to/react-server-components
React Router v7은 기존 SPA/SSR 컴포넌트 생태계와의 호환성을 유지하기 때문에, 기본값은 기존 React 컴포넌트이며 RSC를 명시적으로 활성화/선언하여 사용해야 한다.
Framework Mode와 Data Mode에서 RSC를 사용 가능하며, 이 글에서는 Framework Mode에 대해서만 설명한다.
⚠️ 공식 문서에 따르면 RR7에서의 Server Component 지원은 아직 실험 단계이기 때문에 신중하게 사용해야 한다.
Vite 설정 파일(vite.config.ts)에서 @react-router/dev/vite의 unstable_reactRouterRSC 및 @vitejs/plugin-rsc 플러그인을 순서대로 등록한다.
import { defineConfig } from "vite";
import { unstable_reactRouterRSC as reactRouterRSC } from "@react-router/dev/vite";
import rsc from "@vitejs/plugin-rsc";
export default defineConfig({
plugins: [reactRouterRSC(), rsc()], // rsc() 플러그인이 reactRouterRSC() 뒤에 위치해야 함
});
RSC를 적용하지 않은 일반 라우트를 선언할 때는 컴포넌트를 default export 했지만, 서버 컴포넌트를 선언할 때는 ServerComponent를 명시적으로 export하여 해당 라우트를 서버 컴포넌트로 선언한다.
그 외 에러바운더리나 레이아웃도 서버/클라이언트용으로 나뉜다.
ServerComponent, ServerErrorBoundary, ServerLayout , ServerHydrateFallbackdefault, ErrorBoundary, Layout , HydrateFallback⚠️ 서버용
export와 클라이언트용export는 상호 배타적으로 동작하여 동시에 같이 사용할 수 없다.
import type { Route } from "./+types/route";
import { Outlet } from "react-router";
import { getMessage } from "./message";
export async function loader() {
return {
message: await getMessage(),
};
}
export function ServerComponent({
loaderData,
}: Route.ServerComponentProps) {
return (
<>
<h1>Server Component Route</h1>
<p>Message from the server: {loaderData.message}</p>
<Outlet />
</>
);
}
서버 컴포넌트 내부에서 클라이언트 컴포넌트를 직접 import하여 사용한다.
import { Counter } from "./counter";
export function ServerComponent() {
return (
<>
<h1>Counter</h1>
<Counter />
</>
);
}
RR7 RSC만의 독특한 특징으로, 클라이언트 컴포넌트의 loader나 action 함수에서 JSON 데이터 뿐만 아니라 React Element(서버 전용 UI)를 직접 반환할 수 있다.
// app/routes/user-profile.tsx
import type { Route } from "./+types/route";
import { db } from "~/db.server";
export async function loader({ params }: Route.LoaderArgs) {
const user = await db.user.findUnique({
where: { id: params.userId },
});
if (!user) {
throw new Response("User Not Found", { status: 404 });
}
return {
message: "Message from the server!",
element: (
<div className="user-card">
<p><strong>이름:</strong> {user.name}</p>
<p><strong>이메일:</strong> {user.email}</p>
<p><strong>가입일:</strong> {user.createdAt.toLocaleDateString()}</p>
</div>
),
};
}
export default function Route({
loaderData,
}: Route.ComponentProps) {
return (
<>
<h1>{loaderData.message}</h1>
{loaderData.element}
</>
);
}
Suspense를 활용한 부분 스트리밍페이지 전체를 Suspense로 감싸야 하는 기존 SSR과 다르게, RSC에서는 필요한 부분에만 Suspense를 적용하여 UX를 최적화할 수 있다.
import { Suspense } from 'react';
import FastHeader from '@/components/FastHeader';
import SlowAnalytics from '@/components/SlowAnalytics'; // DB 조회가 3초 걸리는 컴포넌트!!
import AnalyticsSkeleton from '@/components/AnalyticsSkeleton';
export default function DashboardPage() {
return (
<main>
{/* 1. 빠른 컴포넌트는 즉시 렌더링 */}
<FastHeader />
{/* 2. 느린 컴포넌트는 Suspense로 격리하여 스켈레톤 UI 제공 */}
<Suspense fallback={<AnalyticsSkeleton />}>
<SlowAnalytics />
</Suspense>
</main>
);
}
실수로 서버 전용 코드가 클라이언트 환경에서 사용되거나, 클라이언트 전용 코드가 서버 환경에서 사용되는 것을 환경 오염이라고 한다.
export async function getData() {
const res = await fetch('https://external-service.com/data', {
headers: {
authorization: process.env.API_KEY,
},
})
return res.json()
}
따라서 서버 전용 파일의 최상단에는 import 'server-only' 를, 클라이언트 전용 파일의 최상단에는 import 'client-only' 작성해두면 환경 오염이 발생했을 때 빌드타임에 에러가 발생하여 미리 환경 오염을 방지할 수 있다.
npm install server-only client-only
❓
use client와client-only의 차이는?
use client
- 역할: 클라이언트 컴포넌트로 동작/클라이언트 boundary 생성
- 클라이언트 전용 코드(React Hooks, 이벤트 핸들러, 브라우저 api) 사용 가능
- 서버 전용 코드 사용 불가
import 'client-only'
- 역할: 서버 컴포넌트의 import 차단
- 코드가 실행되는 방식이나 동작에는 영향을 미치지 않음
use() 훅을 사용한 Server → Client Promise 전달React 19에서 추가된 use() API를 활용하면 서버 컴포넌트에서 Promise를 await하는 게 아니라 Promise 자체를 바로 클라이언트 컴포넌트로 전달할 수 있다. 이를 통해 클라이언트 렌더링 blocking을 막고, 클라이언트 컴포넌트 내부에서 Suspense와 함께 사용하여 유연한 데이터 로딩 패턴을 만들 수 있다.
// 1. app/page.tsx (Server Component)
import { Suspense } from 'react';
import UserProfile from './UserProfile';
export default function Page() {
// await를 붙이지 않고 Promise 자체를 생성
const userPromise = fetchUserData();
return (
<Suspense fallback={<div>유저 정보 로딩 중...</div>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
// 2. app/UserProfile.tsx (Client Component)
'use client';
import { use } from 'react';
export default function UserProfile({ userPromise }: { userPromise: Promise<UserData> }) {
// use() Hook을 사용하여 Promise가 resolved될 때까지 대기 및 데이터 추출
const user = use(userPromise);
return <div>{user.name}님 환영합니다!</div>;
}
💡 RSC Payload란?
서버 컴포넌트 트리를 압축 및 직렬화한 데이터 포맷으로, 아래 내용들을 포함한다.
- 서버 컴포넌트의 렌더링 결과
- 클라이언트 컴포넌트가 들어갈 위치를 나타내는 Placeholder 및 실행할 자바스크립트 파일의 참조
- 서버 컴포넌트에서 클라이언트 컴포넌트로 전달할 props 값
| 비교 항목 | Server Component (RSC) | Client Component ('use client') |
|---|---|---|
| 기본 렌더링 위치 | 서버(Server)에서만 실행 | 서버(초기 SSR 시 HTML 생성) + 브라우저(Client) |
| JS 번들 용량 | 0바이트 (Zero Bundle Size) | 자바스크립트 코드가 번들에 포함됨 |
async/await 작성 | ⭕ 가능 (컴포넌트 함수 자체를 async로 작성) | ❌ 불가 |
| React Hooks | ❌ 불가 (useState, useEffect 등) | ⭕ 가능 (useState, useEffect, useRef 등) |
| 이벤트 핸들러 | ❌ 불가 (onClick, onChange 등) | ⭕ 가능 |
| 브라우저 API | ❌ 불가 (window, localStorage 등) | ⭕ 가능 (예: useEffect 내부) |
| 백엔드 자원 접근 | ⭕ 가능 (DB, 파일 시스템, API Key 등) | ❌ 불가 |
| 선언 방법 | 기본값(Next.js) 또는 ServerComponent export(RR7) | 파일 최상단에 'use client' 지시어 명시 |
marked), 날짜 가공(moment), 구문 강조(highlight.js) 등 (클라이언트 번들 용량 절감)onClick, onChange, onSubmit 등의 이벤트 핸들러 처리useState, useReducer, useContext 등을 통한 UI 상태 제어window, document, localStorage, canvas 제어 등useSearchParams, useRouter 등 사용 시'use client' 지시어는 페이지 전체나 최상단에 붙이지 말고, 실제 상호작용이 필요한 최하단 버튼/폼 컴포넌트에만 선언하여 클라이언트 자바스크립트 번들 용량을 최소화import하지 않고 children props로 주입받아 렌더링❓ Server First 규칙은 RR7에서는 적용하기 힘든 규칙 아닌가?
프레임워크 기본 설정값 관점에서는 RR7에 적용되지 않지만, RSC를 도입한 프로젝트의 설계 철학 관점에서는 RR7에서도 동일하게 적용되는 규칙이다. 서버 컴포넌트를 기본으로 가져가는 것이 번들 용량 절감에 유리하기 때문이다. 따라서 RR7에서도 RSC 모드를 활성화했다면 라우트를
ServerComponent로 먼저 설계하는 것이 권장된다.