useMemo

zzwwoonn·2022년 6월 2일
0

React

목록 보기
17/23

컴포넌트를 최적화 하기 위해 사용되는 가장 대표적인 훅(Hook)으로 useMemo가 있다. useMemo를 사용하여 연산한 값을 재사용함으로써 쓸떼없는 리렌더링을 줄이고 성능을 최적화하는 것이다.

배경지식

Memoization (메모이제이션)
useMemo는 말 그대로 메모를 하는 방법으로 성능을 향상시킨다. 메모이제이션이란 동일한 값을 리턴하는 함수를 반복적으로 호출해야하는 경우에 맨 처음에 계산해두었던 값을 메모리에 저장해두고 다시 꺼내쓰는 방법이다. 다시 계산을 할 필요가 없으므로 성능을 향상시킨다.

아래 코드를 통해 useMemo 즉, Memoization 이 필요한 이유에 대해 알아보자.

import React, { useState } from 'react';

const bigCalculate = (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 Calculator 😰 </h2>
        <input type="number" value={bignum} 
			onChange={(e) => {setBigNum(parseInt(e.target.value))}} />   
        <span> + 10000 = { bigvalue } </span>
    </div>
  );
}

export default App;

만약 위와 같은 코드가 있다고 가정했을 때 동작 방식은 간략하게 다음과 같다.

bigNum은 state로 선언되어 input 태그 안에 값과 매핑된다. onChange 이벤트 리스너를 통해서 input 태그 안에 값이 바뀔 때마다 bigNum state 값이 바뀌고 App 컴포넌트가 렌더링된다. bigValue는 bignum을 인자로 받아 bigCalculate 함수가 실행되고 이는 엄청나게 오래 걸리는 연산이다.

또한 추가로 알아야 하는 사실은 함수형 컴포넌트는 함수라는 사실이다. App 컴포넌트는 함수형 컴포넌트이다. App이 렌더링된다는 것은 App이라는 함수가 호출이 된다는 것이다. App 내부의 bigValue 변수도 계속해서 다시 초기화되고 bigCalculate 함수도 계속해서 호출될 것이다.

input 태그 안에 입력을 할 때마다
=> bignum state의 변화
=> 컴포넌트 내부의 state의 변화
=> 컴포넌트의 리렌더링 조건에 해당
=> bigCalculate 의 실행
=> 오래 걸리는 연산 ... ~~~

useMemo를 써야하는 이유

결론적으로 페이지가 리렌더링 되는 경우마다 for loop(i : 0~99999999)를 새로 돌아야 하는 것이다. console을 찍어보면 for loop 덕분에 조금씩 딜레이가 걸리면서 출력이 되는 것을 확인할 수 있다.

만약 다른 변수(state)가 하나 더 있어서 2개의 state가 따로따로 계속해서 변화한다면? 서로가 서로에게 영향을 미치면서? 계속해서(셀 수 없이..) 리렌더링이 일어날 것이다.

이 비효율적인 동작을 피하기 위해서 필요한 것이 useMemo이다.

useMemo를 사용하면 어떤 조건이 만족되었을 때만 변수들이 초기화되게 할 수 있다. 만약 그 조건이 만족되지 않더라도 App 컴포넌트가 렌더링이 될 때 변수를 다시 초기화하는 것이 아니라 기존에 갖고있던 값을 그대로 사용하게 해준다(메모이제이션).

useMemo 사용 문법

const value = useMemo(() => {return calculate()}, [item]);

useMemo는 두 개의 인자를 받는다. 첫 번째 인자로는 콜백 함수, 두 번째 인자로는 배열을 받는다.

첫 번째 인자인 콜백 함수는 우리가 메모이제이션 해야 하는 값을 계산해서 리턴해주는 함수이다. 이 콜백 함수가 리턴하는 값이 바로 useMemo가 리턴하는 값이 된다.

두 번째 인자인 배열은 의존성 배열이라고도 부른다(useEffect와 유사하다) useMemo는 배열의 값이 업데이트 될 때만 콜백 함수를 호출하여 메모이제이션 된 값을 업데이트해서 다시 메모이제이션 한다.

만약 빈 배열을 넘겨준다면, 맨 처음 컴포넌트가 마운트 되었을 때만 값을 계산하고, 이후에는 항상 메모이제이션된 값을 꺼내와서 사용된다.

예제 코드

// UseMemoPracticePage.jsx

import { useState } from 'react';
import CatInfo from '../Component/CatInfo';

function UseMemoPracticePage() {
	const [count, setCount] = useState(0);
	// 카운트는 메모 안해줬음 => 그냥 렌더링 확인용

	const [age, setAge] = useState(1);
	// age는 메모 해줬음 => useMemo를 사용해줬을 때 렌더링 확인용

	function plusCOunt() {
		setCount(count + 1);
	}

	function plusAge() {
		setAge(age + 1);
	}

	return (
		<div style={{ padding: '40px' }}>
			<CatInfo name="서지원 닮은 고양이" age={age} />
			{console.log('render1')}
			<button onClick={plusCOunt} style={{ marginTop: '20px' }}>
				카운트: {count}
			</button>
			<br />
			<button onClick={plusAge} style={{ marginTop: '20px' }}>
				나이: {age}
			</button>
		</div>
	);
}
export default UseMemoPracticePage;
// CatInfo.jsx

import React, { useMemo } from 'react';

const CatInfo = ({ name, age }) => {
	function checkRerender() {
		console.log('render2');
	}

	const memo = useMemo(checkRerender, [name, age]);
	// 뒤에 인자인 name이나 age가 변경되지 않는 이상 checkRerender 함수가 실행되지 않는다.
	// 둘 중에 하나라도 변경이 있으면 함수가 실행이 된다.

	return (
		<div>
			<div>이름: {name}</div>
			<div>나이: {age}</div>
		</div>
	);
};

export default CatInfo;

카운트 state는 useMemo를 사용하지 않았다
=> useMemo가 적용되지 않았을 때 페이지 리렌더링 확인용

나이 state는 useMemo를 사용했다
=> useMemo를 사용해줬을 때의 렌더링 확인용

CatInfo 의 코드를 보면 useMemo를 사용했다. 뒤에 인자인 name이나 age가 변경되지 않는 이상 checkRerender 함수가 실행되지 않는다. 반대로 말하면 둘 중에 하나라도 변경이 있으면 checkRerender 함수가 실행이 될 것이다.

제일 처음 페이지가 렌더링 됐을 때

제일 처음 페이지가 렌더링이 됐을 때는 모든 함수가 렌더링이 되니까

render1 과 render2가 나란히 나온다.

카운트만 눌렀을 때

카운트만 눌렀을 때는 render1 만 출력된다. 이는 checkRerender 함수가 호출되지 않았다는 소리이고 CatInfo의 props로 주어진 (useMemo 로 엮어준) 변수들의 변화가 없으므로 useMemo가 적용되어 렌더링이 다시 되지 않은 것이다.

나이를 눌렀을 때

나이(age) 버튼을 눌렀을 때는 render1과 render2가 출력된다.

예제 코드 2

useState, useEffect 로도 되는거 아니야? 둘이 비슷한거 같은데 굳이 useMemo를 써야 하는 이유는?

// UseMemoPracticePage.jsx

import CatInfo from '../Component/CatInfo';
import React, { useState, useEffect } from 'react';

function UseMemoPracticePage() {
	const [count, setCount] = useState(0);
	// 카운트는 메모 안해줬음 => 그냥 렌더링 확인용

	const [age, setAge] = useState(1);
	// age는 메모 해줬음 => useMemo를 사용해줬을 때 렌더링 확인용

	function plusCOunt() {
		setCount(count => count + 1);
	}

	function plusAge() {
		setAge(age => age + 1);
	}

	const [num, setNum] = useState(0);
	const [isFront, setIsFront] = useState(true);

	const developer = isFront ? '프론트엔드' : '백엔드';

	useEffect(() => {
		console.log('useEffect');
	}, [developer]);

	return (
		<>
			<div style={{ padding: '40px' }}>
				<CatInfo name="서지원 닮은 고양이" age={age} />
				{console.log('render1')}
				<button onClick={plusCOunt} style={{ marginTop: '20px' }}>
					카운트: {count}
				</button>
				<br />
				<button onClick={plusAge} style={{ marginTop: '20px' }}>
					나이: {age}
				</button>
			</div>
			<hr />
			<div style={{ padding: '40px' }}>
				<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 UseMemoPracticePage;

페이지가 처음 마운트됐을 때는 render1, render2, useEffect 가 나란히 출력될 것이다.

얼마나 공부했는데 ? 밑에 있는 input 태그에 값(num)이 계속 바뀔 때마다 onChange 이벤트가 캐치되고, 예제 코드 1에서와 마찬가지로 해당 컴포넌트 내부의 state 값의 변경이 있으므로 리렌더링의 조건이 되어 페이지가 리렌더링 된다. 따라서 값을 입력하면서 안에 숫자가 바뀔 때 마다 render1이 출력된다.

위의 코드에서 useEffect의 의존성 배열(리액트가 계속 지켜보고 있는 값)에 developer를 넣어뒀기 때문에, 맨 처음에 컴포넌트가 화면에 렌더링이 될 때 또는 developer가 바뀌었을 때만 콘솔이 찍힐 것이다.

결과는? 당연하게 input태그를 백날 고쳐도 render3는 출력되지 않는다. useEffect가 불릴 지 말지 리액트가 판단하는 기준은 의존성 배열에 들어있는 값이 렌더링 이전과 이후의 차이가 있는지이다. 그래서 developer 값이 바뀌었을 때만 useEffect가 호출되는 것이다.

그런데 만약 의존성 배열로 전달한 값이 string, number와 같은 Primitive type(원시값)이 아니라 객체(Object)라면 ?

const developer = isFront ? '프론트엔드' : '백엔드';

위의 코드를

const developer = {
	field : isFront ? '프론트엔드' : '백엔드' 
}

이렇게 고치고 다시 실행시켜본다.

developer를 변경할 때 뿐만 아니라, 공부 시간(num)을 변경할 때에도 똑같이 render3가 출력된다.

왜 ?

이는 바로 JS의 변수, 자료형의 특징 때문이다.
참고 - 원시값과 객체의 비교 (velog.zwon)

어떤 변수에 원시 타입 값을 할당하면 그 값은 변수 상자 안에 넣어진다. 하지만 객체 타입은 바로 상자 안에 들어가지 않고, 그 객체가 담긴 메모리 주소가 상자에 들어간다. 같은 원시값을 가지고 있는 변수를 === 연산자로 비교하면 true가 나온다. 왜냐하면 변수라는 상자 안에 담긴 값이 같기 때문이다. 하지만 같아 보이는 객체를 넣어준 두 변수를 비교하면 false가 나온다. 왜냐하면 obj1과 obj2 안에 담겨 있는 것은 메모리 상의 주소이기 때문이고, 두 객체는 다른 주소를 가지고 있기 때문이다.

출처 - https://velog.io/@hyun/


공부시간(num) state를 변경했을 때 App 컴포넌트가 리렌더링 되고 (컴포넌트 내부의 state 값의 변화가 있었으므로) developer 이라는 변수(객체)도 다른 주소를 새로 할당받을 것이다.

그래서 리액트의 관점에서는 developer 변수 안에 들어있는 주소가 바뀌었기 때문에 useEffect가 호출된 것이다.

그러면 App 컴포넌트가 렌더링이 될 때 developer가 다시 초기화되는 것을 막아주면 문제가 해결될 것이다. developer는 isFront state가 변경될 때만 초기화가 되면 된다.

=> useMemo를 사용해서 developer를 메모이제이션 해 보자.

정상적으로(원하는 대로) 잘 동작하는 것을 확인할 수 있다.

useEffect 와 useMemo 의 차이점은..?

useMemo는 “값의 변화”에 초점을 조금 더 맞춘 듯 하다. 값이 변하지 않는다면 ? 다시 계산할 필요가 없다는 점을 이용해 예제를 하나 만들어보았다. 억지로 끼워맞춘 느낌이긴 한데.. 이게 최선이다.

UseMemoPracticePage

<div style={{ padding: '40px' }}>
				<input
					type="number"
					value={numA}
					onChange={e => {
						setNumA(e.target.value);
					}}
				/>
				<input
					type="number"
					value={numB}
					onChange={e => {
						setNumB(e.target.value);
					}}
				/>
				<hr />
				<p>useEffect 사용 </p>
				<UseCalculate numA={numA} numB={numB} />
				<hr />
				<p>useMemo 사용</p>
				<UseCalculateWithMemo numA={numA} numB={numB} />
</div>

위의 코드는 UseMemoPracticePage 이며 자식 컴포넌트로 useCalculate 와 UseCalculateWithMemo가 있다.

useCalculate

import CatInfo from '../Component/CatInfo';
import React, { useState, useEffect, useMemo } from 'react';
import expensiveCalculation from './expensiveCalculation';

const useCalculate = props => {
	const [result, setResult] = useState(0);

	useEffect(() => {
		console.log('useCalculate Rerender !! ');
		setResult(Number(props.numA + props.numB));
	}, [props.numA, props.numB]);

	return result;
};

export default useCalculate;

UseCalculateWithMemo

import CatInfo from '../Component/CatInfo';
import React, { useState, useEffect, useMemo } from 'react';
import expensiveCalculation from './expensiveCalculation';

const useCalculateWithMemo = props => {
	let sumVal = Number(props.numA + props.numB);

	return useMemo(() => {
		console.log('useCalculateWithMemo Rerender !! ');
		return sumVal;
	}, [sumVal]);
};

export default useCalculateWithMemo;

이를 실행해보면 numA와 numB에 숫자 입력값을 넣는데, 0을 계속해서 뒤에 붙이면 값의 변화는 있지만(useEffect 트리거) 둘의 합의 변화는 없기 때문에(useMemo 적용, 메모리에 이전의 값을 기억하고 있다가 값이 달라졌는지만 체크한다 )

0의 입력을 계속 붙일 때마다

useCalculate Rerender !! 는 콘솔에 찍히고

useCalculateWithMemo Rerender !! 는 콘솔에 나오지 않는다. ⇒ 리렌더링 x

이렇게 억지로 예제를 만들어봤지만, 이게 둘의 차이점은 아니지 싶어 구글링을 해봤다. 둘의 차이점은 실행되는 시점에 있다고 한다.

대부분의 참고문서는 useEffect는 렌더링이 되고 난 이후에 트리거 되고, useMemo는 렌더링이 되기 이전에 트리거된다고 한다.

또 다른 예시를 들어보면

function expensiveCalculation(x) { return x+1; };

라는 코드가 있을 때

  • The useMemo version immediately renders 1.

⇒ useMemo는 즉시 1을 렌더한다.

  • The useEffect version renders null, then after the component renders the effect runs, changes the state, and queues up a new render with 1.

⇒ 반면 useEffect는 제일 처음 null 값을 렌더링하고, 컴포넌트가 구성 요소에 의해 렌더링이 되고 난 이후, state가 바뀌게 되고, 대기열에 1이 올라가면서 렌더링이 된다.

간단하게.. 이게 맞는지는 모르지만 최대한으로 끼워맞춰서 이해를 해보면 , useEffect를 이용할 경우 이전의 값들을 계속 쌓아오면서 계산을 다시 하는 과정이 불가피한데? useMemo는 이전의 값을 메모리에 기억하고 있다가 바로 꺼내쓰므로 쓸떼없는 리렌더링이 일어나지 않고, 성능이 훨씬 뛰어날 수 있다는 내용이다.

마지막으로 리액트 공식문서에 나와있는 내용을 살펴보자.

“생성(create)” 함수와 그것의 의존성 값의 배열을 전달하세요. useMemo는 의존성이 변경되었을 때에만 메모이제이션된 값만 다시 계산 할 것입니다. 이 최적화는 모든 렌더링 시의 고비용 계산을 방지하게 해 줍니다.

useMemo로 전달된 함수는 렌더링 중에 실행된다는 것을 기억하세요. 통상적으로 렌더링 중에는 하지 않는 것을 이 함수 내에서 하지 마세요. 예를 들어, 사이드 이펙트(side effects)는 useEffect에서 하는 일이지 useMemo에서 하는 일이 아닙니다.

배열이 없는 경우 매 렌더링 때마다 새 값을 계산하게 될 것입니다.

useMemo는 성능 최적화를 위해 사용할 수는 있지만 의미상으로 보장이 있다고 생각하지는 마세요. 가까운 미래에 React에서는, 이전 메모이제이션된 값들의 일부를 “잊어버리고” 다음 렌더링 시에 그것들을 재계산하는 방향을 택할지도 모르겠습니다. 예를 들면, 오프스크린 컴포넌트의 메모리를 해제하는 등이 있을 수 있습니다. useMemo를 사용하지 않고도 동작할 수 있도록 코드를 작성하고 그것을 추가하여 성능을 최적화하세요.

[출처] - Hooks API Reference

0개의 댓글