Death By a Thousand Cuts
잦은 상태 업데이트는 성능 이슈가 됩니다.
이는 보통 하나의 컴포넌트가 너무 많은 일을 할 때 발생합니다.
이 문제는 식별하기 쉽습니다.
하지만 하나의 상태 업데이트에 너무 많은 컴포넌트가 묶여있으면 찾기 어렵습니다.
이게 contextAPI의 단점입니다.
따라서, 컴포넌트를 일단 작게 만드는게 중요합니다.
기존의 최적화 방법들엔 아래와 같은 문제들이 존재합니다.
- useMemo, callback등 캐시 방법은 앱의 복잡도를 높입니다.
- 리액트는 렌더링 외에, 최적화를 위한 연산을 수행합니다.
- 때로는 메모보다 그냥 렌더링하는게 성능이 좋을 수도 있습니다.(ex 긴 리스트 컴포넌트)
불필요한 리렌더링을 방지할 수 있는 성능 최적화 방법들을 알아보겠습니다.
여러개의 글을 짬뽕해서 작성했는데 원문들이 기억이 안나는군요...
1. view 데이터를 ViewComponent 가까이에 위치시킨다.
연관된 데이터와 컴포넌트를 최대한 가깝게 위치하는것,
이것을 코로케이션(colocation)이라고 하며 앱의 성능과 확장성을 동시에 개선할 수 있는 정말 좋은 방법입니다.
dogName 인풋 value 데이터가 그리드 데이터와 state와 같이 존재하면,
해당 context의 state에 의존하는 모든 컴포넌트를 리렌더링합니다.
이것이 우리가 원하는 결과일까요?
dogName이 바뀌었다고 그리드가 렌더링될 필요가 있을까요?
// 컨텍스트 컨슈머는 동시에 업데이트 됩니다.
function AppProvider({children}) {
const [state, dispatch] = React.useReducer(appReducer, {
// 💣 remove the dogName state because we're no longer managing that
dogName: '',
grid: initialGrid,
})
return (
<AppStateContext.Provider value={state}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
)
}
function appReducer(state, action) {
switch (action.type) {
// we're no longer managing the dogName state in our reducer
// 💣 remove this case
case 'TYPED_IN_DOG_INPUT': {
return {...state, dogName: action.dogName}
}
case 'UPDATE_GRID_CELL': {
return {...state, grid: updateGridCellState(state.grid, action)}
}
case 'UPDATE_GRID': {
return {...state, grid: updateGridState(state.grid)}
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
DogName과 Grid가 보여주는 데이터 간 연관성은 업습니다.
따라서 DogName을 리듀서에서 삭제하고, Input으로 옮깁니다.
// 자신만의 state를 사용한다.
function DogNameInput() {
const [dogName, setDogName] = React.useState('')
function handleChange(event) {
const newDogName = event.target.value
setDogName(newDogName)
}
return (
<form onSubmit={e => e.preventDefault()}>
<label htmlFor="dogName">Dog Name</label>
<input
value={dogName}
onChange={handleChange}
id="dogName"
placeholder="Toto"
/>
{dogName ? (
<div>
<strong>{dogName}</strong>, I've a feeling we're not in Kansas anymore
</div>
) : null}
</form>
)
}
DogName 상태를 해당 컴포넌트에서 바로 찾을 수 있습니다.
또한 reducer를 볼 필요가 없습니다.
2. Context를 분리한다.
DogName을 여러 컴포넌트에서 사용해야 할 수 있습니다.
그러면 해당 데이터를 다른 컨텍스트로 분리합니다.
function dogReducer(state, action) {
switch (action.type) {
case 'TYPED_IN_DOG_INPUT': {
return {...state, dogName: action.dogName}
}
default: {
throw new Error(`Unhandled action type: ${action.type}`)
}
}
}
function DogProvider(props) {
const [state, dispatch] = React.useReducer(dogReducer, {dogName: ''})
const value = [state, dispatch]
return <DogContext.Provider value={value} {...props} />
}
function useDogState() {
const context = React.useContext(DogContext)
if (!context) {
throw new Error('useDogState must be used within the DogStateProvider')
}
return context
}
ContextProvider는 최대한 실제로 데이터가 필요한 곳 가까이 두는것이 좋습니다. (성능 및 유지 보수성)
function App() {
const forceRerender = useForceRerender()
return (
<div className="grid-app">
<button onClick={forceRerender}>force rerender</button>
<div>
<DogProvider>
<DogNameInput />
</DogProvider>
<AppProvider>
<Grid />
</AppProvider>
</div>
</div>
)
}
3. Consuming Component가 하는 일을 줄인다.
변화를 감지하는 역할과, 변화를 반영하는 역할을 분리합니다.
Presentation / Container 분리. VAC 패턴처럼 컴포넌트의 책임을 나눕니다.
React VAC Pattern - View 로직과 JSX의 의존성을 최소화 하자! (naver.com)
아래 코드에선, useAppState를 Cell에서 사용하고 있습니다.
이는 Cell 상태 중 하나만 바뀌어도, 모든 Cell이 리렌더링 됩니다.
이는 Cell이 grid의 모든 셀의 상태를 구독하고 있기 때문입니다.
자신이 변화를 반영해야 할 Cell Data는 단 하나의 셀의 상태임에도 말입니다.
function Cell({row, column}) {
const state = useAppState()
const cell = state.grid[row][column]
const dispatch = useAppDispatch()
const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
return (
<button
className="cell"
onClick={handleClick}
style={{
color: cell > 50 ? 'white' : 'black',
backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
}}
>
{Math.floor(cell)}
</button>
)
}
Cell = React.memo(Cell)
Cell 컴포넌트를 Cell / CellImpl로 분리하겠습니다.
Cell 컴포넌트에 context에서 데이터를 가져오는 책임을 할당합니다. 즉 변화를 감지하는 책임을 할당합니다.
CellImpl 컴포넌트엔 변화를 반영, 즉 렌더링의 책임을 할당합니다.
// Cell은 컨텍스트에서 데이터를 가져오는 책임
function Cell({row, column}) {
const state = useAppState()
const cell = state.grid[row][column]
return <CellImpl cell={cell} row={row} column={column} />
}
Cell = React.memo(Cell)
// CellImpl은 디스패치를 이용해 데이터를 변경하는 책임.
function CellImpl({cell, row, column}) {
const dispatch = useAppDispatch()
const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
return (
<button
className="cell"
onClick={handleClick}
style={{
color: cell > 50 ? 'white' : 'black',
backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
}}
>
{Math.floor(cell)}
</button>
)
}
CellImpl = React.memo(CellImpl)
주: 여기서 vac pattern을 사용한다면, cellImpl로 handleClick을 넘기는 방식으로 구현하는 것도 가능합니다.
stateless와 stateful을 분리하는 것이죠.
지금까지 한 작업은
- 모든 앱 상태를 소비하고,
- 중요한 상태의 일부를 캐치해,
- 이 컴포넌트의 기본 구현(Impl)에 전달하는
책임이 있는 중간 컴포넌트를 만드는 것입니다.
기본 구현(Impl) 컴포넌트는 메모이제이션을 활용할 수 있습니다.
4. 💯 3번의 작업을 추상화하는 고차 컴포넌트 만들기.
3번에서 한 작업은 꽤 반복적인 패턴입니다.
해당 작업을 추상화하는 High Order Component를 만들어 봅시다.
react-redux를 쓰면 connect를 쓸 수 있습니다. 위 컴포넌트는 contextAPI 대상힙니다.
팁 : HOC 사용 시 forwardRef 해주는게 좋습니다.
1. 3번의 CellImpl에 해당하는 (컨테이너) 컴포넌트를 만듭니다.
function Cell({state: cell, row, column}) {
const dispatch = useAppDispatch()
const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
return (
<button
className="cell"
onClick={handleClick}
style={{
color: cell > 50 ? 'white' : 'black',
backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
}}
>
{Math.floor(cell)}
</button>
)
}
2.다음 역할을 하는 중간 고차 컴포넌트를 만듭니다.
- 1번의 Impl 컴포넌트를 메모
- selector를 이용해 context State에서 Impl 컴포넌트의 렌더링 관심사 데이터를 가져옴
- 해당 컴포넌트에 필요한 데이터 및 props를 전달하는 함수 컴포넌트를 리턴
- forwardRef는 덤
// slice는 셀렉터임.
function withStateSlice(Comp, slice) {
const MemoComp = React.memo(Comp)
function Wrapper(props, ref) {
const state = useAppState()
return <MemoComp ref={ref} state={slice(state, props)} {...props} />
}
Wrapper.displayName = `withStateSlice(${Comp.displayName || Comp.name})`
return React.memo(React.forwardRef(Wrapper))
}
3.다음과 같이 사용합니다.
Cell = withStateSlice(Cell, (state, {row, column}) => state.grid[row][column])
전체 구현 흐름은 다음과 같음
function withStateSlice(Comp, slice) {
const MemoComp = React.memo(Comp)
function Wrapper(props, ref) {
const state = useAppState()
return <MemoComp ref={ref} state={slice(state, props)} {...props} />
}
Wrapper.displayName = `withStateSlice(${Comp.displayName || Comp.name})`
return React.memo(React.forwardRef(Wrapper))
}
// 컨테이너 컴포넌트
function Cell({state: cell, row, column}) {
const dispatch = useAppDispatch()
const handleClick = () => dispatch({type: 'UPDATE_GRID_CELL', row, column})
return (
<button
className="cell"
onClick={handleClick}
style={{
color: cell > 50 ? 'white' : 'black',
backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
}}
>
{Math.floor(cell)}
</button>
)
}
Cell = withStateSlice(Cell, (state, {row, column}) => state.grid[row][column])
5. 💯 Use recoil
리코일은 전역 Map과 같은 데이터 객체에서 key를 통해 value를 가져오는 방식으로 사용합니다.
props를 primitive로 변경할수 있어 props 관리가 유리해지고,
상위 컴포넌트에서 내려준 리스트에서 해당 컴포넌트의 데이터를 추출하는 것이 아닌( 탑 다운),
전체 데이터를 갖고 있는 Map에서 해당 데이터를 구독하는 컴포넌트만 리렌더링하는 식으로 변화합니다.(바텀업)
개인적으로 recoil을 쓸 수 있는 환경이라면 쓰는게 좋다고 생각하는데,
비동기 상태와의 연계가 별로여서 계륵같습니다.
보통 react 개발자들은 swr이나, react-query를 사용할텐데, 해당 서버사이드 상태관리 도구와 recoil의 상태 연계가 좀 거시기합니다.
atom의 개념이 이펙트도 해당 아톰 내부로 캡슐화하는게 컨셉이라서, 중복 관리의 애매함이 있습니다.
개인적으로 jotai-query 를 사용하는 게 괜찮아 보입니다만, 프로젝트에 도입해 본 적은 없습니다.
나중에 한번 사용해보고 싶네요.
Recoil - 또 다른 React 상태 관리 라이브러리? | TOAST UI :: Make Your Web Delicious!
const initialGrid = Array.from({length: 100}, () =>
Array.from({length: 100}, () => Math.random() * 100),
)
// Recoil은 컨텍스트를 대체하는 API라 생각합니다.
const cellAtoms = atomFamily({
key: 'cells',
default: ({row, column}) => initialGrid[row][column],
})
// 상태 업데이트 콜백
function useUpdateGrid() {
return useRecoilCallback(({set}) => ({rows, columns}) => {
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
if (Math.random() > 0.7) {
set(cellAtoms({row, column}), Math.random() * 100)
}
}
}
})
}
// recoil 상태 업데이트 훅 사용.
function Grid() {
const updateGrid = useUpdateGrid()
const [rows, setRows] = useDebouncedState(50)
const [columns, setColumns] = useDebouncedState(50)
const updateGridData = () => updateGrid({rows, columns})
return (
<AppGrid
onUpdateGrid={updateGridData}
rows={rows}
handleRowsChange={setRows}
columns={columns}
handleColumnsChange={setColumns}
Cell={Cell}
/>
)
}
// row, column을 파라미터로 받아 해당 아톰만 subscribe
function Cell({row, column}) {
const [cell, setCell] = useRecoilState(cellAtoms({row, column}))
const handleClick = () => setCell(Math.random() * 100)
return (
<button
className="cell"
onClick={handleClick}
style={{
color: cell > 50 ? 'white' : 'black',
backgroundColor: `rgba(0, 0, 0, ${cell / 100})`,
}}
>
{Math.floor(cell)}
</button>
)
}
참고
https://github.com/kentcdodds/react-performance/blob/main/src/exercise/06.md
'FrontEnd' 카테고리의 다른 글
리액트 라우터 v6(React Router v6) 딥 다이브 (0) | 2022.06.09 |
---|---|
프론트엔드 지식 : Javascript Critical Rendering Path (0) | 2022.06.08 |
리액트 성능 최적화 : Production Monitoring (0) | 2022.06.05 |
리액트 성능 최적화 : Virtual DOM (0) | 2022.06.05 |
리액트 성능 최적화 :contextAPI (0) | 2022.06.05 |