문서
App Router 사용하기
컴포지션 패턴

컴포지션 패턴

React 애플리케이션을 구축할 때, 애플리케이션의 어떤 부분을 서버에서 렌더링하고 어떤 부분을 클라이언트에서 렌더링할지 고려해야 합니다. 이 페이지에서는 서버 및 클라이언트 컴포넌트를 사용할 때의 권장 컴포지션 패턴을 다룹니다.

서버 및 클라이언트 컴포넌트를 언제 사용해야 할까요?

다음은 서버 및 클라이언트 컴포넌트의 다양한 사용 사례에 대한 간단한 요약입니다:

무엇을 해야 하나요?서버
컴포넌트
클라이언트
컴포넌트
데이터 가져오기
백엔드 리소스에 직접 접근
서버에 민감한 정보 보관 (액세스 토큰, API 키 등)
서버에 큰 의존성 유지 / 클라이언트 측 JavaScript 감소
상호작용 및 이벤트 리스너 추가 (onClick(), onChange() 등)
상태 및 생명주기 효과 사용 (useState(), useReducer(), useEffect() 등)
브라우저 전용 API 사용
상태, 효과 또는 브라우저 전용 API에 의존하는 사용자 정의 훅 사용
React 클래스 컴포넌트 사용

서버 컴포넌트 패턴

클라이언트 측 렌더링을 선택하기 전에 데이터 가져오기나 데이터베이스 또는 백엔드 서비스 접근과 같은 작업을 서버에서 수행하고 싶을 수 있습니다.

서버 컴포넌트로 작업할 때의 일반적인 패턴은 다음과 같습니다:

컴포넌트 간 데이터 공유

서버에서 데이터를 가져올 때 다른 컴포넌트 간에 데이터를 공유해야 하는 경우가 있을 수 있습니다. 예를 들어, 동일한 데이터에 의존하는 레이아웃과 페이지가 있을 수 있습니다.

(서버에서 사용할 수 없는) React Context를 사용하거나 props로 데이터를 전달하는 대신, fetch 또는 React의 cache 함수를 사용하여 데이터가 필요한 컴포넌트에서 동일한 데이터를 가져올 수 있습니다. 이때 동일한 데이터에 대해 중복 요청을 걱정할 필요가 없습니다. 이는 React가 fetch를 확장하여 데이터 요청을 자동으로 메모이제이션하고, fetch를 사용할 수 없을 때 cache 함수를 사용할 수 있기 때문입니다.

React의 메모이제이션에 대해 자세히 알아보세요.

서버 전용 코드를 클라이언트 환경에서 제외하기

JavaScript 모듈은 서버와 클라이언트 컴포넌트 모듈 모두에서 공유될 수 있으므로, 서버에서만 실행되도록 의도된 코드가 클라이언트로 스며들 수 있습니다.

예를 들어, 다음과 같은 데이터 가져오기 함수를 살펴보겠습니다:

lib/data.ts
export async function getData() {
  const res = await fetch("https://external-service.com/data", {
    headers: {
      authorization: process.env.API_KEY,
    },
  });
 
  return res.json();
}
lib/data.js
export async function getData() {
  const res = await fetch("https://external-service.com/data", {
    headers: {
      authorization: process.env.API_KEY,
    },
  });
 
  return res.json();
}

얼핏 보면 getData가 서버와 클라이언트 모두에서 작동하는 것처럼 보입니다. 그러나 이 함수에는 서버에서만 실행되도록 의도된 API_KEY가 포함되어 있습니다.

환경 변수 API_KEYNEXT_PUBLIC으로 시작하지 않으므로 서버에서만 접근할 수 있는 비공개 변수입니다. 환경 변수가 클라이언트에 유출되는 것을 방지하기 위해 Next.js는 비공개 환경 변수를 빈 문자열로 대체합니다.

결과적으로 getData()를 클라이언트에서 가져와 실행할 수 있지만 예상대로 작동하지 않습니다. 그리고 변수를 공개로 만들면 함수가 클라이언트에서 작동하겠지만, 민감한 정보를 클라이언트에 노출하고 싶지 않을 수 있습니다.

서버 코드의 의도하지 않은 클라이언트 사용을 방지하기 위해 server-only 패키지를 사용하여 개발자가 실수로 이러한 모듈을 클라이언트 컴포넌트로 가져올 경우 빌드 시 오류를 발생시킬 수 있습니다.

server-only를 사용하려면 먼저 패키지를 설치하세요:

Terminal
npm install server-only

그런 다음 서버 전용 코드가 포함된 모듈에 패키지를 가져옵니다:

lib/data.js
import "server-only";
 
export async function getData() {
  const res = await fetch("https://external-service.com/data", {
    headers: {
      authorization: process.env.API_KEY,
    },
  });
 
  return res.json();
}

이제 getData()를 가져오는 모든 클라이언트 컴포넌트는 이 모듈이 서버에서만 사용할 수 있다는 빌드 시 오류를 받게 됩니다.

대응되는 client-only 패키지를 사용하여 window 객체에 접근하는 코드와 같은 클라이언트 전용 코드가 포함된 모듈을 표시할 수 있습니다.

서드파티 패키지 및 프로바이더 사용

서버 컴포넌트는 새로운 React 기능이므로 에코시스템의 서드파티 패키지와 프로바이더들은 useState, useEffect, createContext와 같은 클라이언트 전용 기능을 사용하는 컴포넌트에 "use client" 지시어를 추가하기 시작하고 있습니다.

현재 npm 패키지의 많은 컴포넌트들이 클라이언트 전용 기능을 사용하지만 아직 지시어가 없습니다. 이러한 서드파티 컴포넌트는 "use client" 지시어가 있는 클라이언트 컴포넌트 내에서는 예상대로 작동하지만 서버 컴포넌트 내에서는 작동하지 않습니다.

예를 들어, 가상의 acme-carousel 패키지를 설치했고 이 패키지에는 <Carousel /> 컴포넌트가 있다고 가정해 봅시다. 이 컴포넌트는 useState를 사용하지만 아직 "use client" 지시어가 없습니다.

클라이언트 컴포넌트 내에서 <Carousel />을 사용하면 예상대로 작동합니다:

app/gallery.tsx
"use client";
 
import { useState } from "react";
import { Carousel } from "acme-carousel";
 
export default function Gallery() {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>사진 보기</button>
 
      {/*  Carousel이 클라이언트 컴포넌트 내에서 사용되므로 작동합니다 */}
      {isOpen && <Carousel />}
    </div>
  );
}
app/gallery.js
"use client";
 
import { useState } from "react";
import { Carousel } from "acme-carousel";
 
export default function Gallery() {
  const [isOpen, setIsOpen] = useState(false);
 
  return (
    <div>
      <button onClick={() => setIsOpen(true)}>사진 보기</button>
 
      {/*  Carousel이 클라이언트 컴포넌트 내에서 사용되므로 작동합니다 */}
      {isOpen && <Carousel />}
    </div>
  );
}

그러나 서버 컴포넌트 내에서 직접 사용하려고 하면 오류가 발생합니다:

app/page.tsx
import { Carousel } from "acme-carousel";
 
export default function Page() {
  return (
    <div>
      <p>사진 보기</p>
 
      {/*  오류: `useState`는 서버 컴포넌트 내에서 사용할 수 없습니다 */}
      <Carousel />
    </div>
  );
}
app/page.js
import { Carousel } from "acme-carousel";
 
export default function Page() {
  return (
    <div>
      <p>사진 보기</p>
 
      {/*  오류: `useState`는 서버 컴포넌트 내에서 사용할 수 없습니다 */}
      <Carousel />
    </div>
  );
}

이는 Next.js가 <Carousel />이 클라이언트 전용 기능을 사용하고 있음을 모르기 때문입니다.

이를 해결하려면 클라이언트 전용 기능에 의존하는 서드파티 컴포넌트를 자체 클라이언트 컴포넌트로 감쌀 수 있습니다:

app/carousel.tsx
"use client";
 
import { Carousel } from "acme-carousel";
 
export default Carousel;
app/carousel.js
"use client";
 
import { Carousel } from "acme-carousel";
 
export default Carousel;

이제 서버 컴포넌트 내에서 <Carousel />을 직접 사용할 수 있습니다:

app/page.tsx
import Carousel from "./carousel";
 
export default function Page() {
  return (
    <div>
      <p>사진 보기</p>
 
      {/*  Carousel이 클라이언트 컴포넌트이므로 작동합니다 */}
      <Carousel />
    </div>
  );
}
app/page.js
import Carousel from "./carousel";
 
export default function Page() {
  return (
    <div>
      <p>사진 보기</p>
 
      {/*  Carousel이 클라이언트 컴포넌트이므로 작동합니다 */}
      <Carousel />
    </div>
  );
}

대부분의 서드파티 컴포넌트를 클라이언트 컴포넌트 내에서 사용할 가능성이 높기 때문에 이러한 작업을 많이 할 필요는 없을 것입니다. 그러나 한 가지 예외는 프로바이더입니다. 프로바이더는 React 상태와 컨텍스트에 의존하며 일반적으로 애플리케이션의 루트에 필요하기 때문입니다. 아래에서 서드파티 컨텍스트 프로바이더에 대해 자세히 알아보세요.

컨텍스트 프로바이더 사용하기

컨텍스트 프로바이더는 일반적으로 현재 테마와 같은 전역적 관심사를 공유하기 위해 애플리케이션의 루트 근처에 렌더링됩니다. React 컨텍스트는 서버 컴포넌트에서 지원되지 않으므로, 애플리케이션의 루트에서 컨텍스트를 생성하려고 하면 오류가 발생합니다:

app/layout.tsx
import { createContext } from "react";
 
//  createContext는 서버 컴포넌트에서 지원되지 않습니다
export const ThemeContext = createContext({});
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  );
}
app/layout.js
import { createContext } from "react";
 
//  createContext는 서버 컴포넌트에서 지원되지 않습니다
export const ThemeContext = createContext({});
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>
      </body>
    </html>
  );
}

이를 해결하려면 클라이언트 컴포넌트 내에서 컨텍스트를 생성하고 프로바이더를 렌더링하세요:

app/theme-provider.tsx
"use client";
 
import { createContext } from "react";
 
export const ThemeContext = createContext({});
 
export default function ThemeProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
app/theme-provider.js
"use client";
 
import { createContext } from "react";
 
export const ThemeContext = createContext({});
 
export default function ThemeProvider({ children }) {
  return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}

이제 서버 컴포넌트가 클라이언트 컴포넌트로 표시되었으므로 프로바이더를 직접 렌더링할 수 있습니다:

app/layout.tsx
import ThemeProvider from "./theme-provider";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}
app/layout.js
import ThemeProvider from "./theme-provider";
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

프로바이더가 루트에서 렌더링되면 앱 전체의 다른 클라이언트 컴포넌트에서 이 컨텍스트를 사용할 수 있습니다.

알아두면 좋은 점: 프로바이더를 트리에서 가능한 깊게 렌더링해야 합니다 - ThemeProvider가 전체 <html> 문서를 감싸지 않고 {children}만 감싸는 것에 주목하세요. 이렇게 하면 Next.js가 서버 컴포넌트의 정적 부분을 더 쉽게 최적화할 수 있습니다.

라이브러리 작성자를 위한 조언

비슷한 방식으로, 다른 개발자들이 사용할 패키지를 만드는 라이브러리 작성자들은 "use client" 지시어를 사용하여 패키지의 클라이언트 진입점을 표시할 수 있습니다. 이를 통해 패키지 사용자가 서버 컴포넌트에서 패키지 컴포넌트를 직접 가져올 수 있으며, 감싸는 컴포넌트를 만들 필요가 없습니다.

트리에서 더 깊은 곳에 'use client' 사용하기를 통해 패키지를 최적화할 수 있으며, 이를 통해 가져온 모듈이 서버 컴포넌트 모듈 그래프의 일부가 될 수 있습니다.

일부 번들러는 "use client" 지시어를 제거할 수 있다는 점에 주의해야 합니다. React Wrap BalancerVercel Analytics 리포지토리에서 esbuild를 구성하여 "use client" 지시어를 포함하는 방법의 예를 찾을 수 있습니다.

클라이언트 컴포넌트

클라이언트 컴포넌트를 트리 아래로 이동하기

클라이언트 JavaScript 번들 크기를 줄이기 위해 클라이언트 컴포넌트를 컴포넌트 트리 아래로 이동하는 것을 권장합니다.

예를 들어, 정적 요소(예: 로고, 링크 등)와 상태를 사용하는 대화형 검색 바가 있는 레이아웃이 있을 수 있습니다.

전체 레이아웃을 클라이언트 컴포넌트로 만드는 대신, 대화형 로직을 클라이언트 컴포넌트(예: <SearchBar />)로 이동하고 레이아웃을 서버 컴포넌트로 유지하세요. 이렇게 하면 레이아웃의 모든 컴포넌트 JavaScript를 클라이언트로 보내지 않아도 됩니다.

app/layout.tsx
// SearchBar는 클라이언트 컴포넌트입니다
import SearchBar from "./searchbar";
// Logo는 서버 컴포넌트입니다
import Logo from "./logo";
 
// Layout은 기본적으로 서버 컴포넌트입니다
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  );
}
app/layout.js
// SearchBar는 클라이언트 컴포넌트입니다
import SearchBar from "./searchbar";
// Logo는 서버 컴포넌트입니다
import Logo from "./logo";
 
// Layout은 기본적으로 서버 컴포넌트입니다
export default function Layout({ children }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  );
}

서버에서 클라이언트 컴포넌트로 props 전달하기 (직렬화)

서버 컴포넌트에서 데이터를 가져오는 경우, 데이터를 클라이언트 컴포넌트에 props로 전달하고 싶을 수 있습니다. 서버에서 클라이언트 컴포넌트로 전달되는 props는 React에 의해 직렬화 가능해야 합니다.

클라이언트 컴포넌트가 직렬화할 수 없는 데이터에 의존하는 경우, 서드파티 라이브러리를 사용하여 클라이언트에서 데이터를 가져오거나 Route Handler를 통해 서버에서 가져올 수 있습니다.

서버와 클라이언트 컴포넌트 교차하기

클라이언트와 서버 컴포넌트를 교차할 때, UI를 컴포넌트 트리로 시각화하는 것이 도움이 될 수 있습니다. 루트 레이아웃부터 시작하여, 이는 서버 컴포넌트입니다. 그런 다음 "use client" 지시어를 추가하여 특정 하위 트리의 컴포넌트를 클라이언트에서 렌더링할 수 있습니다.

이러한 클라이언트 하위 트리 내에서도 서버 컴포넌트를 중첩하거나 서버 액션을 호출할 수 있지만, 주의해야 할 몇 가지 사항이 있습니다:

  • 요청-응답 생명주기 동안 코드는 서버에서 클라이언트로 이동합니다. 클라이언트에 있는 동안 서버의 데이터나 리소스에 접근해야 한다면, 서버로 새로운 요청을 만들게 됩니다 - 앞뒤로 전환하는 것이 아닙니다.
  • 서버에 새로운 요청이 이루어지면, 클라이언트 컴포넌트 내에 중첩된 것들을 포함하여 모든 서버 컴포넌트가 먼저 렌더링됩니다. 렌더링된 결과(RSC 페이로드)에는 클라이언트 컴포넌트의 위치에 대한 참조가 포함됩니다. 그런 다음 클라이언트에서 React는 RSC 페이로드를 사용하여 서버와 클라이언트 컴포넌트를 단일 트리로 조정합니다.
  • 클라이언트 컴포넌트는 서버 컴포넌트 후에 렌더링되므로 서버 컴포넌트를 클라이언트 컴포넌트 모듈로 가져올 수 없습니다(서버로 새로운 요청이 필요하기 때문). 대신 서버 컴포넌트를 클라이언트 컴포넌트에 props로 전달할 수 있습니다. 아래의 지원되지 않는 패턴지원되는 패턴 섹션을 참조하세요.

지원되지 않는 패턴: 클라이언트 컴포넌트로 서버 컴포넌트 가져오기

다음 패턴은 지원되지 않습니다. 서버 컴포넌트를 클라이언트 컴포넌트로 가져올 수 없습니다:

app/client-component.tsx
"use client";
 
// 서버 컴포넌트를 클라이언트 컴포넌트로 가져올 수 없습니다.
import ServerComponent from "./Server-Component";
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  );
}
app/client-component.js
"use client";
 
// 서버 컴포넌트를 클라이언트 컴포넌트로 가져올 수 없습니다.
import ServerComponent from "./Server-Component";
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0);
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      <ServerComponent />
    </>
  );
}

지원되는 패턴: 서버 컴포넌트를 클라이언트 컴포넌트에 props로 전달하기

다음 패턴은 지원됩니다. 서버 컴포넌트를 클라이언트 컴포넌트에 prop으로 전달할 수 있습니다.

일반적인 패턴은 React children prop을 사용하여 클라이언트 컴포넌트에 "슬롯" 을 만드는 것입니다.

아래 예시에서 <ClientComponent>children prop을 받습니다:

app/client-component.tsx
"use client";
 
import { useState } from "react";
 
export default function ClientComponent({
  children,
}: {
  children: React.ReactNode;
}) {
  const [count, setCount] = useState(0);
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
      {children}
    </>
  );
}
app/client-component.js
"use client";
 
import { useState } from "react";
 
export default function ClientComponent({ children }) {
  const [count, setCount] = useState(0);
 
  return (
    <>
      <button onClick={() => setCount(count + 1)}>{count}</button>
 
      {children}
    </>
  );
}

<ClientComponent>children이 결국 서버 컴포넌트의 결과로 채워질 것이라는 것을 모릅니다. <ClientComponent>의 유일한 책임은 children어디에 배치될지를 결정하는 것입니다.

부모 서버 컴포넌트에서 <ClientComponent><ServerComponent>를 모두 가져와서 <ServerComponent><ClientComponent>의 자식으로 전달할 수 있습니다:

app/page.tsx
// 이 패턴은 작동합니다:
// 서버 컴포넌트를 클라이언트 컴포넌트의 자식이나 prop으로 전달할 수 있습니다.
import ClientComponent from "./client-component";
import ServerComponent from "./server-component";
 
// Next.js의 페이지는 기본적으로 서버 컴포넌트입니다
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}
app/page.js
// 이 패턴은 작동합니다:
// 서버 컴포넌트를 클라이언트 컴포넌트의 자식이나 prop으로 전달할 수 있습니다.
import ClientComponent from "./client-component";
import ServerComponent from "./server-component";
 
// Next.js의 페이지는 기본적으로 서버 컴포넌트입니다
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}

이 접근 방식을 사용하면 <ClientComponent><ServerComponent>가 분리되어 독립적으로 렌더링될 수 있습니다. 이 경우 자식 <ServerComponent><ClientComponent>가 클라이언트에서 렌더링되기 훨씬 전에 서버에서 렌더링될 수 있습니다.

알아두면 좋은 점:

  • "콘텐츠 끌어올리기" 패턴은 부모 컴포넌트가 다시 렌더링될 때 중첩된 자식 컴포넌트의 재렌더링을 피하는 데 사용되었습니다.
  • children prop에만 국한되지 않습니다. JSX를 전달하는 데 어떤 prop이든 사용할 수 있습니다.