Web Devlopment/NextJs

#32. Interleaving Server and Client Components

키모형 2025. 12. 30. 21:28
반응형

Interleaving Server and Client Components (Next.js App Router)

App Router에서는 page.tsx, layout.tsx가 기본적으로 Server Component입니다.
브라우저 상호작용(상태, 이벤트, 브라우저 API)이 필요할 때만 파일 상단에 'use client'를 붙여 Client Component 경계(boundary) 를 만듭니다.

핵심 규칙은 2줄로 요약됩니다.

  • Server Component는 Server/Client를 모두 import 가능
  • Client Component는 Client만 import 가능 (Server import 불가)

이 규칙을 바탕으로, 요청하신 4가지 케이스를 정리합니다.


1) Client Component 내에서 다른 Client Component 사용

가장 일반적인 케이스입니다. Client 컴포넌트는 다른 Client 컴포넌트를 자유롭게 import해서 사용합니다.

// components/Button.client.tsx
"use client";

export function Button({ onClick, children }: any) {
  return <button onClick={onClick}>{children}</button>;
}
 
// components/Counter.client.tsx
"use client";

import { useState } from "react";
import { Button } from "./Button.client";

export function Counter() {
  const [n, setN] = useState(0);
  return (
    <div>
      <p>{n}</p>
      <Button onClick={() => setN((v: number) => v + 1)}>+</Button>
    </div>
  );
}
 

✅ 포인트

  • Client Component는 useState, useEffect 등 훅 사용 가능
  • 대신 Client Component는 async function(서버처럼 await 렌더) 불가이므로, 데이터는 서버에서 준비해서 props로 넘기거나, 클라이언트에서 fetch로 가져오는 방식으로 처리합니다.

2) Client Component 내에서 Server Component를 쓰고 싶을 때 (children/slot 패턴)

❌ 이렇게는 안 됩니다:

// Client에서 Server를 import (금지)
"use client";
import { ServerList } from "./ServerList"; // ❌

export function Panel() {
  return <ServerList />;
}
 
이유: Client 컴포넌트는 Server 컴포넌트를 import할 수 없습니다.

✅ 정석 해결: “Server에서 조립하고 Client에는 children으로 꽂기”

Client는 Server를 import하지 않고, Server 부모가 Server 컴포넌트를 렌더링한 결과(ReactNode)를 children으로 전달합니다.

// components/Panel.client.tsx
"use client";

import { useState } from "react";

export function Panel({ title, children }: { title: string; children: React.ReactNode }) {
  const [open, setOpen] = useState(true);

  return (
    <section>
      <header style={{ display: "flex", gap: 8 }}>
        <h2>{title}</h2>
        <button onClick={() => setOpen((v) => !v)}>{open ? "닫기" : "열기"}</button>
      </header>
      {open ? <div>{children}</div> : null}
    </section>
  );
}
// components/ServerList.tsx (Server Component)
export async function ServerList() {
  await new Promise((r) => setTimeout(r, 2000));
  return (
    <ul>
      <li>서버 데이터 1</li>
      <li>서버 데이터 2</li>
    </ul>
  );
}
// app/page.tsx (Server Component)
import { Panel } from "@/components/Panel.client";
import { ServerList } from "@/components/ServerList";

export default function Page() {
  return (
    <main>
      <Panel title="서버 리스트">
        <ServerList />
      </Panel>
    </main>
  );
}
 

✅ 핵심 포인트

  • Client는 Server를 “import”하지 않습니다.
  • Server가 <ServerList />를 렌더링한 결과를 children(슬롯) 으로 Client에 전달합니다.
  • 이 패턴은 Providers(Context Provider)를 root layout에서 감쌀 때도 동일하게 적용됩니다.
    (RootLayout=Server, Providers=Client, 페이지(children)=Server를 “children으로 통과”)

3) Server Component 내에서 다른 Server Component 사용

가장 자연스러운 방식입니다. Server끼리는 자유롭게 import/사용합니다.

// components/Product.server.tsx
export async function Product() {
  await new Promise((r) => setTimeout(r, 2000));
  return <div>Product</div>;
}

// components/Reviews.server.tsx
export async function Reviews() {
  await new Promise((r) => setTimeout(r, 4000));
  return <div>Reviews</div>;
}

// app/product-reviews/page.tsx (Server)
import { Product } from "@/components/Product.server";
import { Reviews } from "@/components/Reviews.server";

export default function Page() {
  return (
    <div>
      <h1>Product Reviews</h1>
      <Product />
      <Reviews />
    </div>
  );
}
✅ 포인트
  • Server Component는 async/await로 데이터 로딩(또는 지연 테스트)을 자연스럽게 할 수 있습니다.
  • Streaming을 보이게 하려면 경계를 나눠야 하므로, 필요하면 <Suspense> 또는 loading.tsx를 함께 사용합니다.

4) Server Component 내에서 Client Component 사용

✅ 가능합니다. Server는 Client를 import해서 렌더 트리에 끼워 넣을 수 있습니다.

// components/Carousel.client.tsx
"use client";

export function Carousel({ items }: { items: string[] }) {
  return (
    <div>
      {items.map((x) => (
        <span key={x} style={{ marginRight: 8 }}>{x}</span>
      ))}
    </div>
  );
}
 
// app/page.tsx (Server)
import { Carousel } from "@/components/Carousel.client";

export default async function Page() {
  // 서버에서 데이터 준비 가능
  const items = ["A", "B", "C"];

  return (
    <div>
      <h1>Home</h1>
      <Carousel items={items} />
    </div>
  );
}

 

✅ 주의할 점(실무에서 중요)

  1. Client로 넘기는 props는 직렬화 가능한 값이어야 합니다.
    문자열/숫자/배열/평범한 객체 OK, 함수/클래스/특수 객체는 일반적으로 불가합니다.
  2. Server에서 Client를 상위에 두면 그 아래가 모두 Client가 되는 건 아니지만,
    'use client' 경계를 어디에 두느냐에 따라 클라이언트 번들이 커질 수 있으므로 Client 컴포넌트는 “필요한 곳에만” 배치하는 것이 좋습니다.

한 장 요약 (규칙 암기용)

  • Client → Client import OK
  • Client → Server import NO
    • ✅ 해결: Server가 조립해서 Client의 children(slot)로 전달
  • Server → Server import OK
  • Server → Client import OK (단, props 직렬화/번들 크기 주의)
반응형