[React] Memoization을 통한 성능 최적화: memo, useMemo, useCallback
React에서 성능 최적화는 매우 중요한 주제이다.
특히, 컴포넌트가 많아지고 복잡해질수록 성능 저하가 발생할 수 있다. 이런 문제를 해결하기 위해 React는 다양한 최적화 기법을 제공한다.
이번 포스트에서는 useCallback, React.memo, memo에 대해 자세히 알아보자.
React.memo란?
const MemoizedComponent = memo(SomeComponent, arePropsEqual?)
매개변수
Component
: 메모Memoize하려는 컴포넌트이다. memo는 이 컴포넌트를 수정하지 않고 대신 새로운 메모된 컴포넌트를 반환한다. 함수와 forwardRef 컴포넌트를 포함한 모든 유효한 React 컴포넌트가 허용된다.(optional) arePropsEqual
: 컴포넌트의 이전 Props와 새로운 Props의 두 가지 인수를 받는 함수이다. 이전 Props와 새로운 Props가 동일한 경우, 컴포넌트가 이전 Props와 동일한 결과를 렌더링하고 새로운 Props에서도 이전 Props와 동일한 방식으로 동작하는 경우 true를 반환해야 한다. 그렇지 않으면 false를 반환해야 한다. 일반적으로 이 함수를 지정하지 않는다. React는 기본적으로 Object.is로 각 Props를 비교한다.
반환값
memo
는 새로운 React 컴포넌트를 반환한다. memo에 제공한 컴포넌트와 동일하게 동작하지만, 부모가 리렌더링되더라도 Props가 변경되지 않는 한 React는 이를 리렌더링하지 않는다.
목적
React는 일반적으로 부모가 리렌더링될 때마다 컴포넌트를 리렌더링한다. memo
를 사용하면, 새로운 Props가 이전 Props와 같으면 부모 컴포넌트가 다시 렌더링되더라도 React가 해당 컴포넌트를 다시 렌더링하지 않도록 만들 수 있다. 이러한 컴포넌트를 Memoized 상태라고 한다.
- 같은 props로 렌더링될 경우 컴포넌트의 리렌더링을 건너뛴다.
React.memo
는 고차 컴포넌트(Higher-Order Component)로, 불필요한 리렌더링을 방지하기 위해 사용된다.
동작 원리
- props가 변하지 않았다면 이전 렌더링 결과를 그대로 재사용한다.
- 기본적으로
Object.is
비교 를 사용하여얕은 비교(shallow comparison)
를 수행한다. - 비교 방식을 커스터마이징할 수 있다.
사용법
import { memo, useState } from 'react';
export default function MyApp() {
const [name, setName] = useState('');
const [address, setAddress] = useState('');
return (
<>
<label>
Name{': '}
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label>
Address{': '}
<input value={address} onChange={(e) => setAddress(e.target.value)} />
</label>
<Greeting name={name} />
</>
);
}
const Greeting = memo(function Greeting({ name }) {
console.log('Greeting was rendered at', new Date().toLocaleTimeString());
return (
<h3>
Hello{name && ', '}
{name}!
</h3>
);
});
위 예시에서 Greeting 컴포넌트는 name이 Props 중 하나이기 때문에 name이 변경될 때마다 리렌더링 된다. 하지만 address는 Greeting의 Props가 아니기 때문에 address가 변경될 때는 리렌더링되지 않는다.
사용자 정의 비교 함수 지정하기 예제
드물지만 메모화된 컴포넌트의 Props 변경을 최소화하는 것이 불가능할 수 있다. 이 경우 사용자 정의 비교 함수를 제공하여 React가 얕은 비교를 사용하는 대신에 이전 Props와 새로운 Props를 비교할 수 있다. 이 함수는 memo의 두 번째 인수로 전달된다. 새로운 Props가 이전 Props와 동일한 결과를 생성하는 경우에만 true를 반환해야 한다. 그렇지 않으면 false를 반환해야 한다.
const Chart = memo(function Chart({ dataPoints }) {
// ...
}, arePropsEqual);
function arePropsEqual(oldProps, newProps) {
return (
oldProps.dataPoints.length === newProps.dataPoints.length &&
oldProps.dataPoints.every((oldPoint, index) => {
const newPoint = newProps.dataPoints[index];
return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
})
);
}
주의사항
memo
는 성능 최적화를 위해서 사용해야 한다. memo 없이 코드가 작동하지 않는다면, 먼저 근본적인 문제를 찾아서 해결해라. 이후에 memo를 추가하여 성능을 개선할 수 있다.
memo로 최적화하는 것은 컴포넌트가 정확히 동일한 Props로 자주 리렌더링 되고, 리렌더링 로직이 비용이 많이 드는 경우에만 유용하다. 컴포넌트가 리렌더링 될 때 인지할 수 있을 만큼의 지연이 없다면 memo가 필요하지 않다.
memo는 객체 또는 렌더링 중에 정의된 일반 함수처럼 항상 다른 Props가 컴포넌트에 전달되는 경우에 완전히 무용지물이다. 따라서 memo
와 함께 useMemo
와 useCallback
이 종종 필요한다.
useCallback이란?
useCallback은 리렌더링 간에 함수 정의를 캐싱해 주는 React Hook이다.
const cachedFn = useCallback(fn, dependencies);
목적
- 콜백 함수를 메모이제이션하여, 동일한 의존성이 유지될 경우 같은 함수 참조를 반환한다.
- 자식 컴포넌트에 함수를 props로 전달할 때, 매 렌더링마다 새로운 함수 인스턴스를 생성하지 않도록 한다.
사용법
리렌더링 간에 함수 정의를 캐싱하려면 컴포넌트의 최상단에서 useCallback을 호출하자.
import { useCallback } from 'react';
export default function ProductPage({ productId, referrer, theme }) {
const handleSubmit = useCallback((orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}, [productId, referrer]);
useCallback(fn, deps)
는useMemo(() => fn, deps)
와 동일하게 동작한다.- 함수의 참조 동일성을 유지하여 불필요한 렌더링을 방지할 수 있다.
매개변수
-
fn
: 캐싱할 함숫값이다. 이 함수는 어떤 인자나 반환값도 가질 수 있다. React는 첫 렌더링에서 이 함수를 반환한다. (호출하는 것이 아니다!) 다음 렌더링에서 dependencies 값이 이전과 같다면 React는 같은 함수를 다시 반환한다. 반대로 dependencies 값이 변경되었다면 이번 렌더링에서 전달한 함수를 반환하고 나중에 재사용할 수 있도록 이를 저장한다. React는 함수를 호출하지 않는다. 이 함수는 호출 여부와 호출 시점을 개발자가 결정할 수 있도록 반환된다. -
dependencies
: fn 내에서 참조되는 모든 반응형 값의 목록이다. 반응형 값은 props와 state, 그리고 컴포넌트 안에서 직접 선언된 모든 변수와 함수를 포함한다. 린터가 React를 위한 설정으로 구성되어 있다면 모든 반응형 값이 의존성으로 올바르게 명시되어 있는지 검증한다. 의존성 목록은 항목 수가 일정해야 하며 [dep1, dep2, dep3]처럼 인라인으로 작성해야 한다. React는 Object.is 비교 알고리즘을 이용해 각 의존성을 이전 값과 비교한다.
반환값
최초 렌더링에서는 useCallback
은 전달한 fn함수를 그대로 반환한다.
후속 렌더링에서는 이전 렌더링에서 이미 저장해 두었던 fn함수를 반환하거나 (의존성이 변하지 않았을 때), 현재 렌더링 중에 전달한 fn함수를 그대로 반환한다.
최초 렌더링에서 useCallback
으로부터 반환되는 함수는 호출시에 전달할 함수이다.
이어지는 렌더링에서 React는 의존성을 이전 렌더링에서 전달한 의존성과 비교한다. 의존성 중 하나라도 변한 값이 없다면(Object.is
로 비교), useCallback은 전과 똑같은 함수를 반환한다. 그렇지 않으면 useCallback은 이번 렌더링에서 전달한 함수를 반환한다.
다시 말하면, useCallback은 의존성이 변하기 전까지 리렌더링 간에 함수를 캐싱한다.
주의사항
- useCallback은 Hook이므로, 컴포넌트의 최상위 레벨 또는 커스텀 Hook에서만 호출할 수 있다. 반복문이나 조건문 내에서 호출할 수 없다. 이 작업이 필요하다면 새로운 컴포넌트로 분리해서 state를 새 컴포넌로 옮겨야 한다.
- React는 특별한 이유가 없는 한 캐시 된 함수를 삭제하지 않는다. 예를 들어 개발 환경에서는 컴포넌트 파일을 편집할 때 React가 캐시를 삭제한다. 개발 환경과 프로덕션 환경 모두에서, 초기 마운트 중에 컴포넌트가 일시 중단되면 React는 캐시를 삭제한다. 앞으로 React는 캐시 삭제를 활용하는 더 많은 기능을 추가할 수 있다. 예를 들어, React에 가상화된 목록에 대한 빌트인 지원이 추가한다면, 가상화된 테이블 뷰포트에서 스크롤 밖의 항목에 대해 캐시를 삭제하는것이 적절할 것이다. 이는 useCallback을 성능 최적화 방법으로 의존하는 경우에 개발자의 예상과 일치해야 한다. 그렇지 않다면
state
변수 나ref
가 더 적절할 수 있다.
useCallback 필요성
// ShippingForm.tsx
import { memo } from 'react';
const ShippingForm = memo(function ShippingForm({ onSubmit }) {
// ...
});
// ProductPage.tsx
function ProductPage({ productId, referrer, theme }) {
// theme이 바뀔때마다 다른 함수가 될 것이다...
function handleSubmit(orderDetails) {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
}
return (
<div className={theme}>
{/* ... 그래서 ShippingForm의 props는 같은 값이 아니므로 매번 리렌더링 할 것이다.*/}
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}
자바스크립트에서 function () {}
나 () => {}
은 항상 다른 함수를 생성한다. 이것은 {} 객체 리터럴
이 항상 새로운 객체를 생성하는 방식과 유사하다. 보통의 경우에는 문제가 되지 않지만, 여기서는 ShippingForm
props는 절대 같아질 수 없고 memo
최적화는 동작하지 않을 것이라는 걸 의미한다. 여기서 useCallback
이 유용하게 사용된다.
function ProductPage({ productId, referrer, theme }) {
// React에게 리렌더링 간에 함수를 캐싱하도록 요청한다...
const handleSubmit = useCallback(
(orderDetails) => {
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
},
[productId, referrer],
); // ...이 의존성이 변경되지 않는 한...
return (
<div className={theme}>
{/* ...ShippingForm은 같은 props를 받게 되고 리렌더링을 건너뛸 수 있다.*/}
<ShippingForm onSubmit={handleSubmit} />
</div>
);
}
handleSubmit
을 useCallback
으로 감쌈으로써 리렌더링 간에 이것이 (의존성이 변경되기 전까지는) 같은 함수라는 것을 보장한다. 특별한 이유가 없다면 함수를 꼭 useCallback
으로 감쌀 필요는 없다. 이 예시에서의 이유는 memo
로 감싼 컴포넌트에 전달하기 때문에 해당 함수가 리렌더링을 건너뛸 수 있기 때문이다.
Effect가 너무 자주 실행되는 것을 방지하기
가끔 Effect 안에서 함수를 호출해야 할 수도 있다.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
function createOptions() {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
// ...
이것은 문제를 발생시킨다. 모든 반응형 값은 Effect의 의존성으로 선언되어야 한다. 하지만 createOptions를 의존성으로 선언하면 Effect가 채팅방과 계속 재연결되는 문제가 발생한다.
객체 ({})
, 배열 ([])
은 리터럴로 생성할 때마다 새로운 참조(주소)를 갖는 값이기 때문이다.
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // 🔴 문제점: 이 의존성은 매 렌더링마다 변경된다.
// ...
이를 해결하기 위해, Effect에서 호출하려는 함수를 useCallback으로 감쌀 수 있다.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
const createOptions = useCallback(() => {
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}, [roomId]); // ✅ roomId가 변경될 때만 변경된다.
useEffect(() => {
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [createOptions]); // ✅ createOptions가 변경될 때만 변경된다.
// ...
이것은 리렌더링 간에 roomId가 같다면 createOptions 함수는 같다는 것을 보장한다. 하지만, 함수 의존성을 제거하는 것이 더 좋다. 함수를 Effect 안으로 이동시켜보자.
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
useEffect(() => {
function createOptions() { // ✅ useCallback이나 함수 의존성이 필요하지 않다.
return {
serverUrl: 'https://localhost:1234',
roomId: roomId
};
}
const options = createOptions();
const connection = createConnection(options);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ roomId가 변경될 때만 변경된다.
// ...
이제 코드는 더 간단해졌고 useCallback은 필요하지 않습니다. Effect의 의존성 제거에 대해 스스로 더 공부해보며 좋다.
React.memo + useCallback 함께 사용하기
function Parent() {
const [text, setText] = useState('');
const handleItemClick = () => {
console.log('Item clicked');
};
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ListItem onClick={handleItemClick} />
</>
);
}
위 코드에서 handleItemClick은 매 렌더링마다 새로 만들어진다 (참조가 바뀌기 때문)
그래서 React.memo(ListItem)으로 감싸도 리렌더링이 된다.따라서 React.memo 최적화가 무효하게 된다.
이때 Reeact.memo와 useCallback을 함께 사용하면 최적화할 수 있다.
const ListItem = memo(function ListItem({ onClick }: { onClick: () => void }) {
console.log('ListItem 렌더링');
return <div onClick={onClick}>📌 List Item</div>;
});
function Parent() {
const [text, setText] = useState('');
const handleItemClick = useCallback(() => {
console.log('Item clicked');
}, []); // 의존성 없음 → 참조 유지됨
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<ListItem onClick={handleItemClick} />
</>
);
}
위 코드에서 결과적으로, 입력창에 타이핑해도 ListItem은 리렌더링 안된다. handleItemClick이 useCallback
으로 참조가 유지되기 때문이다.
ListItem은 React.memo
로 감싸서 props
변화를 감지하는데, onClick
참조가 안 바뀌면 리렌더링을 생략하기 때문이다.
useCallback과 useMemo는 어떤 연관이 있을까?
useMemo
가 useCallback
과 함께 쓰이는 것을 자주 봤을 것이다. 두 hook은 모두 자식 컴포넌트를 최적화할 때 유용하다. 무언가를 전달할 때 memoization
(다른 말로는 캐싱
)을 할 수 있도록 해준다.
import { useMemo, useCallback } from 'react';
function ProductPage({ productId, referrer }) {
const product = useData('/product/' + productId);
const requirements = useMemo(() => {
// 함수를 호출하고 그 결과를 캐싱한다.
return computeRequirements(product);
}, [product]);
const handleSubmit = useCallback(
(orderDetails) => {
// 함수 자체를 캐싱한다.
post('/product/' + productId + '/buy', {
referrer,
orderDetails,
});
},
[productId, referrer],
);
return (
<div className={theme}>
<ShippingForm requirements={requirements} onSubmit={handleSubmit} />
</div>
);
}
차이점은 무엇을 캐싱하는지이다.
useMemo
는 호출한 함수의결과값
을 캐싱한다. 이 예시에서는 computeRequirements(product) 함수 호출 결과를 캐싱해서 product가 변경되지 않는 한 이 결과값이 변경되지 않도록 한다. 이것은 불필요하게ShippingForm
을 리렌더링하지 않고requirements
객체를 넘겨줄 수 있도록 해준다. 필요할 때 React는 렌더링 중에 넘겨주었던 함수를 호출하여 결과를 계산한다.useCallback
은함수 자체
를 캐싱한다. useMemo와 달리, 전달한 함수를 호출하지 않는다. 그 대신, 전달한 함수를 캐싱해서 productId나 referrer이 변하지 않으면handleSubmit
자체가 변하지 않도록 한다. 이것은 불필요하게ShippingForm
을 리렌더링하지 않고 handleSubmit 함수를 전달할 수 있도록 해준다. 함수의 코드는 사용자가 폼을 제출하기 전까지 실행되지 않을 것이다.
이미 useMemo에 익숙하다면 useCallback을 다음과 같이 생각하는 것이 도움이 될 수 있다.
// (React 내부의) 간단한 구현
function useCallback(fn, dependencies) {
return useMemo(() => fn, dependencies);
}
useMemo란?
useMemo(calculateValue, dependencies);
컴포넌트의 최상위 레벨에 있는 useMemo
를 호출하여 재렌더링 사이의 계산을 캐싱한다.
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
매개변수
calculateValue
: 캐싱하려는 값을 계산하는 함수이다. 순수해야 하며 인자를 받지 않고, 모든 타입의 값을 반환할 수 있어야 한다. React는 초기 렌더링 중에 함수를 호출한다. 다음 렌더링에서, React는 마지막 렌더링 이후 dependencies가 변경되지 않았을 때 동일한 값을 다시 반환한다. 그렇지 않다면 calculateValue를 호출하고 결과를 반환하며, 나중에 재사용할 수 있도록 저장한다.dependencies
: calculateValue 코드 내에서 참조된 모든 반응형 값들의 목록입니다. 반응형 값에는 props, state와 컴포넌트 바디에 직접 선언된 모든 변수와 함수가 포함된다. 만약 linter가 React용으로 설정된 경우 모든 반응형 값이 의존성으로 올바르게 설정되었는지 확인할 수 있다. 의존성 목록은 일정한 수의 항목을 가져야 하며, [dep1, dep2, dep3]와 같이 인라인 형태로 작성돼야 한다. React는 Object.is 비교를 통해 각 의존성 들을 이전 값과 비교한다.
반환값
- 초기 렌더링에서 useMemo는 인자 없이 calculateValue를 호출한 결과를 반환한다.
- 다음 렌더링에서, 마지막 렌더링에서 저장된 값을 반환하거나(종속성이 변경되지 않은 경우), calculateValue를 다시 호출하고 반환된 값을 저장한다.
목적
- 값의 메모이제이션
- 렌더링 시 반복되는 비용이 큰 계산을 캐싱하여 성능을 최적화
동작 원리
- 의존성 배열(
deps
)의 값이 변하지 않으면 이전 계산 값을 재사용한다. - 의존성이 변경되면 새롭게 계산된다.
예제: 비싼 계산 캐싱하기
function FibonacciCalculator({ n }: { n: number }) {
const fib = useMemo(() => {
function calculateFib(n: number): number {
if (n <= 1) return n;
return calculateFib(n - 1) + calculateFib(n - 2);
}
return calculateFib(n);
}, [n]);
return (
<p>
{n}번째 피보나치 수는 {fib}
</p>
);
}
주의사항
- 모든 계산을 memoize하는 것은 오히려 성능에 악영향을 줄 수 있다.
- 비용이 큰 연산에만 사용해야 효과적이다.
차이점 정리
항목 | React.memo | useCallback | useMemo |
---|---|---|---|
목적 | 컴포넌트의 불필요한 리렌더링 방지 | 콜백 함수의 참조 동일성 유지 | 값(계산 결과)의 참조 동일성 유지 및 재계산 방지 |
사용 위치 | 컴포넌트 외부 (고차 컴포넌트 형태로) | 컴포넌트 내부 | 컴포넌트 내부 |
대상 | 함수형 컴포넌트 | 콜백 함수 | 어떤 계산 결과 값 |
커스텀 비교 | React.memo(Component, areEqual) 형태로 가능 | 불가능 | 불가능 |
- 참조 동일성 유지: 이전과 같은 객체(또는 함수, 배열)를 계속 재사용해서, 메모리 주소가 바뀌지 않도록 하는 것 (즉, 매번 새로 만들지 않고 같은 참조값을 유지하는 것)
언제 써야 할까?
React.memo 추천 상황
- 자식 컴포넌트가 props가 바뀌지 않음에도 자주 렌더링되는 경우
- 렌더링 비용이 높은 컴포넌트 (예: 복잡한 UI 구조, 대량 리스트 등)
useCallback 추천 상황
- 함수를 자식 컴포넌트의 props로 전달하는 경우
- React.memo와 함께 사용하여 불필요한 자식 리렌더링을 방지하고 싶을 때
useMemo 추천 상황
- 계산 비용이 크고 자주 호출되는 연산이 있을 때 (예: 필터링, 정렬 등)
- 동일한 입력에 대해 계산 결과를 재사용하고 싶을 때
Memoization 주의사항
최적화는 꼭 필요한 경우에만 적용해야 하며, 과도한 사용은 복잡성과 성능 저하를 초래할 수 있다.
useMemo/useCallback은 단순한 값보다 더 복잡한 로직을 수행한다. Memoization을 수행하는 과정에서 이전 값이랑 비교하고, 캐시 유지하고, 의존성 검사등을 수행하기 때문이다.
또한, 캐시 저장 공간(메모리)을 사용하기 때문에, 메모리 사용량이 증가할 수 있다.
그래서 간단한 연산에 useMemo나 useCallback을 사용하는 것은 오히려 역효과이다.
단순한 연산이면 그냥 새로 계산하는 게 더 빠르고 덜 복잡할 수도 있다. 상황에 따라서 적절한 방법을 선택하는 것이 중요하다.
예를 들어,
const doubled = useMemo(() => value * 2, [value]);
value * 2
는 계산 비용이 매우 낮은 연산이므로, useMemo
사용하지 않고 그냥 const doubled = value * 2;
로 작성하는 것이 더 간단하고 효율적이다.
useMemo
는 Memory를 사용하고, 비교 연산을 수행하기 때문에, 단순한 연산에는 오히려 성능 저하를 초래할 수 있다.
마무리
React.memo
는 props가 바뀌지 않으면 컴포넌트의 리렌더링을 방지하는 고차 컴포넌트(HOC)이다.useCallback
은 함수 자체를 메모이제이션하여, 렌더링 간에 같은 함수 참조를 유지한다.
→ 자식 컴포넌트에 props로 전달할 때, 불필요한 리렌더링을 방지할 수 있다.useCallback
은React.memo
로 감싼 컴포넌트에 함수를 props로 전달할 때 함께 사용하면 효과적이다.useMemo
는 계산 비용이 큰 값을 캐싱하여, 동일한 의존성에서 값을 재계산하지 않고 재사용할 수 있다.- 성능 최적화를 위한 메모이제이션은 꼭 필요한 경우에만 사용하는 것이 좋다.
→ 불필요한 사용은 오히려 복잡성과 메모리 낭비, 성능 저하를 유발할 수 있다.