컴포넌트를 최적화하기 위해 사용하는 대표적인 훅 useMemo
, useCallback
중에서, useMemo
를 알아볼 것이다. useMemo
를 사용하는 방법과, 사용하면 왜 좋은지를 예제를 통해 알아본다.
useMemo
라는 이름에서 알 수 있듯이 useMemo
는 말 그대로 메모를 하는 방법으로 성능을 향상시킨다. 메모이제이션이란 동일한 값을 리턴하는 함수를 반복적으로 호출해야 한다면, 맨 처음 값을 계산할 때 메모리에 저장해서 필요할 때마다 또 다시 계산하지 않고, 저장한 값을 재사용하는 기법이다. 더 쉽게 말하자면, 자주 필요한 값을 처음 한 번만 계산하여 어딘가 저장해뒀다가 그 값이 필요할 때마다 꺼내 쓰는 방법이 메모이제이션이다.
useMemo
의 사용법을 알아보기 전에, 꼭 알아둬야 할 것이 있다. 함수형 컴포넌트는 함수라는 사실이다. 너무 당연한 말 같지만, 바꿔 말하자면 함수형 컴포넌트가 렌더링이 된다는 것은 그 함수가 호출된다는 의미이다. 함수는 호출될 때마다 함수 내부에 정의되어 있는 모든 변수를 초기화한다.
function myComponent(){
const value = calculate();
return <div> { value } </div>;
}
<myComponent />
위의 함수형 컴포넌트를 보면, 함수 내부에 있는 value
라는 변수가 하나 있다. 이 변수는 calculate()
라는 함수로부터 값을 받아 온다.
function calculate(){
return 1+1;
}
calculate()
함수는 이렇게 1+1의 값을 계산하여 리턴해주는 간단한 함수라고 해 보자.
대부분의 리액트 컴포넌트는 props
와 state
값의 변화로 수많은 리렌더링이 된다. 컴포넌트가 렌더링이 될 때마다, value
변수는 초기화되기 때문에 calculate()
함수는 반복적으로 호출이 될 것이다. 만약 calculate()
함수가 어떤 무겁고 복잡한 계산을 수행하는 함수라면, 이는 성능 저하를 초래할 것이다. 렌더링이 될 때마다 무의미한 계산을 반복하여 value
변수에 같은 값을 반복해서 전달하기 때문이다. useMemo
를 사용하면 이런 상황을 해결할 수 있다.
useMemo
는 처음에 계산된 값을 메모리에 저장해서 컴포넌트가 반복적으로 렌더링이 되어도 다시 함수를 호출하지 않고, 이전의 결과값을 재사용할 수 있게 해준다.
const value = useMemo(
() => {return calculate()},
[item]);
useMemo
는 두 개의 인자를 받는다. 첫 번째 인자로는 콜백 함수, 두 번째 인자로는 배열을 받는다. 첫 번째 인자인 콜백 함수는 우리가 메모이제이션 해야 하는 값을 계산해서 리턴해주는 함수이다. 이 콜백 함수가 리턴하는 값이 바로 useMemo
가 리턴하는 값이 된다. 두 번째 인자인 배열은 의존성 배열이라고도 부른다. useMemo
는 배열의 값이 업데이트 될 때만 콜백 함수를 호출하여 메모이제이션 된 값을 업데이트해서 다시 메모이제이션 한다. 만약 빈 배열을 넘겨준다면, 맨 처음 컴포넌트가 마운트 되었을 때만 값을 계산하고, 이후에는 항상 메모이제이션된 값을 꺼내와서 사용된다.
useMemo
가 항상 좋은 것만은 아니다. 값을 재활용하기 위해 따로 메모리를 사용하기 때문에, 불필요한 값까지 메모이제이션 해버리면 오히려 성능이 악화될 수 있다. 뭐든지 그렇겠지만 필요한 상황에만 적절히 사용하는 것이 좋다.
예제코드를 통해 좀더 자세히 살펴보자.
// App.js
import React, { useState } from 'react';
const bigCaluclate = (num) => {
console.log("big calculate 😰");
for (let i=0; i< 999999999; i++ ) {} // 오래 걸리는 계산
return num + 10000;
}
function App() {
const [bignum, setBigNum] = useState(1);
const bigvalue = bigCaluclate(bignum);
return (
<div>
<h2> Big Caluclator 😰 </h2>
<input type="number" value={bignum}
onChange={(e) => {setBigNum(parseInt(e.target.value))}} />
<span> + 10000 = { bigvalue } </span>
</div>
);
}
export default App;
App
컴포넌트 안에는 number 타입의 input 태그가 하나 있다. input value로는 bignum
가 들어있다. bigvalue
는 bigCaluclate()
의 결과를 담는 변수이고, bigCaluclate()
함수는 들어온 인자 bignum
에 10000을 더해서 반환해주는 함수이다. bigCaluclate()
함수 내부에는 의미 없는 for 반복문을 999999999번 반복하게끔 넣어 두었다.
App
컴포넌트는 함수형 컴포넌트이기 때문에, App
이 렌더링된다는 것은 App
이라는 함수가 호출이 된다는 것이다. App
내부의 bigvalue
변수도 계속해서 다시 초기화될 것이고, 그말인즉슨 calculate()
함수도 반복적으로 호출될 것이다. 또한, 브라우저에 있는 숫자를 변경하면 state가 변경되는 것이기 때문에 App
컴포넌트가 다시 렌더링될 것이다. 그러면 정말 렌더링이 될 때마다 bigCaluclate()
함수가 호출되는지 확인해 보자.
숫자를 변경시킬 때마다 실제로 bigCaluclate()
내부에 있는 console.log("calculate")
가 실행되는 것을 볼 수 있다. 999999999번 반복문을 거쳐야 하기 때문에 약간의 딜레이를 가지며 콘솔이 찍힌다.
이번에는 smallnum
이라는 state를 하나 더 추가해서 bignum
과 비교해 볼 것이다. bignum
과 똑같은 작업을 해 줄 것이다.
import React, { useState } from 'react';
const bigCaluclate = (num) => {
console.log("big calculate 😰");
for (let i=0; i< 999999999; i++ ) {} // 오래 걸리는 계산
return num + 10000;
}
const smallCalculate = (num) => {
console.log("small calculate 🤗");
return num + 1;
}
function App() {
const [bignum, setBigNum] = useState(1);
const [smallnum, setSmallNum] = useState(1);
const bigvalue = bigCaluclate(bignum);
const smallvalue = smallCalculate(smallnum);
return (
<>
<div>
<h2> Big Caluclator 😰 </h2>
<input type="number" value={bignum}
onChange={(e) => {setBigNum(parseInt(e.target.value))}} />
<span> + 10000 = { bigvalue } </span>
</div>
<div>
<h2> Small Caluclator 🤗 </h2>
<input type="number" value={smallnum}
onChange={(e) => {setSmallNum(parseInt(e.target.value))}} />
<span> + 1 = { smallvalue } </span>
</div>
</>
);
}
export default App;
Small Calculator도 마찬가지로, 숫자를 증가시킬 때마다 콘솔이 찍힐 것이다. 대신 반복문을 제거했고, 더하는 값도 1로 낮췄으므로 딜레이가 없고 바로바로 콘솔이 찍힐 것이다. 그런데, 실제 숫자를 변경시켜 보면 Big Calculator와 마찬가지로 1초 정도 딜레이가 있는 것을 확인할 수 있다. 그 이유는 App
컴포넌트가 함수형 컴포넌트이기 때문이다.
우리가 Small Calculator의 숫자를 변경시키게 되면, smallnum
이라는 state가 바뀌게 된다. state가 바뀐다는 말은 App
컴포넌트가 다시 렌더링된다는 뜻이다. 그러면 App
안에 정의되어 있는 bignum
또한 초기화가 될 것이고, bignum
의 값을 할당하기 위해 bigCalculate
함수가 호출되어 버린다. 그래서 smallnum
을 바꿔도 bigCalculate
안에 있는 999999999번의 반복문이 실행되어 반응이 느린 것이다.
그러면, 이 비효율적인 동작을 피하기 위해서 smallnum
이라는 변수를 바꿀 때 bigCalculate
가 불리지 않게 하는 방법을 찾아야 한다. 이 때 필요한 것이 useMemo
이다. useMemo
를 사용하면 어떤 조건이 만족되었을 때만 변수들이 초기화되게 할 수 있다. 만약 그 조건이 만족되지 않더라도 App
컴포넌트가 렌더링이 될 때 변수를 다시 초기화하는 것이 아니라 기존에 갖고있던 값을 그대로 사용하게 해준다(메모이제이션).
import React, { useState, useMemo } from 'react'; // useMemo import하기
const bigCaluclate = (num) => {
console.log("big calculate 😰");
for (let i=0; i< 999999999; i++ ) {} // 오래 걸리는 계산
return num + 10000;
}
const smallCalculate = (num) => {
console.log("small calculate 🤗");
return num + 1;
}
function App() {
const [bignum, setBigNum] = useState(1);
const [smallnum, setSmallNum] = useState(1);
// const bigvalue = bigCaluclate(bignum);
const bigvalue = useMemo(()=>{
// 콜백함수의 리턴값으로 우리가 memoize 할 값을 준다.
return bigCaluclate(bignum);
// 의존성 배열에 값을 넣어주면, 그 값이 바뀔 때만 콜백함수 안의
// bigCalculate가 호출되어 bigvalue에 들어간다.
}, [bignum]);
const smallvalue = smallCalculate(smallnum);
return (
<>
<div>
<h2> Big Caluclator 😰 </h2>
<input type="number" value={bignum}
onChange={(e) => {setBigNum(parseInt(e.target.value))}} />
<span> + 10000 = { bigvalue }</span>
</div>
<div>
<h2> Small Caluclator 🤗 </h2>
<input type="number" value={smallnum}
onChange={(e) => {setSmallNum(parseInt(e.target.value))}} />
<span> + 1 = { smallvalue }</span>
</div>
</>
);
}
export default App;
기존의 bigvalue
를 주석 처리하고, useMemo
를 이용하여 코드를 작성했다. useMemo
의 첫 번째 인자 콜백함수의 리턴값으로는 bigCaluclate(bignum)
를 넣어주었고, 두 번째 인자의 배열에는 bignum
을 넣어주었다. 이렇게 하면 bignum
의 값이 변경될 때에만 콜백 안에 있는 bigCalculate
가 호출되어 bigvalue
을 초기화한다.
브라우저에서 확인해보면, Big Calculator를 사용했을 때는 똑같이 딜레이가 있지만 Small Calculator를 사용했을 때는 딜레이가 전혀 없는 것을 볼 수 있다.
사실 개발을 하다 보면 변수 할당에 1초나 걸릴 일이 거의 없다. 실제 개발을 할 때 유용한, useMemo
가 빛을 발하는 상황을 다른 예제로 알아보자.
import React, { useState, useMemo } from 'react';
function App() {
const [num, setNum] = useState(0);
const [isFront, setIsFront] = useState(true);
const developer = isFront ? '프론트엔드' : '백엔드';
return (
<div>
<h2> 나의 공부시간 🙇🏻♀️ </h2>
<input type="number" value={num} onChange={ (e) => {setNum(e.target.value)} }/>
<h2> 어떤 개발을 하나요? 👩🏻💻 </h2>
<p> 분야: {developer} </p>
<button onClick={() => setIsFront(!isFront)}> 공부하자 </button>
</div>
);
}
export default App;
위와 같이 간단한 페이지를 구성했다. 이 코드에 useEffect
를 하나 추가해 보자.
import React, { useState, useMemo, useEffect } from 'react';
function App() {
const [num, setNum] = useState(0);
const [isFront, setIsFront] = useState(true);
const developer = isFront ? '프론트엔드' : '백엔드';
useEffect(() => {
console.log("useEffect✨")
},[developer]);
return (
<div>
<h2> 나의 공부시간 🙇🏻♀️ </h2>
<input type="number" value={num} onChange={ (e) => {setNum(e.target.value)} }/>
<h2> 어떤 개발을 하나요? 👩🏻💻 </h2>
<p> 분야: {developer}</p>
<button onClick={() => setIsFront(!isFront)}> 공부하자 </button>
</div>
);
}
export default App;
useEffect
의 의존성 배열에 developer
를 넣어뒀기 때문에, 맨 처음에 컴포넌트가 화면에 렌더링이 될 때 또는 developer
가 바뀌었을 때만 콘솔이 찍힐 것이다. 정말 그런지 확인해 보자.
공부시간을 증가시킬 땐 반응이 없지만, 공부하자 버튼을 통해 developer
를 변경할 때엔 콘솔이 찍힌다. useEffect
가 불릴 지 말지 리액트가 판단하는 기준은, 의존성 배열에 들어있는 값이 렌더링 이전과 이후의 차이가 있는지이다. 그래서 developer
값이 바뀌었을 때만 useEffect
가 호출된다.
그런데, 의존성 배열로 전달한 값이 string과 같은 Primitive type이 아니라 객체라면 이야기가 달라진다. 이번에는 developer
에다 객체를 할당해 보자.
import React, { useState, useMemo, useEffect } from 'react';
function App() {
const [num, setNum] = useState(0);
const [isFront, setIsFront] = useState(true);
// const developer = isFront ? '프론트엔드' : '백엔드';
const developer = {
field : isFront ? '프론트엔드' : '백엔드'
}
useEffect(() => {
console.log("useEffect✨")
},[developer]);
return (
<div>
<h2> 나의 공부시간 🙇🏻♀️ </h2>
<input type="number" value={num} onChange={ (e) => {setNum(e.target.value)} }/>
<h2> 어떤 개발을 하나요? 👩🏻💻 </h2>
<p> 분야: {developer.field} </p>
<button onClick={() => setIsFront(!isFront)}> 공부하자 </button>
</div>
);
}
export default App;
이번에도 브라우저로 useEffect
의 콘솔을 확인해 보자.
developer
를 변경할 때 뿐만 아니라, 공부시간 number
를 변경할 때에도 똑같이 useEffect
가 불러지고 있다. 왜 그럴까?
자바스크립트에는 원시 타입(Primitive), 객체(Objet) 타입의 자료형이 존재한다.
어떤 변수에 원시 타입 값을 할당하면 그 값은 변수 상자 안에 넣어진다. 하지만 객체 타입은 바로 상자 안에 들어가지 않고, 그 객체가 담긴 메모리 주소가 상자에 들어간다.
같은 원시값을 가지고 있는 변수를 ===
연산자로 비교하면 true
가 나온다. 왜냐하면 변수라는 상자 안에 담긴 값이 같기 때문이다. 하지만 같아 보이는 객체를 넣어준 두 변수를 비교하면 false
가 나온다. 왜냐하면 obj1과 obj2 안에 담겨 있는 것은 메모리 상의 주소이기 때문이고, 두 객체는 다른 주소를 가지고 있기 때문이다.
공부시간 num
state를 변경했을 때, App
컴포넌트가 리렌더링 되고 developer
이라는 변수도 다른 주소를 다시 할당받을 것이다. 그래서 리액트의 관점에서는 developer
안에 들어있는 주소가 바뀌었기 때문에 useEffect
가 호출된 것이다.
그러면 App
컴포넌트가 렌더링이 될 때 developer
가 다시 초기화되는 것을 막아주면 문제가 해결될 것이다. developer
는 isFront
state가 변경될 때만 초기화가 되면 된다. useMemo
를 다시 사용해서, developer
를 메모이제이션 해 보자.
const developer = useMemo(()=>{
return {
field : isFront ? '프론트엔드' : '백엔드'
};
}, [isFront])
첫 번째 인자인 콜백함수에는 우리가 메모이제이션할 값을 넣어줘야 하므로 객체를 넣어준다. 그리고 두 번째 인자인 의존성 배열에는 콜백함수가 호출되는 조건을 넣어준다. 결과를 확인해보자.
공부하자 버튼을 누를 때만 useEffect
가 호출되는 것이 확인된다. 원하는 결과가 나왔다.
짱~