메모이제이션 기법을 활용한 React 성능 최적화
Min-jun Kim
Dev Intern · Leapcell

소개
프론트엔드 개발의 동적인 세계에서 부드럽고 반응성이 좋은 사용자 경험을 제공하는 것은 무엇보다 중요합니다. 선언적이고 컴포넌트 기반 라이브러리인 React는 복잡한 UI 구성을 단순화합니다. 그러나 애플리케이션의 크기와 복잡성이 증가함에 따라 불필요한 컴포넌트 리렌더링은 상당한 성능 병목 현상이 될 수 있습니다. 이러한 리렌더링은 느린 인터페이스, CPU 사용량 증가 및 사용자 경험 저하로 이어질 수 있습니다. 이 글에서는 이러한 불필요한 리렌더링을 방지하기 위한 메모이제이션 기법, 특히 React.memo
, useCallback
, useMemo
의 중요한 역할에 대해 살펴보고, 이를 통해 React 애플리케이션을 최적화하여 더 빠르고 효율적인 사용자 인터페이스를 보장합니다.
핵심 개념 이해
메모이제이션의 메커니즘을 자세히 살펴보기 전에, 이러한 최적화 기법의 기초가 되는 몇 가지 기본 개념에 대한 명확한 이해를 확립해 보겠습니다.
리렌더링: React에서 컴포넌트는 상태나 props가 변경될 때 "리렌더링"됩니다. 부모 컴포넌트가 리렌더링될 때, 기본적으로 props가 실제로 변경되었는지 여부에 관계없이 모든 자식 컴포넌트도 리렌더링됩니다. 이러한 리렌더링의 연쇄는 종종 성능 문제의 원인이 됩니다.
메모이제이션: 기본적으로 메모이제이션은 비용이 많이 드는 함수 호출 결과를 저장하고 동일한 입력이 다시 발생할 때 캐시된 결과를 반환하여 컴퓨터 프로그램의 속도를 높이는 데 사용되는 최적화 기법입니다. React에서는 이 개념을 컴포넌트와 함수에 적용하여 비용이 많이 드는 작업을 다시 실행하지 않도록 합니다.
참조 동등성: 이 개념은 React에서 메모이제이션이 작동하는 방식을 이해하는 데 중요합니다. JavaScript에서 객체와 배열은 값이 아닌 참조로 비교됩니다. 이는 동일한 속성을 가졌지만 메모리 주소가 다른 두 객체가 같지 않은 것으로 간주됨을 의미합니다. 예를 들어, {} === {}
는 false
로 평가됩니다. 많은 일반적인 성능 문제점은 내용이 변경되지 않았더라도 모든 렌더링에서 의도치 않게 새로운 개체 또는 배열 참조를 생성하는 것에서 비롯됩니다.
불필요한 리렌더링 방지
React는 컴포넌트에 대한 React.memo
, 함수에 대한 useCallback
, 값에 대한 useMemo
의 세 가지 강력한 메모이제이션 도구를 제공합니다. 각 도구를 실제 예와 함께 자세히 살펴보겠습니다.
컴포넌트 최적화를 위한 React.memo
React.memo
는 함수형 컴포넌트를 감싸는 고차 컴포넌트(HOC)입니다. 컴포넌트의 렌더링 출력을 "메모화"하고 마지막 렌더링 이후 props가 얕게 변경된 경우에만 컴포넌트를 다시 렌더링합니다. 이는 종종 렌더링 전반에 걸쳐 동일한 props를 받는 프레젠테이션 컴포넌트에 특히 유용합니다.
메시지를 표시하는 간단한 ChildComponent
를 고려해 보겠습니다.
import React from 'react'; const ChildComponent = ({ message }) => { console.log('ChildComponent re-rendered'); return <p>{message}</p>; }; export default ChildComponent;
이제 ParentComponent
에서 이 컴포넌트를 사용해 보겠습니다.
import React, { useState } from 'react'; import ChildComponent from './ChildComponent'; const ParentComponent = () => { const [count, setCount] = useState(0); const fixedMessage = "Hello from child!"; return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <ChildComponent message={fixedMessage} /> </div> ); }; export default ParentComponent;
"Increment Count" 버튼을 클릭하면 ParentComponent
가 리렌더링됩니다. 결과적으로 ChildComponent
도 리렌더링되며, message
prop이 변경되지 않았음에도 불구하고 콘솔에 "ChildComponent re-rendered"가 표시됩니다.
이러한 불필요한 리렌더링을 방지하기 위해 ChildComponent
를 React.memo
로 감쌀 수 있습니다.
import React from 'react'; const ChildComponent = ({ message }) => { console.log('Memoized ChildComponent re-rendered'); return <p>{message}</p>; }; export default React.memo(ChildComponent); // <--- React.memo 적용
이제 ParentComponent
가 count
변경으로 인해 리렌더링될 때, ChildComponent
는 message
prop이 변경된 경우에만 다시 렌더링됩니다. fixedMessage
는 계속 일정하므로 count
가 업데이트될 때 콘솔 로그가 더 이상 나타나지 않습니다.
React.memo
사용 시기:
- 프레젠테이션 컴포넌트: 주로 데이터를 표시하고 내부 상태가 거의 없는 컴포넌트.
- 렌더링이 복잡한 컴포넌트: 컴포넌트의 렌더링 로직이 계산적으로 복잡한 경우.
- 자주 변경되지 않는 prop을 받는 컴포넌트: 부모가 자주 리렌더링되더라도 자식 컴포넌트가 거의 변경되지 않는 props를 받는 경우.
주의: React.memo
는 props에 대한 얕은 비교를 수행합니다. prop이 객체나 배열이고 내용이 변경되었지만 참조가 동일하게 유지되면 React.memo
는 리렌더링을 방지하지 못합니다. 이때 useCallback
과 useMemo
가 사용됩니다.
함수 메모이제이션을 위한 useCallback
콜백 함수를 자식 컴포넌트에 props로 전달할 때, 특히 메모화된 컴포넌트(예: React.memo
로 감싸진 컴포넌트)의 경우, 부모 리렌더링 시 함수 참조가 변경되지 않도록 하는 것이 중요합니다. 변경될 경우 자식 컴포넌트는 여전히 리렌더링되어 React.memo
의 이점을 상쇄할 수 있습니다. useCallback
은 함수 자체를 메모화하여 이를 돕습니다.
예제를 수정하여 ChildComponent
에 함수를 전달해 보겠습니다.
import React, { useState } from 'react'; const MemoizedChildComponent = React.memo(({ onClick }) => { console.log('MemoizedChildComponent re-rendered'); return <button onClick={onClick}>Click Child</button>; }); const ParentComponent = () => { const [count, setCount] = useState(0); const handleClick = () => { console.log('Child button clicked!'); }; return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <MemoizedChildComponent onClick={handleClick} /> </div> ); }; export default ParentComponent;
MemoizedChildComponent
가 메모화되었음에도 불구하고, ParentComponent
가 리렌더링될 때 새로운 handleClick
함수 참조가 생성됩니다. onClick
prop의 참조가 변경되기 때문에 MemoizedChildComponent
는 여전히 리렌더링됩니다.
이를 해결하기 위해 useCallback
을 사용합니다.
import React, { useState, useCallback } from 'react'; const MemoizedChildComponent = React.memo(({ onClick }) => { console.log('MemoizedChildComponent re-rendered'); return <button onClick={onClick}>Click Child</button>; }); const ParentComponent = () => { const [count, setCount] = useState(0); const handleClick = useCallback(() => { // <--- 함수 메모화 console.log('Child button clicked!'); }, []); // 빈 의존성 배열은 한 번만 생성됨을 의미 return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <MemoizedChildComponent onClick={handleClick} /> </div> ); }; export default ParentComponent;
이제 handleClick
이 메모화되었습니다. 의존성(이 경우 []
로 인해 해당되는 것이 없음)이 변경되지 않는 한 useCallback
은 리렌더링 전반에 걸쳐 동일한 함수 인스턴스를 반환합니다. 이를 통해 MemoizedChildComponent
는 onClick
prop(함수 참조)이 실제로 변경된 경우에만 리렌더링됩니다.
의존성 배열: useCallback
의 두 번째 인수는 의존성 배열입니다. 이 배열의 값이 변경되면 useCallback
은 새 함수 인스턴스를 반환합니다. 메모화된 함수 내에서 사용되는 컴포넌트 범위의 모든 값을 포함하는 것이 중요합니다. 의존성을 잊으면 오래된 클로저(이전 값을 사용하는 함수)가 발생할 수 있습니다.
값 메모이제이션을 위한 useMemo
useMemo
는 useCallback
과 유사하지만 함수를 메모하는 대신 계산된 값을 메모합니다. 이는 비용이 많이 드는 계산이나 메모화된 자식 컴포넌트에 props로 전달될 때 참조 동등성을 유지해야 하는 개체/배열 리터럴을 만드는 데 유용합니다.
비용이 많이 드는 계산이 있는 시나리오를 상상해 보겠습니다.
import React, { useState, useMemo } from 'react'; const MemoizedChildComponent = React.memo(({ data }) => { console.log('MemoizedChildComponent re-rendered'); return ( <ul> {data.map(item => <li key={item}>{item}</li>)} </ul> ); }); const ParentComponent = () => { const [count, setCount] = useState(0); const expensiveCalculation = (num) => { console.log('Performing expensive calculation...'); let result = 0; for (let i = 0; i < 100000000; i++) { result += i; } return result + num; }; const calculatedValue = expensiveCalculation(count); // 이 계산은 모든 렌더링에서 실행됨 return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <p>Expensive Result: {calculatedValue}</p> <MemoizedChildComponent data={['item1', 'item2']} /> </div> ); }; export default ParentComponent;
여기서 expensiveCalculation
은 ParentComponent
가 리렌더링될 때마다 실행됩니다. 심지어 count
(계산의 입력)가 expensiveCalculation
이 마지막으로 실행되었을 때와 비교했을 때 변경되지 않았더라도 말입니다.
useMemo
를 사용하여 이를 최적화할 수 있습니다.
import React, { useState, useMemo } from 'react'; const MemoizedChildComponent = React.memo(({ data }) => { console.log('MemoizedChildComponent re-rendered'); return ( <ul> {data.map(item => <li key={item}>{item}</li>)} </ul> ); }); const ParentComponent = () => { const [count, setCount] = useState(0); const expensiveCalculation = (num) => { console.log('Performing expensive calculation...'); let result = 0; for (let i = 0; i < 100000000; i++) { result += i; } return result + num; }; const calculatedValue = useMemo(() => { // <--- 값 메모화 return expensiveCalculation(count); }, [count]); // 'count'가 변경될 때만 다시 계산 // 참조 동등성을 유지하기 위해 개체 리터럴을 메모화하는 예 const listData = useMemo(() => ['item1', 'item2', `Count: ${count}`], [count]); return ( <div> <h1>Parent Count: {count}</h1> <button onClick={() => setCount(prev => prev + 1)}>Increment Count</button> <p>Expensive Result: {calculatedValue}</p> <MemoizedChildComponent data={listData} /> </div> ); }; export default ParentComponent;
이제 useMemo
안의 expensiveCalculation
은 count
가 변경될 때만 실행됩니다. 그렇지 않으면 useMemo
는 이전에 계산된 값을 반환합니다. 마찬가지로, count
가 변경될 때만 listData
가 새 참조를 받으므로 MemoizedChildComponent
는 꼭 필요한 경우가 아니면 리렌더링되지 않습니다.
useMemo
사용 시기:
- 비용이 많이 드는 계산: 값이 props 또는 상태에서 파생되고 계산이 계산적으로 복잡한 경우.
- 참조 동등성 유지: 메모화된 자식 컴포넌트에 개체 또는 배열을 props로 전달할 때, 자식 컴포넌트의 불필요한 리렌더링을 방지하기 위해.
중요: useMemo
와 useCallback
은 무분별하게 사용해서는 안 됩니다. React 훅은 어느 정도의 오버헤드를 가집니다. 병목 현상을 식별했거나 메모화된 자식 컴포넌트에 대한 참조 동등성을 유지해야 하는 성능 최적화를 위해 주로 사용하십시오. 과도한 사용은 때때로 더 큰 복잡성을 야기하고 메모이제이션 확인 오버헤드로 인해 성능이 약간 저하될 수도 있습니다.
결론
React.memo
, useCallback
, useMemo
는 매우 성능이 뛰어난 애플리케이션을 구축하는 데 필요한 React 개발자 도구 모음에서 귀중한 도구입니다. 이러한 메모이제이션 기법을 지능적으로 적용하면 불필요한 컴포넌트 리렌더링을 효과적으로 방지하고 계산 오버헤드를 크게 줄이며 더 부드럽고 반응성이 좋은 사용자 경험을 제공할 수 있습니다. 이러한 훅을 전략적으로 활용하면 비용이 많이 드는 작업이 다시 실행되는 것을 방지하여 React 애플리케이션의 효율성과 반응성을 최적화할 수 있습니다.