프론트엔드 성능을 위한 useMemo와 useCallback의 이해 및 효과적인 적용
Ethan Miller
Product Engineer · Leapcell

소개
프론트엔드 개발의 활기차고 끊임없이 진화하는 환경에서 React는 핵심 기술로서 자리매김했습니다. 애플리케이션이 복잡해지고 사용자의 즉각적이고 반응적인 인터페이스에 대한 기대치가 높아짐에 따라, 성능 최적화는 필수적인 요소가 되었습니다. React가 성능 튜닝을 위해 제공하는 수많은 도구 중에서 useMemo와 useCallback은 자주 논의되는 주제입니다. 그러나 이들의 실제 영향과 올바른 적용은 종종 오해받습니다. 개발자들은 과잉 최적화를 하거나, 반대로 진정한 이점을 명확히 이해하지 못한 채 복잡성에 대한 두려움으로 인해 사용을 꺼릴 수 있습니다. 이 글은 useMemo와 useCallback을 명확히 설명하고, 작동 방식, 실질적인 사용 사례를 안내하며, 고성능 React 애플리케이션 구축에 있어 이 훅들이 진정으로 도움이 되는 시점을 결정하도록 돕는 것을 목표로 합니다.
핵심 개념 설명
최적화 잠재력을 탐구하기 전에, useMemo와 useCallback을 뒷받침하는 핵심 개념에 대한 기본 이해를 확립해 보겠습니다.
참조 동일성 (Referential Equality)
useMemo와 useCallback의 핵심에는 참조 동일성 개념이 있습니다. JavaScript에서 기본 타입(문자열, 숫자, 불리언, null, undefined, 심볼, BigInt)은 값으로 비교됩니다. 그러나 비기본 타입(객체, 배열, 함수)은 메모리의 참조로 비교됩니다. 동일한 내용을 가진 두 객체라도 메모리 상의 위치가 다르면 참조 동일성이 아닙니다.
const obj1 = { a: 1 }; const obj2 = { a: 1 }; console.log(obj1 === obj2); // false const arr1 = [1, 2, 3]; const arr2 = [1, 2, 3]; console.log(arr1 === arr2); // false const func1 = () => console.log('hello'); const func2 = () => console.log('hello'); console.log(func1 === func2); // false (func1과 func2가 정확히 같은 함수 인스턴스를 가리키는 경우가 아니라면)
React는 참조 동일성을 사용하여 props 또는 state가 변경되었는지 판단합니다. 객체나 함수인 prop의 참조가 변경되면, 내부 내용이 동일하더라도 React는 이를 새로운 prop으로 간주하고 자식 구성 요소를 잠재적으로 다시 렌더링할 수 있습니다.
메모이제이션 (Memoization)
메모이제이션은 비용이 많이 드는 함수 호출의 결과를 저장하고, 동일한 입력이 다시 발생할 때 캐시된 결과를 반환하여 주로 컴퓨터 프로그램 속도를 높이는 데 사용되는 최적화 기법입니다. 본질적으로 함수 반환 값에 대한 일종의 캐싱입니다.
React의 재조정 프로세스 (Reconciliation Process)
React 구성 요소는 기본적으로 상태나 props가 변경될 때마다 다시 렌더링됩니다. React의 가상 DOM과 재조정 알고리즘은 매우 효율적이지만, 불필요한 다시 렌더링은 여전히 성능 병목 현상을 일으킬 수 있습니다. 특히 복잡한 구성 요소나 많은 자식을 가진 구성 요소의 경우에 그렇습니다. useMemo와 useCallback은 React가 중복 작업을 피하도록 도와 이 프로세스를 최적화하는 데 기여합니다.
useMemo 훅
useMemo는 렌더링 간에 계산 결과를 캐시할 수 있게 해주는 React 훅입니다. '생성' 함수와 종속성 배열이라는 두 개의 인수를 받습니다. 생성 함수는 종속성 중 하나가 변경된 경우에만 다시 실행됩니다.
import React, { useMemo } from 'react'; function MyComponent({ list }) { // `expensiveCalculation`은 `list`의 참조가 변경될 때만 다시 실행됩니다. const expensiveResult = useMemo(() => { console.log('비싼 계산 수행 중...'); return list.map(item => item * 2); // 비싼 연산의 예 }, [list]); return ( <div> {expensiveResult.map(item => ( <span key={item}>{item} </span> ))} </div> ); }
이 예에서 expensiveResult는 메모이제이션됩니다. MyComponent가 list 외의 다른 prop이 변경되거나(또는 자체 내부 상태가 변경되어) 다시 렌더링되는 경우, list.map 연산은 다시 실행되지 않고 캐시된 expensiveResult가 대신 사용됩니다. 이는 계산 시간을 절약합니다.
useCallback 훅
useCallback은 렌더링 간에 함수 정의를 메모이제이션할 수 있게 해주는 React 훅입니다. 본질적으로 함수에 대한 useMemo입니다. 함수와 종속성 배열을 받습니다. 콜백의 메모이제이션된 버전을 반환하며, 이 버전은 종속성 중 하나가 변경된 경우에만 변경됩니다.
import React, { useState, useCallback } from 'react'; function ParentComponent() { const [count, setCount] = useState(0); // `handleClick`은 `count`의 참조가 변경될 때만 다시 생성됩니다 (숫자의 경우 발생하기 어렵습니다). // 또는 종속성 (이 경우 없음, 그러나 일반적으로 외부 상태/props)이 변경될 때. const handleClick = useCallback(() => { setCount(c => c + 1); }, []); // 빈 종속성 배열은 이 함수가 한 번 생성되어 절대 변경되지 않음을 의미합니다. return ( <div> <p>Count: {count}</p> <ChildComponent onClick={handleClick} /> </div> ); } function ChildComponent({ onClick }) { console.log('ChildComponent 렌더링됨'); // ParentComponent가 다시 렌더링되면 렌더링됩니다. 메모이제이션되지 않은 경우. return <button onClick={onClick}>Increment</button>; }
ParentComponent에서 handleClick은 useCallback을 사용하여 생성됩니다. 빈 종속성 배열([])을 사용하면 이 함수 인스턴스는 ParentComponent가 처음 렌더링될 때 한 번만 생성됩니다. ParentComponent의 이후 렌더링은 handleClick을 다시 정의하지 않으므로 참조 동일성이 보존됩니다.
useMemo와 useCallback이 진정으로 최적화하는 경우
이 훅들의 진정한 가치는 특정 시나리오에서 불필요한 작업이나 다시 렌더링을 방지할 때 나타납니다.
메모이제이션된 자식 구성 요소의 다시 렌더링 방지
이것이 가장 일반적이고 영향력 있는 사용 사례입니다. React.memo로 래핑된(또는 PureComponent를 상속받는 클래스 컴포넌트) 자식 구성 요소에 객체나 함수를 prop으로 전달하는 경우, useMemo와 useCallback이 중요해집니다. 이러한 훅이 없으면, prop의 내용 이 논리적으로 변경되지 않았더라도, 부모의 모든 다시 렌더링마다 참조 가 변경되어, 메모이제이션된 자식에게 다시 렌더링을 강제합니다.
useCallback 예제:
최적화를 위해 React.memo를 사용하는 Button 구성 요소를 고려해 보세요.
import React, { useState, useCallback, memo } from 'react'; // 메모이제이션된 자식 구성 요소 const MyButton = memo(({ onClick, label }) => { console.log('Button 렌더링됨:', label); return <button onClick={onClick}>{label}</button>; }); function Container() { const [count, setCount] = useState(0); const [toggle, setToggle] = useState(false); // useCallback이 없으면, handleIncrement는 모든 렌더링마다 새로운 함수가 되어 // MyButton이 불필요하게 다시 렌더링될 것입니다. const handleIncrement = useCallback(() => { setCount(prevCount => prevCount + 1); }, []); // 종속성: 없음, setCount는 안정적이므로 // 상태 변수에 의존하는 함수 const handleToggle = useCallback(() => { setToggle(prevToggle => !prevToggle); }, []); return ( <div> <p>Count: {count}</p> <p>Toggle: {toggle ? 'On' : 'Off'}</p> <MyButton onClick={handleIncrement} label="Increment Count" /> <MyButton onClick={handleToggle} label="Toggle" /> <button onClick={() => setCount(count + 10)}>부모 다시 렌더링 강제</button> </div> ); }
"부모 다시 렌더링 강제" 버튼을 클릭하면 (count를 업데이트하여 Container를 다시 렌더링함), "Button 렌더링됨: Increment Count"와 "Button 렌더링됨: Toggle"이 기록되지 않는 것을 볼 수 있습니다. 이는 handleIncrement와 handleToggle이 참조 동일성을 유지하고 MyButton이 메모이제이션되기 때문입니다. useCallback이 없었다면 두 버튼 모두 다시 렌더링되었을 것입니다.
useMemo 예제:
마찬가지로, props로 전달되는 객체의 경우:
import React, { useState, useMemo, memo } from 'react'; const UserCard = memo(({ user }) => { console.log('UserCard 렌더링됨:', user.name); return ( <div> <h3>{user.name}</h3> <p>Age: {user.age}</p> </div> ); }); function UserProfile() { const [age, setAge] = useState(30); const [name, setName] = useState("Alice"); const [count, setCount] = useState(0); // 부모 다시 렌더링을 트리거하는 관련 없는 상태 // 이 `user` 객체는 메모이제이션되지 않으면 모든 렌더링마다 다시 생성됩니다. // useMemo를 사용하면 `name` 또는 `age`가 변경될 때만 변경됩니다. const user = useMemo(() => ({ name, age }), [name, age]); return ( <div> <UserCard user={user} /> <button onClick={() => setAge(age + 1)}>나이 증가</button> <button onClick={() => setCount(count + 1)}>관련 없는 상태 업데이트 ({count})</button> </div> ); }
"관련 없는 상태 업데이트" 버튼을 클릭하면 UserProfile이 다시 렌더링됩니다. 그러나 "UserCard 렌더링됨: Alice"는 기록되지 않습니다. 이는 user 객체의 참조가 useMemo 덕분에 동일하게 유지되고 UserCard가 메모이제이션되기 때문입니다.
비용이 많이 드는 계산 방지
연산 집약적인 작업을 수행하는 함수나 코드 블록이 있고, 그 결과가 특정 값에만 의존한다면, useMemo는 모든 렌더링마다 이 작업을 불필요하게 반복하는 것을 방지할 수 있습니다.
import React, { useState, useMemo } from 'react'; function ItemList({ items, filterText }) { const [sortOrder, setSortOrder] = useState('asc'); // 이 필터링 및 정렬 작업은 `items`가 클 경우 비용이 많이 들 수 있습니다. // `items`, `filterText`, 또는 `sortOrder`가 변경될 때만 다시 실행하기를 원합니다. const filteredAndSortedItems = useMemo(() => { console.log('필터링 및 정렬된 항목들의 재계산 중...'); let result = items; if (filterText) { result = result.filter(item => item.name.includes(filterText)); } if (sortOrder === 'asc') { result.sort((a, b) => a.name.localeCompare(b.name)); } else { result.sort((a, b) => b.name.localeCompare(a.name)); } return result; }, [items, filterText, sortOrder]); return ( <div> <input type="text" value={filterText} /* onChange 핸들러 */ /> <button onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}> 정렬: {sortOrder} </button> <ul> {filteredAndSortedItems.map(item => ( <li key={item.id}>{item.name}</li> ))} </ul> </div> ); }
이 시나리오에서 useMemo는 필터링 및 정렬 로직이 ItemList의 모든 다시 렌더링 시가 아니라 관련 종속성이 변경될 때만 실행되도록 보장합니다.
효과 최적화
useEffect를 다룰 때, 모든 렌더링마다 참조가 변경되는 함수나 객체를 전달하면 효과가 불필요하게 다시 실행될 수 있으며, 이는 성능 문제나 버그(예: 데이터 재요청)로 이어질 수 있습니다. useCallback 또는 useMemo는 이러한 종속성을 안정화할 수 있습니다.
import React, { useState, useEffect, useCallback } from 'react'; function DataFetcher({ userId }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); // 데이터 페치를 위한 함수. 메모이제이션되지 않으면, userId가 변경되지 않았더라도 // 모든 렌더링마다 새로운 함수 인스턴스가 되어 useEffect가 불필요하게 다시 실행될 것입니다. const fetchData = useCallback(async () => { setLoading(true); try { const response = await fetch(`/api/users/${userId}`); const result = await response.json(); setData(result); } catch (error) { console.error("데이터 페치 오류:", error); } finally { setLoading(false); } }, [userId]); // fetchData는 userId가 변경될 때만 변경됩니다. useEffect(() => { fetchData(); }, [fetchData]); // 효과는 fetchData (따라서 userId)가 변경될 때만 다시 실행됩니다. if (loading) return <div>데이터 로딩 중...</div>; if (!data) return <div>데이터를 찾을 수 없습니다.</div>; return <div>사용자 이름: {data.name}</div>; }
여기서 fetchData는 useCallback으로 래핑됩니다. 이는 useEffect 훅이 userId (해당 종속성)가 실제로 변경될 때만 다시 실행되도록 보장하여 중복 API 호출을 방지합니다.
사용하지 말아야 할 경우 (또는 도움이 되지 않는 경우)
useMemo와 useCallback이 효과가 없거나 심지어 해로울 수 있는 경우를 이해하는 것도 똑같이 중요합니다.
-
간단한 계산: 사소한 계산이나 간단한 함수 정의의 경우,
useMemo/useCallback의 오버헤드(메모이제이션 캐시 생성, 종속성 비교)가 잠재적인 성능 이점보다 클 수 있습니다.// 실제 이득 없음, 오버헤드만 추가됨 const sum = useMemo(() => a + b, [a, b]); -
React.memo로 래핑되지 않은 구성 요소: 메모이제이션된 prop을 받는 자식 구성 요소 자체(React.memo를 통해 또는PureComponent로)가 메모이제이션되지 않았다면, props의 참조가 변경되든 상관없이 다시 렌더링됩니다. 이 경우useMemo/useCallback은 자식의 다시 렌더링을 방지하는 데 아무런 효과가 없습니다. -
상태 업데이트를 위한 빈 종속성 배열의
useCallback: 일반적으로 유용하지만, 상태 설정자를 위한 빈 종속성 배열의useCallback은 오해의 소지가 있을 수 있습니다. React의 상태 설정 함수(setCount등)는 안정성이 보장되며 절대 다시 생성되지 않습니다. 따라서 빈 종속성 배열로useCallback으로 래핑하는 것은 불필요합니다.// 불필요함, setCount는 이미 안정적임 const handleSetCount = useCallback(() => setCount(0), []);그러나 콜백이 현재 상태나 props에 의존하는 경우, 종속성을 올바르게 지정해야 합니다.
-
과도한 사용:
useMemo와useCallback을 과도하게 사용하면 코드가 더 복잡해지고 디버깅이 어려워지며, 현명하게 적용되지 않으면 자체 성능 오버헤드를 발생시킬 수 있습니다. 항상 최적화 전에 측정하십시오. React DevTools 프로파일러는 실제 성능 병목 현상을 식별하는 데 훌륭한 도구입니다.
결론
useMemo와 useCallback은 주로 메모이제이션과 참조 동일성을 활용하여 불필요한 계산과 메모이제이션된 구성 요소의 다시 렌더링을 방지함으로써 React의 성능 최적화 도구 모음에서 강력한 도구입니다. 이들은 메모이제이션된 자식 구성 요소에 전달되어 prop 참조(함수, 객체)를 안정화하거나, 실제로 비용이 많이 드는 계산의 결과를 캐시할 때 가장 효과적입니다. 그러나 자체 오버헤드를 수반하므로, 보편적인 해결책이 아닌 식별된 성능 병목 현상에 초점을 맞춰 신중하게 적용해야 합니다. 기본 원리와 실질적인 적용 사례를 이해하면 개발자가 이러한 훅들을 진정한 가치를 제공하는 곳에 전략적으로 배포함으로써 더 빠르고 효율적인 React 애플리케이션을 구축할 수 있습니다.