[React] 최적화 > useMemo, useCallback, React.memo

Gee·2022년 2월 16일
5
post-thumbnail

React 최적화 공부를 하면서 useMemo, useCallback, React.memo를 접하게 되었다. 어떨때 쓰이는지 제대로 알 필요가 있어서 따로 학습하고 정리하려고 합니다.

최적화에 사용되는 Memoization

Memoization이란 이전 값을 메모리에 저장해 동일한 계산의 반복을 제거해 빠른 처리를 가능하게 하는 기술이다. useMemo, useCallback, React.memo 는 모두 Memoization을 기반으로 동작한다. 각각 어떻게 동작하는지 알아보자.

React.memo

const MyComponent = React.memo(function MyComponent(props) {
	/* props를 사용하여 랜더링 */
})

React.memo는 고차 컴포넌트입니다.
컴포넌트가 동일한 props로 동일한 결과를 렌더링 해낸다면, React.memo를 호출하고 결과를 메모이징하도록 래핑하여 경우에 따라 성능 향상을 누릴 수 있습니다. 즉, React 는 컴포넌트 렌더링하지 않고 마지막으로 렌더링 된 결과를 재사용합니다.

React.memo는 props 변화에만 영향을 줍니다. React.memo로 감싸진 함수 컴포넌트 구현에 useState, useReducer 또는 useContext훅을 사용한다면, 여전히 state나 context가 변할 때 다시 렌더링됩니다.

props가 갖는 복잡한 객체에 대하여 얕은 비교(원시 값의 경우는 같은 값을 갖는지 확인하고 객체나 배열과 같은 참조 값은 같은 주소 값을 갖고 있는지 확인)만 수행하는 것이 기본동작이고, 다른 비교 동작을 원한다면 두 번째 인자로 별도의 비교 함수를 제공하면 됩니다.

function MyComponent(props) {
  /* props를 사용하여 렌더링 */
}
function areEqual(prevProps, nextProps) {
  /*
  nextProp가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환
  */
}
export default React.memo(MyComponent, areEqual);

React.memo를 언제 써야하는 것일까?

이러한 React 최적화 방식들을 공부하면서 접했던 내용은 React.memo의 내부 동작 원리보다는 무조건 적인 사용을 지양하라는 것이었다. 그 이유는 최적화를 위한 연산이 불필요한 경우엔 비용만 발생시키기 때문이다. React.memo는 다음과 같은 상황에서 사용을 권장한다.
1. Pure Functional Component에서
2. Rendering이 자주일어날 경우
3. re-rendering이 되는 동안에도 계속 같은 props값이 전달될 경우
4. UI element의 양이 많은 컴포넌트의 경우
일반적인 경우는 불필요한 render가 많이 발생하는 곳에서 사용하는 것이 좋다고 생각한다.

언제 React.memo를 사용하지 말아야 할까

위에 언급한 상황말고는 사용할 필요가 없을 가능성이 높다.
성능적인 이점을 얻지 못한다면 메모이제이션을 사용하지 않는 것이 좋다.

성능 관련 변경이 잘못 적용된다면 성능이 오히려 악화될 수 있다. React.memo()를 현명하게 사용해야할 것이다.

또한, 기술적으로는 가능하지만 클래스 기반의 컴포넌트를 React.memo()로 래핑하는 것은 적절하지 않다. 클래스 기반의 컴포넌트에서는 PureComponent를 확장하여 사용하거나, shouldComponentUpdate()매서드를 구현하는 것이 적절하다.

쓸모없는 props 비교

렌더링될 때 props가 다른 경우가 대부분인 컴포넌트를 생각해보면, 메모이제이션 기법의 이점을 얻기 힘들다.

props가 자주 변하는 컴퍼넌트를 React.memo()로 래핑할지라도, React는 두 가지 작업을 리렌더링 할 때마다 수행할 것이다.

  1. 이전 props와 다음 props의 동등 비교를 위해 비교 함수를 수행한다.
  2. 비교 함수는 거의 항상 false를 반환할 것이기 때문에, React는 이전 렌더링 내용과 다음 렌더링 내용을 비교할 것이다.
    비교 함수의 결과는 대부분 false를 반환하기에 props 비교는 불필요하게 된다.

React.memo의 주의사항 - 부모가 전달하는 callback 함수

function MyApp({ store, cookies }) {
  return (
    <div className="main">
      <header>
        <MemoizedLogout
          username={store.username}
          onLogout={() => cookies.clear()}
        />
      </header>
      {store.content}
    </div>
  );
}

위와 같은 MyApp component의 경우 컴포넌트는 onLogout과 username이란 두 개의 props를 전달받게 된다. MemoizedLogout이 React.memo로 래핑된 함수 컴포넌트라고 할 때, MyApp이 re-rendering되더라도 MemoizedLogout에 전달되는 props값이 동일하다면 MemoizedLogout component는 리랜더링을 피할 수 있을까? -> 정답은 아니다 !!!!

onLogout의 callback 함수는 MyApp이 리랜더링 될 때마다 새로운 참조값을 갖게 될 것이다. 함수의 내용은 같더라도 참조값이 다르면 MemoizedLogout은 리랜더링이 발생할 것이고, React.memo는 오히려 memoization에 쓸데없는 메모리만 낭비하는 것이다. 이를 위해 useCallback 을 통해 callback함수를 동일한 callback인스턴스로 설정한다.

function Logout({ username, onLogout }) {
  return <div onClick={onLogout}>Logout {username}</div>;
}

const MemoizedLogout = React.memo(Logout);
function MyApp({ store, cookies }) {
	const Logout = useCallback(()=> {
    cookies.clear()
    }, [])
      return (
    	<div className="main">
            <header>
                <MemoizedLogout 
                    username={store.username} 
                    onLogout=	{onLogout} />
            </header>
      		{store.content}
    	</div>
  );
}

항상 같은 함수 인스턴스를 반환하기 때문에 MemoizedLogout 의 React.memo가 정상 기능을 수행한다.

useMemo

useMemo는 매 번 render할 때마다 메모리가 많이 소모되는 값들을 계산하지 않고 functional components를 최적화하는데 도움을 준다. useMemo는 dependency 리스트를 생성하는 것을 도와주며, 그 중 하나가 변경되면 바로 값을 계산한다. 예를 들어 다음과 같은 컴포넌트를 생각해보자.

// useMemo를 사용하지 않은 컴포넌트
import React from "react";
const computeValueFromProp = (prop) => {
// 에너지가 많이 소모되는 계산이 일어남
}
const ComponentThatRendersOften = ({ prop1, prop2 }) => {
	const valueComputedFromProp1 = computeValueFromProp(prop1);
    return (
        <>
            <div >{valueComputedFromProp1}</div>
            <div >{prop2}</div>
        </>
        );
    };

위 컴포넌트가 다시 render되는 수간에 valueComputedProp는 매 번 다시 계산하게 될 것이다. 컴포넌트가 매우 작으면 상관없겠지만 만약 이 컴포넌트가 많은 props를 가진 큰 컴포넌트일 경우, state가 업데이트 되었다고 생각해보자. prop나 state 가 바뀌는 순간 마다, 이 컴포넌트는 값들을 다시 계산하거나 render할 것이다. 이 값들을 다시 계산할 필요가 없는 경우에도 굳이 다시 계산하는 것은 매-우 비효율적이다.

이상적으로 valueComputedFromProp1은 prop1이 변경되었을 때만 다시 계산되어야 하고, prop2가 변경되었을 때는 계산되지 않아야 한다. 그럼 이제 useMemo를 통해 prop1이 변경되었을 때만 값을 계산하도록 바꿔보자.

// useMemo를 사용한 예제
import React, { useMemo } from "react";
 
const computeValueFromProp = (prop) => {
  // 에너지가 많이 소모되는 계산이 일어남
}
 
const ComponentThatRendersOften = ({ prop1, prop2 }) => {
 
const valueComputedFromProp1 = useMemo(() => {
  return computeValueFromProp(prop1)
}, [prop1]);
 
return (
    <>
       <div >{valueComputedFromProp1}</div>
       <div >{prop2}</div>
    </>
  );
};

이제 valueComputedFromProp1은 prop1이 변경될 때만 다시 계산된다. 만약에 prop2가 업데이트된 상태로 re-render가 실행되었다면 valueComputedFromProp1은 마지막으로 계산된 값을 사용하고 다시 계산하지 않는다.

useMemo는 2가지 인자를 가진다.
1. 계산된 값을 return 하는 callback함수
2. useMemo에게 언제 다시 계산해야할지 알려 줄 dependency 리스트 배열

useMemo의 두 번째 인자로 넣은[prop1]은 prop1이 변경될때만 계산을 다시 하라고 알려주기 위한 배열이다. 만약 우리가 prop1뿐만 아니고 prop2가 변경되었을 때도 다시 계산하는 것이 필요하다면 배열은 [prop1, prop2]가 될 것이다. 만약에 최초의 mount 시에만 계산을 하고 싶다면 빈 배열 []을 넣으면 된다.

useCallback

useCallback은 리액트의 랜더링 성능을 위해 제공되는 훅이다.
컴포넌트가 렌더링될 때마다 새로운 함수를 생성해서 자식 컴포넌트의 속성값으로 입력하는 경우가 많다.

import React, {useSatate} from 'react';
import {saveToServer} from './api';
import UserEdit from './UserEdit';

function Profile(){
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  
  return (
    <div>
      <p>{`name is ${name}`}</p>
      <p>{`age is ${age}`}</p>
      <UserEdit 
        onSave={() => saveToServer(name, age)}
        setName={setName}
        setAge={setAge}
      />
    </div>
  );
}

Profile 컴포넌트가 렌더링될 때마다 UserEdit 컴포넌트의 onSave 속성값으로 새로운 함수가 입력된다. 따라서 UserEdit 컴포넌트에서 React.memo를 사용해도 onSave 속성값이 항상 변경되고 그 때문에 불필요한 렌더링이 발생한다. onSave 속성값은 name이나 age값이 변경되지 않으면 항상 같아야 한다.
useCallback 훅을 사용하면 불필요한 렌더링을 막을 수 있다. 다음은 useCallback 훅을 사용한 코드다.

function Profile(){
  const [name, setName] = useState('');
  const [age, setAge] = useState(0);
  const onSave = useCallback(() => saveToServer(name, age), [name, age]);
  return (
    <div>
    	<p>{`name is ${name}`}</p>
    	<p>{`age is ${age}`}</p>
	<UserEdit onSave={onSave} setName={setName} setAge={setAge} />
    </div>
  );
}

이전에 onSave 속성값으로 전달했던 것과 같은 함수를 useCallback 훅의 첫번째 매개변수로 입력한다. useCallback 훅의 두 번째 매개변수는 의존성 배열이다. 의존성 배열이 변경되지 않으면 이전에 생성한 함수가 재사용된다. 따라서 name과 age값이 변경되지 않으면, UserEdit 컴포넌트의 onSave 속성값으로 항상 같은 함수가 전달된다.

최적화에 대한 생각

작은 프로젝트에서만 해당 방법들을 사용해보았다. 조금 더 큰 프로젝트를 진행하게 되었을 때, 최적화를 어떻게하면 더 좋을지를 고민하고 적절하게 사용할 수 있도록해야겠다.

+(11/25 내용 추가)
최적화는 이전에 계산했던 것을 하지 않기 위해 그 결과를 기록하고, 그 기록을 다시 꺼내 쓰는 방식으로 돌아간다.

즉, useMemo의 경우 계산된 값을 기록해두고 재사용하고 useCallback의 경우 이전 함수를 기록해두고 재사용하는 것이다.
하지만, 최적화도 각각을 비교하다보니 비용이 드는 것은 사실이다. 그래서 '적절히', '필요한' 곳에 사용하라고하지만 그 경우는 생각보다 찾기도 어렵고 React에서도 React 성능 자체가 좋기때문에 큰 영향을 주지 않는다고 하긴한다.. ( 공식 문서에서 봤음.. 어렵다 어려워 )

또 의문이 드는 것은 useCallback의 경우, 어차피 부모 컴포넌트가 리랜더링되면 하위 컴포넌트 또한 리랜더링되는 것이 아닌가..?
이 경우에는 위에서 같이 공부했던 React.memo 와 같이 사용했을 때 유용하다고 합니다.@..@

최근에 useEvent라는 새로운 훅이 나왔는데, 참으로 유용하다고 하긴합니다!
아직 공부해야할 게 많아서 article만 읽어봤는데 의존성에서 벗어나질 못했던 부분을 어느정도 해결해준다고 합니다 : ) 천천히 공부해보도록 할게요..! 화이팅

profile
작은 실패, 빠른 피드백, 다시 시도

0개의 댓글