개발을 공부하기 시작하면서 "메모이제이션" 이라는 단어를 자주 듣게 되었는데, 최적화에 관련된 내용이었다는 건 얼핏 알고 있었지만, 정확한 개념에 대해선 모르고 있었다.
특히 React의 경우, useCallback
과 useMemo
가 메모이제이션을 지원하는 Hook이라고 하였는데, 사용을 해보려고해도 애초에 메모이제이션이 뭔지 잘 모르니 사용 방법에 대한 이해가 도저히 되지 않았었다.
메모이제이션은 뭐고 useCallback
과 useMemo
는 어떻게 메모이제이션을 지원하는걸까?
메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다.
- 출처: 위키백과
웹과 앱의 규모가 점점 커지면서 처리할 데이터의 양 또한 증가하였다. 더 많은 양의 컴퓨팅이 진행되다보니 로딩 속도를 줄이기 위한 최적화에도 당연히 관심이 쏠릴 수 밖에 없었다.
그래서 이전 데이터를 캐시하고 똑같은 입력이 다시 발생할 때 바로 반환하는 방식으로 어플리케이션의 구동 속도가 증가할 수 있었다.
메모이제이션이 얼마나 효과적인지 알려주는 예제는 피보나치 수열을 함수로 만드는 것이라고 한다.
function fibonacci(n) {
if (n <= 1) {
return 1
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
만약 fibonacci(5)
의 값을 얻고싶다면, n의 값이 1이 될때가지 여러 분기가 생기면서 동일한 함수가 계속 실행된다.
각 분기점에서 새롭게 계산을 진행할 때 n의 값이 같은 함수가 여러개 생성되는데, 이렇게 중복 연산이 생기면 생길수록 더 많은 작업이 수행되고 최종 케이스에 도달하는데까지 시간이 많이 소비된다.
위와 같이 fib(3)
, fib(2)
, fib(1)
, fib(0)
함수들는 각 각 1번 이상이 실행되었다.
메모이제이션은 이런 상황에서 중복된 연산이 진행되지 않도록 한다고 한다.
function fibonacci(n,memo) {
memo = memo || {}
if (memo[n]) {
return memo[n]
}
if (n <= 1) {
return 1
}
return memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo)
}
memo[n]
의 값이 있는 경우, 이전에 memo에 캐시해뒀던 값을 리턴하게 된다. 캐시된 값을 리턴하기 때문에 함수는 더 이상 같은 인자에 대한 계산을 지속적으로 진행하지 않게 된다.
첫번째 방식의 함수와 비교했을 때, 특정 값을 캐시하고 추가적인 계산이 일어나지 않는다는 것이 어떤 의미인지 보다 더 파악할 수 있다.
메모이제이션은 성능을 최적화하기 때문에 굉장히 중요한 개념이라고 생각한다. 리액트에서도 성능 최적화를 위해 useMemo()
와 useCallback()
을 사용한다고 많이 들었지만, 애초에 개념에 대해 잘 몰랐기 때문에 설명이 와닿지 않았었다.
메모이제이션의 개념과 중요성에 대해 간단하게나마 알아봤으니 useMemo()
와 useCallback()
에 대해서도 간단하게 정리해야겠다.
useMemo()
의 첫번째 파라미터에는 어떻게 연산할지 정의하는 함수를 넣어주면 되고 두번째 파라미터에는 deps 배열을 넣어주면 되는데, 이 배열 안에 넣은 내용이 바뀌면, 우리가 등록한 함수를 호출해서 값을 연산해주고, 만약에 내용이 바뀌지 않았다면 이전에 연산한 값을 재사용한다.
import { useState } from "react";
const hardCalculate = (number) => {
console.log("어려운 계산!");
for (let i = 0; i < 99999999; i++) {} // 생각하는 시간
return number + 10000;
};
const easyCalculate = (number) => {
console.log("쉬운 계산!");
return number + 1;
};
function App() {
const [hardNumber, setHardNumber] = useState(1);
const [easyNumber, setEasyNumber] = useState(1);
const hardSum = hardCalculate(hardNumber);
const easySum = easyCalculate(easyNumber);
return (
<div>
<h3>어려운 계산기</h3>
<input
type="number"
value={hardNumber}
onChange={(e) => setHardNumber(parseInt(e.target.value))}
/>
<span> + 10000 = {hardSum}</span>
<h3>쉬운 계산기</h3>
<input
type="number"
value={easyNumber}
onChange={(e) => setEasyNumber(parseInt(e.target.value))}
/>
<span> + 1 = {easySum}</span>
</div>
);
}
export default App;
이 예시에서 쉬운 계산기의 input의 값을 변경할 경우, App 컴포넌트 전체가 리렌더링 되기 때문에 hardCalculate()
도 다시 실행된다. 굳이 리렌더링이 발생할 필요 없는 상황이기 때문에, hardCalculate()
를 useMemo()
로 감쌀 수 있다.
const hardSum = useMemo(() => {
return hardCalculate(hardNumber);
}, [hardNumber]);
const easySum = easyCalculate(easyNumber);
useMemo()
의 첫 번째 인자로 콜백 함수인 hardCalculate()
를 넣고, 의존성 배열 안엔 실행 조건인 hardNumber
를 넣는다면, hardNumber가 변경될때만 콜백함수가 실행되고, 만약 hardNumber가 변하지 않았다면 이전에 캐시했던 값을 뱉어내는 것이다.
useCallback()
도 useMemo()
와 비슷하게 특정한 값을 재사용하고 싶을 때 사용한다. 다만, useCallback()
은 특정 함수 자체를 새로 만들지 않고 재사용하기 위해 사용한다.
예를 들어 리액트 컴포넌트 안에 함수가 선언되어있을 때 이 함수는 해당 컴포넌트가 렌더링 될 때마다 새로운 함수가 생성되는데, useCallback을 사용하면 해당 컴포넌트가 렌더링 되더라도 그 함수가 의존하는 값(deps)들이 바뀌지 않는 한 기존 함수를 재사용할 수 있다.
함수를 생성하는 것 자체도 리소스가 드는 일인데, useCallback()
을 사용하면, 원본 함수는 가비지 컬렉팅 되지 않고, 메모리에서 계속해서 한 공간을 차지함으로서 리소스를 세이브하게 되는 것이다.
function App() {
const [name, setName] = useState('');
const onSave = useCallback(() => {
console.log(name);
}, [name]);//name이 변경될 때에만 함수 재생성.
return (
<div className="App">
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Profile onSave={onSave} />
</div>
);
}
위의 예시를 보자면 onSave()
함수는 App 컴포넌트에서 name이 변경되지 않는 이상 함수가 새로 만들어지지 않는다. 함수를 계속해서 사용할 수 있기 때문에 <Profile>
컴포넌트의 반복적인 리렌더링도 방지할 수 있다.
출처: