Skip to content

Latest commit

 

History

History
333 lines (238 loc) · 12.6 KB

컴포지트 패턴.md

File metadata and controls

333 lines (238 loc) · 12.6 KB

서버 그리고 클라이언트 컴포지트 패턴

  • 리액트 웹을 빌드할때 어플리케이션에서 클라이언트 또는 서버 렌더링 두개중 뭘 해야할지 고민이 필요함
  • 해당 페이지에서는 경우별로 어떤 패턴을 사용해야할지 다룸

언제 서버/클라이언트 컴포넌트를 사용할까?

뭐가 필요한지 서버 컴포넌트 클라이언트 컴포넌트
데이터 가져오기 O X
직접 백엔드 리소스 가져오기 O X
민감한 정보 감추기 O X
큰 종속성을 서버에서 유지/클라이언트 사이드 JS 감소하기 O X
인터렉티브한 이벤트 다루기(onClick 등) X O
상태를 사용하고 라이프사이클 이벤트 다루기 X O
브라우저에서만 작동하는 API 접근 X O
상태나 브라우저 API에 의존하는 커스텀 훅 쓰기 X O
리액트 class 컴포넌트 쓰기 X O

서버 컴포넌트 패턴

  • 클라이언트 측 렌더링을 진행하기 전에 서버에서 데이터 로딩, DB 접근 등 일부 작업을 수행해야 할수도 있음

컴포넌트 간 데이터 공유하기

  • 서버에서 데이터를 가져올때 다른 컴포넌트와 동일한 데이터를 공유해야할수도 있음
  • 예를 들면 레이아웃이나 페이지가 동일한 데이터에 의존하는 경우임
  • 서버에서 사용이 불가능한 Context를 사용하거나 props로 데이터를 전달하는 대신 동일한 데이터를 중복요청할 염려없이 fetch 또는 cache 함수를 사용해서 동일한 데이터 로딩이 가능함
  • fetch를 확장해서 데이터 요청을 자동으로 캐싱하고, fetch를 못쓸경우는 cache 함수를 쓸 수 있음

서버전용 코드를 클라이언트에서 제외시키기

  • JS 모듈을 서버와 클라이언트 컴포넌트 모듈 모두에서 공유가 가능함
  • 서버에서만 실행할려고 했던 코드가 클라이언트에 포함될수도 있음
export async function getData() {
  const res = await fetch("https://external-service.com/data", {
    headers: {
      authorization: process.env.API_KEY,
    },
  });

  return res.json();
}

언뜻 보면 서버/클라 모두에서 작동하는 것처럼 보임.
하지만 위 함수는 서버에서만 실행되도록 작성한 API_KEY가 포함되어있음

API_KEY의 경우 NEXT_PUBLIC 접두사가 없으므로 서버에서만 접근 가능한 환경변수임
클라이언트에서는 환경변수 유출 방지를 위해서 빈 문자열로 바꿔줌

결론은 클라이언트에서 getData() 함수를 사용할수는 있지만 원하는대로 작동은 안함
하지만 공개로 설정해버리면 공개하기 싫은 값을 클라이언트에 공개할 수 도 있음

이러한 의도치 않은 보안사고를 위해서 server-only 패키지를 사용하면 빌드시간에 에러를 발생시킬수 있음

npm install server-only
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() 함수는 클라이언트 컴포넌트에서 사용하는 경우 빌드에러가 발생함


외부 라이브러리나 프로바이더 사용하기

  • 외부 라이브러리나 프로바이더들은 useState, useEffect와 같은 클라이언트 전용 기능을 사용하기 위해서 "use client" 를 추가하기 시작함
  • 하지만 npm에 공개된 많은 라이브러리들은 "use client"를 추가하지 않았음
  • 클라이언트 컴포넌트에서는 잘 작동하지만 서버 컴포넌트에선 작동하지 않음
  • 아래 예시에서 <Carousel/>은 useState를 사용하지만 아직 use client 가 없음, 하지만 use client를 직접 선언하면 사용이 가능함
"use client";

import { useState } from "react";
import { Carousel } from "acme-carousel";

export default function Gallery() {
  let [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>View pictures</button>

      {/* 작동됨, Carousel은 클라이언트 컴포넌트에서 사용됨 */}
      {isOpen && <Carousel />}
    </div>
  );
}

하지만 <Carousel>을 서버 컴포넌트에서 사용하면 에러가 발생함
이유는 Next.js에서 <Carousel>이 클라이언트 전용 기능인걸 모르기 때문임

"useState" can not be used within Server Components

이 에러를 해결하기 위해서 외부 라이브러리를 한번 감사써 사용이 가능함

"use client";

import { Carousel } from "acme-carousel";

export default Carousel;

그러면 이제 서버 컴포넌트에서도 사용이 가능해짐

import Carousel from "./carousel";

export default function Page() {
  return (
    <div>
      <p>View pictures</p>
      <Carousel />
    </div>
  );
}

사실 대부분의 외부 라이브러리는 클라이언트 컴포넌트 내에서 사용할 가능성이 높기 때문에 감쌀 필요가 없을것으로 예상함

그러나 프로바이더는 React 상태와 Context에 의존하며 일반적으로 Application의 Root에서 필요함

Context 프로바이더 사용하기

  • Context Provider는 보통 어플리케이션의 루트에 렌더링되고 테마같은 글로벌 관심사를 공유함
  • 서버 컴포넌트에선 React Context가 지원되 않으므로 루트에서 컨텍스트를 생성하면 오류가 발생함
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>
  );
}

해결 방법은 Context를 만들고 클라이언트 컴포넌트 내부에서 렌더링하면됨

"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>;
}

이제 서버 컴포넌트가 클라이언트 컴포넌트로 표시되었으므로 Provider를 직접 렌더링 할 수 있음

import ThemeProvider from "./theme-provider";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ThemeProvider>{children}</ThemeProvider>
      </body>
    </html>
  );
}

루트에서 렌더링된 Provider를 사용하면 앱의 다른 모든 클라이언트 컴포넌트가 해당 컨텍스트를 사용할 수 있음

라이브러리 제작자들에게 권고하는 사항

  • 다른 개발자가 사용할 패키지를 만드는 사람들은 "use client" 를 사용해서 클라이언트 진입점을 표시할 수 있음
  • 이러면 굳이 개발자가 래핑경계를 만들지 않고 서버 컴포넌트에서 직접 사용이 가능함
  • 트리의 더 깊은곳에서 use client를 사용해서 가져온 모듈이 서버 컴포넌트 모듈 그래프의 일부가 되도록 성능 최적화가 가능함
  • 하지만 일부 번들러가 "use client" 를 제거할 수 도 있으니 조심해야함

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

  • JS 번들 크기를 줄일려면 클라이언트 컴포넌트를 트리 아래로 내리는걸 추천함
  • 예를 들어서 정적(로고, 링크) 및 상태를 사용하는 검색창 등이 있음
  • 전체를 클라이언트 컴포넌트로 만드는 대신 로직을 클라이언트 컴포넌트로 이동시키고 레이아웃은 서버 컴포넌트로 유지시킴
  • 즉 레이아웃의 모든 컴포넌트를 클라이언트로 보낼 필요가 없음
// SearchBar는 클라이언트 컴포넌트
import SearchBar from "./searchbar";
import Logo from "./logo";

// Layout은 기본적으로 서버 컴포넌트
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <nav>
        <Logo />
        <SearchBar />
      </nav>
      <main>{children}</main>
    </>
  );
}

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

  • 서버 -> 클라이언트 컴포넌트로 props를 전달하는 경우도 있음
  • 해당 props를 react에서 직렬화가 가능해야함
  • 만약 직렬화가 불가능한 데이터에 의존하는 경우는 클라이언트에서 데이터를 가져오거나 루트 핸들러를 통해서 서버에서 데이터를 가져올 수 있음

서버와 클라이언트 컴포넌트의 간섭

  • 동시에 렌더링이 필요한 경우는 UI를 컴포넌트 트리로 시작화 하는것이 좋음
  • 서버 컴포넌트인 루트 레이아웃에서 "use client"를 추가해서 클라이언트에서 컴포넌트의 특정 하위 트리를 렌더링 할 수 있음
  • 이러한 클라이언트 하위 트리에서 여전히 서버 컴포넌트를 중첩하거나 서버 액션 사용이 가능하지만 주의할점이 있음

주의할점

req/res 수명주기동안 코드는 서버에서 클라이언트로 이동함
클라이언트에서 서버의 데이터나 리소스에 접근이 필요한 경우 앞-뒤로 전환이 아니라 서버에 새로운 요청을 하게됨

서버에 새로운 요청이 가게되면 클라이언트 컴포넌트 내부에 중첩된 컴포넌트를 포함해서 모든 서버 컴포넌트가 먼저 렌더링됨

렌더링된 결과에는 클라이언트의 컴포넌트의 위치에 대한 참조가 RSC Payload에추가됨

그리고 나서 클라이언트에서 RSC Payload를 사용해서 서버/클라이언트 컴포넌트를 단일 트리로 조정함

클라이언트 컴포넌트는 서버 컴포넌트 이후에 렌더링 되기 때문에 서버 컴포넌트를 클라이언트 컴포넌트 모듈로 가져올수는 없음

대신 서버 컴포넌트를 클라이언트 컴포넌트에 props로 전달할수는 있음


지원되지 않는 패턴 : 클라이언트 컴포넌트에서 서버 컴포넌트 참조

"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 />
    </>
  );
}

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

  • 일반적인 패턴은 children을 활용해서 클라이언트 컴포넌트에 {slot}을 생성하는 방법
"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}
    </>
  );
}

위 예제에서 클라이언트 컴포넌트는 children으로 서버 컴포넌트가 채워질것을 모름

클라이언트 컴포넌트가 가진 유일한 책임은 결국 자식이 배치될 위치를 결정하는것임

루트(부모) 컴포넌트에서 클라이언트, 서버 컴포넌트를 모두 가져오고 클라이언트 컴포넌트로 서버 컴포넌트를 props로 전달도 가능함

import ClientComponent from "./client-component";
import ServerComponent from "./server-component";

export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}

이러한 접근방식을 사용하면 클라/서버 컴포넌트가 서로 분리되어서 독립적으로 렌더링이 가능함

이러한 경우 클라이언트 컴포넌트가 렌더링되기 이전에 서버에서 서버 컴포넌트가 먼저 렌더링 될수도 있음