[React] Memoization

27px·2022년 10월 19일
0

성능 최적화

내부적으로 React는 UI를 최신화하기 위해 비용이 많이 드는 DOM작업의 수를 최소화하기 위해 몇 가지 방법을 활용한다. 많은 애플리케이션에서 React를 사용하면 성능을 최적화하기 위해 많은 작업을 수행하지 않고도 빠른 사용자 인터페이스를 제공할 수 있다.

  1. 프로덕트 빌드를 사용해라 (React Developer Tools for Chrome)

    • 개발 모드의 React 기반 사이트에 접속하면 아이콘의 배경이 빨간색으로 표시되는데 앱을 개발할 때에는 개발 모드, 사용자에게 배포할 때에는 프로덕션 모드를 사용해야 한다.
    • .production.min.js로 끝나는 React 파일만이 프로덕션 환경에 적합하다.
  2. 재조정(Reconciliation)을 피해라
    컴포넌트의 props이나 state가 변경되면 React는 새로 반환된 엘리먼트를 이전에 렌더링된 엘리먼트와 비교하여 실제 DOM 업데이트가 필요한 지 여부를 결정한다. 같지 않을 경우 React는 DOM을 업데이트합니다.

Memoization?

  • 결과를 캐싱하고, 다음 작업에서 캐싱한 것을 재사용하는 비싼 작업의 속도를 높이는 JS기술
  • 이전 값을 메모리에 저장해 동일한 계산의 반복을 제거해 빠른 처리를 가능하게 하는 기술
  • 캐시에 초기 작업 결과를 저장하여 사용함으로써 최적화할 수 있다.

React에서는 useMemo, useCallback, React.memo가 memoization을 기반으로 작동한다.
우선 React.memo는 HOC(고차 컴포넌트) 이고, useCallback과 useMemo는 Hooks이다.

HOC(High Order Componenet)? 고차 컴포넌트는 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수이다.

const 강력한컴포넌트 = HighOrderComponenet(컴포넌트); 

React.memo(컴포넌트)

: 컴포넌트를 메모이제이션하는 것

  • 직접 컴포넌트를 감싸서 사용한다. 이전 값을 메모리에 저장해 동일한 계산의 반복을 제거한다. props가 변경될 때까지 현재 memoized된 값을 그대로 사용하여 리렌더링을 막는다.
    물론, 자기 자신의 state가 변하면 리렌더링이 일어난다.
const MyComponent = React.memo(function MyComponent(props) {
  /* props를 사용하여 렌더링 */
});

Reeact.memo는 props를 비교할 때 얕은 비교를 진행하는데, 원시 값의 경우는 같은 값을 갖는지 확인하고 참조값은 같은 주소 값을 가지고 있는 지 확인한다. 결론적으로 memo는 props의 값이 원시값일 때만 유효하고 참조값일 때는 유효하지 않는다.
리액트에서 객체를 비교할 때는 "얕은 비교"를하기 때문이다.

얕은 비교? 객체 주소에 의한 비교, 객체의 내용물은 비교X (설령 값이 같을 지라도 주소가 다르면 다르다고 판단한다.)

props가 갖는 복잡한 객체에 대하여 얕은 비교만 수행하는 것이 기본동작이며 다른 비교 동작을 원한다면, 두 번째 인자로 별도의 비교함수를 제공해야 한다.

즉, 2번째 비교 함수를 직접만들어서 그 내부에 깊은 비교를 할 수 있도록 로직을 구현하면 렌더링 방지를 할 수 있다.

const CounterA = React.memo(({ count }) => {
  useEffect(() => {
    console.log(`counter A :: count ${count}`);
  });
  return <div>{count}</div>;
});

const CounterB = ({ obj }) => {
  useEffect(() => {
    console.log(`counter B :: obj ${obj.count}`);
  });
  return <div>{obj.count}</div>;
};

const areEqual = (prevProp, nextProp) => {
  //이전 prop 값과 지금 prop값을 비교해서 같다면 true를 반환-> 렌더링X
  // 다르다면 false 반환 -> 렌더링 O
  //   if (prevProp.obj.count === nextProp.obj.count) {
  //     return true;
  //   } else {
  //     return false;
  //   }
  //위의 코드는 어차피 boolean 값을 반환하면되니까 아래와 같이 조건식 자체의 결과를 리턴해주면 된다.
  return prevProp.obj.count === nextProp.obj.count;
};

const MemoizedCounterB = React.memo(CounterB, areEqual);

function OptimizeTest() {
  const [count, setCount] = useState(1);
  const [obj, setObj] = useState({ count: 1 });

  return (
    <div style={{ padding: 50 }}>
      <div>
        Counter A
        <button
          onClick={() => {
            setCount(count);
          }}
        >
          버튼A
        </button>
        <CounterA count={count} />
      </div>
      <div>
        counter B
        <button
          onClick={() => {
            setObj({ count: obj.count });
          }}
        >
          버튼B
        </button>
        <MemoizedCounterB obj={obj} />
      </div>
    </div>
  );
}

export default OptimizeTest;

memo는 오직 성능 최적화를 위해서만 사용된다. 렌더링을 '방지'하기 위해서는 사용하면 안된다.

useMemo(콜백함수, [deps])

: 연산 결과를 메모이제이션하는 것

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useMemo()는 메모이제이션된 을 반환한다.
인자로는 2개를 받는다. 생성 함수와 그것의 의존성 값의 배열을 전달해야 한다.
그렇게되면 useMemo는 의존성이 변경되었을 때만 메모이제이션된 값만 다시 계산한다.
(배열이 없는 경우 매 렌더링 때마다 새 값을 계산하게 된다. deps에 값을 넣으셈)
-> 이 최적화는 모든 렌더링 시의 고비용 계산을 방지해준다.

👾 useMemo로 전달된 함수는 렌더링 중에 실행된다. 통상적으로 렌더링 중에는 하지 않는 것을 이 함수 내에서 하면 안된다.
👾 의존성 값의 배열은 함수에 인자로 전달되진 않는다. 하지만, 함수 안에서 참조되는 모든 값은 의존성 값의 배열에 나타나야 한다.

e.g.) side effects는 useEffect에서하는 일이지 useMemo에서 하는 일이 아니다.


  //리액트에서는 return을 가지고 있는 함수를 메모이제이션할 수 있다.
  //메모이제이션하고 싶은 함수를 useMemo를 통해 감싸주면 된다.
  //useMemo()의 첫번째 인자로 콜백함수를 받고 이 콜백함수가 리턴하는 값(연산)을 최적화할 수 있도록 도와준다.
  //useMemo(콜백함수, [deps])-> 콜백함수를 유즈메모로 감싸주면 리턴해주는 아이는 더 이상 함수가 아니다.
  //그렇기 때문에 호출할 때에 ()는 없애줘야 한다. deps는 useEffect의 의존성 배열과 같은 역할을 한다.
  //해당 값에 어떤 값이 변했을 때에만 콜백 연산을 다시 수행할 지 알려준다.
  const getDiaryAnalysis = useMemo(() => {
    console.log("일기 분석 시작");
    const goodCount = data.filter((item) => item.emotion >= 3).length;
    const badCount = data.length - goodCount;
    const goodRatio = (goodCount / data.length) * 100;
    return { goodCount, badCount, goodRatio };
  }, [data.length]);

  //useMemo를 함수에 씌워주었으니 
  // const { goodCount, badCount, goodRatio } = getDiaryAnalysis();
  const { goodCount, badCount, goodRatio } = getDiaryAnalysis;

위의 예시 코드에서 주의점은 getDiaryAnalysis는 함수였지만 useMemo로 콜백함수를 감싸준 뒤부터는 리턴 값이 함수가 아니라 값이기 때문에 ()로 호출하는 것이 아니라 그냥 변수로 대입해줘야 한다. 코드에서는 구조분해할당을 통해 useMemo의 결과값을 각각의 변수에 할당해주었다.

useCallback

const memoizedCallback = useCallback(()=>{
	dosomething(a,b);
}, [a,b])

메모이제이션된 콜백을 반환합니다. useMemo() Hook의 경우는 메모이제이션된 "값"을 반환했지만 useCallback() Hook은 메모이제이션된 "콜백 함수"를 반환합니다.

어떤 컴포넌트에 함수를 prop으로 내려주게 됐을 때 useCallback을 사용하면 불필요한 리렌더링을 방지할 수 있다.

App.js

//코드 중략....
 const onCreate = useCallback((author, contents, emotion) => {
    //현재 시간은 지역상수로 그냥 여기서 만들어줘도 됨
    const created_date = new Date().getTime();

    const newItem = {
      author,
      contents,
      emotion,
      created_date,
      id: dataId.current,
    };
    //다음 일기 아이디는 다른 아이디를 가져야 하니까 1추가해주는 로직
    dataId.current += 1;
    //최신 아이템이 위로 오게 만들어줘야 하기때문에 원래 데이터를 뒤에 전개연산자로 펼쳐주기
    setData([newItem, ...data]);
  }, []);

예를 들어, DiaryEditor 컴포넌트가 props로 onCreate 함수를 내려받는다고 했을 때 작성된 일기 리스트가 삭제됐을 때, 불필요하게 렌더링이 일어나고 있다. 이 때, onCreate함수가 있는 App.js에서 해당 함수에 useCallback을 적용하고 빈배열을 deps로 주게되면 처음 마운트 됐을 때만 한번 함수가 실행되고 그뒤로는 렌더링을 방지할 수 있다.

하지만, 이렇게되면 일기를 하나 새로 추가했을 때, 이전에 jsonplaceholder로 받아온 기본 20개의 리스트가 사라지고 새로 추가된 일기만 남게된다.
이유: deps의 값이 빈배열이기 때문이다. 그렇다고 deps에 data를 넣게 되면 다시 일기를 수정하거나 삭제했을때도 diaryEditor 컴포넌트가 리렌더링되는 딜레마에 빠지게 된다.

이럴 때는 setData()의 인자로 새로운 값이 아닌 함수 자체를 넘기는 방식으로 업데이트하면 해결된다.

 const onCreate = useCallback((author, contents, emotion) => {
    //현재 시간은 지역상수로 그냥 여기서 만들어줘도 됨
    const created_date = new Date().getTime();

    const newItem = {
      author,
      contents,
      emotion,
      created_date,
      id: dataId.current,
    };
    //다음 일기 아이디는 다른 아이디를 가져야 하니까 1추가해주는 로직
    dataId.current += 1;
    //최신 아이템이 위로 오게 만들어줘야 하기때문에 원래 데이터를 뒤에 전개연산자로 펼쳐주기
    setData((data)=>[newItem, ...data]);
  }, []);

위와 같이 기존 data를 인자로 받아서 새로운 객체를 리턴하는 콜백함수 자체를 setData의 인자로 넘겨주었더니 해결되었다. 사실상 이 방법은 setData(data+1); 를 setData((prev) => prev + 1)로 바꾸는 것과 동일해보인다. 후자의 방법이 이전 state값에 의존하고 있어 더 정확하다고 알고 있다. 이 방법은 함수형 업데이트라고 부른다.
data 라는 콜백함수의 인자는 이전 state값의 스냅샷이기 때문에 deps가 빈배열이어도 이전 값을 알고 있어 문제를 방지해준다고 한다.

usecallBack의 가장 중요한 부분은 바로 setData()의 업데이트를 함수형 업데이트 방식으로 처리하는 것이다. 이전의 함수들도 아래와 같이 최적화할 수 있다.

 //리스트 아이템 삭제 기능
  const onRemove = useCallback((targetId) => {
    setData((data) => data.filter((item) => item.id !== targetId));
  }, []);

  //리스트 아이템 수정 기능
  const onEdit = useCallback((targetId, newContents) => {
    setData(
      //원래 데이터 배열의 item(일기) 객체의 아이디가
      //수정하려는 id와 동일하다면 그 객체의 일기요소를 전개 연산자로 풀어주고 contents의 값을 새로운 내용으로 덮어준다. 아니라면 원래 값유지
      (data) =>
        data.map((item) =>
          item.id === targetId ? { ...item, contents: newContents } : item
        )
    );
  }, []);
profile
안녕하세요?

0개의 댓글