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 모두 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 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에서 지속적으로 상호작용해야 하는 영역에 필요하다.

"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 경계를 둔다.

다음 값도 주의한다.

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는 다음 상황에 자연스럽다.

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으로 바꾸지 않는다.

데이터 변경 후 cache

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가 반드시 필요한 경우:

서버에서 가능한 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에 다음이 들어가는지 확인한다.

bundle analyzer와 build output을 이전 commit과 비교한다.

보안 검사

성능 측정

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 설계다.

좋은 경계는 client 기능을 없애는 것이 아니라 client JavaScript가 정말 필요한 곳을 작고 명확하게 만드는 경계다.

관련 노트와 참고 자료