patterns
PatternsAbout

Provider 패턴

여러 자식 컴포넌트 간에 데이터를 공유한다

📮 피드백 보내기

앱 내의 여러 컴포넌트들이 데이터를 사용 할 수 있게 해야 하는 상황이 있다. props 를 통해서 데이터를 전달하는 방식이 있지만 앱 내의 모든 컴포넌트들이 데이터에 접근해야 하는 경우 이 작업을 하기 매우 번거롭다.

그리고 종종 prop drilling이라 불리는 안티패턴을 사용하게 되는데. 아주 멀리있는 컴포넌트 트리까지 props를 내려주게 되면 prop에 의존되는 컴포넌트들을 나중에 리펙토링하기란 거의 불가능해지며. 어떤 데이터가 어디로부터 전해져 오는지조차 알기 어렵게 된다.

App 컴포넌트가 있고. 특정 데이터를 가지고 있다고 가정해 보자. 컴포넌트 트리의 마지막 노드에는 ListItem, Header, Text 컴포넌트가 있고 App 이 가진 데이터를 필요로 한다. 이 컴포넌트들에게 데이터를 주려면 여러 중간 컴포넌트들에게 데이터를 내려주어야 한다.

코드베이스는 아래와 같이 작성될 것이다.

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 라는 프로퍼티의 이름을 변경해야 하는 경우. 모든 컴포넌트를 수정해야 한다. 앱의 규모가 클 수록 점점 더 어려워지는 것이다.

데이터가 필요하지 않는 컴포넌트는 props를 받지 않도록 수정하는것이 바람직하다. 그러기 위해선 prop drilling에 의존하지 않고 컴포넌트가 직접 데이터에 접근할 수 있는 방법이 필요하다.

Provider 패턴은 이런 경우에 매우 유용하다. Provider 패턴을 이용하면 각 레이어에 직접 데이터를 주지 않고도 여러 컴포넌트들에게 데이터에 접근할 수 있게 구현할 수 있다.

먼저 모든 컴포넌트를 Provider 로 감싼다. Provider 는 HOC로 Context 객체를 제공한다. React가 제공하는 createContext 메서드를 활용하여 Context 객체를 만들어낼 수 있다.

Provider 컴포넌트는 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 훅을 활용하여 data 에 접근할 수 있다. 아래 예제에서 이 훅은 data 와 연관된 DataContext 를 받아 data 를 읽고 쓸 수 있는 컨텍스트 객체를 제공한다.

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 를 prop으로 받지 않게 되었다. prop drilling도 필요 없어졌으며 전보다 훨씬 리펙토링하기도 수월해졌다.


Provider 패턴은 전역 데이터를 공유하기에 딱 좋다. 보통 UI테마를 여러 컴포넌트들이 공유해 사용하기 위해 쓴다.

아래의 간단한 예제를 살펴보자

사용자가 스위치를 토글하여 라이트모드와 다크모드를 전환할 수 있도록 구현하려고 한다. 사용자가 스위치를 클릭하여 다크모드 또는 라이트모드 전환을 할 때. 배경과 텍스트의 색상이 변경되어야 한다. 현재 테마의 값을 직접 내리는 대신. 컴포넌트들을 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의 자식 컴포넌트로 존재하는 동안 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 은 theme 컨텍스트를 직접 사용할 수 있다.

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

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

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

이것으로 테마를 쓰지 않는 컴포넌트가 불필요하게 데이터를 받지 않도록 구현했다.

import React, { useState } from 'react'
import './styles.css'

import List from './List'
import Toggle from './Toggle'

export const themes = {
  light: {
    background: '#fff',
    color: '#000',
  },
  dark: {
    background: '#171717',
    color: '#fff',
  },
}

export const ThemeContext = React.createContext()

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

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

  return (
    <div className={`App theme-${theme}`}>
      <ThemeContext.Provider value={{ theme: themes[theme], toggleTheme }}>
        <>
          <Toggle />
          <List />
        </>
      </ThemeContext.Provider>
    </div>
  )
}

Hooks

각 컴포넌트에서 useContext 를 직접 import하는 대신 필요로 하는 컨텍스트를 직접 반환하는 훅을 구현할 수 있다.

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

훅이 유효하게 사용되는지 검증하기 위해 컨텍스트가 falsy value일 때 예외를 발생시키도록 구현한다.

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

컴포넌트들을 ThemeContext.Provider 로 직접 래핑하게 하는 것 대신. HOC를 만들어 간단하게 쓸 수 있도록 할 수 있다. 이렇게 하면 컨텍스트 로직과 렌더링 로직을 분리하여 재 사용성을 증가시킬 수 있다.

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 훅을 사용하면 된다.

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

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

각기 다른 컨텍스트를 위한 훅을 만드는 것으로 쉽게 컴포넌트의 렌더 로직과 Provider의 로직을 분리할 수 있다.


사례 분석

어떤 라이브러리는 자식 컴포넌트들이 값을 쉽게 사용할 수 있도록 자체적으로 Provider를 제공한다. styled-components 가 좋은 예시이다.

아래 예제를 이해하는데 styled-components에 대한 경험은 필요 없습니다.

styled-components는 ThemeProvider 를 제공하므로 직접 구현할 필요가 없다. 각 styled component는 해당 Provider의 값에 접근할 수 있다.

위에 구현했던 예제와 같은 예시를 styled-components를 활용하여 구현하였다. ThemeProvider 를 styled-componets 로 부터 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](http://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-components 가 쉽게 활용할 수 있다.

import React, { useState } from 'react'
import { ThemeProvider } from 'styled-components'
import './styles.css'

import List from './List'
import Toggle from './Toggle'

export 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')
  }

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

장점

컴포넌트 트리의 각 노드에 데이터를 전달하지 않아도 다수의 컴포넌트에 데이터를 전달할 수 있다.

리펙토링 과정에 개발자가 실수할 확률을 줄여준다. 이전에는 prop의 이름을 변경하기 위해서 모든 컴포넌트를 찾아다니며 코드를 수정해야 했다.

prop-drilling을 하지 않아도 된다. 이전에는 앱의 데이터 흐름을 알기 매우 어려웠다. 어떤 prop이 어디서 생겨나고 어디서 사용되는지 파악이 어려웠다. Provider 패턴을 이용하면 데이터가 필요없는 컴포넌트에 불필요하게 prop을 받을 필요가 없어진다.

컴포넌트들이 전역 상태에 접근할 수 있도록 Provider 패턴을 활용하여 전역 상태를 유지하자.


단점

Provider 패턴을 과하게 사용할 경우 특정 상황에서 성능 이슈가 발생할 수 있다. 컨텍스트를 참조하는 모든 컴포넌트는 컨텍스트 변경시마다 모두 리렌더링된다.

아래 예제는 단순한 카운터로 Increment 버튼은 Button 컴포넌트 안에 있고. Reset 버튼은 Reset 컴포넌트 안에 있다. reset을 누르면 카운트가 0으로 초기화된다.

Increment 버튼을 누르면 카운트만 증가되는것이 아니라 예상과 달리 Reset 컴포넌트 내의 date도 리렌더링되는것을 볼 수 있다.

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'))

Reset 컴포넌트는 useCountContext 를 사용하고 있기 때문에 리렌더링된다. 비슷한 소규모 앱에서는 이것이 큰 문제가 되지는 않지만, 큰 규모의 앱에서는 여러 앱 끼리 자주 업데이트 된 값을 넘길 경우 성능에 악영향을 끼칠 수 있다.

컴포넌트 쓰지 않는 값의 업데이트로 인해 불필요하게 렌더링되는것을 막기 위해서는 여러 Provider로 쪼갤 필요가 있다.

참조

  • Patterns
  • About
  • Submit Feedback
© 2020-2022 Patterns.dev. All rights reserved.