Next.js의 Server Component와 Client Component

1. 개요

Next.js App Router는 기본적으로 component를 서버에서 render합니다. 이는 이전 Pages Router의 “모두 클라이언트” 모델과 완전히 다릅니다. 이 글은 Server Component와 Client Component가 어떻게 다른지 이해하고, 언제 "use client"를 반드시 사용해야 하는지, 그리고 두 가지 component를 하나의 render 트리에서 함께 사용하면서 오류를 피하는 방법을 보여줍니다. 읽고 나면 component가 서버에 있어야 할지 클라이언트에 있어야 할지 직접 결정할 수 있고, 그 경계를 넘나들면서 데이터를 올바르게 전달하는 방법을 알게 될 것입니다.

2. Server Component란 무엇인가

Server Component는 App Router(app/ 디렉터리)에서 기본적인 component 유형입니다. 완전히 서버에서 실행되며 자신의 JavaScript를 브라우저로 전송하지 않습니다.

// app/products/page.tsx
async function getProducts() {
  const res = await fetch("https://api.shop.local/products", { cache: "no-store" });
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <ul>
      {products.map((p: { id: number; name: string }) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

주목할 점:

  • ProductsPage는 async function입니다. Server Component는 직접 await를 사용할 수 있으며, useEffect나 useState로 데이터를 fetch할 필요가 없습니다.
  • fetch 명령은 서버에서 실행되므로 API 키나 민감한 로직이 절대 브라우저에 노출되지 않습니다.
  • 파일 시작에 "use client" 줄이 없으므로 Next.js는 자동으로 이를 Server Component로 인식합니다.

2.1. Server Component를 우선시해야 하는 이유

Server Component는 클라이언트로 전송되는 JavaScript 번들 크기를 줄입니다. component 자체와 그 import들(클라이언트화되지 않은 경우)은 브라우저용 번들에 포함되지 않기 때문입니다. 또한 데이터베이스, 파일 시스템, 또는 비밀 환경 변수에 직접 접근할 수 있으며, API route를 통해 노출할 필요가 없습니다.

Request → Server render (fetch, DB query) → HTML + RSC payload → Browser

브라우저는 미리 render된 HTML과 component 트리를 설명하는 payload(RSC payload)만 받으며, Server Component의 JavaScript 코드는 받지 않습니다.

3. Client Component란 무엇인가

Client Component는 브라우저에서 실행되어야 하는 component입니다. state, effect, 이벤트 핸들러, 또는 localStorage, window 같은 Web API가 필요할 때입니다. 파일 시작 부분에 "use client" directive를 추가하여 선언합니다.

// app/components/LikeButton.tsx
"use client";

import { useState } from "react";

export default function LikeButton() {
  const [liked, setLiked] = useState(false);

  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? "❤️ Liked" : "🤍 Like"}
    </button>
  );
}

설명:

  • "use client"는 파일의 첫 번째 줄(모든 import보다 먼저)이어야 하며, 이 파일에서 import된 모든 것도 클라이언트 번들에 포함된다는 경계를 표시합니다.
  • useState는 Client Component에서만 작동합니다. 브라우저에서 실행되는 JavaScript가 state 변경에 따른 리렌더링을 관리하기 때문입니다.
  • onClick은 이벤트 핸들러입니다. Client Component가 필요하다는 명확한 신호입니다.

참고: "use client"는 component가 오직 클라이언트에서만 실행된다는 의미가 아닙니다. Next.js는 여전히 처음에 서버에서 render합니다(SSR). 그 후 브라우저에서 “hydrate”되어 component가 상호작용 가능해집니다.

4. Client Component를 반드시 사용해야 할 때

몇 가지 경우에 반드시 "use client"를 추가해야 합니다. 그렇지 않으면 빌드 또는 런타임 오류가 발생합니다:

Error: useState can not be used in Server Components.

그러한 경우들은:

  • React hook 사용: useState, useEffect, useReducer, useContext 등
  • 이벤트 핸들러 연결: onClick, onChange, onSubmit 등
  • 브라우저 전용 Web API 사용: window, document, localStorage
  • 위의 것들이 포함된 custom hook 사용
  • 클라이언트 전용 라이브러리 사용(예: window 기반의 일부 charting 라이브러리)

반대로 component가 props만 받고 순수 JSX를 render하며, hook이나 이벤트 핸들러가 없다면, Client Component와 함께 있어도 Server Component로 두세요. 불필요하게 번들 크기를 증가시키지 않습니다.

5. Server Component와 Client Component 결합하기

이 모델의 가장 좋은 점은 children(composition)을 통해 Server Component를 Client Component에 중첩할 수 있다는 것입니다. 직접 import하지 않아도 됩니다.

// app/components/Modal.tsx
"use client";

import { useState } from "react";

export default function Modal({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open details</button>
      {open && <div className="modal">{children}</div>}
    </>
  );
}
// app/orders/[id]/page.tsx
import Modal from "@/app/components/Modal";

async function getOrder(id: string) {
  const res = await fetch(`https://api.shop.local/orders/${id}`);
  return res.json();
}

export default async function OrderPage({ params }: { params: { id: string } }) {
  const order = await getOrder(params.id);

  return (
    <Modal>
      {/* OrderDetail은 여전히 Server Component이며, children으로 "전달"될 뿐입니다 */}
      <p>Order ID: {order.id}</p>
      <p>Total: {order.total.toLocaleString("en-US")} $</p>
    </Modal>
  );
}

메커니즘의 설명:

  • Modal은 열고/닫는 state를 관리하는 Client Component입니다.
  • 내부 콘텐츠(<p>Order ID...</p>)는 OrderPage(Server Component)에서 render되고 children으로 Modal에 전달됩니다.
  • Next.js는 이 children 부분을 먼저 서버에서 render하고, 나중에 hydrate할 때 결과를 Client Component의 {children} 위치에 “삽입”합니다. 덕분에 getOrder 호출은 클라이언트화될 필요가 없습니다.

참고: Server Component를 직접 import하고 Client Component 내부에서 사용할 수 없습니다(예: "use client" 파일에서 import OrderDetail from "./OrderDetail"). Next.js는 OrderDetail을 클라이언트 번들로 끌어당길 것으로 간주하고, async function component가 클라이언트에서 유효하지 않다는 오류를 보고합니다. children 또는 props를 통한 composition이 올바른 방법입니다.

6. Server Component에서 Client Component로 데이터 전달하기

Server Component에서 Client Component로 전달되는 props는 직렬화 가능해야 합니다. 즉, JSON으로 변환할 수 있어야 합니다. function, class instance, 또는 복잡한 Date object는 전달할 수 없습니다.

// app/components/PriceTag.tsx
"use client";

export default function PriceTag({ amount, currency }: { amount: number; currency: string }) {
  return <span>{amount.toLocaleString("en-US")} {currency}</span>;
}
// app/products/[id]/page.tsx
import PriceTag from "@/app/components/PriceTag";

export default async function ProductDetailPage({ params }: { params: { id: string } }) {
  const product = await fetch(`https://api.shop.local/products/${params.id}`).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <PriceTag amount={product.price} currency="USD" />
    </div>
  );
}

여기서 amount(number)와 currency(string)는 모두 primitive 타입이며 직렬화 가능합니다. Server Component에서 PriceTag로 callback function을 전달하려고 하면 Next.js가 빌드/런타임에서 오류를 보고합니다:

Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".

Client Component에서 서버 로직을 호출하려면(예: 폼 제출, 데이터 저장), 일반 function 대신 Server Action(“use server“로 표시)을 사용하세요.

7. Server Action — 역방향 연결

Client Component가 서버로 돌아가서 logic을 실행해야 할 때(데이터 쓰기, DB 호출), 별도의 API route를 만드는 대신 Server Action을 사용하세요.

// app/actions/order-actions.ts
"use server";

export async function cancelOrder(orderId: string) {
  await fetch(`https://api.shop.local/orders/${orderId}/cancel`, { method: "POST" });
}
// app/components/CancelButton.tsx
"use client";

import { cancelOrder } from "@/app/actions/order-actions";
import { useState } from "react";

export default function CancelButton({ orderId }: { orderId: string }) {
  const [pending, setPending] = useState(false);

  async function handleClick() {
    setPending(true);
    await cancelOrder(orderId);
    setPending(false);
  }

  return (
    <button onClick={handleClick} disabled={pending}>
      {pending ? "Canceling..." : "Cancel order"}
    </button>
  );
}
  • cancelOrder는 "use server"로 표시됩니다. Next.js는 자동으로 숨겨진 endpoint를 생성하여 Client Component가 route handler를 직접 작성하지 않고도 호출할 수 있습니다.
  • CancelButton은 일반 함수처럼 import하고 호출합니다. 실제로는 네트워크 호출이지만, 코드는 자연스럽게 읽혀집니다.

8. 분리된 로직에 대한 테스트 작성하기

Server Component는 보통 데이터 fetch/transform 로직을 포함하므로, JSX가 없는 순수 로직을 분리하면 더 쉽게 테스트할 수 있습니다. component 전체를 render하는 테스트보다 낫습니다.

// app/lib/pricing.ts
export function formatPrice(amount: number, currency: string): string {
  if (amount < 0) {
    throw new Error("amount cannot be negative");
  }
  return `${amount.toLocaleString("en-US")} ${currency}`;
}
// app/lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./pricing";

describe("formatPrice", () => {
  it("formats valid amount", () => {
    expect(formatPrice(150000, "USD")).toBe("150,000 USD");
  });

  it("throws error when amount is negative", () => {
    expect(() => formatPrice(-1, "USD")).toThrow("amount cannot be negative");
  });
});

프로젝트가 Client Component 부분에 Jest + Testing Library를 사용하면(예: LikeButton), 일반 React component처럼 테스트하세요. Jest는 "use client" directive에 대해 알 필요가 없습니다. Next.js 번들러에서만 의미가 있으며, 테스트 런타임에 영향을 주지 않습니다.

// app/components/LikeButton.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import LikeButton from "./LikeButton";

describe("LikeButton", () => {
  it("toggles state on click", () => {
    render(<LikeButton />);
    const button = screen.getByRole("button");

    expect(button).toHaveTextContent("Like");
    fireEvent.click(button);
    expect(button).toHaveTextContent("Liked");
  });
});

9. 일반적인 오류

App Router로 전환할 때 자주 마주치는 몇 가지 오류:

  • hook을 사용할 때 "use client"를 잊음: 오류 useState can not be used in Server Components 발생. 그 hook을 사용하는 파일에 directive를 추가하세요. 호출하는 파일이 아니라 hook을 정의한 파일에 추가합니다.
  • component 트리에서 너무 높게 "use client" 배치: 예를 들어 layout.tsx 전체를 client로 표시하면, 직접 import된 경우 그 안의 모든 것(Server Component 자식 포함)도 클라이언트 번들에 끌어당겨져 불필요하게 번들 크기가 증가합니다. 필요한 최소한의 component만 클라이언트화하세요(예: 버튼, form input). page 전체가 아니라.
  • 직렬화 불가능한 function 또는 object를 props로 전달: Server Component에서 Client Component로 callback을 전달하려고 시도합니다. Server Action("use server")을 대신 사용하세요.
  • "use client" 파일 내에서 Server Component를 import한 후 여전히 서버에서 실행될 것으로 기대: 불가능합니다. children/props를 통한 composition을 사용해야 합니다.
  • Client Component에서 secret key와 함께 fetch 호출: API 키가 브라우저로 전송되는 JavaScript 번들에 노출됩니다. secret이 필요한 모든 작업은 항상 Server Component 또는 Server Action에 있어야 합니다.

참고: “모든 것을 Client Component로 전환하면 예전 코드처럼 된다”는 생각은 안전하지 않습니다. 불필요한 각 Client Component는 번들 크기를 증가시키고, 상호작용 시간(TTI)을 느리게 하며, 서버 render의 이점을 잃게 합니다.

10. 결론

Server Component는 App Router의 기본값이며, 데이터 fetch, 민감한 자원 접근, 그리고 JavaScript 번들 축소에 적합합니다. Client Component는 state, effect, 또는 이벤트 핸들러가 필요할 때만 사용하세요. 이 두 가지는 composition(children, 직렬화 가능한 props)과 Server Action(Client Component가 서버 로직을 다시 호출하는 방법)을 통해 결합됩니다. 이 경계를 확실히 이해하면 번들 크기를 최적화하고 민감한 데이터를 서버에서 안전하게 유지할 수 있습니다.

nextjsreactserver-componentclient-componentapp-router