[Next.js] Provider Pattern(번역)

련지·2024년 8월 11일

Next.js

목록 보기
3/3

Provider Pattern을 아십니까

Next.js + zustand

Next.js + zustand를 사용해본 경험은... 가히 좌절스러웠다
zustand에 저장된 값에 접근하려면 "use client"; 가 필수라서 너무 많은 컴포넌트들이 클라이언트 컴포넌트로 변했다
클라이언트 컴포넌트가 나쁜 건 아니지만...

'이게 React랑 다른 게 뭔가...' 싶은 마음이...

어 근데 저번엔 괜찮았는데?

그런데 생각해보니 직전 프로젝트에서는 redux나 zustand를 안 썼다
코드를 다시 보니 Provider 라는 걸 썼길래 공부해봤다

교재

https://www.patterns.dev/vanilla/provider-pattern
위 사이트에 예시도 세세하게 잘 나와 있고 설명이 직관적이어서 좋았다!
단점은?

영어다

...
어차피 나도 공부해야 하고 공부하려면 해석해야 하고...
영어 공부랑 개발 공부를 동시에 할 겸 해석해가며 공부했다
도움이 될까 하여 자체 해석본을 첨부한다

한글 해석

가끔, 여러 개의 컴포넌트에서 같은 데이터를 사용하고 싶을 때가 있다.

props를 통해 컴포넌트 간 데이터는 전달할 수 있지만, 만약 애플리케이션의 거의 모든 컴포넌트들이 해당 데이터를 필요로 한다면 이는 매우 어려운 일이다.

그래서 종종 우리는 컴포넌트 트리의 저 아래까지 props를 전달하고 전달하는 prop drilling을 하게되는 경우가 많다.

props에 의존하고 있는 컴포넌트의 코드를 리팩토링 하는 건 거의 불가능에 가까우며, 이 데이터가 어디서부터 온 것인지 알기 매우 어렵다.

만약 어떤 데이터를 가지고 있는 하나의 App 컴포넌트가 있다고 해보자.

그리고 컴포넌트 트리의 저 밑에는 App 컴포넌트의 데이터가 필요한 ListItem, Header, Text 컴포넌트가 있다.

이 하위 컴포넌트들에게 데이터를 전달하기 위해서, 수많은 컴포넌트들을 거쳐 데이터를 전달해야 한다.

코드로 하면 이렇게 될 것이다.

function App() {
  const data = { ... }

  return (
    <div>
      <SideBar data={data} />
      <Content data={data} />
    </div>
  )
}

const SideBar = ({ data }) => <List data={data} />
const List = ({ data }) => <ListItem data={data} />
const ListItem = ({ data }) => <span>{data.listItem}</span>

const Content = ({ data }) => (
  <div>
    <Header data={data} />
    <Block data={data} />
  </div>
)
const Header = ({ data }) => <div>{data.title}</div>
const Block = ({ data }) => <Text data={data} />
const Text = ({ data }) => <h1>{data.text}</h1>

이런 식으로 props를 넘기는 것은 꽤나 지저분해질 수 있다.

만약 훗날 data prop의 이름을 변경하고 싶다면, 모든 컴포넌트에 있는 prop의 이름도 함께 변경해야 한다.

애플리케이션 규모가 커질 수록 prop drilling은 헷갈리게 될 것이다.

이러한 문제점은 데이터를 필요로 하지 않는 컴포넌트 층을 건너뛰어버린다면 최적화가 가능하다.

우리에게는 기존의 prop drilling이 아니라, 데이터에 접근하려는 컴포넌트들에게 data를 직접적으로 전달할 수 있는 무언가가 필요하다.

여기서 Provider Pattern이 우리를 도와줄 수 있다!

Provider Pattern을 사용하면, 여러 컴포넌트들이 데이터에 접근 가능하게 할 수 있다.

수많은 컴포넌트 층을 통해 데이터를 내려 보내는 것이 아니라, 모든 컴포넌트들을 Provider로 감싸는 것이다.

ProviderContext 객체를 통해 제공되는 고차 컴포넌트다.

React의 createContext 메서드를 사용해 Context 객체를 만들 수 있다.

Provider는f 내려 보내고 싶은 데이터를 포함한 value라는 prop을 받는다.

Provider로 감싸진 모든 컴포넌트들은 이 value prop의 값에 접근할 수 있다.

const DataContext = React.createContext()

function App() {
  const data = { ... }

  return (
    <div>
      <DataContext.Provider value={data}>
        <SideBar />
        <Content />
      </DataContext.Provider>
    </div>
  )
}

이제 더이상 data prop을 일일이 내려 보낼 필요가 없다!

그럼 ListItem, Header, Text 컴포넌트는 어떻게 data의 값에 접근할 수 있는 걸까?

각 컴포넌트들은 useContext라는 hook을 사용해 data에 접근할 수 있다.

이 hook은 datavalue로 받는 context를 인자로 받는다.

이 경우에는 DataContext가 되겠다.

useContext hook은 context 객체에서 데이터를 읽고 쓰게 해준다.

const DataContext = React.createContext();

function App() {
  const data = { ... }

  return (
    <div>
      <SideBar />
      <Content />
    </div>
  )
}

const SideBar = () => <List />
const List = () => <ListItem />
const Content = () => <div><Header /><Block /></div>

function ListItem() {
  const { data } = React.useContext(DataContext);
  return <span>{data.listItem}</span>;
}

function Text() {
  const { data } = React.useContext(DataContext);
  return <h1>{data.text}</h1>;
}

function Header() {
  const { data } = React.useContext(DataContext);
  return <div>{data.title}</div>;
}

data의 값을 사용하지 않는 컴포넌트들은 data를 신경 쓸 필요가 전혀 없다.

우리는 더이상 정작 props를 필요로 하지도 않는 수많은 컴포넌트 층을 거치며 props를 전달할 필요가 없다.

이 덕에 리팩토링은 훨씬 쉬워질 것이다.

Provider Pattern은 전역 데이터를 관리할 때 굉장히 유용하다.

Provider Pattern이 주로 사용되는 예시는 여러 컴포넌트들과 UI 테마를 공유하는 것이다.


어떤 목록을 보여주는 심플한 애플리케이션이 있다고 해보자.

사용자가 스위치를 toggle하는 것으로 라이트 모드와 다크 모드를 전환할 수 있게 하고 싶다.

사용자가 라이트 모드에서 다크 모드로, 다크 모드에서 라이트 모드로 변경할 때, 배경 색상과 글자 색상이 바뀌어야 한다!

현재의 테마 값을 각각의 컴포넌트로 내려 보내는 대신, 이 컴포넌트들을 ThemeProvider로 감싸고, 현재의 테마 색상을 provider로 전달할 것이다.

export const ThemeContext = React.createContext();

const themes = {
  light: {
    background: "#fff",
    color: "#000",
  },
  dark: {
    background: "#171717",
    color: "#fff",
  },
};

export default function App() {
  const [theme, setTheme] = useState("dark");

  function toggleTheme() {
    setTheme(theme === "light" ? "dark" : "light");
  }

  const providerValue = {
    theme: themes[theme],
    toggleTheme,
  };

  return (
    <div className={`App theme-${theme}`}>
      <ThemeContext.Provider value={providerValue}>
        <Toggle />
        <List />
      </ThemeContext.Provider>
    </div>
  );
}

ToggleList 컴포넌트 모두 ThemeContext provider에 감싸져 있기 때문에, 이 컴포넌트들은 provider의 value로 전달되는 themetoggleTheme에 접근할 수 있다.

따라서 Toggle 컴포넌트에서 toggleTheme 함수를 사용해 테마를 갱신할 수 있다.

import React, { useContext } from "react";
import { ThemeContext } from "./App";

export default function Toggle() {
  const theme = useContext(ThemeContext);

  return (
    <label className="switch">
      <input type="checkbox" onClick={theme.toggleTheme} />
      <span className="slider round" />
    </label>
  );
}

List 컴포넌트 자체는 현재 테마에 대해 신경쓸 필요가 없지만, ListItem 컴포넌트들은 그렇지 않다!

ListItem 컴포넌트에서 theme context를 직접적으로 사용할 수 있다.

import React, { useContext } from "react";
import { ThemeContext } from "./App";

export default function TextBox() {
  const theme = useContext(ThemeContext);

  return <li style={theme.theme}>...</li>;
}

완벽하다! ☆.*=

이제 현재 테마 값에 관련 없는 컴포넌트들에게 어떠한 데이터도 전달하지 않아도 된다.

Hook

context를 컴포넌트에 제공해주는 hook을 만들 수 있다.

useContextContext를 매번 컴포넌트에 import 하는 대신, 필요한 context를 반환하는 hook을 사용할 수 있다.

function useThemeContext() {
  const theme = useContext(ThemeContext);
  return theme;
}

테마가 유효한 값임을 검증하기 위해, useContext(ThemeContext)falsy value를 반환할 때 에러를 던지도록 하자.

function useThemeContext() {
  const theme = useContext(ThemeContext);
  if (!theme) {
    throw new Error("useThemeContext must be used within ThemeProvider");
  }
  return theme;
}

컴포넌트들을 ThemeContext.Provider 로 직접 감싸는 대신, 우리는 고차 컴포넌트(HOC)를 만들어 사용할 수 있다.

이 고차 컴포넌트는 어떤 컴포넌트를 감쌌을 때, 감싸진 컴포넌트의 값을 반환한다.

이렇게 하면 렌더링 컴포넌트에서 context 로직을 분리할 수 있기 때문에 provider의 재사용성을 높일 수 있다.

function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("dark");

  function toggleTheme() {
    setTheme(theme === "light" ? "dark" : "light");
  }

  const providerValue = {
    theme: themes[theme],
    toggleTheme,
  };

  return (
    <ThemeContext.Provider value={providerValue}>
      {children}
    </ThemeContext.Provider>
  );
}

export default function App() {
  return (
    <div className={`App theme-${theme}`}>
      <ThemeProvider>
        <Toggle />
        <List />
      </ThemeProvider>
    </div>
  );
}

ThemeContext에 접근해야 하는 컴포넌트들은 이제 간편하게 useThemeContext hook을 사용할 수 있다.

export default function TextBox() {
  const theme = useThemeContext();

  return <li style={theme.theme}>...</li>;
}

다른 context를 위한 hook을 만든다면, data를 렌더링하는 컴포넌트로부터 provider의 로직을 분리하는 것이 쉬워진다.


Case Study

Some libraries provide built-in providers, which values we can use in the consuming components

몇몇 라이브러리들은 내장형 provider를 제공한다.

이러한 예시로 아주 좋은 것은 styled-components다.

이 예시를 이해하기 위해 styled-components 사용 경험은 없어도 된다.

styled-components 라이브러리는 ThemeProvider를 제공해준다.

각각의 *styled component*들은 이 provider의 value에 접근할 수 있다!

우리가 직접 context API를 만드는 대신, 이미 제공된 걸 사용할 수 있다!

위의 예시와 같은 예시를 사용해보자.

그리고 styled-components 에서 import한 ThemeProvider로 컴포넌트들을 감싸보자.

import { ThemeProvider } from "styled-components";

export default function App() {
  const [theme, setTheme] = useState("dark");

  function toggleTheme() {
    setTheme(theme === "light" ? "dark" : "light");
  }

  return (
    <div className={`App theme-${theme}`}>
      <ThemeProvider theme={themes[theme]}>
        <Toggle toggleTheme={toggleTheme} />
        <List />
      </ThemeProvider>
    </div>
  );
}

ListItem 컴포넌트에 인라인 방식으로 style prop을 전달하는 대신, 우리는 이것을 styled.li 컴포넌트로 만들 것이다.

*styled component* 이기 때문에, theme이라는 값에 접근할 수 있다!

import styled from "styled-components";

export default function ListItem() {
  return (
    <Li>
      Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
      tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
      veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
      commodo consequat.
    </Li>
  );
}

const Li = styled.li`
  ${({ theme }) => `
     background-color: ${theme.backgroundColor};
     color: ${theme.color};
  `}
`;

멋지다 ☆.*=

이제 ThemeProvider를 사용해 모든 *styled component*들에게 간편하게 스타일을 적용할 수 있다!


Tradeoffs

장점

Provider Pattern과 Context API는 일일이 컴포넌트 층을 통해 데이터를 내려 보내지 않아도 많은 컴포넌트들에게 데이터를 전달할 수 있게 해준다.

이것은 코드를 리팩토링 할 때 예상치 못한 오류가 발생할 확률을 줄여준다.

이전에는 만약 prop의 이름을 변경하고 싶다면, 이 값이 사용되고 있는 모든 부분의 이름도 변경해야 했다.

이제 우리는 더이상 패턴을 파괴한다고 볼 수 있는 prop drilling과 씨름할 필요가 없다.

이전에는 어떤 prop의 값이 시작된 지점이 매번 명확한 것은 아니었기에, 애플리케이션의 데이터 흐름에 대해 이해하기 어려웠을 것이다.

Provider Pattern을 사용하면 더이상 데이터를 필요로 하지 않는 컴포넌트에 불필요하게 props를 전달하지 않아도 된다.

Provider Pattern을 사용하면 컴포넌트들에게 전역 상태 값에 대한 접근 권한을 줄 수 있으므로, 전역으로 상태를 유지하기가 쉽다.

단점

어떤 경우에는, Provider Pattern을 남용하는 것은 성능 악화를 초래할 수 있다.

context를 사용하는 모든 컴포넌트들은 상태가 바뀔 때마다 재렌더링 되기 때문이다.

예를 하나 들어보자.

여기 Button 컴포넌트의 Increment 버튼을 누를 때마다 값이 증가하는 심플한 카운터가 있다.

그리고 Reset 컴포넌트의 Reset 버튼을 누르면 카운터가 0으로 초기화된다.

하지만 Increment를 누를 때, 재렌더링 되는 건 카운터 뿐이 아니다.

Reset 컴포넌트의 날짜 역시 재렌더링 된다!

Reset 컴포넌트도 useCountContext를 사용하기 때문에 재렌더링 되는 것이다.

애플리케이션 규모가 작을 때 이것은 큰 문제가 되지 않는다.

반면 애플리케이션 규모가 클 때, 여러 컴포넌트에 자주 바뀌는 값을 넘긴다면 성능에 나쁜 영향을 줄 수 있다.

컴포넌트로 하여금 불필요하게 갱신될 수 있는 값이 포함된 provider를 사용하게 하지 않도록, 각 상황에 맞는 여러 개의 provider를 만드는 것이 좋다.

profile
기술 블로그도 재미있을 수 있잖아요

2개의 댓글

comment-user-thumbnail
2025년 3월 27일

잘 봤습니다 ㅎㅎㅎㅎ 설명 엄청 잘해주시네요 따봉~

1개의 답글