React.memo, useCallback, useMemo 는 리액트 성능 향상을 위해서는 빠지지 않고 등장하는 기능들이다.
이번에는 memo, useCallback를 사용해서 컴포터넌트의 리렌더링 발생을 줄여나가는 방법에 대해서 알아보려고 한다.
예제로 사용 될 컴포넌트는 다음과 같다.
기능은 각 버튼 클릭 시 클릭한 횟수를 보여주는 아주 간단한 기능이다. 그리고 두개의 버튼을 클릭한 횟수 전체를 보여주는 sum 도 제공한다.
import "../App.css";
import { useState } from "react";
const Performance1 = () => {
const [sum, setSum] = useState(0);
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleCount1Change = () => {
setCount1(count1 + 1);
setSum(count1 + 1 + count2);
}
const handleCount2Change = () => {
setCount2(count2 + 1);
setSum(count1 + count2 + 1);
}
return (
<div className="App">
<h2>페이지1</h2>
<div>
<h1>SUM : {sum}</h1>
</div>
<div>
<p>{count1}</p>
<button onClick={handleCount1Change}>Increment Count 1</button>
</div>
<div>
<p>{count2}</p>
<button onClick={handleCount2Change}>Increment Count 2</button>
</div>
</div>
)
};
export default Performance1;
App.css
ul {
display: flex;
}
ul span {
display: block;
padding: 20px 10px;
}
h2 {
font-size: 30px;
font-weight: bold;
}
button {
background-color: blue;
color: white;
padding: 10px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
}
위의 코드를 순차적으로 React.memo와 useCallback을 적용해가면서 어떻게 성능 향상이 되어가는지를 알아보겠다.
현재는 컴포넌트 하나에 모든 코드가 포함되어 있기 때문에 버튼 클릭 시 Performance1 컴포넌트 전체가 리렌더링 된다.
1. React.memo, useCallback을 통한 성능 향상에 초점이 맞춰져 있기 때문에 컴포넌트 분리 부분에서는 완벽한 구조로 변경되지 않을 수 있습니다.
2. 리렌더링 횟수를 좀 더 시각적으로 볼 수 있도록 리액트 개발툴의 하이라이트에 표시되는 숫자를 이미지로 사용하였습니다.
1️⃣ 컴포넌트 분리
열어서 코드 확인!
import "../App.css";
import { useState } from "react";
const Count1 = ({ count1, onCount1Change }) => {
console.log("1️⃣ Count1 component rendered");
return (
<div>
<p>{count1}</p>
<button onClick={onCount1Change}>Increment Count 1</button>
</div>
);
};
const Count2 = ({ count2, onCount2Change }) => {
console.log("2️⃣ Count2 component rendered");
return (
<div>
<p>{count2}</p>
<button onClick={onCount2Change}>Increment Count 2</button>
</div>
);
};
const Performance2 = () => {
const [sum, setSum] = useState(0);
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleCount1Change = () => {
setCount1(count1 + 1);
setSum(count1 + 1 + count2);
}
const handleCount2Change = () => {
setCount2(count2 + 1);
setSum(count1 + count2 + 1);
}
return (
<div className="App">
<h2>페이지2</h2>
<div>
<h1>SUM : {sum}</h1>
</div>
<Count1 count1={count1} onCount1Change={handleCount1Change} />
<Count2 count2={count2} onCount2Change={handleCount2Change} />
</div>
)
};
export default Performance2;
변경 사항
1. Count1, Count2 로 각각 컴포넌트를 생성
2. count1, count2 상태와 상태 값 변경 함수를 각 Count 컴포넌트에게 전달하도록 구성
위의 변경 사항은 count 출력 부분과 버튼을 컴포넌트로 분리하였다. 상태는 아직 부모 컴포넌트인 Performance2에서 관리하기 때문에 버튼 클릭 시 모든 컴포넌트가 리렌더링 된다.
아래 이미지를 확인해보면 어느 버튼을 누르더라도 모든 컴포넌트가 리렌더링 되는 것을 확인할 수 있다.
2️⃣ memo 사용을 통한 리렌더링 방지 확인
열어서 코드 확인!
import "../App.css";
import React, { useState } from "react";
const Count1 = ({ count1, onCount1Change }) => {
console.log("1️⃣ Count1 component rendered");
return (
<div>
<p>{count1}</p>
<button onClick={onCount1Change}>Increment Count 1</button>
</div>
);
};
const Count1Memo = React.memo(Count1);
const Count2 = ({ count2, onCount2Change }) => {
console.log("2️⃣ Count2 component rendered");
return (
<div>
<p>{count2}</p>
<button onClick={onCount2Change}>Increment Count 2</button>
</div>
);
};
const Count2Memo = React.memo(Count2);
const Performance3 = () => {
const [sum, setSum] = useState(0);
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleCount1Change = () => {
setCount1(count1 + 1);
setSum(count1 + 1 + count2);
}
const handleCount2Change = () => {
setCount2(count2 + 1);
setSum(count1 + count2 + 1);
}
return (
<div className="App">
<h2>페이지3</h2>
<div>
<h1>SUM : {sum}</h1>
</div>
<Count1Memo count1={count1} onCount1Change={handleCount1Change} />
<Count2Memo count2={count2} onCount2Change={handleCount2Change} />
</div>
)
};
export default Performance3;
memo를 사용하면 Props가 변경되지 않으면 렌더링을 건너뛸 수 있다. 라고 공식문서에서는 설명하고 있다.
memo를 적용 후 버튼을 클릭해보자!
아래의 이미지에서 확인해보면 Count1, Count2 왼쪽에 아이콘이 추가된것을 확인할 수 있다. memo를 사용하면 추가되는 것으로 보인다.
결과를 보면 memo를 사용했음에도 불구하고 버튼 클릭 시마다 모든 컴포넌트가 리렌더링이 된다. 그 이유는 다음 순서에서 확인해보자.
3️⃣ useCallback 사용하기
memo는 기본적으로 Props가 변경되지 않아야 리렌더링 되는 것을 방지할 수 있다. 그렇다면 Count 1번 버튼을 클릭 했을 때 count2 상태가 변경되지 않았으니 Props가 변경되지 않은 것이 아니냐고 할 수 있다.
여기서 반은 맞고 반은 틀리다. count2의 값은 변경되지 않은 것이 맞다. 하지만 handleCount2Change 함수는 변경이 되었다.
왜냐하면 부모 컴포넌트인 Performance3이 리렌더링되면서 handleCount2Change1와 handleCount2Change2가 새롭게 생성되기 때문이다. 이는 리액트와 무관하게 함수가 새롭게 호출되면서 해당 함수의 실행컨텍스트가 생성되고 내부에 존재하는 변수가 새롭게 생성되고 초기화되기 때문이다.
사실 count1, count2 또한 새롭게 생성되는 것은 맞지만 값은 변경되지 않고 이전 값을 그대로 유지하고 있다. memo에서 Props가 변경되지 않음을 확인하는 건 얕은 비교를 한다. 즉 기본 타입인 count1, count2는 값이 동일하기 때문에 변경되지 않은 것으로 판단하지만 함수인 handleCount1Change와 handleCount2Change는 동일한 기능이지만 얕은 비교로 인해 변경 된 것으로 판단하게 된다.
이 문제를 해결하기 위해서 useCallback을 사용할 수 있다. useCallback은 의존배열에 전달된 값이 변경되지 않으면 새롭게 함수를 생성하지 않고 기존의 함수를 반환해준다.
이정도까지 했으니 이제는 리렌더링을 방지할 수 있으거라는 기대가 된다.
열어서 코드 확인!
import "../App.css";
import React, { useCallback, useState } from "react";
const Count1 = ({ count1, onCount1Change }) => {
console.log("1️⃣ Count1 component rendered");
return (
<div>
<p>{count1}</p>
<button onClick={onCount1Change}>Increment Count 1</button>
</div>
);
};
const Count1Memo = React.memo(Count1);
const Count2 = ({ count2, onCount2Change }) => {
console.log("2️⃣ Count2 component rendered");
return (
<div>
<p>{count2}</p>
<button onClick={onCount2Change}>Increment Count 2</button>
</div>
);
};
const Count2Memo = React.memo(Count2);
const Performance4 = () => {
const [sum, setSum] = useState(0);
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleCount1Change = useCallback(() => {
setCount1(count1 + 1);
setSum(count1 + 1 + count2);
}, [count1, count2]);
const handleCount2Change = useCallback(() => {
setCount2(count2 + 1);
setSum(count1 + count2 + 1);
}, [count1, count2])
return (
<div className="App">
<h2>페이지4</h2>
<div>
<h1>SUM : {sum}</h1>
</div>
<Count1Memo count1={count1} onCount1Change={handleCount1Change} />
<Count2Memo count2={count2} onCount2Change={handleCount2Change} />
</div>
)
};
export default Performance4;
하지만 결과를 확인해보면 아직도 리렌더링이 발생하는 것을 확인할 수 있다. 이 문제는 다음 순서에서 알아보자.
4️⃣ useCallback의 의존 배열
위에서 잠깐 언급 했듯이 useCallback의 의존 배열에 전달 된 값이 변경되지 않아야 동일한 함수를 반환해준다. handleCount1Change, handleCount2Change 내부에서는 count1, count2 모두를 사용하기 때문에 의존 배열에 두개의 상태를 모두 추가하였다.
버튼을 클릭하면 count1 또는 count2 중 하나는 무조건 값이 바뀌기 때문에 항상 두 함수를 새로운 함수로 생성되게 되는 것이다. 그렇다면 의존성 배열에 전달한 상태가 바뀌기 때문에 함수가 생성되는 거라면 의존성 배열에 상태를 제거해보자.
열어서 코드 확인!
import "../App.css";
import React, { useCallback, useState } from "react";
const Count1 = ({ count1, onCount1Change }) => {
console.log("1️⃣ Count1 component rendered");
return (
<div>
<p>{count1}</p>
<button onClick={onCount1Change}>Increment Count 1</button>
</div>
);
};
const Count1Memo = React.memo(Count1);
const Count2 = ({ count2, onCount2Change }) => {
console.log("2️⃣ Count2 component rendered");
return (
<div>
<p>{count2}</p>
<button onClick={onCount2Change}>Increment Count 2</button>
</div>
);
};
const Count2Memo = React.memo(Count2);
const Performance5 = () => {
const [sum, setSum] = useState(0);
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleCount1Change = useCallback(() => {
setCount1(count1 + 1);
setSum(count1 + 1 + count2);
}, []);
const handleCount2Change = useCallback(() => {
setCount2(count2 + 1);
setSum(count1 + count2 + 1);
}, [])
return (
<div className="App">
<h2>페이지5</h2>
<div>
<h1>SUM : {sum}</h1>
</div>
<Count1Memo count1={count1} onCount1Change={handleCount1Change} />
<Count2Memo count2={count2} onCount2Change={handleCount2Change} />
</div>
)
};
export default Performance5;
이번에는 완전히 다른 결과가 나왔다. 리렌더링은 안되지만 리렌더링 되어야 할 것도 되지 않는 문제가 발생하였다. 이 문제의 해결방법은 다음 순서에서 알아보자.
5️⃣ set함수에 함수 전달
의존성 배열에 전달 된 상태를 제거함으로써 두 함수는 처음 한번 생성된 이후로 다시 생성되지 않는다. 그래서 처음 생성되었을 당시의 상태와 set 함수를 기억하게 된다(클로저).
즉, 버튼 클릭으로 인해 상태가 변경되면 set 함수 또한 새롭게 생성된다. 하지만 handleCount1Chnage는 변경 되지 전 set 함수를 기억하고 호출하기 때문에 상태값이 변경되지 않는 것이다.
이 문제를 해결하면서 handleCount1Chnage 또한 새롭게 생성되지 않도록 해주는 방법이 바로 set 함수에 함수를 전달해서 상태값을 변경하는 방법을 사용하는 것이다.
아래와 같이 set 함수에 함수를 전달하면 파라미터로 이전 상태값을 전달 받을 수 있고, return으로 전달한 값을 새로운 상태로 저장해준다.
setCount1((prev) => prev + 1);
열어서 코드 확인!
import "../App.css";
import React, { useCallback, useState } from "react";
const Count1 = ({ count1, onCount1Change }) => {
console.log("1️⃣ Count1 component rendered");
return (
<div>
<p>{count1}</p>
<button onClick={onCount1Change}>Increment Count 1</button>
</div>
);
};
const Count1Memo = React.memo(Count1);
const Count2 = ({ count2, onCount2Change }) => {
console.log("2️⃣ Count2 component rendered");
return (
<div>
<p>{count2}</p>
<button onClick={onCount2Change}>Increment Count 2</button>
</div>
);
};
const Count2Memo = React.memo(Count2);
const Performance6 = () => {
const [sum, setSum] = useState(0);
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
const handleCount1Change = useCallback(() => {
setCount1((prev) => prev + 1);
setSum((prev) => prev + 1);
}, []);
const handleCount2Change = useCallback(() => {
setCount2((prev) => prev + 1);
setSum((prev) => prev + 1);
}, [])
return (
<div className="App">
<h2>페이지6</h2>
<div>
<h1>SUM : {sum}</h1>
</div>
<Count1Memo count1={count1} onCount1Change={handleCount1Change} />
<Count2Memo count2={count2} onCount2Change={handleCount2Change} />
</div>
)
};
export default Performance6;
드디어 우리가 원했던 결과인 Count1 버튼 클릭 시 Count2 컴포넌트는 리렌더링이 되지 않는 것을 확인할 수 있다.
🎈 결론
위에서 보았듯이 단순하게 memo와 useCallback을 적용한 것만으로는 성능 향상이 되지 않는다. 리렌더링을 방지하기 위해서는 동작에 대한 원리를 알고 사용해야 원하는 결과를 얻을 수 있다. 이 글에서 확인한 내용 이외에도 더욱 복잡한 환경에서 사용하려면 좀 더 복잡한 과정을 거쳐야 리렌더링을 방지할 수 있다(컴포넌트에 객체 타입의 상태를 전달하는 상황 등).
공식문서에서도 언급되어 있는 내용으로 대부분의 많은 상황에서는 memo를 사용했을 때 원하는 성능 개선 효과를 얻지 못할 수 있다고 한다.
다음은 공식문서에서 추천하는 내용으로, memo를 사용하기 전에 다음과 같은 내용을 확인해볼만하다.
- JSX를 자식으로 전달하기
- State를 과도하게 끌어올려서 사용하지 않기
- 불필요한 Effect 사용 자제하기
- Effect에 불필요한 의존성 제거
'React' 카테고리의 다른 글
[React Query] 데이터는 언제 fetch 될까? - (2) Refetch (0) | 2025.05.11 |
---|---|
[React Query] 데이터는 언제 fetch 될까? - (1) Fresh와 Stale 상태 (0) | 2025.05.10 |
[React] 함수를 상태에 저장하기 (0) | 2025.03.19 |
[Zustand] Zustand 리렌더링 (0) | 2025.03.07 |
[React] Context API 성능개선 (0) | 2025.02.15 |