frontend

usememo,usecall,memo 발표 기록용

하리하링웹 2023. 12. 22. 19:58

React.memo개요

유저들은 반응이 빠른 UI를 선호한다.

100ms 미만의 UI 응답 지연은 유저들이 즉시 느낄 수 있고, 100ms에서 300ms가 지연되면 이미 유저들은 상당한 지연으로 느낀다.

UI 성능을 증가시키기 위해, React는 고차 컴퍼넌트 개념인 memo를 제공하며 이는 렌더링 결과를 메모이징(Memoizing)함으로써, 불필요한 리렌더링을 건너뛸수있게 해준다.

컴퍼넌트가 React.memo()로 래핑 될 때, React는 컴퍼넌트를 렌더링하고 결과를 메모이징(Memoizing)한다. 그리고 다음 렌더링이 일어날 때 props가 같다면, React는 메모이징(Memoizing)된 내용을 재사용한다.

 

공식문서에서의 정의

memo(Component, arePropsEqual?)

• Component: 메모이즈하려는 컴포넌트이다. 이 메모는 이 컴포넌트를 수정하지 않고 새로운, 메모이즈된 컴포넌트를 반환한다. 함수 및 forwardRef 컴포넌트를 포함한 모든 리액트 컴포넌트가 허용된다.

optional arePropsEqual: 두 인자를 받는 함수로, 컴포넌트의 이전 props와 새로운 props이다. 이 함수는 이전과 새로운 프롭스가 동일하면 true를 반환해야 한다. 다시 말해, 새로운 props로도 컴포넌트가 동일한 출력을 렌더링하고 동일한 방식으로 동작하는 경우에 true를 반환해야 한다. 그렇지 않으면 false를 반환해야 한다. 보통은 사용하지 않으며, React는 각 prop을 Object.is로 비교한다.

return: 메모는 새로운 React 구성 요소를 반환한다. props가 변경되지 않는 한 부모가 다시 렌더링될 때 React가 항상 다시 렌더링하지 않는다는 점을 제외하면 메모에 제공된 구성 요소와 동일하게 동작한다.

 

예시

사용 예시

export function Movie({ title, releaseDate }) {
  return (
    <div>
      <div>Movie title: {title}</div>
      <div>Release date: {releaseDate}</div>
    </div>
  );
}

export const MemoizedMovie = React.memo(Movie);

위 코드에서 MemoizedMovie의 렌더링 결과는 메모이징 되어있다. 만약 title이나 releaseData 같은 props가 변경 되지 않는다면 다음 렌더링 때 메모이징 된 내용을 그대로 사용하게 된다.

 

// 첫 렌더. React는 MemoizedMovie 함수를 호출한다.
<MemoizedMovie
  movieTitle="Heat"
  releaseDate="December 15, 1995"
/>

// 다시 렌더링 할 때 React는 MemoizedMovie 함수를 호출하지 않는다.
// 리렌더링을 막는다.
<MemoizedMovie
  movieTitle="Heat"
  releaseDate="December 15, 1995"
/>

메모이징 한 결과를 재사용 함으로써, React에서 리렌더링을 할 때 가상 DOM에서 달라진 부분을 확인하지 않아 성능상의 이점을 누릴 수 있다.

클래스 컴퍼넌트 또한 PureComponent로 동일한 내용이 구현되어 있다.

Prop 비교

React.memo()는 props 혹은 props의 객체를 비교할 때 얕은(shallow) 비교를 한다.

비교 방식을 수정하고 싶다면 React.memo() 두 번째 매개변수로 비교함수를 만들어 넘겨주면 된다.

React.memo(Component, [areEqual(prevProps, nextProps)]);

 

 

areEqual(prevProps, nextProps) 함수는 prevProps와 nextProps가 같다면 true를 반환할 것이다.

예를들어 Movie의 props가 동일한지 수동으로 비교해보자.

function moviePropsAreEqual(prevMovie, nextMovie) {
  return (
    prevMovie.title === nextMovie.title &&
    prevMovie.releaseDate === nextMovie.releaseDate
  );
}

const MemoizedMovie2 = React.memo(Movie, moviePropsAreEqual);

moviePropsAreEqual() 함수는 이전 props와 현재 props가 같다면 true를 반환할 것이다.

 

언제 React.memo()를 써야할까

같은 props로 렌더링이 자주 일어나는 컴퍼넌트

React.memo()는 함수형 컴퍼넌트에 적용되어 같은 props에 같은 렌더링 결과를 제공한다.

React.memo()를 사용하기 가장 좋은 케이스는 함수형 컴퍼넌트가 같은 props로 자주 렌더링 될거라 예상될 때이다.

일반적으로 부모 컴퍼넌트에 의해 하위 컴퍼넌트가 같은 props로 리렌더링 될 때가 있다.

위에서 정의한 Movie를 다시 사용해서 예시를 들어보자. 여기 Movie의 부모 컴퍼넌트인 실시간으로 업데이트되는 영화 조회수를 나타내는 MovieViewsRealtime 컴퍼넌트가 있다.

 

이 어플리케이션은 주기적(매초)으로 서버에서 데이터를 폴링(Polling)해서 MovieViewsRealtime 컴퍼넌트의 views를 업데이트한다.

// Initial render
<MovieViewsRealtime
  views={0}
  title="Forrest Gump"
  releaseDate="June 23, 1994"
/>

// After 1 second, views is 10
<MovieViewsRealtime
  views={10}
  title="Forrest Gump"
  releaseDate="June 23, 1994"
/>

// After 2 seconds, views is 25
<MovieViewsRealtime
  views={25}
  title="Forrest Gump"
  releaseDate="June 23, 1994"
/>

MovieViewsRealtime에 메모이징된 컴퍼넌트인 MemoizedMovie를 사용해 성능을 향상해보자

function MovieViewsRealtime({ title, releaseDate, views }) {
  return (
    <div>
      <MemoizedMovie title={title} releaseDate={releaseDate} />
      Movie views: {views}
    </div>
  );
}

title 혹은 releaseDate props가 같다면, React는 MemoizedMovie를 리렌더링 하지 않을 것이다. 이렇게 MovieViewsRealtime 컴퍼넌트의 성능을 향상할 수 있다.

컴퍼넌트가 같은 props로 자주 렌더링되거나, 무겁고 비용이 큰 연산이 있는 경우, React.memo()로 컴퍼넌트를 래핑할 필요가 있다.

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

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

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

 

 

 

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

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

비교 함수의 결과는 대부분 false를 반환하기에 props 비교는 불필요하게 된다.

React.memo() 은 성능 개선의 도구

엄밀히 말하면, React에서는 성능 개선을 위한 하나의 도구로 메모이제이션을 사용한다.

대부분의 상황에서 React는 메모이징 된 컴퍼넌트의 리렌더링을 피할 수 있지만, 렌더링을 막기 위해 메모이제이션에 의존하면 안된다.

 

 

 

Usecallback

useCallback(fn, dependencies)

렌더링 사이에 함수 정의를 캐시하려면 컴포넌트의 최상위에서 useCallback을 호출하세요.

import { useCallback } from 'react';

export default function ProductPage({ productId, referrer, theme }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }, [productId, referrer]);

fn : 원하는 함수 값을 캐시하는 함수입니다. 이 함수는 어떤 인수를 받아도 상관없고 어떤 값이든 반환할 수 있습니다. React는 초기 렌더링 중에 이 함수를 호출하지 않고 반환할 것입니다. 다음 렌더링에서는 dependency가 마지막 렌더링 이후 변경되지 않았다면 동일한 함수를 반환할 것입니다. 그렇지 않으면 현재 렌더링 중에 전달한 함수를 반환하고 나중에 재사용될 수 있도록 저장합니다. React는 함수를 호출하지 않습니다. 함수는 호출 여부와 시기를 결정할 수 있도록 여러분에게 반환됩니다.

dependencies : fn 코드 내에서 참조된 모든 반응형 값의 목록입니다. 반응형 값에는 props, state, 그리고 컴포넌트 본문에서 직접 선언된 모든 변수와 함수가 포함됩니다. React를 위해 린터가 구성되어 있다면 모든 반응형 값이 올바르게 종속성으로 지정되었는지 확인할 것입니다. 종속성 목록은 상수 개수의 항목을 가져야 하며 [dep1, dep2, dep3]과 같이 인라인으로 작성되어야 합니다. React는 각 종속성을 이전 값과 Object.is 비교 알고리즘을 사용하여 비교합니다.

 

 

Usecallback이 필요한 경우

UseEffect가 자주 실행되는 것을 방지

useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // 🔴 Problem: This dependency changes on every render
  // ...

 

이 문제를 해결하기 위해, 효과에서 호출해야 하는 함수를 useCallback으로 래핑할 수 있습니다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const createOptions = useCallback(() => {
    return {
      serverUrl: '<https://localhost:1234>',
      roomId: roomId
    };
  }, [roomId]); // ✅ Only changes when roomId changes

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [createOptions]); // ✅ Only changes when createOptions changes
  // .../

 

아래와 같은 방식으로도 변경 가능하다.

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() { // ✅ No need for useCallback or function dependencies!
      return {
        serverUrl: '<https://localhost:1234>',
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ Only changes when roomId changes
  // ...

 

 

custom hook 최적화

function useRouter() {
  const { dispatch } = useContext(RouterStateContext);

  const navigate = useCallback((url) => {
    dispatch({ type: 'navigate', url });
  }, [dispatch]);

  const goBack = useCallback(() => {
    dispatch({ type: 'back' });
  }, [dispatch]);

  return {
    navigate,
    goBack,
  };
}

주의점

function ProductPage({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails,
    });
  }); // 🔴 Returns a new function every time: no dependency array
  // ...

UseMemo 개요

const cachedValue = useMemo(calculateValue, dependencies)

calculateValue : 캐시하려는 값을 계산하는 함수입니다. 이 함수는 순수해야 하며 어떠한 인자도 받지 않아야 하며, 어떤 타입의 값이라도 반환해야 합니다. React는 초기 렌더 중에 이 함수를 호출할 것입니다. 다음 렌더에서는 dependency가 마지막 렌더 이후에 변경되지 않았다면 React는 이전에 반환된 동일한 값을 다시 반환할 것입니다. 그렇지 않으면 calculateValue를 호출하고 그 결과를 반환하여 나중에 재사용할 수 있도록 저장합니다.”

 

dependencies : calculateValue 코드 내에서 참조된 모든 반응형 값의 목록입니다. 반응형 값에는 props, state, 그리고 컴포넌트 본문에서 직접 선언된 모든 변수와 함수가 포함됩니다. React를 위해 린터가 구성되어 있다면 모든 반응형 값이 올바르게 dependency로 지정되었는지 확인할 것입니다. 종속성 목록은 상수 개수의 항목을 가져야 하며 [dep1, dep2, dep3]과 같이 인라인으로 작성되어야 합니다. React는 각 종속성을 이전 값과 Object.is 비교 알고리즘을 사용하여 비교합니다.

Returns

"초기 렌더링에서 useMemo는 인자 없이 calculateValue를 호출한 결과를 반환합니다.

다음 렌더링에서는 dependency가 변경되지 않았다면 이전 렌더링에서 이미 저장된 값을 반환하거나, dependency가 변경되었다면 다시 calculateValue를 호출하고 calculateValue의 반환 결과를 반환합니다."

 

UseMemo 사용법

Skipping expensive recalculations

import { useMemo } from 'react';

function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  // ...
}

Skipping re-rendering of components

export default function TodoList({ todos, tab, theme }) {
  // Every time the theme changes, this will be a different array...
  const visibleTodos = filterTodos(todos, tab);
  return (
    <div className={theme}>
      {/* ... so List's props will never be the same, and it will re-render every time */}
      <List items={visibleTodos} />
    </div>
  );
}

"위의 예에서 filterTodos 함수는 항상 새로운 배열을 생성하며, 마치 {} 객체 리터럴이 항상 새로운 객체를 생성하는 것과 유사합니다. 보통 이것은 문제가 되지 않을 수 있지만, 이것은 List 프롭이 결코 동일하지 않을 것이고, 따라서 메모 최적화가 작동하지 않을 것을 의미합니다. 이 때 useMemo가 유용하게 사용됩니다:”

export default function TodoList({ todos, tab, theme }) {
  // Tell React to cache your calculation between re-renders...
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] // ...so as long as these dependencies don't change...
  );
  return (
    <div className={theme}>
      {/* ...List will receive the same props and can skip re-rendering */}
      <List items={visibleTodos} />
    </div>
  );
}

Memoizing a dependency of another Hook

 

컴포넌트 본문에서 직접 생성된 객체에 의존하는 계산이 있다고 가정해보겠습니다.

function Dropdown({ allItems, text }) {
  const searchOptions = { matchMode: 'whole-word', text };

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // 🚩 Caution: Dependency on an object created in the component body
  // ...

 

 

이와 같이 객체에 의존하는 것은 메모이제이션의 목적을 상쇄시킵니다. 컴포넌트가 다시 렌더링될 때 컴포넌트 본문 안의 모든 코드가 다시 실행됩니다. searchOptions 객체를 생성하는 코드 라인도 매번 다시 실행됩니다. searchOptions는 useMemo 호출의 종속성이기 때문에 매번 다르기 때문에 React는 종속성이 다르다는 것을 알고 매번 searchItems를 다시 계산합니다.

function Dropdown({ allItems, text }) {
  const searchOptions = useMemo(() => {
    return { matchMode: 'whole-word', text };
  }, [text]); // ✅ Only changes when text changes

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes
  // ...

위 예제에서 만약 text가 변경되지 않았다면 searchOptions 객체도 변경되지 않을 것입니다. 그러나

더 나은 해결책은 searchOptions 객체 선언을 useMemo 계산 함수 내부로 이동시키는 것입니다.

function Dropdown({ allItems, text }) {
  const visibleItems = useMemo(() => {
    const searchOptions = { matchMode: 'whole-word', text };
    return searchItems(allItems, searchOptions);
  }, [allItems, text]); // ✅ Only changes when allItems or text changes
  // ...

React.memo() 와 콜백 함수

함수 객체는 "일반" 객체와 동일한 비교 원칙을 따른다. 함수 객체는 오직 자신에게만 동일하다.

 

몇가지 함수를 비교해보자.

function sumFactory() {
  return (a, b) => a + b;
}

const sum1 = sumFactory();
const sum2 = sumFactory();

console.log(sum1 === sum2); // => false
console.log(sum1 === sum1); // => true
console.log(sum2 === sum2); // => true

함수 sum1과 sum2는 팩토리에 의해 생성된 함수다. 두 함수 모두 두 숫자를 더해주는 함수이다. 그러나 sum1과 sum2는 다른 함수 객체이다.

부모 컴퍼넌트가 자식 컴퍼넌트의 콜백 함수를 정의한다면, 새 함수가 암시적으로 생성될 수 있다. 이것이 어떻게 메모이제이션을 막는지 보고, 수정해보자.

 

Logout 컴퍼넌트는 콜백 prop인 onLogout을 갖는다.

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

const MemoizedLogout = React.memo(Logout);

함수의 동등성이란 함정 때문에, 메모이제이션을 적용할 때는 콜백을 받는 컴퍼넌트 관리에 주의해야한다. 리렌더를 할 때 마다 부모 함수가 다른 콜백 함수의 인스턴스를 넘길 가능성이 있다.

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

동일한 username 값이 전달되더라도, MemoizedLogout은 새로운 onLogout 콜백 때문에 리렌더링을 하게 된다.

메모이제이션이 중단되게 되는 것이다.

 

이 문제를 해결하려면 onLogout prop의 값을 매번 동일한 콜백 인스턴스로 설정해야만 한다.useCallback()을 이용해서 콜백 인스턴스를 보존시켜보자.

const MemoizedLogout = React.memo(Logout);

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

useCallback(() => { cookies.clear() }, []) 는 항상 같은 함수 인스턴스를 반환한다. MemoizedLogout의 메모이제이션이 정상적으로 동작하도록 수정되었다.

 

 

Dependency

React will compare each dependency with its previous value using the [Object.is](<https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is>) comparison algorithm.

==

두 값이 동등한지를 비교한다. 타입 강제 변환을 수행한 다음 값이 같다면 true를 반환한다.

"1" == 1; // true
1 == "1"; // true
0 == false; // true
0 == null; // false
0 == undefined; // false
0 == !!null; // true, look at Logical NOT operator
0 == !!undefined; // true, look at Logical NOT operator
null == undefined; // true

const number1 = new Number(3);
const number2 = new Number(3);
number1 == 3; // true
number1 == number2; // false

===

값과 타입이 모두 동일한지를 엄격하게 비교한다. 값과 타입이 같다면 true를 반환하고, 하나라도 다르다면 false를 반환한다.

 

Object.is()

===와 유사하게 엄격한 비교를 수행한다. 하지만 Object.is는 일부 특별한 경우에 다르게 동작한다

  • +0과 0을 같지 않다고 판단
  • NaN을 동일하다고 판단

 

// Case 1: 평가 결과는 ===을 사용한 것과 동일합니다
Object.is(25, 25); // true
Object.is("foo", "foo"); // true
Object.is("foo", "bar"); // false
Object.is(null, null); // true
Object.is(undefined, undefined); // true
Object.is(window, window); // true
Object.is([], []); // false
const foo = { a: 1 };
const bar = { a: 1 };
const sameFoo = foo;
Object.is(foo, foo); // true
Object.is(foo, bar); // false
Object.is(foo, sameFoo); // true

// Case 2: 부호 있는 0
Object.is(0, -0); // false
Object.is(+0, -0); // false
Object.is(-0, -0); // true

// Case 3: NaN
Object.is(NaN, 0 / 0); // true
Object.is(NaN, Number.NaN); // true

 

 

 

const A = {
  "Hello":"World"
}

const B = {
  "Hello":"World"
}
console.log(Object.is(A,A)) //true
console.log(Object.is(A,B)) //false
console.log(Object.is({},{})) //false
console.log(Object.is([],[])) //fals

 

 

https://github.com/kentcdodds/use-deep-compare-effect