Provider 패턴
여러 자식 컴포넌트에서 데이터를 사용하능하게 한다
📜 원문: patterns.dev - provider pattern
📜번역 : https://patterns-dev-kr.github.io/design-patterns/provider-pattern/
앱 내의 여러 컴포넌트들이 데이터를 사용 할 수 있게 해야 하는 상황이 있습니다. 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>
)
}
Toggle 과 List 컴포넌트가 ThemeContext Provider의 자식 컴포넌트로 존재하는 동안 value 로 넘겼던 theme 과
toggleTheme 값에 접근할 수 있습니다.
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를 활용하여 구현하였습니다. T
hemeProvider 를 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로 쪼갤 필요가 있죠.
참조
'프로그래밍(Basic) > 디자인 패턴(JS)' 카테고리의 다른 글
[바미] Container/Presentational 패턴 (0) | 2022.09.15 |
---|---|
[바미] Prototype 패턴 (0) | 2022.09.14 |
[바미] Proxy 패턴 (0) | 2022.09.12 |
[바미] Singleton 패턴 (0) | 2022.09.02 |
[바미] Design Pattern 소개 (0) | 2022.08.31 |