본문 바로가기

웹 개발

디자인 패턴) Provider Pattern

이 글을 공부하면서 번역하고 정리한 글입니다. 


여러 자식 컴포넌트에서 데이터를 사용할 수 있도록 만드는 패턴

애플리케이션에서 데이터를 여러 컴포넌트에서 사용하고 싶을 때가 있습니다. props를 이용해서 데이터를 전달할 수 있긴 하지만 이 방법으로 애플리케이션의 거의 대부분의 컴포넌트에 데이터를 전달하는 것은 어렵습니다.

 

우리는 종종 prop drilling이라고 불리는 현상을 마주칩니다. 이는 props를 컴포넌트 트리의 깊은 곳까지 전달하는 경우에 발생합니다. 이 경우에 props에 의존한 코드를 리팩토링하는 것은 거의 불가능해지고 특정 데이터가 어디서 오는지 알기 매우 어려워집니다. 

 

특정 데이터를 가진 '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 drilling은 더 복잡해질 것입니다. 

또한 이 데이터가 필요하지 않은 중간 계층의 컴포넌트에는 굳이 prop을 전달하지 않는 것이 더 효율적입니다. 그래서 우리는 컴포넌트가 prop drilling에 의존하지 않고 특정 데이터에 바로 접근할 수 있게 하는 무언가가 필요합니다. 

 

그 무언가가 바로 Provider 패턴입니다! Provider 패턴을 사용하면 데이터를 여러 컴포넌트에서 사용할 수 있습니다. props를 통해 데이터를 각 계층에 데이터를 전달하기보다는 모든 컴포넌트를 Provider로 감쌀 수 있습니다. Provider는 Context라는 객체가 제공하는 고차원 컴포넌트입니다. React에서는 createContext라는 메소드를 사용해서 Context 객체를 생성할 수 있습니다.

 

다음 예제에서 Provider는 value라는 prop에 data를 전달합니다. 그럼 그 하위에 SideBar, Content 컴포넌트 뿐만 아니라 SideBar, Content 컴포넌트 하위의 컴포넌트 등 더 깊은 부분에 사용된 컴포넌트인 ListItem, Header, Text 컴포넌트도 data에 접근할 수 있게 됩니다. 

const DataContext = React.createContext()
 
function App() {
  const data = { ... }
 
  return (
    <div>
      <DataContext.Provider value={data}>
        <SideBar />
        <Content />
      </DataContext.Provider>
    </div>
  )
}

 그렇다면 ListItem, Header, Text 컴포넌트는 어떻게 data에 접근할 수 있을까요? 바로 'useContext' 훅을 사용합니다.

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

 

useContext에 createContext로 만든 context를 전달하면 data 값에 접근할 수 있습니다. 이제 data를 사용할 필요 없는 컴포넌트인 SideBar, Content라는 컴포넌트는 data를 가지지 않아도 되고 data를 props로 전달해야 하는 과정을 생각하지 않아도 됩니다.

 


Provider패턴은 전역 데이터를 공유하는데 아주 유용합니다. provider 패턴은 주로 많은 컴포넌트에서 UI 테마 상태를 공유할 때 주로 사용됩니다. 다음은 어플리케이션 전역에 적용되는 다크모드, 라이트모드를 구현한 예제입니다. 컴포넌트들을 최상단의 ThemeProvider로 감싸고 그 provider를 통해 현재 활성화된 theme color를 하위의 모든 컴포넌트들에게 전달할 수 있습니다. 

 

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

Toggle과 List 컴포넌트는 ThemeContext 라는 provider로 감싸져있기 때문에 value라는 prop을 통해 providerValue 객체의 값에 접근할 수 있습니다.

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

theme이라는 변수로 ThemeContext를 받아오고 그 안에는 theme이라는 변수와 toggleTheme이라는 함수가 있습니다. 따라서 Toggle 컴포넌트에서는 providerValue의 toggleTheme이라는 함수를 사용할 수 있고, 이 함수를 통해서 theme을 light 또는 dark로 변경할 수 있습니다. 

import React, { useContext } from "react";
import { ThemeContext } from "./App";
 
export default function TextBox() {
  const theme = useContext(ThemeContext);
 
  return <li style={theme.theme}>...</li>;
}

List 컴포넌트 역시 theme 이라는 변수에 context를 받아오고 그 안에 theme이라는 변수에 접근하여 사용할 수 있습니다. 이 방법을 사용하면 List 컴포넌트는 theme의 현재 값을 신경쓰지 않아도 되고 이는 context에서만 잘 관리하면 됩니다. 


Hooks

컴포넌트에 context를 제공하는 hook을 만들 수 있습니다. useContext와 Context를 각 컴포넌트에서 import하는 방법 대신에 필요한 context를 반환하는 hook을 만들어 봅시다. 

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

다음은 ThemeContext를 반환하는 hook입니다. 유효성 검증을 위해 useContext(ThemeContext)가 falsy한 값을 반환한 경우 에러를 띄워줍니다. 

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

그리고 Toggle, List와 같이 theme context가 필요한 컴포넌트들을 직접 ThemeContext.Provider로 감싸는 방법 대신 ThemeProvider라는 HOC를 만들어 값을 제공할 수 있습니다. 이 방법을 사용하면 context 관련 로직과 컴포넌트 랜더링 관련 로직을 분리할 수 있어 provider의 재사용성이 높아집니다. 

export default function TextBox() {
  const theme = useThemeContext();
 
  return <li style={theme.theme}>...</li>;
}

ThemeProvider 하위의 컴포넌트들은 useThemeContext hook을 사용해서 간단하게 ThemeContext에 접근할 수 있게 됩니다. 

서로 다른 context 마다 다음과 같이 hook을 만들어 사용하면 provider의 로직와 컴포넌트 자체의 로직을 분리할 수 있어 좋습니다. 


Case Study 

어떤 라이브러리들은 built-in provider를 제공하는데 그 예로 'styled-components'가 있습니다. 

styled-component 라이브러리는 ThemeProvider라는 것을 제공하여 각 styled component들은 이 provider의 값들에 접근할 수 있습니다! context API를 직접 만들어 쓰지 않고 제공해주는 것을 사용할 수 있습니다. 

위에서 설명했던 List와 같은 예제를 가지고 styled-copmonent 라이브러리에서 제공하는 ThemeProvider를 import하여 컴포넌트들을 감싸봅시다. 

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 컴포넌트에 inline으로 style prop을 전달하지 않고 styled.li 컴포넌트를 만들 수 있습니다. styled.li 컴포넌트인 Li 컴포넌트는 styled component이기 때문에 theme context의 값들에 접근할 수 있게 됩니다. 

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

Tradeoffs

Pros

  • provider 패턴과 context API를 사용하면 여러 계층의 컴포넌트에 각각 데이터를 전달할 필요 없이 많은 컴포넌트에 데이터를 전달할 수 있게 됩니다. 
  • 여러 컴포넌트에서 사용하는 값을 context 한 곳에서 관리하고 provider로 제공하기 때문에 이름을 변경하거나 값 또는 함수 로직을 변경할 때 편합니다. 
  • prop-drilling은 prop의 값이 어디에서 온 건지 명확하지 않을 때가 있어서 어플리케이션 내의 데이터 흐름을 이해하기 어렵게 만들기 때문에 안티 패턴 중 하나입니다. provider 패턴을 사용하면 prop-drilling을 피할 수 있습니다. 
  • 전역 상태를 사용하기 좋습니다. 

Cons

  • context를 사용하는 모든 컴포넌트들이 context의 값이 바뀔 때마다 재랜더링되기 때문에 provider 패턴을 과하게 사용하는 경우 성능 이슈가 발생할 수 있습니다. 

counter 컴포넌트를 예를 들어 봅시다.

  • Button 컴포넌트: 'Increment' 버튼을 클릭하면 값이 증가합니다. 
  • Reset 컴포넌트: 'Reset count' 버튼을 클릭하면 값이 0으로 초기화됩니다. 

이 둘은 모두 CounterContext를 사용합니다. 

import React, { useState, createContext, useContext, useEffect } from "react";
import ReactDOM from "react-dom";
import moment from "moment";

import "./styles.css";

const CountContext = createContext(null);

function Reset() {
  const { setCount } = useCountContext();

  return (
    <div className="app-col">
      <button onClick={() => setCount(0)}>Reset count</button>
      <div>Last reset: {moment().format("h:mm:ss a")}</div>
    </div>
  );
}

function Button() {
  const { count, setCount } = useCountContext();

  return (
    <div className="app-col">
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <div>Current count: {count}</div>
    </div>
  );
}

function useCountContext() {
  const context = useContext(CountContext);
  if (!context)
    throw new Error(
      "useCountContext has to be used within CountContextProvider"
    );
  return context;
}

function CountContextProvider({ children }) {
  const [count, setCount] = useState(0);
  return (
    <CountContext.Provider value={{ count, setCount }}>
      {children}
    </CountContext.Provider>
  );
}

function App() {
  return (
    <div className="App">
      <CountContextProvider>
        <Button />
        <Reset />
      </CountContextProvider>
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

Increment 버튼을 클릭해서 CounterContext내 count 값이 1 증가하면 Reset 버튼은 count가 올라간다고 해서 변경되는 것은 없지만 CounterContext를 사용하기 때문에 Button 컴포넌트와 마찬가지로 Reset 컴포넌트로 재랜더링됩니다. 규모가 큰 애플리케이션일수록 이 문제점은 더 커집니다. 자주 값이 자주 변경되는 것들을 많은 컴포넌트에 전달하면 성능이 안좋아지겠죠? 

따라서 업데이트가 발생하는 값을 가진 context를 불필요하게 provider로 제공하는 일은 없어야 합니다. 그러기 위해서는 각 사용 케이스마다 provider를 잘 분리해서 만들어야겠죠? ㅎㅎ

 

이제 집에 가야징 희희 

'웹 개발' 카테고리의 다른 글

디자인 패턴) Container/Presentational Pattern  (0) 2023.07.17
디자인 패턴) Prototype Pattern  (0) 2023.07.12