
Photo by Michiel Annaert
TkDodo의 The Uphill Battle of Memoization을 번역한 글입니다.
React.memo를 사용하기 전에 해봐야 할 것을 알려주는 좋은 글은 이미 많습니다. Dan의 before you memo와 Kent의 simple trick to optimize React re-renders 모두 좋은 글이죠.
상태를 아래로 옮기거나 내용물을 위로 끌어올려 컴포넌트 합성으로 문제를 해결한다는 아이디어인데, 컴포넌트 합성은 React의 자연스러운 사고 모형이므로 훌륭한 방법이죠. Dan이 말했듯 이제는 현실이 된 서버 컴포넌트에서도 잘 작동할 겁니다.
하지만 제가 읽은 글들 대부분에서는 그 이유가 빠져있었습니다. 합성은 훌륭하죠, 그런데 React.memo를 사용하면 무슨 문제가 있는 걸까요? 왜 합성만큼 좋지 못한 걸까요?
자, 제 생각은 이렇습니다.
메모이제이션은 망가지기 너무 쉽다.
복습하자면, React는 컴포넌트 트리를 렌더링할 때 모든 자식에 대해 하향식으로 렌더링합니다. 일단 렌더링이 시작되면 우리가 멈출 방법은 없습니다. 렌더링은 상태가 화면에 정확하게 반영되고 있음을 보장하므로 보통은 장점입니다. 게다가 렌더링은 대개 빠르죠.
물론 예외도 있습니다. 우리 모두에겐 바꾸기 힘든 이유 때문에 원하는 만큼 빠르게 렌더링 되지 않는 컴포넌트가 있죠. 다행히도 React는 "같은 것"을 발견하면 렌더링을 "중단"할 수 있습니다. 애초에 그러니까 내용물을 끌어올리는 기법 같은 게 통할 수 있는 겁니다.
function App() {
return (
<ColorPicker>
<p>Hello, world!</p>
<ExpensiveTree />
</ColorPicker>
)
}
function ColorPicker({ children }) {
const [color, setColor] = React.useState('red')
return (
<div style={{ color }}>
<input
value={color}
onChange={(event) => setColor(event.target.value)}
/>
{children}
</div>
)
}
children의 참조값이 항상 같다면, React는 렌더링을 단축할 수 있습니다. color가 바뀌어도 ExpensiveTree는 리렌더링 되지 않죠.
다른 해결책은 하나의 컴포넌트 안에서 전부 렌더링 되도록 하되, ExpensiveTree 컴포넌트를 React.memo로 감싸는 것입니다.
function App() {
const [color, setColor] = useState('red')
return (
<div style={{ color }}>
<input
value={color}
onChange={(event) => setColor(event.target.value)}
/>
<p>Hello, world!</p>
<ExpensiveTree />
</div>
)
}
function ExpensiveComponent() {
return <div>I'm expensive!</div>
}
const ExpensiveTree = React.memo(ExpensiveComponent)
컴포넌트를 React.memo로 감싸면, React는 prop이 바뀌지 않는 한 해당 컴포넌트(와 그의 자식들)의 렌더링을 건너뜁니다. 이렇게 하면 컴포넌트 구조를 바꾸는 것과 결과는 같지만 나중에 망가지기 훨씬 쉽습니다.
컴포넌트를 메모이제이션 하면 React는 각 prop을 Object.is로 비교합니다. prop이 바뀌지 않았다면 리렌더링을 생략할 수 있죠. 현재 예시의 컴포넌트에는 prop이 없으므로 리렌더링이 생략됩니다. prop으로 원시값을 사용해도 잘 생략되겠지만 함수, 객체, 배열을 사용하면 잘 안될 겁니다.
ExpensiveComponent에 prop으로 style을 전달해서 잘못된 게 없어 보이는 변경 사항을 만들어 보겠습니다.
function ExpensiveComponent({ style }) {
return <div style={style}>I'm expensive!</div>
}
const ExpensiveTree = React.memo(ExpensiveComponent)
나중에 prop이 추가되어 컴포넌트가 변화하는 건 흔하죠. 문제는 ExpensiveTree 컴포넌트를 소비하는 쪽에서 해당 컴포넌트가 메모이제이션 됐다는 사실을 모를 수도 있다는 겁니다. 어찌됐든 메모이제이션은 성능 최적화이며 구현상의 디테일일 뿐이죠.
이제 ExpensiveTree 컴포넌트를 렌더링할 때 prop으로 인라인 스타일을 추가해 보겠습니다.
<ExpensiveTree style={{ backgroundColor: 'blue' }} />
이 실수로 메모이제이션을 망쳤습니다. style prop은 렌더링 될 때마다 새로운 객체가 되기 때문이죠. React에게는 prop이 바뀐 것처럼 보이므로 렌더링을 건너뛸 수 없는 겁니다.
물론 style prop을 React.useMemo로 감싸서 해결할 수 있습니다.
function App() {
const memoizedStyle = React.useMemo(
() => ({ backgroundColor: 'blue' }),
[]
)
return <ExpensiveTree style={memoizedStyle} />
}
이 예제는 간단해서 가능했지만, 메모이제이션이 필요한 prop이 더 많다면 코드가 어떨지 상상해 보세요. 코드를 추론하기 더 어렵게 만들 테고 소비자가 실제로 메모이제이션을 할 거라는 보장도 없습니다.
style 자체가 ExpensiveTree를 렌더링하는 컴포넌트의 prop으로 들어오면 훨씬 더 어려워집니다.
function App({ style }) {
const memoizedStyle = React.useMemo(() => style, [style])
return <ExpensiveTree style={memoizedStyle} />
}
이 메모이제이션은 실질적으로 아무 소용이 없습니다. App에 전달되는 style이 인라인 객체인지 아닌지 알 수 없으니 App 내부에서 메모이제이션 하는 건 무의미하죠. App을 호출하는 쪽에서 참조값을 안정적으로 만들어줘야 합니다.
더 안 좋은 건 성능 개선을 망가뜨릴 수 있는 방법이 더 있다는 것입니다. 메모이제이션 된 컴포넌트가 children을 받는다면 기대한 대로 동작하지 않습니다.
function App() {
return (
<ExpensiveTree>
<p>안녕, 세상아!</p>
</ExpensiveTree>
)
}
function ExpensiveComponent({ children }) {
return (
<div>
나 비싸요!
{children}
</div>
)
}
const ExpensiveTree = React.memo(ExpensiveComponent)
어후... 인정해야겠네요. 저는 이게 메모이제이션을 망가뜨린다는 걸 오랫동안 몰랐습니다. 그런데 왜 그런 걸까요? 항상 똑같은, 안정적인 <p> 태그를 자식으로 전달하고 있잖아요, 맞죠? 글쎄요, 사실은 그렇지 않습니다. JSX는 렌더링할 때마다 새로운 객체를 생성하는 React.createElement의 문법적 설탕(syntactic sugar)일 뿐입니다. 그러니 똑같은 <p> 태그로 보여도 참조값은 다른 거죠.
메모이제이션 된 컴포넌트에 전달할 자식도 물론 useMemo로 감쌀 수 있지만, 지금쯤이면 우리가 거의 이길 수 없는 고지전을 하고 있다는 사실을 깨달았을 겁니다. 다음 사람이 메모이제이션 된 컴포넌트의 prop에 빈 객체나 배열을 fallback 값으로 넘겨주면 다시 원점으로 돌아가게 되거든요.
// fallback-value
// 💥 왜 햄보칼 수가 없어! 😭
<ExpensiveTree someProp={someStableArray ?? []} />
React.memo를 사용하는 건 좀 지뢰밭 같으니, 제시된 대안 중 하나를 선택하는 게 훨씬 더 나은 것 같습니다. 그런데 가끔은 컴포넌트를 메모이제이션 할 수 밖에 없을 때도 있습니다. 트위터에서 발견한 건데 이 글에 대한 아이디어를 떠올리게 해준 사례입니다.
저는 React의 성능을 최적화해야 할 일이 거의 없어요.
그런데 거대한 테이블 5개랑 요약 표시줄이 있는 페이지가 있는데요. 테이블 하나가 변경되면 전부 다 렌더링 됩니다. 이게 느리더라고요.
해결 방법:
1. 각 테이블을 memo로 감쌌습니다.
2. 전달되는 함수를 useCallback으로 감쌌습니다.
훨!씬! 빨라졌습니다.
Cory House의 트윗
위 트윗의 컴포넌트 트리는 아마 이런 모습일 겁니다(테이블을 5개가 아니라 2개로 간략히 함).
function App() {
const [state, setState] = React.useState({
table1Data: [],
table2Data: [],
})
return (
<div>
<Table1 data={state.table1Data} />
<Table2 data={state.table2Data} />
<SummaryBar />
</div>
)
}
state에는 두 테이블의 데이터가 있고, SummaryBar는 모든 데이터에 접근할 수 있어야 합니다. 상태를 테이블 안으로 옮길 수 없고, 컴포넌트를 다른 방식으로 구성할 수도 없습니다. 메모이제이션이 유일한 선택지 같습니다.
렌더링이 시작되면 멈출 방법이 없다고 했던 거 기억하시나요? 그건 여전히 사실입니다. 하지만 렌더링이 시작되는 것을 애초에 막을 수 있다면 어떨까요? 🤔
state가 App의 최상위에 있는 게 아니라면 state가 바뀔 때마다 전체 트리를 리렌더링 할 필요는 없습니다. 최상위 말고 어디에 있을 수 있을까요? 아래로 옮길 수 없다는 건 이미 확인했으니 옆으로 옮겨봅시다. React의 바깥으로요.
이게 바로 상태 관리 도구 대부분이 하는 일입니다. 상태 관리 도구는 상태를 React 바깥에 저장하고, 컴포넌트 트리에서 변경 사항을 알아야 하는 부분의 리렌더링을 외과 수술하듯 정교하게 트리거하죠. 전에 React Query를 사용해 봤다면, 거기에서 일어나는 일이 바로 이런 겁니다. 이 기법을 사용하지 않으면 원하는 것보다 훨씬 더 많은 리렌더링을 보게 될 겁니다.
네, 맞아요. 제가 제시하는 대안은 효과적인 상태 관리자를 투입하는 겁니다. 저에게 제일 익숙한 zustand를 사용해 보겠습니다.
const useTableStore = create((set) => ({
table1Data: [],
table2Data: [],
actions: {...}
}))
export const useTable1Data = () =>
useTableStore((state) => state.table1Data)
export const useTable2Data = () =>
useTableStore((state) => state.table2Data)
export const useSummaryData = () =>
useTableStore((state) =>
calculateSummary(state.table1Data, state.table2Data)
)
이제 모든 컴포넌트는 관심 있는 상태를 내부적으로 구독할 수 있으니, 어떠한 하향식 렌더링이라도 전부 피할 수 있습니다. table2Data가 업데이트 돼도 Table1은 리렌더링 되지 않는다는 거죠. 테이블들을 메모이제이션 하는 것만큼 효과적이면서도, 새로운 prop을 추가하면 성능에 안 좋은 영향을 미칠 수 있다는 함정에 빠지지 않습니다.
인정합니다. 여기서 다룬 해결책은 전부 좋지 않습니다. 메모이제이션은 보통 코드를 읽기 어렵게 만들고 잘못되기도 쉬워서 저에겐 최악의 선택지입니다. 외부 상태 관리자를 사용하면 조금 더 낫지만, App은 거기에 의존하게 되죠. 컴포넌트를 구성하는 방식을 조정하는 게 여전히 최선의 방법이지만 항상 할 수 있는 건 아닙니다.
게임의 규칙을 바꿀 수 있다면 그게 정말 좋은 해결책일 겁니다. 꽤 오랫동안 stage 2에 머물러 있는 ECMAScript proposal인 레코드와 튜플은, 함수는 아니지만 배열과 객체를 다루는 데 유용할 겁니다. 관련해서 Sebastien Lorber이 쓴 좋은 글이 있습니다.
또한 React 팀은 React Forget이라는 컴파일러를 개발 중이라고 암시했습니다. 모든 걸 자동으로 메모이제이션 해줄 컴파일러인데, 이걸 적용하면 React.memo를 사용할 때 생길 수 있는 오류 지점(error surface) 없이 성능을 최적화할 수 있을 겁니다.