Next.js Server Component와 Client Component 경계
Next.js Server Component와 Client Component 경계
Next.js App Router의 component는 기본적으로 Server Component다. 데이터 접근과 비밀값을 사용하는 렌더는 서버에 남기고, state·Effect·event handler·browser API가 필요한 leaf만 'use client' 경계로 만든다. 이 directive는 파일 하나의 실행 위치만 바꾸는 표시가 아니라 해당 파일이 import하는 module graph를 client bundle 경계 안으로 끌어온다. 서버 결과는 직렬화 가능한 최소 props로 넘기고 Server Component를 Client Component의 children slot으로 합성할 수 있다.
본문의 코드는 특정 저장소 구현을 복사하지 않고 개념을 설명하기 위해 재구성한 예시다. 이름·경로·수치는 실제 운영 정보와 무관하다.
목차
- #같은 JSX라도 실행 환경이 다르다
- #Server Component를 기본으로 두는 이유
- #Client Component가 필요한 기능
- #use client는 module graph의 경계다
- #상호작용이 필요한 leaf만 client로 자르기
- #서버 데이터를 직렬화 가능한 props로 전달하기
- #Client Component 안에 Server Component를 합성하기
- #Context Provider는 가능한 한 깊게 둔다
- #데이터는 원본에 직접 접근하고 권한을 다시 검사한다
- #Server Function과 Route Handler의 역할
- #loading과 streaming 경계
- #경계가 잘못됐을 때 나타나는 신호
- #빌드와 운영에서 검증할 것
- #정리
- #관련 노트와 참고 자료
같은 JSX라도 실행 환경이 다르다
Server Component와 Client Component 모두 JSX를 반환하므로 코드 모양은 비슷하다. 차이는 어디에서 실행되고 어떤 capability를 사용할 수 있는지에 있다.
export default function ProductTitle({
name,
}: {
name: string;
}) {
return <h1>{name}</h1>;
}
App Router에서 별도 directive가 없는 이 파일은 기본적으로 Server Component다. 서버에서 React Server Component payload를 만들며 component 구현 JavaScript를 일반 client bundle로 보낼 필요가 없다.
Client Component는 파일 맨 위에 'use client'를 둔다.
"use client";
import { useState } from "react";
export function QuantitySelector() {
const [quantity, setQuantity] = useState(1);
return (
<button
type="button"
onClick={() => setQuantity((current) => current + 1)}
>
수량 {quantity}
</button>
);
}
Client Component라는 이름 때문에 처음부터 browser에서만 렌더된다고 오해할 수 있다. 초기 요청에서는 Next.js가 HTML preview 생성에 참여시킬 수 있고, browser에서 JavaScript가 hydrate되어 상호작용이 활성화된다. 핵심은 해당 component code와 dependency가 client module graph에 들어간다는 점이다.
| capability | Server Component | Client Component |
|---|---|---|
| DB·ORM 직접 접근 | 가능 | 불가능 |
| 서버 비밀값 사용 | 가능 | 금지 |
| async data fetch | 자연스러움 | client query 방식 |
useState, useReducer |
불가능 | 가능 |
useEffect |
불가능 | 가능 |
onClick, onChange |
직접 불가능 | 가능 |
window, localStorage |
불가능 | 가능 |
| client bundle에 구현 포함 | 일반적으로 없음 | 있음 |
Server와 Client는 component 스타일의 차이가 아니라 network를 사이에 둔 서로 다른 실행 환경이다.
Server Component를 기본으로 두는 이유
상품 상세 페이지가 DB에서 데이터를 읽어 대부분 정적인 HTML을 렌더한다고 해 보자.
import { getProductForViewer } from "@/data/products";
export default async function ProductPage({
params,
}: {
params: Promise<{ productId: string }>;
}) {
const { productId } = await params;
const product = await getProductForViewer(productId);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>{formatCurrency(product.price)}</strong>
</article>
);
}
Server Component로 두면 다음 이점이 있다.
- DB와 내부 API에 가까운 서버에서 데이터를 읽는다.
- DB driver와 markdown parser 같은 큰 dependency를 browser bundle에서 제외한다.
- access token과 비밀 환경 변수를 client에 보내지 않는다.
- 완성된 content를 HTML/RSC payload로 전달해 초기 콘텐츠를 표시한다.
- server cache와 streaming 전략을 framework 수준에서 사용할 수 있다.
“서버 렌더가 무조건 빠르다”는 뜻은 아니다. 느린 DB query는 server rendering을 막을 수 있고, RSC payload와 HTML도 network로 전송된다. server data fetch를 병렬화하고 cache·Suspense boundary를 설계해야 한다.
export default async function DashboardPage() {
const salesPromise = getSalesSummary();
const inventoryPromise = getInventoryWarnings();
const [sales, inventory] = await Promise.all([
salesPromise,
inventoryPromise,
]);
return <Dashboard sales={sales} inventory={inventory} />;
}
순차 dependency가 없는데 await를 이어 쓰면 server waterfall이 생긴다.
Client Component가 필요한 기능
Client Component는 browser에서 지속적으로 상호작용해야 하는 영역에 필요하다.
- state와 reducer
- lifecycle Effect
- click, input, drag event
window,document,navigator,localStorage- browser 전용 third-party widget
- client Context 소비
"use client";
import { useEffect, useState } from "react";
export function FavoriteButton({
productId,
initialFavorite,
}: {
productId: string;
initialFavorite: boolean;
}) {
const [favorite, setFavorite] = useState(initialFavorite);
useEffect(() => {
analytics.track("favorite_button_visible", { productId });
}, [productId]);
return (
<button
type="button"
aria-pressed={favorite}
onClick={() => setFavorite((current) => !current)}
>
{favorite ? "즐겨찾기 해제" : "즐겨찾기"}
</button>
);
}
페이지 전체를 client로 바꾸지 않고 이 button만 경계로 만든다.
browser API를 module top-level에서 읽지 않는다. Client Component도 초기 HTML 생성 과정에서 server 환경을 거칠 수 있다.
"use client";
const initialWidth = window.innerWidth;
window 접근은 event handler나 Effect로 옮긴다.
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth);
}
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
use client는 module graph의 경계다
'use client'가 있는 파일은 Server Component가 직접 import할 수 있는 client entry point가 된다. 그리고 그 파일이 정적으로 import하는 모듈들도 client graph에 들어갈 수 있다.
"use client";
import { HeavyChart } from "./heavy-chart";
import { formatReport } from "../server/report-formatter";
import { loadInternalConfig } from "../server/config";
component가 작은 button이어도 server-only module과 큰 dependency를 import하면 build error나 bundle 증가가 생긴다.
flowchart TB
A["product-page.tsx
Server"] --> B["favorite-button.tsx
'use client'"]
B --> C["analytics-client.ts
Client graph"]
B --> D["icon.tsx
Client graph"]
A --> E["product-description.tsx
Server graph"]directive를 모든 component 파일에 반복할 필요는 없다. boundary entry 파일에 두면 그 아래 client dependency가 이미 client graph가 된다.
type-only import도 경계를 명확히 한다
공유 type과 server 구현을 같은 모듈에 넣으면 client가 실수로 runtime 코드를 가져올 수 있다.
import type { ProductSummary } from "@/contracts/product";
DTO type, validation schema, server repository를 파일 수준에서 분리한다.
contracts/product.ts 공유 가능한 type
data/products.server.ts DB와 권한 처리
ui/favorite-button.tsx client interaction
server-only module에는 server-only marker를 사용해 Client Component import를 build 단계에서 막을 수 있다.
import "server-only";
export async function getProductForViewer(
productId: string,
): Promise<ProductView> {
// server data access
}
상호작용이 필요한 leaf만 client로 자르기
잘못된 첫 접근은 layout 전체에 'use client'를 붙이는 것이다.
"use client";
export default function ProductLayout({ children }: Props) {
return (
<>
<Logo />
<Navigation />
<Search />
<main>{children}</main>
<Footer />
</>
);
}
Search input만 state가 필요한데 Logo, Navigation, Footer dependency까지 client graph에 포함될 수 있다.
Server layout이 Client Search를 import하도록 뒤집는다.
import { Logo } from "./logo";
import { SearchBox } from "./search-box";
export default function ProductLayout({
children,
}: PropsWithChildren) {
return (
<>
<nav>
<Logo />
<SearchBox />
</nav>
<main>{children}</main>
<Footer />
</>
);
}
"use client";
export function SearchBox() {
const [query, setQuery] = useState("");
return (
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}
이를 client island처럼 생각할 수 있다.
Server: Layout
├─ Server: Logo
├─ Client: SearchBox
├─ Server: Page content
│ └─ Client: FavoriteButton
└─ Server: Footer
client boundary가 작을수록 bundle이 항상 무조건 작다는 보장은 없지만, server-only code가 accidental client dependency가 되는 범위를 줄인다.
서버 데이터를 직렬화 가능한 props로 전달하기
Server Component에서 Client Component로 넘어가는 props는 React가 network boundary를 통해 serialize할 수 있어야 한다.
export default async function ProductPage({ params }: PageProps) {
const product = await getProductForViewer((await params).productId);
return (
<FavoriteButton
productId={product.id}
initialFavorite={product.viewerFavorite}
/>
);
}
DB entity 전체를 그대로 넘기지 않고 Client가 필요한 최소 view model을 만든다.
type FavoriteButtonProps = {
productId: string;
initialFavorite: boolean;
};
함수는 일반 props로 전달할 수 없다.
<FavoriteButton
onClick={() => updateFavoriteOnServer(product.id)}
/>
server function처럼 framework가 별도 reference protocol을 제공하는 경우를 제외하면 closure를 serialize할 수 없다. Client Component가 API나 Server Action을 호출하도록 명시적인 mutation 경계를 둔다.
다음 값도 주의한다.
- DB client와 class instance
- 열린 stream과 socket
- request/response 객체
- 함수와 symbol
- 직렬화 의미가 불분명한 custom type
Date, Map 등 React가 지원하는 직렬화 범위는 버전별 공식 문서를 확인한다. 공개 계약에서는 ISO 문자열과 plain object로 변환하면 경계가 더 명확할 수 있다.
return {
id: row.id,
createdAt: row.createdAt.toISOString(),
price: row.price.toString(),
};
serialize할 수 있는 값이라도 브라우저에 보내면 안 되는 internal cost, access token, 개인정보는 props에 포함하지 않는다.
Client Component 안에 Server Component를 합성하기
Client Component 파일에서 Server Component module을 직접 import하려고 하면 경계가 어긋난다.
"use client";
import { ServerCart } from "./server-cart";
export function CartModal() {
return <ServerCart />;
}
대신 Server Component parent가 server-rendered content를 children이나 slot prop으로 Client Component에 전달한다.
"use client";
export function Modal({
children,
}: {
children: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
장바구니 열기
</button>
{open && (
<div role="dialog" aria-modal="true">
{children}
<button type="button" onClick={() => setOpen(false)}>
닫기
</button>
</div>
)}
</>
);
}
Server parent에서 조합한다.
export default function Page() {
return (
<Modal>
<ServerCart />
</Modal>
);
}
ServerCart는 server에서 먼저 렌더되고 RSC payload가 Client Modal의 slot 위치를 설명한다. 시각적으로 Client 아래에 있다고 module graph에서도 client가 되는 것은 아니다. 누가 import했는가와 어떻게 composition했는가가 중요하다.
Context Provider는 가능한 한 깊게 둔다
React Context는 Server Component에서 직접 소비하는 client state 공유 방식이 아니다. Provider component에 'use client'를 두고 필요한 Client subtree를 감싼다.
"use client";
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({
children,
}: PropsWithChildren) {
const [theme, setTheme] = useState<Theme>("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
root layout에서 전체 <html>을 감싸기보다 실제 client 소비자가 있는 범위에 둔다.
export default function RootLayout({
children,
}: PropsWithChildren) {
return (
<html lang="ko">
<body>
<ServerHeader />
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
Provider가 client라고 해서 children으로 전달된 모든 Server Component 구현이 client bundle로 들어가는 것은 아니다. Server parent가 만들어 slot으로 넘기는 composition을 유지한다.
Provider를 깊게 둘수록 static server 영역을 최적화하고 client state 수명도 실제 route에 맞출 수 있다. 자세한 상태 범위는 React Context가 성능 문제를 만드는 경우와 이어진다.
데이터는 원본에 직접 접근하고 권한을 다시 검사한다
Server Component에서는 browser를 거치지 않고 DB나 내부 service에 직접 접근할 수 있다.
export default async function OrdersPage() {
const viewer = await requireViewer();
const orders = await orderRepository.findVisibleTo(viewer.id);
return <OrderList orders={toOrderViews(orders)} />;
}
같은 Next.js app의 Server Component가 자기 Route Handler를 HTTP로 다시 호출하면 불필요한 network hop과 URL·cookie 전달 문제가 생긴다.
const response = await fetch("https://app.example.test/api/orders");
공유 service나 data access function을 직접 호출한다.
const orders = await getOrdersForViewer();
하지만 Server Component에서 실행된다는 사실만으로 권한이 자동 보장되지는 않는다. URL param과 cookie는 사용자 입력이므로 data access layer에서 인증·인가를 수행한다.
import "server-only";
export async function getOrderForViewer(
orderId: string,
): Promise<OrderView> {
const viewer = await requireViewer();
const order = await db.order.findFirst({
where: {
id: orderId,
userId: viewer.id,
},
});
if (!order) {
notFound();
}
return toOrderView(order);
}
먼저 ID로 조회하고 나중에 owner를 검사하면 TOCTOU나 정보 노출이 생길 수 있다. 가능하면 조회 조건에 권한 predicate를 포함한다.
Server Function과 Route Handler의 역할
Client Component가 서버 변경을 요청할 때 Server Function/Action을 사용할 수 있다.
"use server";
export async function updateProfile(
input: UpdateProfileInput,
): Promise<UpdateProfileResult> {
const viewer = await requireViewer();
const validated = UpdateProfileSchema.parse(input);
return profileService.update(viewer.id, validated);
}
Client에서 import 가능하다고 내부 함수 호출처럼 신뢰하지 않는다. network로 호출 가능한 mutation endpoint이므로 매 실행마다 인증, 권한, validation, rate limit, CSRF 관련 framework 정책을 확인한다.
Route Handler는 다음 상황에 자연스럽다.
- 모바일·외부 client도 호출하는 HTTP API
- webhook 수신
- 표준 HTTP method/status/header가 중요한 endpoint
- 파일·stream 응답
export async function POST(request: Request) {
const signature = request.headers.get("x-provider-signature");
const body = await request.text();
await verifyWebhook(signature, body);
await processWebhook(body);
return new Response(null, { status: 204 });
}
Server Component 데이터 조회를 무조건 Route Handler로 우회하거나, 외부 API 계약을 모두 Server Action으로 바꾸지 않는다.
mutation이 성공하면 사용하는 Next.js cache·tag·path revalidation과 client query cache를 각각 어떤 기준으로 갱신할지 정한다. 자동으로 서로의 cache를 모두 알지는 못한다.
loading과 streaming 경계
Server Component의 느린 데이터 fetch가 page 전체를 막지 않도록 route loading.tsx나 가까운 Suspense boundary를 사용할 수 있다.
export default function DashboardPage() {
return (
<DashboardLayout>
<Summary />
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders />
</Suspense>
</DashboardLayout>
);
}
async function RecentOrders() {
const orders = await getRecentOrders();
return <OrderList orders={orders} />;
}
Summary가 준비되면 먼저 stream하고 RecentOrders는 나중에 채운다. boundary는 기술적으로 component마다 두는 것이 아니라 사용자에게 의미 있는 loading 단위로 둔다.
loading.tsx는 route segment 수준 instant loading UI를 제공하지만 layout 안의 uncached runtime access 위치에 따라 같은 segment loading boundary가 기대대로 덮지 못할 수 있다. 세부 동작은 Next.js 버전과 cache 설정을 공식 문서로 확인한다.
Client fetch가 반드시 필요한 경우:
- geolocation, storage 등 browser 값에 의존
- 사용자 상호작용 후에만 필요한 데이터
- 높은 빈도의 polling이나 realtime client subscription
- 이미 client query cache를 중심으로 한 화면
서버에서 가능한 fetch를 전부 client로 옮기면 JS load → hydration → query의 waterfall이 생길 수 있다.
경계가 잘못됐을 때 나타나는 신호
모든 파일에 use client가 있다
Server Component 기본 이점을 잃고 bundle이 커지며 server-only import 위험이 늘어난다.
직렬화 오류가 자주 난다
Client boundary에 DB 객체, callback, class instance를 너무 많이 넘기고 있을 수 있다. 최소 view model로 바꾼다.
browser bundle에서 server package가 보인다
client entry가 공용 barrel file을 통해 server module까지 import할 수 있다.
export * from "./db";
export * from "./format";
export * from "./client-hook";
server/client barrel을 분리하거나 직접 import한다.
서버에서 window is not defined가 난다
Client Component module top-level이나 third-party library가 browser API를 즉시 읽는다. Effect로 이동하거나 browser-only dynamic loading을 검토한다.
hydration mismatch가 난다
렌더 중 Date.now(), Math.random(), localStorage 값을 사용해 server HTML과 client 첫 결과가 달라질 수 있다.
function Greeting() {
const theme = localStorage.getItem("theme");
return <div data-theme={theme}>안녕하세요.</div>;
}
서버가 알 수 없는 browser state는 안정적인 초기값으로 render한 뒤 Effect에서 동기화하거나 cookie처럼 server가 읽을 수 있는 source를 사용한다.
client child가 server component를 직접 import한다
Server content를 server parent에서 만들어 children slot으로 전달한다.
빌드와 운영에서 검증할 것
개발 server에서 동작하는 것만으로 경계가 안전하다고 증명되지 않는다.
production build
pnpm build
production build에서 server-only import, 직렬화, dynamic route 오류가 더 분명하게 나타날 수 있다.
bundle 분석
client chunk에 다음이 들어가는지 확인한다.
- DB driver
- server SDK
- markdown/CSV 대형 parser
- admin-only dependency
- Node 전용 polyfill
bundle analyzer와 build output을 이전 commit과 비교한다.
보안 검사
- client JS에서 secret 문자열 검색
- HTML/RSC payload에 internal field가 없는지 확인
- source map 공개 범위 확인
- Server Action과 Route Handler authorization test
- 다른 사용자 ID로 직접 접근하는 IDOR test
성능 측정
- client JavaScript transfer/parse/evaluate 시간
- hydration 비용
- RSC payload 크기
- server query latency
- streaming boundary별 content 표시 시간
- navigation request waterfall
client bundle이 줄었어도 server 응답이 느려졌다면 사용자가 더 빨라졌다고 느끼지 못한다. 양쪽 비용을 함께 본다.
- 이 component에 state, Effect, event, browser API가 실제로 필요한가?
'use client'파일이 import하는 dependency는 모두 browser에 보내도 되는가?- server props는 직렬화 가능하고 공개 가능한 최소 데이터인가?
- Client 안의 server-rendered content를 slot composition으로 전달할 수 있는가?
- data access에서 인증과 row-level 권한을 검사하는가?
- Route Handler를 거치지 않고 server source를 직접 호출할 수 있는가?
정리
Server Component와 Client Component 경계는 “서버 렌더링 페이지인가 SPA인가”를 한 번에 고르는 선택이 아니다. 하나의 tree 안에서 데이터와 static UI는 server에 남기고 상호작용이 필요한 작은 영역만 client entry로 자르는 module 설계다.
- App Router component는 기본적으로 Server Component로 둔다.
- state, Effect, event, browser API가 필요한 곳에만
'use client'를 둔다. - directive 아래 import graph가 client bundle에 들어감을 기억한다.
- server data는 직렬화 가능하고 공개 가능한 최소 view model로 전달한다.
- Server Component를 Client Component의 children/slot으로 합성한다.
- server-only marker와 분리된 module 구조로 accidental import를 막는다.
- Server Action과 Route Handler에서도 인증·인가·validation을 반복한다.
- production build, bundle, RSC payload, server latency를 함께 측정한다.
좋은 경계는 client 기능을 없애는 것이 아니라 client JavaScript가 정말 필요한 곳을 작고 명확하게 만드는 경계다.