불필요한 리렌더링 이해와 방지
이번에는 가장 흔하고 중요한 성능 문제 중 하나인 "불필요한 리렌더링(Unnecessary Re-renders)" 을 깊이 이해하고, 이를 효과적으로 방지하기 위한 리액트의 내장 최적화 기법들을 살펴보겠습니다.
리렌더링이란 무엇인가?
리액트는 UI를 업데이트하기 위해 "리렌더링(re-rendering)"이라는 과정을 거칩니다.
- 렌더링(Rendering): 컴포넌트 함수를 호출하여 JSX를 반환하고, 이 JSX를 가상 DOM(Virtual DOM)으로 변환하는 과정입니다.
- 리렌더링(Re-rendering): 컴포넌트의 프롭스(props)나 상태(state)가 변경되었을 때, 리액트가 해당 컴포넌트와 그 자식 컴포넌트들을 다시 렌더링하여 새로운 가상 DOM을 생성하는 과정입니다. 리액트는 이전 가상 DOM과 새 가상 DOM을 비교(재조정, reconciliation)하여 실제 DOM에 필요한 최소한의 변경만을 반영합니다.
리렌더링이 발생하는 주요 원인
- 상태(State) 변경:
useState
훅으로 관리되는 컴포넌트의 상태가 변경될 때.function Counter() { const [count, setCount] = useState(0); // count가 변경되면 Counter 리렌더링 return <button onClick={() => setCount(count + 1)}>{count}</button>; }
- 프롭스(Props) 변경: 부모 컴포넌트로부터 전달받은 프롭스가 변경될 때.
function Parent() { const [data, setData] = useState("Hello"); return <Child message={data} />; // data가 변경되면 Child 리렌더링 } function Child({ message }) { /* ... */ }
- 부모 컴포넌트 리렌더링: 부모 컴포넌트가 리렌더링되면, 기본적으로 모든 자식 컴포넌트도 함께 리렌더링됩니다. 이것이 바로 "불필요한 리렌더링"의 주요 원인입니다.
function Grandparent() { const [value, setValue] = useState(0); return ( <div> <ParentA value={value} /> <ParentB /> {/* ParentB는 value와 관련 없지만, Grandparent가 리렌더링되면 ParentB도 리렌더링됨 */} </div> ); }
- 컨텍스트(Context) 변경:
useContext
를 통해 사용되는 Context의 값이 변경될 때, 해당 Context를 구독하는 모든 컴포넌트가 리렌더링됩니다. forceUpdate
호출: 극히 드물게 사용되며, 강제로 컴포넌트를 리렌더링합니다.
불필요한 리렌더링이 성능에 미치는 영향
리액트의 가상 DOM 비교 알고리즘은 매우 효율적이지만, 불필요한 리렌더링 자체가 비용이 드는 작업입니다.
- 컴포넌트 함수 재실행: 리렌더링 시 컴포넌트 함수가 다시 실행되어 JSX를 생성합니다. 이 과정에서 내부에 포함된 모든 로직(변수 선언, 함수 호출 등)이 다시 실행됩니다.
- 가상 DOM 비교: 새로운 가상 DOM 트리를 생성하고 이전 트리와 비교하는 과정(재조정)도 비용을 발생시킵니다.
- 메모리 사용 증가: 불필요한 객체, 배열, 함수 등의 재생성은 가비지 컬렉션을 유발하고 메모리 사용량을 늘릴 수 있습니다.
- 느린 UI 반응: 특히 복잡한 컴포넌트나 많은 컴포넌트가 동시에 불필요하게 리렌더링되면 UI가 뚝뚝 끊기거나 반응이 느려지는 현상이 발생할 수 있습니다.
React Developer Tools의 "Highlight updates when components render." 기능을 켜고 앱을 사용해 보세요. 아무런 데이터 변경이 없는데도 컴포넌트들이 계속 깜빡인다면, 그것은 불필요한 리렌더링이 발생하고 있다는 명확한 신호입니다.
불필요한 리렌더링 방지 기법 (Memoization)
리액트에서 불필요한 리렌더링을 방지하는 주요 기법은 메모이제이션(Memoization) 입니다. 메모이제이션은 "이전에 계산한 값을 기억해두었다가 동일한 입력이 들어오면 다시 계산하지 않고 저장된 값을 반환하는" 최적화 기법입니다.
React.memo
(컴포넌트 메모이제이션)
React.memo
는 고차 컴포넌트(Higher-Order Component, HOC)로, 컴포넌트의 프롭스가 변경되지 않았다면 해당 컴포넌트를 리렌더링하지 않도록 지시합니다.
작동 방식
React.memo
로 감싸진 컴포넌트는 새로운 프롭스를 받으면, 이전 프롭스와 새로운 프롭스를 얕은 비교(shallow comparison)합니다.- 모든 프롭스가 동일하다고 판단되면, 리액트는 해당 컴포넌트의 렌더링을 건너뛰고 이전에 렌더링된 결과를 재사용합니다.
사용 시점
- 컴포넌트의 렌더링 비용이 비싸고 (내부에 복잡한 계산이나 많은 자식 컴포넌트가 있음)
- 대부분의 경우 프롭스가 변경되지 않는 컴포넌트
예시
// src/components/MemoExample.js
import React, { useState } from 'react';
// 자식 컴포넌트: React.memo로 감싸서 프롭스 변경 시에만 리렌더링되도록 함
const MemoizedChild = React.memo(function MemoizedChild({ count }) {
console.log('MemoizedChild 렌더링됨', count); // 이 로그가 찍히는 시점을 확인!
return <p>Memoized Child Count: {count}</p>;
});
// 자식 컴포넌트: React.memo로 감싸지 않음
const RegularChild = function RegularChild({ name }) {
console.log('RegularChild 렌더링됨', name); // 이 로그가 찍히는 시점을 확인!
return <p>Regular Child Name: {name}</p>;
};
function ParentComponent() {
const [parentCount, setParentCount] = useState(0);
const [childCount, setChildCount] = useState(0); // MemoizedChild에 전달될 값
const [regularName, setRegularName] = useState("Alice"); // RegularChild에 전달될 값
const incrementParent = () => setParentCount(parentCount + 1);
const incrementChild = () => setChildCount(childCount + 1);
const changeRegularName = () => setRegularName("Bob"); // 이름을 변경하지 않으면 RegularChild는 계속 리렌더링됨
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>Parent Count: {parentCount}</p>
<button onClick={incrementParent} className="button" style={{ marginRight: '10px' }}>
Parent Count 증가 (모든 자식 리렌더링)
</button>
<button onClick={incrementChild} className="button">
Memoized Child Count 증가 (MemoizedChild만 리렌더링)
</button>
<button onClick={changeRegularName} className="button secondary" style={{ marginLeft: '10px' }}>
Regular Child Name 변경
</button>
<hr style={{ margin: '20px 0' }}/>
{/* childCount가 변경될 때만 리렌더링 */}
<MemoizedChild count={childCount} />
{/* name이 변경되든 안 되든 ParentComponent 리렌더링 시 항상 리렌더링 */}
<RegularChild name={regularName} />
</div>
);
}
export default ParentComponent;
ParentComponent
의 parentCount
를 증가시키면 ParentComponent
가 리렌더링됩니다. 이때 MemoizedChild
는 count
프롭스가 변경되지 않았으므로 리렌더링되지 않습니다 (콘솔 로그가 찍히지 않음). 반면 RegularChild
는 프롭스가 변경되지 않았더라도 부모가 리렌더링되면 함께 리렌더링됩니다 (콘솔 로그가 계속 찍힘). childCount
를 증가시키면 MemoizedChild
만 리렌더링되는 것을 볼 수 있습니다.
주의사항
- 얕은 비교(Shallow Comparison):
React.memo
는 프롭스를 얕게 비교합니다. 객체나 배열 같은 참조 타입의 프롭스가 항상 새로운 참조로 생성되면 ({}
나[]
리터럴), 내용이 같더라도React.memo
는 다른 것으로 간주하여 리렌더링을 유발할 수 있습니다. - 성능 비용:
React.memo
자체도 프롭스를 비교하는 오버헤드가 있습니다. 불필요한 리렌더링으로 인한 비용보다 프롭스 비교 비용이 더 커진다면 오히려 성능이 저하될 수 있습니다. 항상 프로파일링을 통해 최적화의 효과를 확인해야 합니다.
useCallback
(함수 메모이제이션)
useCallback
훅은 컴포넌트가 리렌더링될 때마다 함수가 재생성되는 것을 방지합니다. 이는 특히 React.memo
로 감싸진 자식 컴포넌트에 콜백 함수를 프롭스로 전달할 때 중요합니다.
문제점:
리액트에서 컴포넌트가 리렌더링될 때마다, 그 컴포넌트 내부에 선언된 함수들도 모두 새로 생성됩니다. React.memo
는 프롭스의 얕은 비교를 수행하는데, 새로 생성된 함수는 이전 함수와 다른 참조값을 가지므로 React.memo
가 이를 '변경되었다'고 판단하여 자식 컴포넌트를 불필요하게 리렌더링하게 됩니다.
해결책:
useCallback
을 사용하여 함수를 메모이제이션하면, 해당 함수는 의존성 배열(deps
)에 있는 값이 변경되지 않는 한 재생성되지 않고 동일한 함수 참조를 유지합니다.
예시
// src/components/UseCallbackExample.js
import React, { useState, useCallback } from 'react';
// Memoized 된 자식 컴포넌트 (함수 프롭스를 받음)
const ButtonComponent = React.memo(({ onClick, label }) => {
console.log(`${label} ButtonComponent 렌더링됨`);
return <button onClick={onClick} className="button" style={{ marginRight: '10px' }}>{label}</button>;
});
function UseCallbackExample() {
const [count, setCount] = useState(0);
const [text, setText] = useState('Hello');
// 🚨 이 함수는 count가 변경될 때마다 재생성됩니다.
const handleIncrementRegular = () => {
setCount(prevCount => prevCount + 1);
};
// ✅ 이 함수는 text가 변경될 때만 재생성됩니다.
const handleAlertText = useCallback(() => {
alert(`Current Text: ${text}`);
}, [text]); // text가 변경될 때만 함수 재생성
// 폼 처리에서 많이 볼 수 있는 패턴:
// 자식 컴포넌트에 onSubmit 핸들러를 넘길 때, 부모의 상태와 관련 없는 경우
const handleFormSubmit = useCallback(() => {
console.log("폼이 제출되었습니다!");
// 여기에 폼 제출 로직 (API 호출 등)
}, []); // 의존성 배열이 비어있으므로 컴포넌트 마운트 시 한 번만 생성됨
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' }}>`useCallback` 예시</h2>
<p>Count: {count}</p>
<p>Text: {text}</p>
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="텍스트 입력"
style={{ width: '100%', padding: '8px', marginBottom: '20px', border: '1px solid #ccc', borderRadius: '4px' }}
/>
<ButtonComponent onClick={handleIncrementRegular} label="Regular Increment" />
<ButtonComponent onClick={handleAlertText} label="Alert Text (useCallback)" />
<ButtonComponent onClick={handleFormSubmit} label="Submit Form (useCallback)" />
<p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
`Regular Increment` 버튼을 클릭할 때마다 `handleIncrementRegular` 함수가 재생성되어<br/>
`Regular Increment` 버튼 컴포넌트도 함께 리렌더링 되는 것을 콘솔에서 확인해보세요.<br/>
`Alert Text` 버튼은 `text`가 변경될 때만 리렌더링 됩니다.
</p>
</div>
);
}
export default UseCallbackExample;
handleIncrementRegular
는 count
상태에 의존하지 않지만, ParentComponent
가 리렌더링될 때마다 재생성되어 ButtonComponent
의 onClick
프롭스가 변경된 것으로 간주됩니다. 반면 handleAlertText
는 useCallback
으로 감싸져 text
가 변경될 때만 재생성되므로, text
가 변경되지 않는 한 ButtonComponent
는 불필요하게 리렌더링되지 않습니다.
useMemo
(값 메모이제이션)
useMemo
훅은 비용이 많이 드는 계산의 결과를 메모이제이션합니다. 의존성 배열(deps
)에 있는 값이 변경되지 않는 한, 이전에 계산된 값을 재사용합니다.
사용 시점
- 컴포넌트 렌더링 시마다 다시 계산되는 복잡한 계산 결과 (예: 큰 배열 필터링, 정렬, 데이터 변환 등)
React.memo
로 감싸진 자식 컴포넌트에 객체나 배열을 프롭스로 전달할 때, 해당 객체/배열이 불필요하게 재생성되어 자식 리렌더링을 유발하는 것을 방지할 때
예시
// src/components/UseMemoExample.js
import React, { useState, useMemo } from 'react';
// Memoized된 자식 컴포넌트
const ProductList = React.memo(({ products }) => {
console.log('ProductList 렌더링됨');
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name} - ${product.price}</li>
))}
</ul>
);
});
function UseMemoExample() {
const [filterText, setFilterText] = useState('');
const [count, setCount] = useState(0); // 다른 상태 변화를 유도하여 리렌더링 테스트
const allProducts = [
{ id: 1, name: 'Apple', price: 1.0 },
{ id: 2, name: 'Banana', price: 0.5 },
{ id: 3, name: 'Cherry', price: 2.0 },
{ id: 4, name: 'Date', price: 1.5 },
];
// 🚨 useMemo를 사용하지 않으면, filterText나 count가 변경될 때마다 이 배열이 재생성됨
// const filteredProducts = allProducts.filter(product =>
// product.name.toLowerCase().includes(filterText.toLowerCase())
// );
// ✅ useMemo를 사용하여 filterText가 변경될 때만 계산 결과를 재생성
const filteredProducts = useMemo(() => {
console.log('filteredProducts 계산됨'); // 이 로그가 찍히는 시점을 확인!
return allProducts.filter(product =>
product.name.toLowerCase().includes(filterText.toLowerCase())
);
}, [filterText, allProducts]); // allProducts는 변하지 않으므로 실제로는 filterText만 의존성으로 작용
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' }}>`useMemo` 예시</h2>
<input
type="text"
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
placeholder="제품 필터링..."
style={{ width: '100%', padding: '8px', marginBottom: '15px', border: '1px solid #ccc', borderRadius: '4px' }}
/>
<button onClick={() => setCount(count + 1)} className="button">
Count 증가 (UseMemoExample 리렌더링) - Current Count: {count}
</button>
<hr style={{ margin: '20px 0' }}/>
{/* filteredProducts가 변경될 때만 ProductList가 리렌더링됨 */}
<ProductList products={filteredProducts} />
<p style={{ marginTop: '20px', fontSize: '0.9em', color: '#555' }}>
`Count 증가` 버튼을 클릭할 때 `UseMemoExample` 컴포넌트가 리렌더링되지만,<br/>
`filteredProducts`는 `filterText`가 변경되지 않는 한 다시 계산되지 않고,<br/>
따라서 `ProductList` 컴포넌트도 불필요하게 리렌더링되지 않습니다.
</p>
</div>
);
}
export default UseMemoExample;
Count 증가
버튼을 클릭하여 UseMemoExample
컴포넌트가 리렌더링될 때 filteredProducts 계산됨
로그가 찍히는지 확인해 보세요. useMemo
를 사용했기 때문에 filterText
가 변경되지 않는 한 해당 로그는 찍히지 않을 것이며, ProductList
도 리렌더링되지 않을 것입니다.
최적화 적용 시 고려사항
- 남용 금지:
React.memo
,useCallback
,useMemo
는 성능 향상을 위한 도구이지만, 이들을 사용하는 것 자체도 오버헤드가 있습니다. 항상 "최적화 전 프로파일링, 최적화 후 프로파일링" 원칙을 지켜서 실제 성능 향상이 있는지 확인해야 합니다. - 얕은 비교의 한계: 참조 타입(객체, 배열)을 프롭스나 의존성으로 사용할 때 얕은 비교의 한계를 이해해야 합니다. 불필요한 재생성을 막기 위해
useCallback
이나useMemo
를 적절히 사용해야 합니다. - 읽기 쉬운 코드 우선: 가독성 좋고 유지보수하기 쉬운 코드가 가장 중요합니다. 성능 문제가 명확하게 드러나기 전까지는 과도한 최적화를 피하는 것이 좋습니다.
- 컴포넌트 구조 개선: 때로는 최적화 훅을 사용하는 것보다 컴포넌트의 책임을 분리하고 상태를 더 낮은 계층으로 끌어내리는 것이 더 근본적인 해결책이 될 수 있습니다.
10장 2절 "불필요한 리렌더링 이해와 방지"는 여기까지입니다. 이 장에서는 리액트의 리렌더링 메커니즘을 이해하고, 불필요한 리렌더링이 성능에 미치는 영향을 알아보았습니다. 또한, 이를 방지하기 위한 핵심적인 메모이제이션 기법인 React.memo
, useCallback
, useMemo
의 사용법과 주의사항을 자세히 살펴보았습니다.