React Server Components 실전 가이드: 번들 크기를 줄이고 useEffect 없이 데이터 페칭하기
useEffect로 데이터를 불러오는 코드를 작성할 때마다 이런 의문이 드신 적 있지 않으신가요? "로딩 상태 관리, 에러 처리, 의존성 배열... 단순히 데이터 하나를 보여주기 위해 이 모든 게 정말 필요한 걸까?" React Server Components(RSC)는 바로 이 질문에서 시작합니다. 서버에서 데이터를 직접 읽어 렌더링하면 될 것을 왜 클라이언트에서 다시 요청하는지를 근본적으로 재검토한 결과물입니다.
RSC는 React 19(2024년 12월 안정 버전)와 함께 공식 표준이 되었고, Next.js 15는 이를 App Router 아키텍처의 기본 패러다임으로 채택했습니다. State of React 2024 설문에 따르면 실제로 RSC를 사용해본 개발자는 아직 29%에 불과합니다. 기술에 대한 긍정적 인식은 절반을 넘지만, 학습 곡선과 프레임워크 의존성이라는 진입 장벽이 여전히 높다는 의미입니다. 이 글은 React 기본 사용 경험이 있고 Next.js App Router로 전환을 고민하는 개발자를 위해 작성되었습니다.
이 글을 읽고 나면 RSC가 기존 SSR과 어떻게 다른지, 번들 크기 감소와 데이터 페칭 단순화를 실제로 어떻게 달성하는지, 그리고 실무에서 자주 마주치는 함정을 어떻게 피하는지를 이해할 수 있습니다. RSC의 핵심은 "서버에서만 실행되는 컴포넌트"라는 단순한 개념이지만, 이를 제대로 이해하고 나면 번들 크기 감소, 데이터 페칭 단순화, 보안 강화를 동시에 얻을 수 있습니다.
목차
핵심 개념
RSC는 전통적인 SSR과 무엇이 다른가
기존 SSR(Server-Side Rendering)은 서버에서 HTML을 생성한 뒤 클라이언트로 전송하고, 이후 클라이언트에서 동일한 JavaScript를 다시 실행해 DOM에 이벤트 핸들러를 연결하는 hydration 과정을 거칩니다. 즉, 서버와 클라이언트 양쪽 모두에서 같은 컴포넌트 코드가 실행됩니다.
Hydration: SSR로 생성된 정적 HTML에 React가 이벤트 리스너와 상태를 연결하여 인터랙티브하게 만드는 과정입니다. 초기 HTML은 빠르게 표시되지만, JavaScript 번들이 로드되고 실행되기 전까지는 실제 인터랙션이 불가능합니다.
RSC는 다릅니다. 서버 컴포넌트는 서버에서만 실행되며, 해당 컴포넌트의 JavaScript 코드 자체가 클라이언트 번들에 포함되지 않습니다. 대신 내부적으로 RSC Payload(Flight Protocol) 이라는 직렬화 형식으로 컴포넌트 트리 정보를 클라이언트에 전달하고, 클라이언트는 이를 바탕으로 DOM을 조작합니다.
RSC Payload(Flight Protocol): 서버 컴포넌트가 렌더링한 결과를 클라이언트로 전달하기 위한 React 전용 직렬화 포맷입니다. HTML 문자열이 아닌 React의 내부 트리 표현을 전달하므로, 클라이언트 컴포넌트와의 병합이 가능합니다.
| 구분 | 전통 SSR | RSC |
|---|---|---|
| 서버 실행 | HTML 생성, 이후 hydration 필요 | 컴포넌트 자체가 서버에서만 실행 |
| 클라이언트 번들 | 모든 컴포넌트 코드 포함 | 서버 컴포넌트 코드 미포함 |
| 데이터 페칭 | useEffect + 클라이언트 API 호출 |
컴포넌트 내 async/await 직접 사용 |
| 인터랙션 | hydration 후 가능 | 클라이언트 컴포넌트 담당 |
컴포넌트 경계 규칙
Next.js App Router 기준으로 app/ 디렉터리 내 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 클라이언트 컴포넌트가 필요한 경우 파일 상단에 "use client" 지시어를 명시합니다.
// 서버 컴포넌트 (별도 지시어 없음 — 기본값)
async function ProductList() {
const products = await db.products.findMany(); // DB 직접 접근 가능
return (
<ul>
{products.map(p => <ProductCard key={p.id} product={p} />)}
</ul>
);
}// 클라이언트 컴포넌트
"use client";
import { useState } from "react";
function AddToCartButton({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false);
const handleAdd = async () => {
setLoading(true);
await addToCart(productId);
setLoading(false);
};
return (
<button onClick={handleAdd} disabled={loading}>
{loading ? "추가 중..." : "장바구니 추가"}
</button>
);
}컴포넌트 경계에서 가장 중요한 규칙은 다음과 같습니다.
- 서버 컴포넌트는 클라이언트 컴포넌트를
import하고 렌더링할 수 있습니다. - 클라이언트 컴포넌트에서 서버 컴포넌트를 직접
import하면, 해당 서버 컴포넌트는 자동으로 클라이언트 컴포넌트로 처리됩니다. 서버 전용 API(fs,prisma등)를 사용하는 경우에만 빌드 에러가 발생합니다. - children props 패턴을 활용하면 클라이언트 컴포넌트가 서버 컴포넌트를 감싸면서도 서버 컴포넌트로서의 기능을 유지할 수 있습니다.
// ✅ children props 패턴 — 클라이언트 컴포넌트가 서버 컴포넌트를 감쌀 수 있음
"use client";
import { useState } from "react";
function Collapsible({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "접기" : "펼치기"}
</button>
{isOpen && children}
</div>
);
}
// app/page.tsx — 서버 컴포넌트에서 조합
async function Page() {
return (
<Collapsible>
<ServerDataList /> {/* 서버 컴포넌트로 유지됨 */}
</Collapsible>
);
}
"use client"전파: 특정 파일에"use client"를 선언하면, 그 파일에서import되는 모든 모듈도 클라이언트 번들에 포함됩니다. 따라서 클라이언트 경계는 가능한 한 컴포넌트 트리의 말단 쪽에 배치하는 것이 번들 크기 최적화에 유리합니다.
Suspense와 스트리밍 렌더링
RSC의 가장 강력한 UX 이점 중 하나는 Suspense 기반 스트리밍 렌더링입니다. 서버 컴포넌트를 <Suspense>로 감싸면, 데이터를 불러오는 동안 fallback UI를 즉시 표시하고 데이터가 준비되면 해당 부분만 스트리밍으로 업데이트합니다. 페이지 전체를 블로킹하지 않고 콘텐츠를 점진적으로 표시할 수 있습니다.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { UserStats } from "./UserStats";
import { RecentOrders } from "./RecentOrders";
export default function DashboardPage() {
return (
<div>
<h1>대시보드</h1>
{/* 빠른 데이터: 먼저 표시 */}
<Suspense fallback={<StatsSkeleton />}>
<UserStats /> {/* 서버 컴포넌트 */}
</Suspense>
{/* 느린 데이터: 독립적으로 로딩 */}
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders /> {/* 서버 컴포넌트 */}
</Suspense>
</div>
);
}Next.js App Router에서는 app/ 디렉터리에 loading.tsx 파일을 생성하는 것만으로 자동으로 <Suspense> 경계가 적용됩니다.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />; // 데이터 로딩 중 표시할 UI
}LCP (Largest Contentful Paint): 페이지에서 가장 큰 콘텐츠 요소가 화면에 렌더링되는 시점을 측정하는 Core Web Vitals 지표입니다. 2.5초 이하가 권장 기준이며, RSC와 Suspense 스트리밍을 함께 사용하면 초기 콘텐츠를 빠르게 전달하여 이 수치 개선에 직접적으로 기여합니다.
실전 적용
예시 1: 대형 라이브러리를 서버에서만 실행하기
Markdown 파서나 구문 강조 라이브러리처럼 수백 KB에 달하는 패키지를 클라이언트 번들에 포함하면 초기 로딩이 느려집니다. "이 무거운 라이브러리를 굳이 브라우저에서 실행해야 할까?"라는 질문이 서버 컴포넌트의 출발점입니다. 서버 컴포넌트에서만 import하면 해당 라이브러리는 클라이언트 번들에서 완전히 제외됩니다.
// app/blog/[slug]/page.tsx — 서버 컴포넌트
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import rehypeSanitize from "rehype-sanitize"; // XSS 방지 필수
import rehypeStringify from "rehype-stringify";
import { notFound } from "next/navigation";
async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // Next.js 15: params는 Promise 타입
const post = await prisma.post.findUnique({ where: { slug } });
if (!post) notFound();
// unified, rehype-* 모두 서버에서만 실행 — 클라이언트 번들에 포함되지 않음
const result = await unified()
.use(remarkParse)
.use(remarkRehype)
.use(rehypeSanitize) // 외부 입력(DB, CMS)의 XSS 위험 제거
.use(rehypeStringify)
.process(post.content);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: String(result) }} />
</article>
);
}| 포인트 | 설명 |
|---|---|
unified, rehype-* import |
서버 컴포넌트이므로 클라이언트 번들에서 완전히 제외 |
rehype-sanitize |
데이터베이스나 CMS 등 외부 입력의 XSS 위험 제거 — 생략 시 보안 취약점 발생 |
prisma 직접 호출 |
API 레이어 없이 DB 직접 접근 가능 |
await params |
Next.js 15에서 params는 Promise 타입으로 변경됨 |
예시 2: 하이브리드 페이지 — 서버와 클라이언트 컴포넌트 조합
사용자 프로필 페이지처럼 데이터 표시(서버)와 인터랙션(클라이언트)이 혼재하는 경우가 실무에서 가장 흔한 패턴입니다. "데이터를 보여주는 부분"과 "사용자가 클릭하는 부분"을 구분해서 설계하면 서버/클라이언트 경계가 자연스럽게 그려집니다.
// app/profile/[userId]/page.tsx — 서버 컴포넌트가 레이아웃 담당
async function ProfilePage({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params; // Next.js 15 기준
try {
// 서버에서 병렬로 데이터 페칭
const [user, posts] = await Promise.all([
getUser(userId),
getPosts(userId),
]);
return (
<div className="profile">
<UserInfo user={user} /> {/* 서버 컴포넌트: 정적 정보 표시 */}
<FollowButton userId={user.id} /> {/* 클라이언트 컴포넌트: 팔로우 인터랙션 */}
<PostList posts={posts} /> {/* 서버 컴포넌트: 게시물 목록 */}
</div>
);
} catch (error) {
throw error; // Next.js error boundary(error.tsx)로 위임
}
}// components/FollowButton.tsx — 클라이언트 컴포넌트
"use client";
import { useState, useTransition } from "react";
import { followUser } from "@/actions/user"; // Server Action
function FollowButton({ userId }: { userId: string }) {
const [isFollowing, setIsFollowing] = useState(false);
const [isPending, startTransition] = useTransition();
const handleFollow = () => {
startTransition(async () => {
await followUser(userId);
setIsFollowing(true);
});
};
return (
<button onClick={handleFollow} disabled={isPending}>
{isPending ? "처리 중..." : isFollowing ? "팔로잉" : "팔로우"}
</button>
);
}Server Actions (
"use server"): 클라이언트에서 호출할 수 있는 서버 측 함수입니다.followUser처럼 mutation 처리에 사용하며, 별도 API 엔드포인트 없이 서버 로직을 직접 호출할 수 있습니다.
예시 3: TanStack Query와의 조합으로 캐싱 전략 구성
서버 컴포넌트가 초기 데이터 로딩을 담당하고, 이후 실시간 갱신이나 낙관적 업데이트가 필요한 경우는 어떻게 처리할까요? TanStack Query를 함께 사용하면 서버의 빠른 초기 렌더링과 클라이언트의 유연한 캐싱을 모두 얻을 수 있습니다. 서버에서 prefetch된 데이터를 클라이언트 QueryClient로 이어받아 추가 네트워크 요청 없이 즉시 화면을 표시합니다.
// lib/dashboard.server.ts — 서버 전용 데이터 접근 함수 (DB 직접 쿼리)
export async function getDashboardStats() {
return prisma.dashboardStats.findFirst({ orderBy: { createdAt: "desc" } });
}
// lib/dashboard.client.ts — 클라이언트에서 사용하는 API 함수
export async function fetchDashboardStats() {
const res = await fetch("/api/dashboard/stats");
if (!res.ok) throw new Error("대시보드 데이터 로딩 실패");
return res.json();
}// app/dashboard/page.tsx — 서버 컴포넌트
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query";
import { getDashboardStats } from "@/lib/dashboard.server";
import { DashboardClient } from "./DashboardClient";
async function DashboardPage() {
const queryClient = new QueryClient();
// 서버에서 미리 데이터 페칭 (초기 HTML에 포함)
await queryClient.prefetchQuery({
queryKey: ["dashboard-stats"],
queryFn: getDashboardStats, // 서버 전용 DB 접근 함수
});
return (
// 서버 데이터를 클라이언트 QueryClient로 전달
<HydrationBoundary state={dehydrate(queryClient)}>
<DashboardClient />
</HydrationBoundary>
);
}// app/dashboard/DashboardClient.tsx — 클라이언트 컴포넌트
"use client";
import { useQuery } from "@tanstack/react-query";
import { fetchDashboardStats } from "@/lib/dashboard.client";
function DashboardClient() {
// 서버에서 prefetch된 데이터를 즉시 사용 (초기 네트워크 요청 없음)
// staleTime이 지나면 fetchDashboardStats()로 자동 재검증
const { data, error } = useQuery({
queryKey: ["dashboard-stats"],
queryFn: fetchDashboardStats, // 클라이언트 API 함수
staleTime: 30 * 1000, // 30초
});
if (error) return <ErrorDisplay message={error.message} />;
return <StatsDisplay stats={data} />;
}장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 번들 크기 감소 | 서버 컴포넌트와 의존 라이브러리가 클라이언트 번들에서 제외됩니다. @next/bundle-analyzer로 측정 시 프로젝트 특성에 따라 18~29% 감소가 보고됩니다. |
| 데이터 페칭 단순화 | useEffect + useState 없이 컴포넌트 내에서 async/await로 직접 데이터를 요청할 수 있습니다. |
| 초기 로딩 성능 | LCP(최대 콘텐츠풀 페인트)가 개선되고 클라이언트의 JavaScript 파싱·실행 부담이 줄어듭니다. |
| 스트리밍 렌더링 | <Suspense>와 결합하여 데이터가 준비된 부분부터 점진적으로 표시할 수 있습니다. |
| SEO 향상 | 완성된 HTML이 초기 응답에 포함되어 검색 엔진 크롤러가 JavaScript 실행 없이도 콘텐츠를 인덱싱할 수 있습니다. |
| 보안 | API 키, DB 연결 문자열 등이 서버 컴포넌트 내에서만 처리되어 클라이언트에 노출되지 않습니다. |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 프레임워크 의존성 | 순수 React만으로는 사용할 수 없고 Next.js 등 별도 프레임워크가 필요합니다. | Next.js App Router 또는 TanStack Start 도입을 검토합니다. |
| 학습 곡선 | 서버/클라이언트 경계, "use client" 전파 규칙, children props 패턴 등 새로운 멘탈 모델이 필요합니다. |
공식 Next.js 문서의 "Rendering" 섹션부터 순서대로 학습하는 것을 권장합니다. |
| 보안 취약점 | 2025년에 Flight Protocol 역직렬화 관련 보안 취약점이 보고되었습니다. | 프레임워크를 최신 버전으로 유지하고 외부 입력에 대한 검증을 철저히 합니다. |
| 인터랙티브 앱에 제한적 | 에디터, 실시간 대시보드처럼 인터랙션이 많은 앱은 대부분 클라이언트 컴포넌트가 필요해 효과가 제한됩니다. | 데이터 표시 레이어와 인터랙션 레이어를 명확히 분리하여 설계합니다. |
| 캐싱 전략 이해 필요 | Next.js의 다층 캐싱(Request Memoization, Data Cache, Full Route Cache)을 이해하지 못하면 예측 불가능한 동작이 발생할 수 있습니다. | cache(), revalidate, no-store 옵션을 명시적으로 지정하는 습관을 들입니다. |
자주 하는 실수
"use client"경계를 너무 상위에 배치하는 경우: 부모 컴포넌트에"use client"를 선언하면 하위 컴포넌트 전체가 클라이언트 번들에 포함됩니다. 예를 들어 버튼 하나 때문에 페이지 전체를 클라이언트 컴포넌트로 만드는 대신, 버튼 컴포넌트만 분리해서"use client"를 선언하면 나머지 트리는 서버 컴포넌트로 유지됩니다.- 서버 컴포넌트에서
useState,useEffect사용 시도: 서버 컴포넌트는 브라우저 API와 React 훅을 사용할 수 없습니다. 빌드 시 오류가 발생하므로, 상태나 side effect가 필요한 경우 해당 로직을 클라이언트 컴포넌트로 분리하는 것이 필요합니다. - 캐싱을 명시하지 않아 예기치 않은 stale 데이터가 반환되는 경우: Next.js는 기본적으로 fetch 요청을 캐싱합니다. 항상 최신 데이터가 필요한 경우
fetch(url, { cache: "no-store" })를 명시하거나, 특정 주기로 갱신이 필요한 경우{ next: { revalidate: 60 } }처럼 명확히 지정하는 것을 권장합니다. - 클라이언트 컴포넌트에서 서버 컴포넌트를
import하면 기능을 잃는다는 점을 인지하지 못하는 경우: 클라이언트 컴포넌트에서 서버 컴포넌트를 직접import하면 빌드 에러는 발생하지 않지만, 해당 서버 컴포넌트는 클라이언트 컴포넌트로 처리됩니다. 서버 전용 API를 사용하는 경우에만 빌드 에러가 발생하므로 문제를 놓치기 쉽습니다. 서버 컴포넌트의 이점을 유지하려면 앞서 소개한 children props 패턴을 활용하는 것이 좋습니다.
마치며
RSC를 도입할 때 진짜 결정은 "서버 컴포넌트를 쓸지 말지"가 아니라, "서버와 클라이언트의 경계를 어디에 그을지"입니다. 데이터를 읽어 표시하는 부분은 서버로, 사용자와 상호작용하는 부분은 클라이언트로 — 이 분리를 명확히 설계할수록 번들 크기 감소와 데이터 페칭 단순화는 자연스럽게 따라옵니다.
지금 바로 시작해볼 수 있는 3단계:
- 새 프로젝트로 App Router 체험:
pnpm create next-app@latest명령어를 실행하면 인터랙티브 CLI가 실행되며 App Router 사용 여부를 묻습니다. App Router를 선택하면app/page.tsx가 기본 서버 컴포넌트로 생성됩니다.async함수로 만들어await fetch()를 직접 사용해보시면useEffect방식과의 차이를 바로 느낄 수 있습니다. - 기존
useEffect데이터 페칭 하나를 서버 컴포넌트로 마이그레이션: 현재 운영 중인 프로젝트에서useEffect로 데이터를 불러오는 컴포넌트를 하나 선택해 서버 컴포넌트로 전환해보시면 좋습니다.@next/bundle-analyzer로 변화 전후 번들 크기를 비교해보시는 것도 좋은 방법입니다. - 공식 문서의 Data Fetching 섹션 정독: Next.js 공식 문서의 캐싱 전략 부분을 읽어보시면
cache(),revalidate,Suspense스트리밍의 관계를 체계적으로 이해할 수 있습니다. 캐싱 전략을 이해하는 것이 RSC를 예측 가능하게 사용하는 핵심 열쇠입니다.
다음 글: Server Actions 완전 정복 — RSC가 "어디서 데이터를 가져올지"를 바꿨다면, Server Actions는 "어떻게 데이터를 보낼지"를 바꿉니다. 폼 제출부터 낙관적 업데이트까지, 별도 API 엔드포인트 없이 서버 로직을 직접 호출하는 새로운 패턴을 다음 글에서 다룹니다.
참고 자료
- React 공식 문서 - Server Components
- Next.js 공식 문서 - Server Components
- Making Sense of React Server Components - Josh W. Comeau
- React Trends in 2025 - Robin Wieruch
- React Server Components in Production: Benefits, Pitfalls and Best Practices for 2026 - Growin
- The Complete Guide to React Server Components: Mental Models for 2025 - DEV Community
- RSC(리액트 서버 컴포넌트) 동작 원리부터 성능 최적화까지
- React Server Components + TanStack Query: The 2026 Data-Fetching Power Duo - DEV Community
- TanStack Blog - React Server Components Your Way
- React Server Components: the Good, the Bad, and the Ugly - mayank.co