React.memo가 쉽게 깨지는 이유와 컴포넌트 구성 및 외부 상태 관리라는 대안을 살펴봅니다.

사진: Michiel Annaert
React.memo (새 창에서 열림)를 사용하기 전에 무엇을 해야 하는지에 관한 좋은 콘텐츠는 이미 많이 있습니다. Dan의 글 메모하기 전에 (새 창에서 열림)와 Kent의 React 리렌더링을 최적화하는 간단한 요령 (새 창에서 열림)은 모두 훌륭한 읽을거리입니다.
핵심은 상태를 아래로 내리거나 콘텐츠를 위로 올려서 컴포넌트 구성이 문제를 해결하게 하는 것입니다. 이는 컴포넌트 구성이 React의 자연스러운 사고 모델이기 때문에 탁월합니다. Dan이 지적하듯, 이제 현실이 된 Server Components와도 이 방식은 잘 작동합니다.
제가 읽은 대부분의 글에서 빠진 것은 이유입니다. 물론 구성은 훌륭하지만, 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> )}
출처: overreacted
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는 props가 바뀌지 않았을 때 해당 컴포넌트와 그 자식의 렌더링을 건너뜁니다. 이는 컴포넌트 구성을 변경하는 것과 분명 같은 결과를 내지만, 나중에 깨뜨리기는 훨씬 쉽습니다.
컴포넌트가 메모이즈되면 React는 각 prop을 Object.is (새 창에서 열림)로 비교합니다. 바뀌지 않았다면 리렌더링을 건너뛸 수 있습니다. 현재 예제에서는 컴포넌트에 props가 아예 없으므로 잘 작동합니다. 원시 값을 props로 사용할 때도 작동하지만, 함수와 객체, 배열에서는 그다지 좋지 않습니다.
ExpensiveComponent에 style prop을 추가하는, 무해해 보이는 변경을 해봅시다.
function ExpensiveComponent({ style }) { return <div style={style}>I'm expensive!</div>}const ExpensiveTree = React.memo(ExpensiveComponent)
이것이 보통 시간이 흐르며 컴포넌트가 발전하는 방식입니다. props가 추가됩니다. 문제는 ExpensiveTree 컴포넌트의 소비자가 이것이 메모이즈되어 있다는 사실을 반드시 알지는 못한다는 점입니다. 결국 이것은 성능 최적화이자 구현 세부 사항일 뿐입니다.
이제 ExpensiveTree 컴포넌트를 렌더링할 때 인라인 스타일 prop을 추가하면,
<ExpensiveTree style={{ backgroundColor: 'blue' }} />
style prop이 매 렌더링마다 새로운 객체가 되기 때문에 의도치 않게 메모이제이션을 망가뜨립니다. React 입장에서는 props가 변경된 것으로 보이므로 렌더링을 건너뛸 수 없습니다.
좋습니다. style prop을 React.useMemo로 감싸서 고칠 수는 있습니다.
function App() { const memoizedStyle = React.useMemo( () => ({ backgroundColor: 'blue' }), [], ) return <ExpensiveTree style={memoizedStyle} />}
단순한 경우에는 가능하지만, 메모이제이션이 필요한 props가 더 많다면 코드가 어떻게 보일지 상상해 보세요. 코드에 대해 추론하기가 더 어려워지고, 소비자가 실제로 그 메모이제이션을 한다는 보장도 없습니다.
style 자체가 ExpensiveTree를 렌더링하는 컴포넌트의 prop으로 들어오는 경우에는 훨씬 더 어려워집니다.
function App({ style }) { const memoizedStyle = React.useMemo(() => style, [style]) return <ExpensiveTree style={memoizedStyle} />}
이 메모이제이션은 실제로 아무것도 이루지 못합니다. style이 App에 인라인 객체로 전달될지 알 수 없으므로 여기서 메모이즈하는 것은 무의미합니다. App을 호출하는 쪽에서 안정적인 참조를 만들어야 합니다.
더 나쁜 점은 성능 개선이 깨질 수 있는 방법이 이것뿐만이 아니라는 것입니다. 또 다른 함정은 메모이즈된 컴포넌트가 children을 받는 경우 예상대로 작동하지 않는다는 점입니다.
function App() { return ( <ExpensiveTree> <p>Hello, world!</p> </ExpensiveTree> )}function ExpensiveComponent({ children }) { return ( <div> I'm expensive! {children} </div> )}const ExpensiveTree = React.memo(ExpensiveComponent)
휴, 인정해야겠습니다. 이것이 메모이제이션을 깨뜨린다는 사실을 저는 오랫동안 몰랐습니다. 왜 그럴까요? 자식으로 항상 같은 안정적인 <p> 태그를 전달하고 있지 않나요? 사실은 그렇지 않습니다. JSX는 React.createElement를 위한 문법적 설탕일 뿐이며, 이는 매 렌더링마다 새로운 객체를 만듭니다. 따라서 <p> 태그가 우리에게는 같아 보이더라도 같은 참조는 아닙니다.
메모이즈된 컴포넌트에 전달하는 자식도 물론 useMemo로 감쌀 수 있습니다. 하지만 이제는 거의 이길 수 없는 험난한 싸움을 하고 있다는 점을 깨닫고 계시길 바랍니다. 다음 사람이 메모이즈된 컴포넌트의 prop에 대한 대체값으로 빈 객체나 배열을 전달하기만 해도, 우리는 다시 원점으로 돌아갑니다.
//💥 why can't we have nice things 😭<ExpensiveTree someProp={someStableArray ?? []} />
그러므로 React.memo를 사용하는 일은 다소 지뢰밭과 같고, 제게는 제안된 대안 중 하나를 선택하는 편이 훨씬 낫습니다. 하지만 때로는 컴포넌트를 메모이즈하는 일을 피할 수 없어 보입니다. 이 블로그 글의 아이디어를 촉발한, Twitter에서 본 예를 살펴보겠습니다.
Cory House @ housecor 저는 React 성능 최적화를 할 일이 거의 없습니다. 하지만 큰 테이블 5개와 요약 표시줄이 있는 페이지가 있습니다. 한 테이블이 바뀌면 모든 것이 렌더링됩니다. 느립니다. 해결책: 1. 각 테이블을 memo로 감쌌습니다. 2. 아래로 전달하는 함수를 useCallback으로 감쌌습니다. 훨씬 빨라졌습니다. - 2023년 9월 28일
여기서 컴포넌트 트리는 대략 다음과 같을 것이라고 예상합니다. 간결성을 위해 테이블은 5개 대신 2개를 사용하겠습니다.
function App() { const [state, setState] = React.useState({ table1Data: [], table2Data: [], }) return ( <div> <Table1 data={state.table1Data} /> <Table2 data={state.table2Data} /> <SummaryBar data={calculateSummary(state.table1Data, state.table2Data)} /> </div> )}
state는 두 테이블의 데이터를 모두 보유하고, SummaryBar는 그 모든 데이터에 접근해야 합니다. 상태를 테이블 아래로 내릴 수도 없고, 컴포넌트를 다른 방식으로 구성할 수도 없습니다. 메모이제이션이 유일한 선택지인 듯 보입니다.
렌더링이 일단 시작되면 멈출 방법이 없다고 말했던 것을 기억하시나요? 여전히 맞는 말이지만, 애초에 렌더링이 시작되지 않게 막는다면 어떨까요… 🤔
state가 App의 최상단에 존재하지 않는다면 바뀔 때마다 전체 트리를 리렌더링할 필요가 없습니다. 하지만 대신 어디에 둘 수 있을까요? 아래로 내릴 수 없다는 점은 이미 확인했으니, 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은 리렌더링되지 않습니다. 이는 테이블을 메모이즈하는 것만큼 효과적이지만, 새 props를 추가하면 성능에 부정적 영향을 줄 수 있는 함정을 겪지 않습니다.
인정하건대, 여기의 모든 해결책이 훌륭하지는 않습니다. 일반적으로 메모이제이션은 코드를 읽기 어렵게 만들고 잘못하기 쉬우므로, 제게는 최악의 선택지입니다. 외부 상태 관리자를 사용하는 편이 조금 낫습니다. 어차피 App에 그 의존성이 있을 수도 있습니다. 컴포넌트를 구성하는 방식을 조정하는 것이 여전히 최선이지만, 언제나 가능한 것은 아닙니다.
진정한 탈출구는 게임의 규칙을 바꾸는 것입니다. 한동안 2단계에 머물러 있는 ECMAScript 제안인 Records and Tuples (새 창에서 열림)는 배열과 객체에는 도움이 되겠지만 함수에는 그렇지 않습니다. Sebastien Lorber (새 창에서 열림)는 이에 관한 훌륭한 글 (새 창에서 열림)을 썼습니다.
React 팀은 또한 모든 것을 자동으로 메모이즈해 줄 것이라는 React Forget (새 창에서 열림)이라는 컴파일러를 작업 중이라고 암시했습니다. 이것이 마련된다면 오류 가능성 없이 React.memo의 성능 최적화를 얻을 수 있을 것입니다.
오늘은 여기까지입니다. 질문이 있다면 bluesky 🦋 (새 창에서 열림)로 편하게 연락하시거나, 아래에 댓글을 남겨 주세요. ⬇️
코드 블록의 고정폭 글꼴이 마음에 드시나요?
monolisa.dev를 확인해 보세요.
© 2026 TkDodo의 블로그. 모든 권리 보유.