React `useMemo`와 `useCallback` 제대로 활용하기: 성능 최적화의 기본
React 성능 최적화의 핵심, `useMemo`와 `useCallback`의 올바른 사용법과 주의점을 실제 코드 예제와 함께 알아봅니다. 불필요한 연산과 렌더링을 줄여 애플리케이션 성능을 향상시키는 방법을 배우세요.
React 애플리케이션을 개발하다 보면 성능에 대한 고민은 늘 동반됩니다. 특히 컴포넌트가 빈번하게 렌더링되거나 복잡한 연산이 포함된 경우, 불필요한 렌더링으로 인한 성능 저하는 사용자의 경험을 해칠 수 있습니다. React는 이러한 성능 문제를 해결하기 위해 useMemo와 useCallback이라는 두 가지 훅을 제공합니다. 하지만 이 훅들을 잘못 사용하면 오히려 성능에 악영향을 줄 수도 있고, 제대로 사용하더라도 그 효과를 체감하기 어려울 때가 있습니다. 이 글에서는 useMemo와 useCallback이 왜 필요하며, 언제 어떻게 사용해야 할 때 성능 최적화의 효과를 제대로 볼 수 있는지, 실제 코드 예제와 함께 알아보겠습니다.
렌더링 최적화의 필요성
React 컴포넌트는 상태(state)나 props가 변경될 때마다 다시 렌더링됩니다. 이는 React의 핵심 동작 방식이지만, 컴포넌트가 복잡해지거나 렌더링 과정에서 비용이 많이 드는 연산이 포함될 경우 문제가 될 수 있습니다. 예를 들어, 큰 데이터셋을 필터링하거나 정렬하는 함수, 또는 외부 라이브러리를 초기화하는 작업 등이 이에 해당합니다. 이러한 연산이 매번 불필요하게 반복된다면 애플리케이션은 느려질 수밖에 없습니다.
useMemo: 값의 메모이제이션
useMemo는 계산 비용이 높은 함수의 결과값을 '메모이제이션(memoization)'하여, 해당 값이 변경되지 않는 한 이전 계산 결과를 재사용하게 해주는 훅입니다. 즉, 함수의 반환값을 기억해두고, 다음 렌더링 시 의존성 배열(dependency array)의 값이 변경되지 않았다면 재계산 없이 기억된 값을 반환합니다.
언제 사용할까?
- 계산 비용이 높은 연산: 복잡한 데이터 변환, 필터링, 정렬 등.
- 객체나 배열의 레퍼런스 동일성 유지: 자식 컴포넌트에 객체나 배열을 props로 전달할 때, 매번 새로운 객체/배열이 생성되어 불필요한 리렌더링을 유발하는 경우.
예제 1: 복잡한 데이터 필터링
1import React, { useState, useMemo } from 'react'; 2 3function ProductList({ products, searchTerm }) { 4 const filteredProducts = useMemo(() => { 5 console.log('Filtering products...'); // 이 로그가 자주 찍히지 않아야 함 6 return products.filter(product => 7 product.name.toLowerCase().includes(searchTerm.toLowerCase()) 8 ); 9 }, [products, searchTerm]); // products 또는 searchTerm이 변경될 때만 다시 계산 10 11 return ( 12 <ul> 13 {filteredProducts.map(product => ( 14 <li key={product.id}>{product.name}</li> 15 ))} 16 </ul> 17 ); 18} 19 20function App() { 21 const [search, setSearch] = useState(''); 22 const allProducts = [ 23 { id: 1, name: 'Laptop' }, 24 { id: 2, name: 'Keyboard' }, 25 { id: 3, name: 'Mouse' }, 26 { id: 4, name: 'Monitor' } 27 ]; 28 29 // App 컴포넌트의 다른 상태나 함수가 변경되어 App이 리렌더링되어도, 30 // search나 allProducts가 변경되지 않으면 ProductList는 리렌더링되지 않고, 31 // filteredProducts는 useMemo에 의해 캐싱된 값을 사용합니다. 32 return ( 33 <div> 34 <input 35 type="text" 36 value={search} 37 onChange={e => setSearch(e.target.value)} 38 placeholder="Search products..." 39 /> 40 <ProductList products={allProducts} searchTerm={search} /> 41 </div> 42 ); 43} 44 45export default App;
위 예제에서 filteredProducts는 products 배열이나 searchTerm이 변경될 때만 다시 계산됩니다. App 컴포넌트가 다른 이유로 리렌더링되더라도, filteredProducts는 이전 값을 그대로 사용하므로 ProductList 컴포넌트는 불필요한 필터링 연산을 다시 하지 않아도 됩니다.
useCallback: 함수 레퍼런스의 메모이제이션
useCallback은 함수 자체를 '메모이제이션'하여, 의존성 배열의 값이 변경되지 않는 한 이전 함수의 레퍼런스를 재사용하게 해주는 훅입니다. 이는 주로 props로 함수를 전달할 때, 특히 React.memo로 감싸진 자식 컴포넌트가 불필요하게 리렌더링되는 것을 방지하기 위해 사용됩니다.
언제 사용할까?
- 자식 컴포넌트에 콜백 함수 전달 시:
React.memo로 최적화된 자식 컴포넌트에 함수를 props로 전달할 때, 해당 함수가 매번 새로 생성되면 자식 컴포넌트가 불필요하게 리렌더링될 수 있습니다.useCallback으로 함수 레퍼런스를 고정하면 이를 방지할 수 있습니다. - 의존성 배열에 함수가 포함될 때:
useEffect,useMemo,useCallback등의 의존성 배열에 함수가 포함될 경우, 해당 함수가 매번 새로 생성되면 무한 루프에 빠지거나 불필요한 실행을 유발할 수 있습니다.
예제 2: React.memo와 함께 사용
1import React, { useState, useCallback } from 'react'; 2 3// React.memo는 props가 변경되지 않으면 컴포넌트 리렌더링을 건너뜁니다. 4const MemoizedButton = React.memo(({ onClick, children }) => { 5 console.log('Button rendered:', children); 6 return <button onClick={onClick}>{children}</button>; 7}); 8 9function Counter() { 10 const [count, setCount] = useState(0); 11 const [otherState, setOtherState] = useState(false); 12 13 // useCallback을 사용하지 않으면, Counter 컴포넌트가 리렌더링될 때마다 14 // increment 함수가 새로 생성되어 MemoizedButton이 불필요하게 리렌더링됨 15 const increment = useCallback(() => { 16 setCount(prevCount => prevCount + 1); 17 }, []); // 빈 배열: 컴포넌트 생명주기 동안 함수 레퍼런스 고정 18 19 return ( 20 <div> 21 <p>Count: {count}</p> 22 <MemoizedButton onClick={increment}>Increment</MemoizedButton> 23 <button onClick={() => setOtherState(!otherState)}>Toggle Other State</button> 24 {/* Toggle Other State 버튼을 눌러도 Increment 버튼은 리렌더링되지 않음 */} 25 </div> 26 ); 27} 28 29export default Counter;
위 예제에서 increment 함수는 useCallback으로 감싸져 Counter 컴포넌트가 리렌더링되어도 동일한 함수 레퍼런스를 유지합니다. 따라서 MemoizedButton은 count 상태가 변경될 때만 리렌더링되고, otherState가 변경되어 Counter가 리렌더링되더라도 MemoizedButton은 리렌더링되지 않습니다.
주의점 및 잘못된 사용 사례
useMemo와 useCallback은 만병통치약이 아닙니다. 무분별하게 사용하면 오히려 성능 저하를 유발할 수 있습니다.
- 모든 것에 적용하지 않기:
useMemo와useCallback자체도 약간의 오버헤드를 발생시킵니다. 계산 비용이 낮거나 함수 레퍼런스 자체가 중요하지 않은 경우, 굳이 사용할 필요가 없습니다. React DevTools Profiler 등을 사용하여 실제 성능 병목 지점을 파악한 후에 적용하는 것이 좋습니다. - 잘못된 의존성 배열: 의존성 배열에 빠진 값이 있다면, 예상치 못한 stale data를 사용하게 되거나 불필요한 recalculation을 피하지 못할 수 있습니다. 반대로, 너무 많은 값이 포함되면 메모이제이션의 이점을 얻지 못하게 됩니다. 의존성 배열은 항상 신중하게 관리해야 합니다. (ESLint의
react-hooks/exhaustive-deps규칙을 활용하는 것이 좋습니다.) - 객체/배열/함수 리터럴 안에서의 사용:
useMemo나useCallback없이 객체 리터럴{}이나 배열 리터럴[]을 직접 사용하면, 매번 새로운 레퍼런스가 생성됩니다. 이를useMemo로 감싸야 레퍼런스 동일성을 유지할 수 있습니다.
결론
useMemo는 값의 재계산을 방지하여 연산 비용을 절감하고, useCallback은 함수 레퍼런스의 동일성을 유지하여 불필요한 자식 컴포넌트 리렌더링을 막는 데 사용됩니다. 이 두 훅은 React 성능 최적화의 중요한 도구이지만, 올바른 상황에 신중하게 적용했을 때 그 효과를 발휘합니다. 최적화는 측정에 기반해야 하며, useMemo와 useCallback은 성능 병목 지점을 해결하기 위한 수단으로 활용되어야 합니다.
useMemo: 계산 비용이 높은 값의 결과를 캐싱합니다.useCallback: 함수 레퍼런스의 동일성을 캐싱합니다.
참고 자료: