React Server Components: 멘탈 모델, 패턴, 그리고 함정들
제가 처음 시작할 때 있었으면 했던 React Server Components 실전 가이드. 멘탈 모델, 실제 패턴, 경계 문제, 그리고 여러분이 겪지 않도록 제가 먼저 저지른 실수들을 다룹니다.
React Server Components를 처음 사용한 3개월 동안 저는 이해하고 있다고 생각했습니다. RFC도 읽었고, 컨퍼런스 발표도 봤고, 데모 앱도 몇 개 만들어봤습니다. 거의 모든 것에 대해 자신 있게 틀리고 있었습니다.
문제는 RSC가 복잡하다는 게 아닙니다. 멘탈 모델이 React에서 지금까지 해왔던 것과 근본적으로 다르다는 것이고, 저를 포함한 모든 사람이 기존 틀에 끼워 맞추려 한다는 겁니다. "SSR이랑 비슷하잖아." 아닙니다. "PHP랑 비슷하잖아." 좀 더 가깝긴 하지만, 아닙니다. "그냥 서버에서 실행되는 컴포넌트잖아." 기술적으로는 맞지만, 실질적으로는 쓸모없는 설명입니다.
이 글은 제가 실제로 알아야 했던 모든 것을, 누군가가 이렇게 설명해줬으면 했던 방식으로 작성한 것입니다. 이론 버전이 아닙니다. 밤 11시에 직렬화 에러를 뚫어져라 바라보면서 왜 그런지 이해해야 하는 그 상황을 위한 글입니다.
실제로 작동하는 멘탈 모델#
잠시 React 렌더링에 대해 알고 있는 모든 것을 잊어보세요. 새로운 그림은 이렇습니다.
기존 React(클라이언트 사이드)에서는 전체 컴포넌트 트리가 JavaScript로 브라우저에 전달됩니다. 브라우저가 다운로드하고, 파싱하고, 실행하고, 결과를 렌더링합니다. 200줄짜리 인터랙티브 폼이든 정적 텍스트 단락이든, 모든 컴포넌트가 동일한 파이프라인을 거칩니다.
React Server Components는 이것을 두 세계로 나눕니다:
Server Components는 서버에서 실행됩니다. 한 번 실행되고, 출력을 생성한 뒤, 결과를 클라이언트에 보냅니다 — 코드가 아니라요. 브라우저는 컴포넌트 함수를 보지도, 의존성을 다운로드하지도, 다시 렌더링하지도 않습니다.
Client Components는 기존 React처럼 동작합니다. 브라우저에 전달되고, 하이드레이트되고, 상태를 유지하고, 이벤트를 처리합니다. 여러분이 이미 알고 있는 React입니다.
저한테 부끄러울 정도로 오래 걸린 핵심 인사이트: Server Components가 기본값입니다. Next.js App Router에서 모든 컴포넌트는 "use client"로 명시적으로 클라이언트에 옵트인하지 않는 한 Server Component입니다. 이것은 우리가 익숙한 것과 반대이며, 컴포지션에 대한 사고방식을 바꿉니다.
렌더링 워터폴#
사용자가 페이지를 요청하면 실제로 일어나는 일은 이렇습니다:
1. Request hits the server
2. Server executes Server Components top-down
3. When a Server Component hits a "use client" boundary,
it stops — that subtree will render on the client
4. Server Components produce RSC Payload (a special format)
5. RSC Payload streams to the client
6. Client renders Client Components, stitching them into
the server-rendered tree
7. Hydration makes Client Components interactive
4단계가 대부분의 혼란이 발생하는 지점입니다. RSC Payload는 HTML이 아닙니다. 컴포넌트 트리를 설명하는 특수한 스트리밍 형식입니다 — 서버가 무엇을 렌더링했고, 클라이언트가 어디서 넘겨받아야 하고, 경계를 넘어 어떤 props를 전달할지를 담고 있습니다.
대략 이런 모습입니다 (단순화):
M1:{"id":"./src/components/Counter.tsx","chunks":["272:static/chunks/272.js"],"name":"Counter"}
S0:"$Sreact.suspense"
J0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome"}],["$","$L1",null,{"initialCount":0}]]}]
이 형식을 암기할 필요는 없습니다. 하지만 이것이 존재한다는 것 — 서버와 클라이언트 사이에 직렬화 계층이 있다는 것 — 을 이해하면 수시간의 디버깅을 아낄 수 있습니다. "Props must be serializable" 에러가 나올 때마다, 그것은 전달하려는 무언가가 이 변환 과정을 살아남을 수 없기 때문입니다.
"서버에서 실행된다"가 정말 의미하는 것#
Server Component가 "서버에서 실행된다"고 말할 때, 문자 그대로를 의미합니다. 컴포넌트 함수가 Node.js(또는 Edge 런타임)에서 실행됩니다. 이것은 다음과 같은 것이 가능하다는 뜻입니다:
// app/dashboard/page.tsx — this is a Server Component by default
import { db } from "@/lib/database";
import { headers } from "next/headers";
export default async function DashboardPage() {
const headerList = await headers();
const userId = headerList.get("x-user-id");
// Direct database query. No API route needed.
const user = await db.user.findUnique({
where: { id: userId },
});
const recentOrders = await db.order.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<div>
<h1>Welcome back, {user.name}</h1>
<OrderList orders={recentOrders} />
</div>
);
}useEffect도 없고, 로딩 상태 관리도 없고, 연결을 위한 API 라우트도 없습니다. 컴포넌트 자체가 데이터 레이어입니다. 이것이 RSC의 가장 큰 장점이고, 처음에는 가장 불편하게 느껴졌던 부분입니다. "그런데 관심사 분리는 어디 간 거지?"라고 계속 생각했거든요.
분리는 "use client" 경계입니다. 그 위는 전부 서버이고, 그 아래는 전부 클라이언트입니다. 그게 여러분의 아키텍처입니다.
서버/클라이언트 경계#
대부분의 사람들이 이해가 무너지는 지점이며, 제가 처음 몇 달간 가장 많은 디버깅 시간을 보낸 곳입니다.
"use client" 디렉티브#
파일 상단의 "use client" 디렉티브는 해당 파일에서 내보내는 모든 것을 Client Component로 표시합니다. 이것은 모듈 수준 어노테이션이지, 컴포넌트 수준이 아닙니다.
// src/components/Counter.tsx
"use client";
import { useState } from "react";
// This entire file is now "client territory"
export function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// This is ALSO a Client Component because it's in the same file
export function ResetButton({ onReset }: { onReset: () => void }) {
return <button onClick={onReset}>Reset</button>;
}흔한 실수: 모든 것을 재내보내기하는 배럴 파일(index.ts)에 "use client"를 넣는 것입니다. 축하합니다, 전체 컴포넌트 라이브러리를 클라이언트 사이드로 만들어버린 겁니다. 이렇게 해서 실수로 200KB의 JavaScript를 전달하는 팀을 본 적 있습니다.
경계를 넘는 것들#
여러분을 구해줄 규칙은 이것입니다: 서버-클라이언트 경계를 넘는 모든 것은 JSON으로 직렬화 가능해야 합니다.
직렬화 가능한 것:
- 문자열, 숫자, 불리언, null, undefined
- 배열과 일반 객체 (직렬화 가능한 값을 포함하는)
- Date (ISO 문자열로 직렬화됨)
- Server Components (JSX로 — 이건 나중에 다루겠습니다)
- FormData
- 타입드 배열, ArrayBuffer
직렬화 불가능한 것:
- 함수 (이벤트 핸들러 포함)
- 클래스 (커스텀 클래스의 인스턴스)
- Symbol
- DOM 노드
- Stream (대부분의 상황에서)
다시 말해 이런 건 안 됩니다:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList"; // Client Component
export default async function Page() {
const items = await getItems();
return (
<ItemList
items={items}
// ERROR: Functions are not serializable
onItemClick={(id) => console.log(id)}
// ERROR: Class instances are not serializable
formatter={new Intl.NumberFormat("en-US")}
/>
);
}해결책은 페이지를 Client Component로 만드는 게 아닙니다. 인터랙티비티는 아래로 밀어내고 데이터 페칭은 위로 끌어올리는 것입니다:
// app/page.tsx (Server Component)
import { ItemList } from "@/components/ItemList";
export default async function Page() {
const items = await getItems();
// Only pass serializable data
return <ItemList items={items} locale="en-US" />;
}// src/components/ItemList.tsx (Client Component)
"use client";
import { useState, useMemo } from "react";
interface Item {
id: string;
name: string;
price: number;
}
export function ItemList({ items, locale }: { items: Item[]; locale: string }) {
const [selectedId, setSelectedId] = useState<string | null>(null);
// Create the formatter on the client side
const formatter = useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }),
[locale]
);
return (
<ul>
{items.map((item) => (
<li
key={item.id}
onClick={() => setSelectedId(item.id)}
className={selectedId === item.id ? "selected" : ""}
>
{item.name} — {formatter.format(item.price)}
</li>
))}
</ul>
);
}"아일랜드" 오해#
처음에 저는 Client Components를 "아일랜드"로 생각했습니다 — 서버 렌더링 콘텐츠 바다 위의 작은 인터랙티브 조각들요. 부분적으로는 맞지만 중요한 디테일을 놓치고 있습니다: Client Component는 children이나 props로 전달받으면 Server Components를 렌더링할 수 있습니다.
이것은 경계가 딱딱한 벽이 아니라는 뜻입니다. 오히려 막(membrane)에 가깝습니다. 서버 렌더링된 콘텐츠가 children 패턴을 통해 Client Components를 관통하여 흐를 수 있습니다. 이건 컴포지션 섹션에서 자세히 다루겠습니다.
데이터 페칭 패턴#
RSC는 데이터 페칭을 근본적으로 바꿉니다. 렌더링 시점에 이미 알 수 있는 데이터를 위한 useEffect + useState + 로딩 상태는 더 이상 필요 없습니다. 하지만 새로운 패턴에는 고유한 함정이 있습니다.
캐싱을 적용한 기본 Fetch#
Server Component에서는 그냥 fetch하면 됩니다. Next.js가 전역 fetch를 확장해서 캐싱을 추가합니다:
// app/products/page.tsx
export default async function ProductsPage() {
// Cached by default — same URL returns cached result
const res = await fetch("https://api.example.com/products");
const products = await res.json();
return (
<div>
{products.map((product: Product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}캐싱 동작을 명시적으로 제어합니다:
// Revalidate every 60 seconds (ISR-like behavior)
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// No caching — always fresh data
const res = await fetch("https://api.example.com/user/profile", {
cache: "no-store",
});
// Cache with tags for on-demand revalidation
const res = await fetch("https://api.example.com/products", {
next: { tags: ["products"] },
});그런 다음 Server Action에서 태그로 재검증할 수 있습니다:
"use server";
import { revalidateTag } from "next/cache";
export async function refreshProducts() {
revalidateTag("products");
}병렬 데이터 페칭#
제가 가장 많이 보는 성능 실수: 병렬로 해도 되는데 순차적으로 데이터를 가져오는 것.
나쁜 예 — 순차적 (워터폴):
// app/dashboard/page.tsx — DON'T DO THIS
export default async function Dashboard() {
const user = await getUser(); // 200ms
const orders = await getOrders(); // 300ms
const notifications = await getNotifications(); // 150ms
// Total: 650ms — each waits for the previous one
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}좋은 예 — 병렬:
// app/dashboard/page.tsx — DO THIS
export default async function Dashboard() {
// All three fire simultaneously
const [user, orders, notifications] = await Promise.all([
getUser(), // 200ms
getOrders(), // 300ms (runs in parallel)
getNotifications(), // 150ms (runs in parallel)
]);
// Total: ~300ms — limited by the slowest request
return (
<div>
<UserInfo user={user} />
<OrderList orders={orders} />
<NotificationBell notifications={notifications} />
</div>
);
}더 나은 예 — 독립적인 Suspense 경계와 함께 병렬로:
// app/dashboard/page.tsx — BEST
import { Suspense } from "react";
export default function Dashboard() {
// Note: this component is NOT async — it delegates to children
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<OrderListSkeleton />}>
<OrderList />
</Suspense>
<Suspense fallback={<NotificationsSkeleton />}>
<Notifications />
</Suspense>
</div>
);
}
// Each component fetches its own data
async function UserInfo() {
const user = await getUser();
return <div>{user.name}</div>;
}
async function OrderList() {
const orders = await getOrders();
return <ul>{orders.map(o => <li key={o.id}>{o.title}</li>)}</ul>;
}
async function Notifications() {
const notifications = await getNotifications();
return <span>({notifications.length})</span>;
}이 마지막 패턴이 가장 강력한 이유는 각 섹션이 독립적으로 로드되기 때문입니다. 사용자는 콘텐츠가 준비되는 대로 볼 수 있으며, 전부 아니면 아무것도 아닌 방식이 아닙니다. 빠른 섹션이 느린 섹션을 기다리지 않습니다.
요청 중복 제거#
Next.js는 단일 렌더링 패스 동안 동일한 URL과 옵션을 가진 fetch 호출을 자동으로 중복 제거합니다. 이것은 불필요한 요청을 피하기 위해 데이터 페칭을 상위로 끌어올릴 필요가 없다는 뜻입니다:
// Both of these components can fetch the same URL
// and Next.js will only make ONE actual HTTP request
async function Header() {
const user = await fetch("/api/user").then(r => r.json());
return <nav>Welcome, {user.name}</nav>;
}
async function Sidebar() {
// Same URL — automatically deduped, not a second request
const user = await fetch("/api/user").then(r => r.json());
return <aside>Role: {user.role}</aside>;
}중요한 주의사항: 이것은 fetch에서만 동작합니다. ORM이나 데이터베이스 클라이언트를 직접 사용하는 경우에는 React의 cache() 함수를 사용해야 합니다:
import { cache } from "react";
import { db } from "@/lib/database";
// Wrap your data function with cache()
// Now multiple calls in the same render = one actual query
export const getUser = cache(async (userId: string) => {
return db.user.findUnique({ where: { id: userId } });
});cache()는 단일 서버 요청의 수명 동안만 중복 제거합니다. 영구적인 캐시가 아닙니다 — 요청별 메모이제이션입니다. 요청이 끝나면 캐시된 값은 가비지 컬렉션됩니다.
컴포넌트 컴포지션 패턴#
패턴을 이해하고 나면 RSC가 진정으로 우아해지는 부분입니다. 그리고 이해하기 전까지는 진정으로 혼란스러운 부분이기도 합니다.
"Children을 구멍으로" 패턴#
RSC에서 가장 중요한 컴포지션 패턴이며, 저한테는 완전히 이해하는 데 몇 주가 걸렸습니다. 문제는 이것입니다: 레이아웃이나 인터랙티비티를 제공하는 Client Component가 있는데, 그 안에 Server Components를 렌더링하고 싶은 경우입니다.
Client Component 파일에서 Server Component를 import할 수는 없습니다. "use client"를 추가하는 순간, 해당 모듈의 모든 것이 클라이언트 사이드가 됩니다. 하지만 children으로 Server Components를 전달할 수는 있습니다:
// src/components/Sidebar.tsx — Client Component
"use client";
import { useState } from "react";
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside className={isOpen ? "w-64" : "w-0"}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "Close" : "Open"}
</button>
{isOpen && (
<div className="sidebar-content">
{/* These children can be Server Components! */}
{children}
</div>
)}
</aside>
);
}// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "@/components/Sidebar";
import { NavigationLinks } from "@/components/NavigationLinks"; // Server Component
import { UserProfile } from "@/components/UserProfile"; // Server Component
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar>
{/* These are Server Components, passed through a Client Component */}
<UserProfile />
<NavigationLinks />
</Sidebar>
<main>{children}</main>
</div>
);
}이것이 동작하는 이유는 Server Components(UserProfile, NavigationLinks)가 서버에서 먼저 렌더링된 다음, 그 출력(RSC payload)이 children으로 Client Component에 전달되기 때문입니다. Client Component는 그것들이 Server Components였다는 사실을 알 필요 없이, 그냥 사전 렌더링된 React 노드를 받을 뿐입니다.
children을 서버 렌더링된 콘텐츠가 통과할 수 있는 Client Component 안의 "구멍"으로 생각하면 됩니다.
Server Components를 Props로 전달#
children 패턴은 React.ReactNode를 받는 모든 prop으로 일반화됩니다:
// src/components/TabLayout.tsx — Client Component
"use client";
import { useState } from "react";
interface TabLayoutProps {
tabs: { label: string; content: React.ReactNode }[];
}
export function TabLayout({ tabs }: TabLayoutProps) {
const [activeTab, setActiveTab] = useState(0);
return (
<div>
<div className="tab-bar" role="tablist">
{tabs.map((tab, i) => (
<button
key={i}
role="tab"
aria-selected={i === activeTab}
onClick={() => setActiveTab(i)}
className={i === activeTab ? "active" : ""}
>
{tab.label}
</button>
))}
</div>
<div role="tabpanel">{tabs[activeTab].content}</div>
</div>
);
}// app/settings/page.tsx — Server Component
import { TabLayout } from "@/components/TabLayout";
import { ProfileSettings } from "./ProfileSettings"; // Server Component — can fetch data
import { BillingSettings } from "./BillingSettings"; // Server Component — can fetch data
import { SecuritySettings } from "./SecuritySettings"; // Server Component — can fetch data
export default function SettingsPage() {
return (
<TabLayout
tabs={[
{ label: "Profile", content: <ProfileSettings /> },
{ label: "Billing", content: <BillingSettings /> },
{ label: "Security", content: <SecuritySettings /> },
]}
/>
);
}각 설정 컴포넌트가 자체 데이터를 가져오는 async Server Component가 될 수 있습니다. Client Component(TabLayout)는 탭 전환만 처리합니다. 이것은 엄청나게 강력한 패턴입니다.
비동기 Server Components#
Server Components는 async일 수 있습니다. 이것은 굉장히 중요한데, 데이터 페칭이 사이드 이펙트가 아니라 렌더링 중에 발생한다는 뜻이기 때문입니다:
// This is valid and beautiful
async function BlogPost({ slug }: { slug: string }) {
const post = await db.post.findUnique({ where: { slug } });
if (!post) return notFound();
const author = await db.user.findUnique({ where: { id: post.authorId } });
return (
<article>
<h1>{post.title}</h1>
<p>By {author.name}</p>
<div dangerouslySetInnerHTML={{ __html: post.htmlContent }} />
</article>
);
}Client Components는 async가 될 수 없습니다. "use client" 컴포넌트를 async로 만들려 하면 React가 에러를 던집니다. 이것은 확고한 제약입니다.
Suspense 경계: 스트리밍 프리미티브#
Suspense는 RSC에서 스트리밍을 얻는 방법입니다. Suspense 경계 없이는, 전체 페이지가 가장 느린 비동기 컴포넌트를 기다립니다. Suspense가 있으면, 각 섹션이 독립적으로 스트리밍됩니다:
// app/page.tsx
import { Suspense } from "react";
import { HeroSection } from "@/components/HeroSection";
import { ProductGrid } from "@/components/ProductGrid";
import { ReviewCarousel } from "@/components/ReviewCarousel";
import { RecommendationEngine } from "@/components/RecommendationEngine";
export default function HomePage() {
return (
<main>
{/* Static — renders immediately */}
<HeroSection />
{/* Fast data — shows quickly */}
<Suspense fallback={<ProductGridSkeleton />}>
<ProductGrid />
</Suspense>
{/* Medium speed — shows when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewCarousel />
</Suspense>
{/* Slow (ML-powered) — shows last, doesn't block the rest */}
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendationEngine />
</Suspense>
</main>
);
}사용자는 HeroSection을 즉시 보고, 그 다음 ProductGrid가 스트리밍으로 들어오고, 리뷰가 들어오고, 추천이 들어옵니다. 각 Suspense 경계가 독립적인 스트리밍 지점입니다.
Suspense 경계를 중첩하는 것도 유효하며 유용합니다:
<Suspense fallback={<DashboardSkeleton />}>
<Dashboard>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentTransactions />
</Suspense>
</Dashboard>
</Suspense>Dashboard는 빠르지만 RevenueChart가 느린 경우, 외부 Suspense가 먼저 resolve되고(대시보드 셸이 표시됨), 차트에 대한 내부 Suspense는 나중에 resolve됩니다.
Suspense와 Error Boundary#
Suspense와 error.tsx를 조합하면 회복력 있는 UI를 만들 수 있습니다:
// app/dashboard/error.tsx — Client Component (must be)
"use client";
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="p-8 text-center">
<h2>Something went wrong loading the dashboard</h2>
<p className="text-gray-500">{error.message}</p>
<button onClick={reset} className="mt-4 px-4 py-2 bg-blue-600 text-white rounded">
Try again
</button>
</div>
);
}error.tsx 파일은 해당 라우트 세그먼트를 React Error Boundary로 자동 래핑합니다. 해당 세그먼트의 Server Component가 throw하면, 전체 페이지가 크래시되는 대신 에러 UI가 표시됩니다.
어떤 것을 언제 사용할지: 의사결정 트리#
RSC로 여러 프로덕션 앱을 구축한 후, 저는 명확한 의사결정 프레임워크에 정착했습니다. 모든 컴포넌트에 대해 실제로 거치는 사고 과정은 이렇습니다:
Server Components로 시작 (기본값)#
모든 컴포넌트는 그래야 할 특별한 이유가 없는 한 Server Component여야 합니다. 이것이 가장 중요한 규칙입니다.
Client Component로 만들어야 할 때:#
1. 브라우저 전용 API를 사용할 때
"use client";
// window, document, navigator, localStorage, etc.
function GeoLocation() {
const [coords, setCoords] = useState<GeolocationCoordinates | null>(null);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
(pos) => setCoords(pos.coords)
);
}, []);
return coords ? <p>Lat: {coords.latitude}</p> : <p>Loading location...</p>;
}2. 상태나 이펙트가 필요한 React 훅을 사용할 때
useState, useEffect, useReducer, useRef (뮤터블 ref용), useContext — 이 중 하나라도 사용하면 "use client"가 필요합니다.
"use client";
function SearchBar() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
// This component MUST be a Client Component because it
// uses useState and manages user input
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
}3. 이벤트 핸들러를 붙일 때
onClick, onChange, onSubmit, onMouseEnter — 모든 인터랙티브 동작은 클라이언트 사이드를 의미합니다.
4. 클라이언트 사이드 라이브러리를 사용할 때
Framer Motion, React Hook Form, Zustand, React Query (클라이언트 사이드 페칭용), Canvas나 SVG에 인터랙티브하게 렌더링하는 모든 차트 라이브러리.
Server Component로 유지해야 할 때:#
- 데이터만 표시할 때 (사용자 인터랙션 없음)
- 데이터베이스나 API에서 데이터를 가져올 때
- 백엔드 리소스에 접근할 때 (파일 시스템, 시크릿이 포함된 환경 변수)
- 클라이언트가 필요 없는 큰 의존성을 import할 때 (마크다운 파서, 구문 강조기, 포맷용 날짜 라이브러리)
- 정적이거나 준정적 콘텐츠를 렌더링할 때
실무에서의 실제 의사결정#
구체적인 예시입니다. 제품 페이지를 만들고 있다고 합시다:
ProductPage (Server)
├── ProductBreadcrumbs (Server) — static navigation, no interactivity
├── ProductImageGallery (Client) — zoom, swipe, lightbox
├── ProductInfo (Server)
│ ├── ProductTitle (Server) — just text
│ ├── ProductPrice (Server) — formatted number, no interaction
│ └── AddToCartButton (Client) — onClick, manages cart state
├── ProductDescription (Server) — rendered markdown
├── Suspense
│ └── RelatedProducts (Server) — async data fetch, slow API
└── Suspense
└── ProductReviews (Server)
└── ReviewForm (Client) — form with validation
패턴을 주목하세요: 페이지 셸과 데이터가 많은 부분은 Server Components입니다. 인터랙티브 아일랜드(ImageGallery, AddToCartButton, ReviewForm)는 Client Components입니다. 느린 섹션(RelatedProducts, ProductReviews)은 Suspense로 감싸져 있습니다.
이것은 이론이 아닙니다. 제 컴포넌트 트리가 실제로 이렇게 생겼습니다.
흔한 실수들 (전부 다 해봤습니다)#
실수 1: 모든 것을 Client Component로 만들기#
Pages Router나 Create React App에서 마이그레이션할 때 가장 저항이 적은 경로는 모든 곳에 "use client"를 붙이는 것입니다. 작동합니다! 아무것도 안 깨집니다! 그런데 전체 컴포넌트 트리를 JavaScript로 전달하면서 RSC의 이점은 전혀 얻지 못하고 있는 겁니다.
루트 레이아웃에 "use client"가 붙어있는 코드베이스를 본 적 있습니다. 그 시점에서 여러분은 말 그대로 추가 단계만 있는 클라이언트 사이드 React 앱을 실행하고 있는 겁니다.
해결법: Server Components에서 시작하세요. 컴파일러가 필요하다고 알려줄 때만(훅이나 이벤트 핸들러를 사용했기 때문에) "use client"를 추가하세요. "use client"를 트리의 가능한 한 아래쪽 리프로 밀어내세요.
실수 2: 경계를 통한 Prop 드릴링#
// BAD: fetching data in a Server Component, then passing it through
// multiple Client Components
// app/page.tsx (Server)
export default async function Page() {
const user = await getUser();
const settings = await getSettings();
const theme = await getTheme();
return (
<ClientShell user={user} settings={settings} theme={theme}>
<ClientContent user={user} settings={settings}>
<ClientWidget user={user} />
</ClientContent>
</ClientShell>
);
}경계를 넘어 전달하는 모든 데이터가 RSC payload로 직렬화됩니다. 같은 객체를 5번 전달하면? payload에 5번 들어갑니다. 이 때문에 RSC payload가 메가바이트 단위로 불어나는 것을 본 적 있습니다.
해결법: 컴포지션을 사용하세요. 데이터를 props로 전달하는 대신 Server Components를 children으로 전달하세요:
// GOOD: Server Components fetch their own data, pass through as children
// app/page.tsx (Server)
export default function Page() {
return (
<ClientShell>
<UserInfo /> {/* Server Component — fetches its own data */}
<Settings /> {/* Server Component — fetches its own data */}
<ClientWidget>
<UserAvatar /> {/* Server Component — fetches its own data */}
</ClientWidget>
</ClientShell>
);
}실수 3: Suspense를 사용하지 않기#
Suspense 없이는, 페이지의 TTFB(Time to First Byte)가 가장 느린 데이터 페칭에 의해 제한됩니다. 나머지 페이지 데이터는 200ms 안에 준비됐는데, 하나의 분석 쿼리가 느려서 4초가 걸리는 대시보드 페이지가 있었습니다.
// BAD: everything waits for everything
export default async function Dashboard() {
const stats = await getStats(); // 200ms
const chart = await getChartData(); // 300ms
const analytics = await getAnalytics(); // 4000ms ← blocks everything
return (
<div>
<Stats data={stats} />
<Chart data={chart} />
<Analytics data={analytics} />
</div>
);
}// GOOD: analytics loads independently
export default function Dashboard() {
return (
<div>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* Takes 4s but doesn't block the rest */}
</Suspense>
</div>
);
}실수 4: 런타임 직렬화 에러#
특히 고통스러운 실수인데, 프로덕션에 올리기 전까지 잡지 못하는 경우가 많기 때문입니다. 경계를 넘어 직렬화 불가능한 것을 전달하면 암호 같은 에러가 발생합니다:
Error: Only plain objects, and a few built-ins, can be passed to
Client Components from Server Components. Classes or null prototypes
are not supported.
흔한 범인들:
- Date 객체 전달 (
.toISOString()대신 사용) - Map이나 Set 전달 (배열/객체로 변환)
- ORM의 클래스 인스턴스 전달 (
.toJSON()또는 일반 객체로 스프레드) - 함수 전달 (로직을 Client Component로 옮기거나 Server Actions 사용)
Decimal필드가 있는 Prisma 모델 결과 전달 (number나string으로 변환)
// BAD
const user = await prisma.user.findUnique({ where: { id } });
// user might have non-serializable fields (Decimal, BigInt, etc.)
return <ClientProfile user={user} />;
// GOOD
const user = await prisma.user.findUnique({ where: { id } });
const serializedUser = {
id: user.id,
name: user.name,
email: user.email,
balance: user.balance.toNumber(), // Decimal → number
createdAt: user.createdAt.toISOString(), // Date → string
};
return <ClientProfile user={serializedUser} />;실수 5: 모든 것에 Context 사용하기#
useContext는 Client Components에서만 동작합니다. Server Component에서 React context를 사용하려 하면 동작하지 않습니다. 테마 context를 쓰겠다고 전체 앱을 Client Component로 만드는 사람을 본 적 있습니다.
해결법: 테마와 기타 전역 상태에는 서버 사이드에서 설정한 CSS 변수를 사용하거나, cookies() / headers() 함수를 사용하세요:
// app/layout.tsx (Server Component)
import { cookies } from "next/headers";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value ?? "light";
return (
<html data-theme={theme} className={theme === "dark" ? "dark" : ""}>
<body>{children}</body>
</html>
);
}진정으로 클라이언트 사이드 상태가 필요한 것(인증 토큰, 장바구니, 실시간 데이터)은, 루트가 아니라 적절한 수준에 얇은 Client Component provider를 만드세요:
// src/providers/CartProvider.tsx
"use client";
import { createContext, useContext, useState } from "react";
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
return (
<CartContext.Provider value={{ items, setItems }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) throw new Error("useCart must be used within CartProvider");
return context;
}// app/shop/layout.tsx (Server Component)
import { CartProvider } from "@/providers/CartProvider";
export default function ShopLayout({ children }: { children: React.ReactNode }) {
// CartProvider is a Client Component, but children flow through as server content
return <CartProvider>{children}</CartProvider>;
}실수 6: 번들 사이즈 영향 무시하기#
RSC의 가장 큰 장점 중 하나는 Server Component 코드가 절대 클라이언트에 전달되지 않는다는 것입니다. 하지만 이것을 능동적으로 생각해야 합니다. 50KB 마크다운 파서를 사용하면서 렌더링된 콘텐츠만 표시하는 컴포넌트가 있다면 — 그건 Server Component여야 합니다. 파서는 서버에 남고, HTML 출력만 클라이언트에 전달됩니다.
// Server Component — marked stays on the server
import { marked } from "marked"; // 50KB library — never ships to client
async function BlogContent({ slug }: { slug: string }) {
const post = await getPost(slug);
const html = marked(post.markdown);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}이것을 Client Component로 만들면 marked가 브라우저로 전달됩니다. 아무 이유 없이요. 처음부터 HTML이 될 수 있었던 콘텐츠를 렌더링하기 위해 50KB의 JavaScript를 다운로드하게 되는 겁니다.
@next/bundle-analyzer로 번들을 확인하세요. 결과에 놀라실 수도 있습니다.
캐싱 전략#
Next.js 15+에서 캐싱은 이전 버전에 비해 크게 단순화되었지만, 이해해야 할 별개의 레이어가 여전히 있습니다.
cache() 함수 (React)#
React의 cache()는 요청별 중복 제거를 위한 것이지, 영구 캐싱을 위한 것이 아닙니다:
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const session = await getSession();
if (!session) return null;
return db.user.findUnique({ where: { id: session.userId } });
});
// Call this anywhere in your component tree during a single request.
// Only one actual database query will execute.이것은 단일 서버 요청 범위입니다. 요청이 끝나면 캐시된 값은 사라집니다. 캐싱이 아니라 메모이제이션입니다.
unstable_cache (Next.js)#
요청 간 영구 캐싱을 위해서는 unstable_cache를 사용합니다 (이름이 영원히 "unstable"이지만, 프로덕션에서 잘 작동합니다):
import { unstable_cache } from "next/cache";
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.product.findMany({
where: { categoryId },
include: { images: true },
});
},
["products-by-category"], // cache key prefix
{
revalidate: 3600, // revalidate every hour
tags: ["products"], // for on-demand revalidation
}
);
// Usage in a Server Component
async function ProductGrid({ categoryId }: { categoryId: string }) {
const products = await getCachedProducts(categoryId);
return <Grid items={products} />;
}무효화하려면:
"use server";
import { revalidateTag } from "next/cache";
export async function onProductUpdate() {
revalidateTag("products");
}정적 vs 동적 렌더링#
Next.js는 라우트에서 사용하는 것에 따라 정적인지 동적인지 결정합니다:
정적 (빌드 타임에 렌더링, 캐시됨):
- 동적 함수 없음 (
cookies(),headers(),searchParams) - 모든
fetch호출에 캐싱 활성화 export const dynamic = "force-dynamic"없음
동적 (요청마다 렌더링):
cookies(),headers(), 또는searchParams사용cache: "no-store"로fetch사용export const dynamic = "force-dynamic"있음next/server의connection()또는after()사용
next build를 실행하면 어떤 라우트가 정적이고 동적인지 확인할 수 있습니다 — 하단에 범례가 표시됩니다:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 92 kB
├ ○ /about 1.1 kB 88 kB
├ ● /blog/[slug] 2.3 kB 89 kB
├ λ /dashboard 4.1 kB 91 kB
└ λ /api/products 0 B 87 kB
○ Static ● SSG λ Dynamic
캐싱 계층 구조#
캐싱을 레이어로 생각하세요:
1. React cache() — per-request, in-memory, automatic dedup
2. fetch() cache — cross-request, automatic for GET requests
3. unstable_cache() — cross-request, for non-fetch operations
4. Full Route Cache — rendered HTML cached at build/revalidation time
5. Router Cache (client) — in-browser cache of visited routes
각 레이어는 다른 목적을 가지고 있습니다. 항상 전부 필요한 건 아니지만, 어떤 것이 활성화되어 있는지 이해하면 "왜 데이터가 업데이트 안 되지?" 문제를 디버깅하는 데 도움이 됩니다.
실전 캐싱 전략#
제가 프로덕션에서 실제로 하는 방식입니다:
// lib/data/products.ts
import { cache } from "react";
import { unstable_cache } from "next/cache";
import { db } from "@/lib/database";
// Per-request dedup: call this multiple times in one render,
// only one DB query runs
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { category: true, images: true },
});
});
// Cross-request cache: results persist across requests,
// revalidate every 5 minutes or on-demand via tag
export const getPopularProducts = unstable_cache(
async () => {
return db.product.findMany({
orderBy: { salesCount: "desc" },
take: 20,
include: { images: true },
});
},
["popular-products"],
{ revalidate: 300, tags: ["products"] }
);
// No caching: always fresh (for user-specific data)
export const getUserCart = cache(async (userId: string) => {
// cache() here is only for per-request dedup, not persistence
return db.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
});경험 법칙: 자주 변경되지 않는 공개 데이터는 unstable_cache를 사용합니다. 사용자별 데이터는 중복 제거만을 위해 cache()를 사용합니다. 실시간 데이터는 캐싱을 전혀 하지 않습니다.
Server Actions: 돌아가는 다리#
Server Actions는 RSC 이야기를 완성하기 때문에 별도 섹션이 필요합니다. Client Components가 API 라우트 없이 서버로 다시 통신하는 방법입니다.
// app/actions/newsletter.ts
"use server";
import { db } from "@/lib/database";
import { z } from "zod";
const emailSchema = z.string().email();
export async function subscribeToNewsletter(formData: FormData) {
const email = formData.get("email");
const result = emailSchema.safeParse(email);
if (!result.success) {
return { error: "Invalid email address" };
}
try {
await db.subscriber.create({
data: { email: result.data },
});
return { success: true };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { error: "Already subscribed" };
}
return { error: "Something went wrong" };
}
}// src/components/NewsletterForm.tsx
"use client";
import { useActionState } from "react";
import { subscribeToNewsletter } from "@/app/actions/newsletter";
export function NewsletterForm() {
const [state, formAction, isPending] = useActionState(
async (_prev: { error?: string; success?: boolean } | null, formData: FormData) => {
return subscribeToNewsletter(formData);
},
null
);
return (
<form action={formAction}>
<input
type="email"
name="email"
required
placeholder="you@example.com"
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? "Subscribing..." : "Subscribe"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
{state?.success && <p className="text-green-500">Subscribed!</p>}
</form>
);
}Server Actions는 RSC 세계에서 "데이터를 어떻게 변경하지?"에 대한 답입니다. 폼 제출, 뮤테이션, 사이드 이펙트를 위한 대부분의 API 라우트를 대체합니다.
Server Actions 핵심 규칙:
- 항상 입력을 검증하세요 (함수가 클라이언트에서 호출 가능합니다 — API 엔드포인트처럼 취급하세요)
- 항상 직렬화 가능한 데이터를 반환하세요
- 서버에서 실행되므로 데이터베이스, 파일 시스템, 시크릿에 접근할 수 있습니다
- 뮤테이션 후 캐시된 데이터를 업데이트하기 위해
revalidatePath()나revalidateTag()를 호출할 수 있습니다
마이그레이션 패턴#
기존 React 앱(Pages Router, Create React App, Vite)이 있다면, RSC로 이동하는 것이 전체 재작성일 필요는 없습니다. 제 접근 방식은 이렇습니다.
1단계: 컴포넌트 매핑#
컴포넌트 트리를 훑으며 모든 것을 분류합니다:
Component State? Effects? Events? → Decision
─────────────────────────────────────────────────────────
Header No No No → Server
NavigationMenu No No Yes → Client (mobile toggle)
Footer No No No → Server
BlogPost No No No → Server
SearchBar Yes Yes Yes → Client
ProductCard No No Yes → Client (onClick) or split
UserAvatar No No No → Server
CommentForm Yes Yes Yes → Client
Sidebar Yes No Yes → Client (collapse toggle)
MarkdownRenderer No No No → Server (big dependency win)
DataTable Yes Yes Yes → Client (sorting, filtering)
2단계: 데이터 페칭을 위로 끌어올리기#
가장 큰 아키텍처 변경은 컴포넌트 내의 useEffect에서 async Server Components로 데이터 페칭을 옮기는 것입니다. 실제 마이그레이션 노력이 들어가는 곳이 바로 여기입니다.
이전:
// Old pattern — data fetching in a Client Component
"use client";
function ProductPage({ id }: { id: string }) {
const [product, setProduct] = useState<Product | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/products/${id}`)
.then(res => res.json())
.then(data => setProduct(data))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <Error message={error} />;
if (!product) return <NotFound />;
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
);
}이후:
// New pattern — Server Component fetches, Client Component interacts
// app/products/[id]/page.tsx (Server Component)
import { db } from "@/lib/database";
import { notFound } from "next/navigation";
import { AddToCartButton } from "@/components/AddToCartButton";
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await db.product.findUnique({ where: { id } });
if (!product) notFound();
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} price={product.price} />
</div>
);
}로딩 상태 관리 없습니다. 에러 상태도 없습니다. useEffect도 없습니다. 프레임워크가 Suspense와 error boundary를 통해 이 모든 것을 처리합니다.
3단계: 인터랙션 경계에서 컴포넌트 분리#
많은 컴포넌트가 작은 인터랙티브 부분이 있는 대부분 정적인 구조입니다. 이를 분리하세요:
이전 (하나의 큰 Client Component):
"use client";
function ProductCard({ product }: { product: Product }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<button onClick={() => setIsFavorite(!isFavorite)}>
{isFavorite ? "♥" : "♡"}
</button>
</div>
);
}이후 (작은 Client 아일랜드가 있는 Server Component):
// ProductCard.tsx (Server Component)
import { FavoriteButton } from "./FavoriteButton";
function ProductCard({ product }: { product: Product }) {
return (
<div className="card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>{product.description}</p>
<span>${product.price}</span>
<FavoriteButton productId={product.id} />
</div>
);
}// FavoriteButton.tsx (Client Component)
"use client";
import { useState } from "react";
export function FavoriteButton({ productId }: { productId: string }) {
const [isFavorite, setIsFavorite] = useState(false);
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
>
{isFavorite ? "♥" : "♡"}
</button>
);
}이미지, 제목, 설명, 가격은 이제 서버 렌더링됩니다. 오직 작은 즐겨찾기 버튼만 Client Component입니다. 더 적은 JavaScript, 더 빠른 페이지 로드.
4단계: API 라우트를 Server Actions로 전환#
자체 프론트엔드만을 위해 존재하는 API 라우트가 있다면(외부 소비자가 아닌), 대부분 Server Actions가 될 수 있습니다:
이전:
// app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validate, save to DB, send email
return Response.json({ success: true });
}
// Client component
const response = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(formData),
});이후:
// app/actions/contact.ts
"use server";
export async function submitContactForm(formData: FormData) {
// validate, save to DB, send email
return { success: true };
}
// Client component — just call the function directly
import { submitContactForm } from "@/app/actions/contact";API 라우트를 유지해야 할 것: 웹훅, 외부 API 소비자, 커스텀 HTTP 헤더나 상태 코드가 필요한 것, 스트리밍 파일 업로드.
5단계: RSC 컴포넌트 테스트#
Server Components를 테스트하려면 async이므로 약간 다른 접근이 필요합니다:
// __tests__/ProductPage.test.tsx
import { render, screen } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
// Mock the database
vi.mock("@/lib/database", () => ({
db: {
product: {
findUnique: vi.fn(),
},
},
}));
describe("ProductPage", () => {
it("renders product details", async () => {
const { db } = await import("@/lib/database");
vi.mocked(db.product.findUnique).mockResolvedValue({
id: "1",
name: "Test Product",
description: "A great product",
price: 29.99,
});
// Server Components are async — await the JSX
const jsx = await ProductPage({
params: Promise.resolve({ id: "1" })
});
render(jsx);
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("A great product")).toBeInTheDocument();
});
});핵심 차이: 컴포넌트 함수가 async이므로 await합니다. 그런 다음 결과 JSX를 렌더링합니다. 나머지는 기존 React Testing Library와 동일하게 동작합니다.
RSC 프로젝트를 위한 파일 구조#
여러 프로젝트를 거치면서 정착한 구조입니다. 의견이 반영된 구조지만, 동작합니다:
src/
├── app/
│ ├── layout.tsx ← Root layout (Server Component)
│ ├── page.tsx ← Home page (Server Component)
│ ├── (marketing)/ ← Route group for marketing pages
│ │ ├── about/page.tsx
│ │ └── pricing/page.tsx
│ ├── (app)/ ← Route group for authenticated app
│ │ ├── layout.tsx ← App shell with auth check
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx ← Suspense fallback for this route
│ │ │ └── error.tsx ← Error boundary for this route
│ │ └── settings/
│ │ └── page.tsx
│ └── actions/ ← Server Actions
│ ├── auth.ts
│ └── products.ts
├── components/
│ ├── ui/ ← Shared UI primitives (mostly Client)
│ │ ├── Button.tsx ← "use client"
│ │ ├── Dialog.tsx ← "use client"
│ │ └── Card.tsx ← Server Component (just styling)
│ └── features/ ← Feature-specific components
│ ├── products/
│ │ ├── ProductGrid.tsx ← Server (async, fetches data)
│ │ ├── ProductCard.tsx ← Server (presentational)
│ │ ├── ProductSearch.tsx ← Client (useState, input)
│ │ └── AddToCart.tsx ← Client (onClick, mutation)
│ └── blog/
│ ├── PostList.tsx ← Server (async, fetches data)
│ ├── PostContent.tsx ← Server (markdown rendering)
│ └── CommentSection.tsx ← Client (form, real-time)
├── lib/
│ ├── data/ ← Data access layer
│ │ ├── products.ts ← cache() wrapped DB queries
│ │ └── users.ts
│ ├── database.ts
│ └── utils.ts
└── providers/
├── ThemeProvider.tsx ← "use client" — wraps parts that need theme
└── CartProvider.tsx ← "use client" — wraps shop section only
핵심 원칙:
- Server Components에는 디렉티브가 없습니다 — 기본값이니까요
- Client Components는 명시적으로 표시합니다 — 한눈에 알 수 있습니다
- 데이터 페칭은
lib/data/에 위치합니다 —cache()또는unstable_cache로 래핑됨 - Server Actions는
app/actions/에 위치합니다 — 앱과 함께 배치되지만, 명확히 분리됨 - Provider는 필요한 최소 범위만 래핑합니다 — 전체 앱이 아닌
결론#
React Server Components는 단순한 새 API가 아닙니다. 코드가 어디서 실행되는지, 데이터가 어디에 있는지, 조각들이 어떻게 연결되는지에 대한 다른 사고방식입니다. 멘탈 모델 전환은 실재하며 시간이 걸립니다.
하지만 한 번 이해가 되면 — 경계와 싸우는 것을 멈추고 경계를 중심으로 설계하기 시작하면 — 이전에 만들던 것보다 더 빠르고, 더 단순하고, 더 유지보수하기 쉬운 앱을 만들게 됩니다. 더 적은 JavaScript가 클라이언트에 전달됩니다. 데이터 페칭에 번거로운 의식이 필요 없습니다. 컴포넌트 트리가 곧 아키텍처가 됩니다.
전환할 가치가 있습니다. 다만 처음 몇 프로젝트는 불편하게 느껴질 것이고, 그건 정상입니다. RSC가 나빠서 힘든 게 아닙니다. 진정으로 새로운 것이기 때문에 힘든 겁니다.
Server Components로 모든 곳에서 시작하세요. "use client"를 리프로 밀어내세요. 느린 것은 Suspense로 감싸세요. 데이터를 렌더링되는 곳에서 가져오세요. children을 통해 컴포지션하세요.
이것이 전체 플레이북입니다. 나머지는 전부 디테일입니다.