React.memo 소개
이번에는 그중에서도 컴포넌트 수준의 최적화를 담당하는 React.memo
에 대해 더 깊이 파고들어 보겠습니다. React.memo
의 작동 원리, 정확한 사용법, 그리고 주의해야 할 함정들을 자세히 알아보고 실습 예제를 통해 명확하게 이해하는 시간을 갖겠습니다.
React.memo
는 왜 필요한가?
리액트 컴포넌트는 기본적으로 부모 컴포넌트가 리렌더링될 때, 자신의 프롭스(props)나 상태(state)가 변경되지 않았더라도 함께 리렌더링됩니다. 이것은 리액트의 "상태가 변경되면 UI를 다시 그린다"는 철학에 따른 자연스러운 동작이지만, 때로는 불필요한 비용을 발생시킬 수 있습니다.
예를 들어, 매우 복잡한 계산을 수행하거나 많은 하위 컴포넌트를 렌더링하는 ExpensiveComponent
가 있다고 가정해 봅시다. 이 컴포넌트가 부모로부터 받는 프롭스는 거의 변경되지 않지만, 부모의 다른 상태 변화로 인해 부모가 리렌더링될 때마다 ExpensiveComponent
도 매번 리렌더링된다면, 이는 애플리케이션의 성능 저하로 이어질 수 있습니다.
React.memo
는 이러한 상황에서 빛을 발합니다. React.memo
는 리액트에게 "이 컴포넌트는 프롭스가 변경되지 않으면 다시 렌더링할 필요가 없어"라고 힌트를 주는 역할을 합니다.
React.memo
의 작동 원리: 얕은 비교
React.memo
는 고차 컴포넌트(Higher-Order Component, HOC)입니다. 즉, 컴포넌트를 인자로 받아 새로운 컴포넌트를 반환하는 함수입니다.
const MemoizedComponent = React.memo(MyComponent);
React.memo
로 감싸진 MyComponent
는 다음과 같이 동작합니다:
MyComponent
가 새로운 프롭스를 전달받으면,React.memo
는 이전 렌더링 시의 프롭스와 현재 전달받은 새로운 프롭스를 얕은 비교(shallow comparison) 합니다.- 모든 프롭스가 얕은 비교 결과 동일하다고 판단되면,
MyComponent
는 리렌더링되지 않고 이전에 렌더링된 결과를 재사용합니다. 즉,MyComponent
함수 자체가 호출되지 않습니다. - 하나라도 다른 프롭스가 있다면,
MyComponent
는 정상적으로 리렌더링됩니다.
얕은 비교(Shallow Comparison)란?
- 원시 타입(Primitive Types - string, number, boolean, null, undefined, symbol, bigint): 값 자체를 비교합니다.
5 === 5
는true
,'hello' === 'world'
는false
. - 참조 타입(Reference Types - object, array, function): 메모리 주소(참조)를 비교합니다. 값이 같더라도 메모리 주소가 다르면
false
로 간주합니다. 이 얕은 비교의 특성 때문에const obj1 = { a: 1 }; const obj2 = { a: 1 }; console.log(obj1 === obj2); // false (다른 메모리 주소) const arr1 = [1, 2]; const arr2 = [1, 2]; console.log(arr1 === arr2); // false (다른 메모리 주소) const func1 = () => {}; const func2 = () => {}; console.log(func1 === func2); // false (다른 메모리 주소)
React.memo
사용 시useCallback
과useMemo
가 함께 필요한 경우가 발생합니다.
React.memo
사용 예시
이전 장에서 다루었던 예시를 다시 한번 살펴보고, React.memo
의 효과를 명확하게 이해해 봅시다.
// src/components/MemoComparison.js
import React, { useState } from 'react';
// 🌟 React.memo로 감싸진 자식 컴포넌트
const MemoizedDisplay = React.memo(({ title, value, onIncrement }) => {
console.log(`MemoizedDisplay [${title}] 렌더링됨`); // 렌더링 시점에 로그 출력
return (
<div style={{ padding: '10px', margin: '5px', border: '1px solid lightblue', borderRadius: '4px' }}>
<h3>{title}</h3>
<p>Value: {value}</p>
{onIncrement && <button onClick={onIncrement} className="button secondary">Increment Memoized Child</button>}
</div>
);
});
// React.memo로 감싸지지 않은 일반 자식 컴포넌트
const RegularDisplay = ({ title, value }) => {
console.log(`RegularDisplay [${title}] 렌더링됨`); // 렌더링 시점에 로그 출력
return (
<div style={{ padding: '10px', margin: '5px', border: '1px solid lightcoral', borderRadius: '4px' }}>
<h3>{title}</h3>
<p>Value: {value}</p>
</div>
);
};
function ParentComponentMemoDemo() {
const [appClicks, setAppClicks] = useState(0); // 앱 전체 클릭 수 (다른 상태 변화)
const [memoizedValue, setMemoizedValue] = useState(0); // MemoizedDisplay에 전달될 값
// ParentComponent가 리렌더링될 때마다 이 함수는 재생성됨 (새로운 참조)
const handleMemoizedIncrement = () => {
setMemoizedValue(prev => prev + 1);
};
return (
<div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`React.memo` 활용 예시</h2>
<p>App Clicks (Parent State): {appClicks}</p>
<button onClick={() => setAppClicks(appClicks + 1)} className="button">
App Clicks 증가 (부모 리렌더링)
</button>
<hr style={{ margin: '20px 0' }}/>
{/* 1. MemoizedDisplay: appClicks 변화로 부모 리렌더링 시, memoizedValue가 변경되지 않으면 리렌더링 안 함 */}
{/* 단, onIncrement 함수가 매번 재생성되므로, 이 경우 MemoizedDisplay는 계속 리렌더링됨! */}
<MemoizedDisplay
title="Memoized Child (with regular function prop)"
value={memoizedValue}
onIncrement={handleMemoizedIncrement}
/>
{/* 2. RegularDisplay: 부모 리렌더링 시 항상 리렌더링 */}
<RegularDisplay
title="Regular Child"
value={appClicks}
/>
<p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
`App Clicks 증가` 버튼을 클릭하면서 콘솔 로그를 확인해보세요.<br/>
`Regular Child`는 항상 렌더링되지만, 첫 번째 `Memoized Child`는 `handleMemoizedIncrement` 함수가 매번 재생성되기 때문에 프롭스가 변경된 것으로 간주되어 계속 리렌더링됩니다.<br/>
이 문제를 해결하려면 `useCallback`이 필요합니다.
</p>
</div>
);
}
export default ParentComponentMemoDemo;
위 코드 실행 결과 분석
App Clicks 증가
버튼을 클릭합니다.ParentComponentMemoDemo
가 리렌더링됩니다.RegularDisplay [Regular Child] 렌더링됨
로그는 매번 찍힐 것입니다.MemoizedDisplay [Memoized Child (with regular function prop)] 렌더링됨
로그도 매번 찍힐 것입니다! 왜 그럴까요?MemoizedDisplay
는React.memo
로 감싸져 있습니다.value
프롭스 (memoizedValue
)는App Clicks
증가 시에는 변경되지 않습니다.- 하지만
onIncrement
프롭스에 전달되는handleMemoizedIncrement
함수는ParentComponentMemoDemo
가 리렌더링될 때마다 새로운 함수 객체로 재생성됩니다. React.memo
의 얕은 비교는handleMemoizedIncrement
가 이전 렌더링 시의 함수와 다른 메모리 주소를 가진 새로운 함수임을 감지합니다.- 따라서
onIncrement
프롭스가 변경된 것으로 간주되어MemoizedDisplay
는 불필요하게 리렌더링됩니다.
이것이 바로 React.memo
만으로는 충분하지 않고, useCallback
과 useMemo
가 함께 사용되어야 하는 이유입니다!
useCallback
, useMemo
과의 시너지
React.memo
를 제대로 활용하려면, 참조 타입의 프롭스(함수, 객체, 배열)가 불필요하게 재생성되지 않도록 useCallback
과 useMemo
를 사용하여 메모이제이션해야 합니다.
// src/components/MemoAndCallbackExample.js
import React, { useState, useCallback, useMemo } from 'react';
// React.memo로 감싸진 자식 컴포넌트
const OptimizedDisplay = React.memo(({ title, value, onAction, data }) => {
console.log(`OptimizedDisplay [${title}] 렌더링됨`);
return (
<div style={{ padding: '10px', margin: '5px', border: '1px solid lightgreen', borderRadius: '4px' }}>
<h3>{title}</h3>
<p>Value: {value}</p>
{onAction && <button onClick={onAction} className="button secondary">Perform Action</button>}
{data && <p>Data items: {data.length}</p>}
</div>
);
});
function ParentComponentOptimized() {
const [appClicks, setAppClicks] = useState(0);
const [displayValue, setDisplayValue] = useState(0);
// ✅ useCallback으로 함수 메모이제이션
// displayValue가 변경될 때만 handleAction 함수가 재생성됨
const handleAction = useCallback(() => {
setDisplayValue(prev => prev + 1);
console.log("Action performed!");
}, []); // 의존성 배열이 비어있으므로 컴포넌트 마운트 시 한 번만 생성됨.
// ✅ useMemo로 객체/배열 메모이제이션
// appClicks가 변경될 때만 dataArray가 재생성됨.
const dataArray = useMemo(() => {
console.log("dataArray 계산됨");
return Array.from({ length: appClicks % 5 + 1 }, (_, i) => `Item ${i}`);
}, [appClicks]); // appClicks가 변경될 때만 dataArray 재생성
return (
<div style={{ maxWidth: '600px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
<h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>`React.memo` + `useCallback` + `useMemo`</h2>
<p>App Clicks (Parent State): {appClicks}</p>
<button onClick={() => setAppClicks(appClicks + 1)} className="button">
App Clicks 증가 (부모 리렌더링)
</button>
<hr style={{ margin: '20px 0' }}/>
{/* OptimizedDisplay: */}
{/* value 프롭스가 변경될 때만 리렌더링 (현재는 변경 안 됨) */}
{/* onAction 프롭스는 useCallback으로 메모이제이션되어 참조가 고정됨 */}
{/* data 프롭스는 useMemo로 메모이제이션되어 appClicks가 특정 값으로 변경될 때만 재생성됨 */}
<OptimizedDisplay
title="Optimized Child"
value={displayValue}
onAction={handleAction}
data={dataArray}
/>
<p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
`App Clicks 증가` 버튼을 여러 번 클릭하면서 콘솔 로그를 확인해보세요.<br/>
`OptimizedDisplay` 컴포넌트와 `dataArray` 계산 로그가 불필요하게 자주 찍히지 않을 것입니다.<br/>
`Perform Action` 버튼을 클릭해야 `OptimizedDisplay`의 `value`가 변경되어 해당 컴포넌트가 렌더링됩니다.
</p>
</div>
);
}
export default ParentComponentOptimized;
위 코드 실행 결과 분석
App Clicks 증가
버튼을 클릭합니다.ParentComponentOptimized
는 리렌더링됩니다.OptimizedDisplay [Optimized Child] 렌더링됨
로그는displayValue
가 변경될 때만 찍힙니다. (즉,Perform Action
버튼을 클릭했을 때)onAction
프롭스는useCallback
으로 감싸져 의존성 배열이 비어있으므로,ParentComponentOptimized
가 리렌더링되더라도 동일한 함수 참조를 유지합니다.data
프롭스는useMemo
로 감싸져appClicks
가 변경될 때만 새로운 배열이 생성되고, 따라서OptimizedDisplay
의data
프롭스 참조는appClicks
가 변경될 때만 바뀝니다. (여기서는appClicks % 5 + 1
로직 때문에 특정appClicks
값에서dataArray
의 내용이 동일하면 재생성되지 않을 수 있습니다.)
dataArray 계산됨
로그는appClicks
가 변경될 때마다 찍힐 것입니다. (이 부분은useMemo
가appClicks
의존성을 가지고 있기 때문입니다.) 하지만OptimizedDisplay
자체는data
프롭스의 참조가 변경될 때만 리렌더링될 것입니다.
이처럼 React.memo
는 useCallback
과 useMemo
와 함께 사용될 때 진정한 성능 최적화 효과를 발휘합니다.
React.memo
의 두 번째 인자
React.memo
는 선택적으로 두 번째 인자로 arePropsEqual
이라는 커스텀 비교 함수를 받을 수 있습니다. 이 함수는 이전 프롭스와 새로운 프롭스를 인자로 받아, 두 프롭스가 동일하다고 판단되면 true
를, 다르다고 판단되면 false
를 반환해야 합니다. true
를 반환하면 리렌더링을 건너뛰고, false
를 반환하면 리렌더링합니다.
기본적인 얕은 비교가 불충분하거나, 특정 프롭스에 대해 더 깊은 비교가 필요할 때 유용합니다. 하지만 대부분의 경우 useCallback
/useMemo
를 통한 참조 일관성 유지로 충분하며, 커스텀 비교 함수는 신중하게 사용해야 합니다.
const MyComponent = React.memo((props) => {
// ...
}, (prevProps, nextProps) => {
// prevProps와 nextProps를 비교하여 리렌더링 여부 결정
// 예: 특정 객체 프롭스의 특정 속성만 비교하고 싶을 때
return prevProps.someValue === nextProps.someValue;
});
언제 React.memo
를 사용해야 하는가?
- 성능 병목 지점을 찾았을 때: React Developer Tools의 Profiler를 통해 불필요한 리렌더링이나 느린 컴포넌트를 식별했을 때.
- 컴포넌트의 렌더링 비용이 높을 때: 컴포넌트가 복잡한 UI를 렌더링하거나, 많은 계산을 수행하는 경우.
- 컴포넌트의 프롭스가 자주 변경되지 않을 때: 부모의 잦은 리렌더링에도 불구하고, 자식 컴포넌트에게 전달되는 프롭스는 대부분 동일하게 유지될 때.
- Pure Component (순수 컴포넌트)처럼 동작해야 할 때: 동일한 프롭스에 대해 항상 동일한 렌더링 결과를 보장하는 컴포넌트.
언제 사용하지 않아야 하는가?
- 프롭스가 자주 변경되는 컴포넌트: 매번 프롭스를 비교하는 오버헤드가 리렌더링 오버헤드보다 커질 수 있습니다.
- 렌더링 비용이 매우 낮은 컴포넌트: 간단한 HTML 요소만 렌더링하는 컴포넌트는 최적화 효과가 미미하고 오히려 오버헤드만 추가합니다.
- 자신만의 상태를 자주 업데이트하는 컴포넌트:
React.memo
는 프롭스에만 관심이 있으며, 컴포넌트 내부 상태 변경 시에는 항상 리렌더링됩니다.
10장 3절 "React.memo
소개"는 여기까지입니다. 이 장에서는 React.memo
의 작동 원리인 얕은 비교를 이해하고, useCallback
, useMemo
와 함께 사용될 때의 시너지를 실습 예제를 통해 확인했습니다. 또한, React.memo
를 언제 사용해야 하고, 언제 피해야 하는지에 대한 가이드라인을 제시했습니다.
이제 여러분은 리액트 애플리케이션에서 컴포넌트 수준의 불필요한 리렌더링을 효과적으로 방지할 수 있는 강력한 도구를 익히셨습니다.