라이브러리&프레임워크/React

[React] react.memo, useMemo, useCallback

youngble 2022. 10. 8. 21:52


일단 react.memo, useMemo, useCallback를 설명하기 앞서 왜 쓰게 되는지, 써야하는 이유가 무엇인지부터 설명 할려고한다.

리랜더링(re-rendering), 재평가(re-evaluate), 재실행(re-run/re-execute)

먼저 리액트의 특성 중하나가 가상돔(Virltual DOM)인데 이는 변화된 state, props 등의 Jsx에서 이전상태와 현재 상태를 비교하여 변한부분만 업데이트 시킨다는 것은 알 것이다. 그말은 state가 변하지 않는다면 해당 컴포넌트의 태그들은 업데이트가 되지 않는다는 것인데 가상돔의 업데이트와 리랜더링과 재평가, 재실행은 같은 맥락이 아니다. 업데이트가 안된다고해서 리렌더링, 재평가, 재실행이 안되는게 아니다.

예를들어 App.js 에서 App 이라는 컴포넌트 안에 하위 컴포넌트 구조로 <Test /> 가 들어있다고 생각해보자.

const App = props =>{
console.log("Running App Component");
const [changeState, setChangeState] = useState(false);

useEffect(()=>{
	setTimeout(()=>{ setChangeState(true)}, 1000)
},[])
	return <Test />
}

export default App;

export const Test = props =>{
	 console.log("Running Test Component");
	return <div>test div</div>
}

간략하게 예시로 만들었기때문에 이론적인 이해하는데만 포커스를 두자.

App에 있는 console.logTest에 있는 console.log몇번 찍힐거라고 예상하는가?

아마 App console.log는 두번, Test console.log는 한번 이뤄질거라고 생각하는 이들이 많다고 생각한다. 업데이트가 이뤄지지 않기때문에 Test 컴포넌트를 재평가/재실행 역시 안한다고 생각하기 때문이다. 하지만 결과는 그렇지 않다.

다음과 같이 App 컴포넌트에 Test 컴포넌트가 jsx로 포함되어있고 처음 리액트 실행시 App 컴포넌트의 "Running App test" 가 실행되고 , Test 컴포넌트안에 있는 "Running Test Component" 를 출력할 것이다. 그리고 setTimeout 함수를 사용하여 1초후에 changeState값을 바꿔주게 되면 App 컴포넌트를 재평가, 재실행하게되면서 "Running App Component" 를 한번더 출력하고 Test 컴포넌트는 리랜더링되지만 바뀐부분이 없으므로 가상돔은 그전과 비교하여 바뀌지 않아서 업데이트는 되지않는다.

하지만 state변화로 인해 App이 재평가 재실행된다면 Test 컴포넌트도 다시 새로 호출하게되고 Test컴포넌트안에있는 "Running Test Component"도 출력하게 된다. 이렇게되면 우리가 원하는 결과가 아니란걸 알수있다. 즉 업데이트가 되지않더라도 App을 다시 실행하면서 Test 컴포넌트 또한 재평가/재실행되고 리렌더링 되는것이다.

따라서 불필요한 재평가/재실행으로 인한 리렌더링을 막기위해서 사용하게 되는 것이 react.memo이다.


react.memo

사용 방법은 아주 간단하다. 불필요한 재평가/재실행으로인해 리렌더링을 원하지 않는 컴포넌트 export 에 react.memo를 쓰면된다.

단 불필요하다는 말은 해당 자식컴포넌트 props로 넘겨주는 state가 바뀌지 않는다는 조건이 있어야한다는것이다.
위의 코드에서는 Test 컴포넌트를 따로분리해서 js 파일로 만들지 않았기 때문에 다음과 같이 쓰면되고

 export const Test = react.memo(props =>{
    console.log("Running Test Component");
   return <div>test div</div>
 })

만약 js파일을 따로 분리했다면 다음과 같이 적용하면된다.

const Test = (props) => {
  console.log("Running Test Component");
  return <div>test div</div>;
};

export default react.memo(Test);

console.log 결과

이렇게 react.memo를 사용하니 우리가 원했던 출력형태가 되었다. App Component는 2번 출력되고, Test Component는 1번만 출력되었다. 이렇게 쉽게 react.memo를 사용할 수 있다면 불필요한 재평가/재실행로 인한 리렌더링을 막을 수 있으니 최적화를위해 모든 컴포넌트에 써야 되는거 아니냐는 의문이 생길 것이다.


그럼 왜 모든 컴포넌트에 react.memo를 쓰지 않는가?

최적화에도 비용이 발생하기 때문이다.
기존의 props 값새로운 props를 비교하기위한 기존의 props를 저장할 공간이 필요하고 이를비교하기 위한 작업을 해야하기 때문에 이 각각의 작업은 개별적인 성능 비용이 필요하게 된다. 따라서 성능 효율은 어떤 컴포넌트를 최적화를 하는지에 따라 달라진다
왜냐하면 ‘컴포넌트를 재평가하는 성능 비용(react.memo를 쓰지않을때) vs 컴포넌트의 props를 비교하는 성능 비용(react.memo를 쓸때, 이전 props와 현재 props를 저장하고 비교하는 작업)’ 의 구조가 되기 때문이다.

따라서 어떤것을 선택하여 맞바꿀것인지는 해당 컴포넌트의 구조, props의 갯수와 로직 복잡도, 자식 컴포넌트의 숫자에 따라 달라지므로 이를 비교하여 선택하여야 할것이다. 따라서 모든 컴포넌트에 react.memo를 쓰게 된다면 오히려 없던 성능저하가 발생할 수도 있기 때문에 필요한 부분에서만 사용해야한다.

 

추가내용 2023/02/23 :

어떻게 기회비용과 계산비용의 차이를 알수있는가 chatgpt로 검색해본결과, 개발자도구의 memory탭에서 'take snapshot'을 통해 해당 객체나 배열등의 목록을 뜨는데 거기서 얼마나 많은 캐싱 메모리를 사용하는지 체크해보면 되는거같다. 하지만 이는 심화과정이므로 추후에 시간을 들여 비교해봐야한다고 생각한다. 


그렇다면 useCallback 이나 useMemo는 왜 사용하는가? react.memo만 사용하면 알아서 최적화 되지않나?

만약 App 컴포넌트가 재평가/재실행이 된다면 기존에 있던 배열, 함수, 변수, 객체 등 재생성(re-creation)과정을 거치기 때문에 만약 props로 넘겨주는게 있다면 함수, 배열, 객체는 새로운 Props 값으로 인식하기 때문이다. 이건 리액트의 특성보단 자바스크립트의 특성인데, 자바스크립트에는 원시값참조값이 있다. 원시값의 경우 숫자, 불리언, 스트링, undefined, null 에 해당하고 참조값객체, 함수, 배열에 해당한다

원시값숫자, boolean, string, undefined, null 일경우는 이전의 props값과 새로들어오는 props값을 비교하여 ===인지 체크하게되는데 재평가/재실행 이후에 생성된 원시값들은 새로운값이더래도 같은값으로 인식하기때문에 재평가가 이뤄지지 않는 것이다.

1 === 1 // true
false === false // true
'string' === 'string' // true

const a = 1;
//이전의 a 값은 1, 재평가/재실행 될때 새로 생성된 a 값또한 1 인데 1===1은 true로 인식하기 때문에 같은 값이라고 인식함

그러나 함수, 배열, 객체의 경우는 이야기가 달라지게 된다.

const obj1 = {};
const obj2 = {};

console.log(obj1 === obj2); // false

const a = [1,2,3];
const b = [1,2,3];

console.log(a === b); // false

const func1 = () =>{
 console.log("func");
}
const func2 = () =>{
 console.log("func");
}

console.log(func1 === func2); // false

위에처럼 obj1 ={} 이라는 객체obj2={} 라는 객체를 만들어서 obj1 === obj2 라고 비교할 시 false로 서로다른 객체로 판별한다.
또한 배열이 만약 a =[1,2,3] 이고 b = [1,2,3] 이더래도 서로다른 배열로 인식하여 같지않게된다.

만약 같은값으로 인식하기위해서 obj2 = obj1 로 obj2 가 같은 메모리의 주소 위치를 가리키도록한다면 obj1 === obj2 를 비교하면 true로 나오게된다. 이러한 역할이 필요하기 때문에 useCallback 이나 useMemo를 사용하게 되는 이유이다. 리액트 내부공간에 저장을 하고 컴포넌트가 재실행될때마다 이를 재사용할 수 있도록 해준다.


useCallback

예를들어

 const togglehandler = useCallback(() => {
    console.log("test");
  }, []);

라고 만든다면 컴포넌트가 재실행될때 useCallback이 리액트가 저장한 함수를 찾아서 같은 함수를 재사용하게 하는 형식이다. 따라서 App 컴포넌트다 재평가/재실행 되더라도 useCallback으로 인해서 toggleHandler 함수재생성되지 않고 기존에 저장된 이전함수로 사용하게 되는 것이다.

이러한 경우때문에 많은 개발자들이 어려움을 겪고 있고 이는 경험많은 개발자 또한 마찬가지이다. 따라서 이를 잘 이해하는것이 중요하다.
useCallback을 사용할때 주의할점이 몇가지 있는데, 그중 자바스크립트의 클로저 특성을 가지고 사용하는 useCallback안에서 사용하는 변수, 함수, 객체등을 자바스크립트는 모두 잠그게 되는데 만약

  const allowHandler = useCallback(() => {
    if (isAllow) {
      console.log("allow!");
    }
  }, []);

이라는 콜백함수를 만들어준다고 한다면, isAllow 변수값 을 잠가서 저장하게 되므로, 실제로 isAllow값이 변하더래도 allowHandler 안에서는 처음 저장한 값으로만 인식하게 된다. 따라서 false가 true 로 바뀌어도 기존이 false라면 계속해서 false로 인식하여 console.log()을 실행하지 않는다.

이는 해당 컴포넌트가 재평가, 재실행 되더라도 useCallback을 통하여 리액트에게 어떤 환경에서든 함수를 재생성을 하지않도록 막아놨기 때문이다. 따라서 이러한 값이 바뀔때 새로 변수값을 변경하고 useCallback함수를 재생성하게 하기위해서 2번째 인자의 의존성배열을 추가해줘야한다.
따라서 다음과 같이 useCallback함수를 만들면된다.

const allowhandler = useCallback(() => {
    if (isAllow) {
      console.log("allow!");
    }
  }, [isAllow]);

이렇게 isAllow값이 변할때마다 해당 useCallback 함수를 재생성하도록 해주면 된다.


useMemo

useMemo의 경우는 useCallback과 달리 함수 전체를 저장하는게 아닌 콜백함수안에 사용하는 반환값을 저장해준다.
예를들어

const sortedList = props.items.sort(a,b) => a-b);

가 있다고 생각하면, 이는 props로 받아온 items 안의 값들을 오름차순으로 정렬하여 sortedList 변수에 담아준다고 생각할때 매번 재실행될때마다 정리해줄필요없이 items 리스트값이 변할때만 새로운 오름차순으로 정렬된 값이 필요하므로 이런경우 useMemo를 사용하면 다음과 같이 된다.

const sortedList = useMemo(()=>{
 return props.items.sort((a,b)=> a-b);
},[items]);

따라서 함수가 아닌 해당 데이터를 저장하기 위해서 사용하게되는데, 실질적으로 useMemo를 사용하기보단 useCallback을 많이 접하고 사용한다고 한다. 이유는 데이터만을 담기위해서 쓰는 상황이 실질적으로 별고 없고 함수를 저장하여 사용빈도가 높고 더 간편하고 많이 쓰기때문에 useCallback을 많이 사용한다는 것이다.