리액트는 대부분 알아서 빠르게 그려줘요. 하지만 무거운 계산이나 아주 큰 리스트에서는 불필요한 리렌더가 느림을 만들 수 있어요. 이번 장은 그럴 때 꺼내 쓰는 세 가지 도구 — React.memo · useMemo · useCallback을 배웁니다.
===로 같은지(참조 동일성), 함수·객체·배열이 새로 만들어졌는지 판단하는 것. 🟪 리액트: React.memo·useMemo·useCallback·리렌더링·의존성 배열. 값을 계산·판단하면 JS, "화면·컴포넌트에 어떻게 반영되나"는 리액트라고 보면 대체로 맞아요.리액트의 규칙은 단순해요. state(또는 props)가 바뀌면 그 컴포넌트가 다시 렌더되고, 그 자식 컴포넌트들도 함께 다시 렌더돼요. 여기서 "렌더"란 컴포넌트 함수가 다시 실행되어 새 가상 DOM을 만드는 것을 말해요. 실제 화면은 달라진 부분만 갱신되지만, 함수가 다시 실행되는 것 자체는 매번 일어납니다.
대부분의 화면에서는 이게 전혀 문제가 안 돼요. 리액트는 아주 빠르거든요. 그런데 이런 상황에서는 이야기가 달라져요.
memo를 붙이면 코드만 복잡해지고 오히려 손해예요. 먼저 평범하게 만들고, 실제로 느린 게 확인됐을 때(측정 후) 필요한 곳에만 이 도구들을 씁니다. 이걸 "필요할 때 최적화(optimize when needed)"라고 해요.React.memo는 컴포넌트를 감싸는 함수예요. 이렇게 감싸면, 부모가 리렌더되더라도 이 컴포넌트가 받는 props가 이전과 똑같으면 리렌더를 건너뜁니다. (이전에 그린 결과를 그대로 재사용해요.)
아래 예제에서 부모의 count가 바뀌어 부모가 리렌더돼도, Child가 받는 name props가 그대로면 Child는 다시 렌더되지 않아요.
import { useState, memo } from 'react';
// memo로 감싼 자식 — props(name)가 같으면 리렌더를 건너뜀
const Child = memo(function Child({ name }) {
console.log('Child 렌더!'); // props가 바뀔 때만 찍힘
return <p>안녕, {name}</p>;
});
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>
count: {count}
</button>
{/* count가 바뀌어 Parent는 리렌더되지만,
name은 그대로라 Child는 렌더를 건너뜀 */}
<Child name="홍길동" />
</div>
);
}
React.memo는 이전 props와 새 props를 얕게 비교(shallow compare)해요. 즉 각 props 값을 ===로 비교합니다. 문자열·숫자처럼 값이 같으면 통과지만, 객체·배열·함수는 "새로 만들어지면 다른 것"으로 봐요. (이게 뒤의 useCallback이 필요한 이유예요.)React.memo(컴포넌트) = "props가 이전과 같으면 이 컴포넌트만 리렌더를 건너뛰어라." 큰 리스트의 각 항목처럼 같은 props로 자주 다시 그려지는 자식에 유용해요.
useMemo는 비용이 큰 계산의 결과값을 기억(메모이제이션)하는 훅이에요. 의존성 배열에 넣은 값이 이전과 같으면, 계산 함수를 다시 실행하지 않고 지난번에 기억해 둔 결과를 그대로 돌려줘요.
use~로 시작하는 리액트의 특별한 기능 함수를 말해요.import { useState, useMemo } from 'react';
function heavyCalc(a) {
// 수만 번 반복하는 무거운 계산이라고 가정
let total = 0;
for (let i = 0; i < 100000000; i++) total += a;
return total;
}
function Demo({ a }) {
const [count, setCount] = useState(0);
// a가 바뀔 때만 heavyCalc를 다시 실행,
// count만 바뀌면 지난 결과를 그대로 재사용
const result = useMemo(() => heavyCalc(a), [a]);
return (
<div>
<p>결과: {result}</p>
<button onClick={() => setCount(count + 1)}>
count: {count}
</button>
</div>
);
}
위에서 버튼을 눌러 count만 바뀌면 Demo는 리렌더되지만, useMemo의 의존성 [a]가 그대로라서 heavyCalc는 다시 돌지 않아요. a가 바뀔 때만 다시 계산합니다.
[a]는 "a가 바뀔 때만 다시 계산해"라는 뜻이에요. []이면 처음 한 번만 계산하고 계속 기억하고, 배열을 빼면(생략) 매 렌더마다 계산해서 memo 효과가 사라져요. useEffect의 의존성 배열과 똑같은 규칙이에요.문제 상황부터 볼게요. 컴포넌트가 렌더될 때마다 그 안에서 정의한 함수는 매번 "새로운 함수"로 다시 만들어져요. 겉보기엔 똑같아 보여도, 자바스크립트에게 () => {}는 만들 때마다 서로 다른 객체거든요.
그래서 React.memo로 감싼 자식에게 함수를 props로 넘기면, 부모가 리렌더될 때마다 그 함수가 새로 만들어져 "props가 바뀐 것"으로 인식돼요. 결국 memo가 소용없이 자식이 매번 리렌더됩니다.
useCallback은 함수 자체를 기억해서 이 문제를 풀어요. 의존성이 그대로면 지난번과 똑같은 함수(같은 참조)를 돌려주므로, memo 자식이 "props가 안 바뀌었네" 하고 리렌더를 건너뛸 수 있어요.
===로 "다른 것"이 되고, 같은 주소를 그대로 쓰면 "같은 것"이 돼요. 이 "주소가 같은가"를 참조 동일성이라고 해요. useCallback이 같은 참조를 유지해 주면 React.memo가 "안 바뀌었네" 하고 리렌더를 건너뛸 수 있는 거예요.import { useState, useCallback, memo } from 'react';
const Button = memo(function Button({ onClick }) {
console.log('Button 렌더!');
return <button onClick={onClick}>클릭</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// useCallback 없이 () => {...} 를 넘기면
// 매 렌더마다 새 함수라 memo가 소용없음.
// useCallback으로 함수를 기억 → 참조가 유지됨
const handleClick = useCallback(() => {
console.log('클릭됨!');
}, []); // 의존성 []: 항상 같은 함수 유지
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<Button onClick={handleClick} />
</div>
);
}
이제 +1 버튼을 눌러 count가 바뀌어도, handleClick은 useCallback 덕분에 같은 함수를 유지하므로 Button은 리렌더되지 않아요.
React.memo로 감싸여 있지 않다면, 함수를 useCallback으로 기억해도 자식은 어차피 부모 따라 리렌더돼요. 그래서 보통 "memo 자식 + 함수 props" 조합에서 useCallback을 씁니다.| 구분 | useMemo | useCallback |
|---|---|---|
| 기억하는 것 | 값 (계산 결과) | 함수 자체 |
| 돌려주는 것 | 계산 함수를 실행한 결과값 | 실행하지 않은 함수 그대로 |
| 대표 형태 | useMemo(() => calc(a), [a]) | useCallback(() => {...}, [a]) |
| 주로 언제 | 비용 큰 계산 결과 재사용 | memo 자식에 함수 props 넘길 때 |
| 의존성 배열 | 둘 다 배열의 값이 바뀔 때만 다시 만듦 (같으면 지난 것 재사용) | |
const r = useMemo(() => calc(a), [a])const fn = useCallback(() => {...}, [deps])