본문 바로가기

개발이야기

When to useMemo and useCallback

이 글은 아래 글을 번역한 글 입니다.

 

When to useMemo and useCallback

Performance optimizations ALWAYS come with a cost but do NOT always come with a benefit. Let's talk about the costs and benefits of useMemo and useCallback.

kentcdodds.com

성능 최적화에는 항상 비용이 따르고, 언제나 이점을 제공하는 것은 아닙니다. useMemo와 useCallback의 비용과 이점에 대해서 이야기해봅시다. 

 

function CandyDispenser() {
  const initialCandies = ["snickers", "skittles", "twix", "milky way"];
  const [candies, setCandies] = React.useState(initialCandies);
  const dispense = candy => {
    setCandies(allCandies => allCandies.filter(c => c !== candy));
  };
  return (
    <div>
      <h1>Candy Dispenser</h1>
      <div>
        <div>Available Candy</div>
        {candies.length === 0 ? (
          <button onClick={() => setCandies(initialCandies)}>refill</button>
        ) : (
          <ul>
            {candies.map(candy => (
              <li key={candy}>
                <button onClick={() => dispense(candy)}>grab</button> {candy}
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  );
}

 

위와 같은 코드에서 코드의 특정 부분을 바꾼후에 여러분에게 하나의 질문을 던질 것입니다. 

제가 바꾸려는 것은 dispense라는 함수를 React.useCallback 안에 넣는 것입니다.

 

const dispense = React.useCallback(candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy));
}, []);

 

여기 원본이 있습니다. 

 

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy));
};

 

여러분에게 질문을 해보겠습니다.  이것들 중 어떤것이 성능상 더 좋을까요? 앞으로가서 여러분의 추측을 답해보세요.

정답은 original입니다. 왜 useCallback이 더 안좋은건가요?

 

우리는 inline 함수들은 성능에 문제를 초래할 수 있다고 많이 들었고 성능 향상을 위해 React.useCallback을 써야한다고 들었습니다. 그러면 어떻게 useCallback을 사용하지 않는 것이 더 좋아진 것 일까요?

 

코드 한줄을 실행하는 것에는 비용이 따릅니다. 이 글을 읽으시는 분들의 수월한 이해를 위해 해당 useCallback 예제를 조금만 바꿔보도록 하겠습니다.

const dispense = (candy) => {
  setCandies((allCandies) => allCandies.filter((c) => c !== candy));
};

const dispenseCallback = React.useCallback(dispense, []);

 

그리고 아래는 초기 dispense 함수입니다.

 

const dispense = (candy) => {
  setCandies((allCandies) => allCandies.filter((c) => c !== candy));
};

 

차이점이 보이시나요?

 

  const dispense = candy => {
      setCandies(allCandies => allCandies.filter(c => c !== candy))
    }

  + const dispenseCallback = React.useCallback(dispense, [])

 

맞습니다. 두개의 예시에서 dispense 함수는 같은 일을 수행하지만 useCallback 버전의 예시가 더 많은 일을 하고 있습니다. useCallback은 함수를 정의하는 일 뿐만 아니라 다양한 일(프로퍼티 셋팅/논리적인 표현식의 실행)을 위해 배열([])을 정의해줘야합니다. 

 

그래서 두개의 예시에서 컴포넌트가 매순간 렌더링 될 때마다 자바스크립트는 메모리에 함수를 저장하게 되며 useCallback이 어떻게 사용되는지에 따라 메모리에 더 많은 함수가 정의될 수가 있는 것입니다.

 

컴포넌트가 두번째로 렌더됐을 때 기존에 있던 dispense 함수는 가비지 컬렉터가 수집하며 새로운 함수가 생성됩니다.  그런데 useCallback을 사용하게 된다면 기존의 함수는 가비지 컬렉터가 수집하지 않고 새로운 함수가 생성이 되어버리죠. 즉 메모리 사용 측면에서 비효율적이라는 것입니다. 

 

이와 관련지어 말하면, 만약 당신이 의존성을 가지고 있다면 React는 이전 function에 매달려 있습니다.(이전 함수 참조) 왜냐하면 memoization은 전형적으로 위의 예제와 같이 의존성을 가지는 event 내에서 오래된 value들의 복사본을 가지고 있는 것을 의미하기 때문입니다. 특별히 당신이 알아차려야 할 것은 React 또한 equality check를 할 때 refererence에 의존한다는 것을 의미합니다. 

 

useMemo는 어떤가요?

useMemo는 useCallback과 어떠한 value type에 상관없이 (함수 뿐만 아니라) memoization을 적용시킨다는 것을 제외하고는 유사합니다. 

 

만약 당신이 매 렌더마다 initialCandies 배열을 initialize하기 싫으면 이렇게 변화를 줄 수 있습니다.

- const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
+ const initialCandies = React.useMemo(
+  () => ['snickers', 'skittles', 'twix', 'milky way'],
+  [],
+ )

 

그리고 나서 문제를 해결했습니다. 하지만 해결한 것은 매우 작은 code를 복잡하게 만들었으므로 그만큼 가치가 없게 됩니다. 사실은 아마 useMemo를 사용하는 것이 좋지 않습니다. 왜냐하면 또 다시 우리는 함수를 call하고, 코드를 메모리 할당을 하고 있기 때문입니다. 위와 같은 시나리오에서는 이렇게 변화시키는 것이 더 좋은 case 입니다.

 

+ const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
  function CandyDispenser() {
-   const initialCandies = ['snickers', 'skittles', 'twix', 'milky way']
    const [candies, setCandies] = React.useState(initialCandies)

하지만 값은 거의 대부분 props로 부터 오고 다른 변수들은 함수안에서 선언되기 때문에 위와 같은 상황은 적습니다.

 

요점은 어느쪽이든 상관없다는 것입니다. 코드를 최적화하는 것의 이점은 매우 미미하며 제품을 개선하는데 시간을 보내는 것이 훨씬 나을 것입니다.

 

성능 개선은 꽁짜가 아닙니다. 코드를 최적화함으로써 얻어지는 효율은 너무나 작아서 어떻게 하면 제품을 더 개선할 수 있을까를 생각하며 시간을 보내는편이 훨씬 나을 것입니다. 

 

최적화는 책임감을 가지고 해야합니다. 

 

그렇다면 언제 useMemo와 useCallback을 사용해야 할까요?

useMemo와 useCallback이 훅으로 만들어진 것은 여러 이유가 있습니다. 

 

1. Referential Equality(참조 동일성)

2. 비용이 많이 드는 계산 (Computationally expensive calculations)

 

자바스크립트/프로그래밍 초보라도 아래의 코드를 이해하는데 오래걸리지는 않을 것입니다.

 

  true === true // true
  false === false // true
  1 === 1 // true
  'a' === 'a' // true
  {} === {} // false
  [] === [] // false
  () => {} === () => {} // false
  const z = {}
  z === z // true
  // NOTE: React actually uses Object.is, but it's very similar to ===

위 코드들에 대해 깊게 다루지 않겠지만 여러분이 컴포넌트 내부에 객체를 선언할 때 리렌더링되면 이전 참조와 다른 객체임은 분명합니다.

리액트에서 참조 동일성을 생각해야하는 두가지 경우가 존재합니다.

 

첫째, dependencies lists(의존성 배열)

 

function Foo({ bar, baz }) {
  const options = { bar, baz };
  React.useEffect(() => {
    buzz(options);
  }, [options]); // we want this to re-run if bar or baz change
  return <div>foobar</div>;
}

function Blub() {
  return <Foo bar="bar value" baz={3} />;
}

 

위의 코드에는 문제가 있는데 useEffect는 options라는 변수를 대상으로 렌더되는 순간마다 참조 동일성을 체크할 것입니다.

자바스크립트가 동작하는 방식 때문에 options는 매렌더마다 새롭게 만들어집니다. 그래서 리액트는 options가 매 렌더마다 변화했는지 check를 하는 테스트를 할 때마다 항상 true를 반환하게 됩니다. 이것은 useEffect callback은 bar나 baz가 변화할 때마다 불리는 것이 아니라 매렌더마다 불리게 되는 것입니다.

 

이것을 고칠 수 있는 두가지 방법이 있습니다.

 

// option 1
function Foo({ bar, baz }) {
  React.useEffect(() => {
    const options = { bar, baz };
    buzz(options);
  }, [bar, baz]); // we want this to re-run if bar or baz change
  return <div>foobar</div>;
}

 

이것은 좋은 옵션이고 만약 진짜 상황에서도 사용될 수 있다면 문제가 해결된 것입니다. 하지만 하나의 상황은 실용적이지 못합니다. 만약 bar나 baz들이 객체거나 배열이거나 함수들일 경우입니다. 

 

function Blub() {
  const bar = () => {};
  const baz = [1, 2, 3];
  return <Foo bar={bar} baz={baz} />;
}

 

이것은 왜 useCallback이나 useMemo가 존재하는지에 대한 정확한 이유가 됩니다. 아래와 같이 고칠 수 있습니다.

 

function Foo({ bar, baz }) {
  React.useEffect(() => {
    const options = { bar, baz };
    buzz(options);
  }, [bar, baz]);
  return <div>foobar</div>;
}
function Blub() {
  const bar = React.useCallback(() => {}, []);
  const baz = React.useMemo(() => [1, 2, 3], []);
  return <Foo bar={bar} baz={baz} />;
}

 

useEffect, useLayoutEffect, useCallback그리고 useMemo에 dependencies list가 전달되었을 때 같게 적용됩니다. 

 

React.memo

function CountButton({onClick, count}) {
  return <button onClick={onClick}>{count}</button>
}

function DualCounter() {
  const [count1, setCount1] = React.useState(0)
  const increment1 = () => setCount1(c => c + 1)

  const [count2, setCount2] = React.useState(0)
  const increment2 = () => setCount2(c => c + 1)

  return (
    <>
      <CountButton count={count1} onClick={increment1} />
      <CountButton count={count2} onClick={increment2} />
    </>
  )
}

두개의 버튼중 하나의 버튼이라도 클릭이 된다면 DualCounter의 상태(state)는 변하게 되고 두개의 CountButton 컴포넌트도 리랜더링을 하게 됩니다. 그런데 실질적으로는 클릭한 함수의 컴포넌트만 다시 랜더 되어야하지 않을까요? 이것을 우리는 “불필요한 리랜더”(unnecessary re-render)라고 부릅니다. 하지만 대부분의 경우 불필요한 리렌더를 크게 신경쓰지 않아도 됩니다. 리액트는 굉장히 빠르고 불필요한 리렌더를 해결하는 것보다 중요한 일들이 있으니까요.

 

사실 곧 보여드릴 최적화가 필요한 예시는 필자가 리액트를 가지고 일하는 기간동안 한번도 본적이 없을 정도로 굉장히 희박한 케이스입니다. 하지만 상호작용이 가능한 그래프나 차트, 애니메이션등과 같이 렌더링이 발생할 때 상당한 시간이 걸리게되는 상황들도 있습니다. 다행히도 리액트의 실용적인 속성 덕분에 해결할 수 있는 방법이 존재합니다. 

 

const CountButton = React.memo(function CountButton({ onClick, count }) {
  return <button onClick={onClick}>{count}</button>;
});

이제 리액트는 CountButton의 props가 변할때만 렌더링합니다. 와! 아직 끝난게 아닙니다. 위에서 이야기한 참조 동일성을 기억하시나요? DualCounter 함수형 컴포넌트에서 함수 increment1과 increment2를 선언했는데요 이 말은 즉, Dual 컴포넌트가 렌더링 될 때마다 안에서 선언한 함수들은 새로 만들어질 것이고 리액트는 두개의 CountButton 컴포넌트를 어쨌거나 다시 렌더링할 것이라는 것이죠.

 

그래서 아래의 예시는 useCallback과 React.memo를 사용해 함수의 재생성과 변수의 재선언을 반지할 수 있는 개선된 코드입니다.

 

 const CountButton = React.memo(function CountButton({onClick, count}) {
    return <button onClick={onClick}>{count}</button>
  })

  function DualCounter() {
    const [count1, setCount1] = React.useState(0)
    const increment1 = React.useCallback(() => setCount1(c => c + 1), [])
    const [count2, setCount2] = React.useState(0)
    const increment2 = React.useCallback(() => setCount2(c => c + 1), [])
    return (
      <>
        <CountButton count={count1} onClick={increment1} />
        <CountButton count={count2} onClick={increment2} />
      </>
    )

 

이렇게 해서 불필요한 리렌더링을 방지할 수 있습니다. 

다시 말씀드리자면 저는 React.memo(그리고 memo의 친구들인 PureComponent와 shouldComponentUpdate)를 기준없이 사용하는것을 반대합니다. 왜냐하면 최적화에는 비용이 따르기 마련이며 코드를 작성하는 사람은 memo의 사용으로 인한 비용과 그리고 그에 따른 이득을 생각하여 memo의 사용이 실질적으로 나에게 도움이 될것인지, 그리고 항상 코드가 의도한 대로 동작하여 memo를 사용함에 있어서 오는 이점을 취할 수 있는 것인지를 확인해야합니다. 

 

비용이 많이 드는 계산(Computationally Expensive Calculations)

비용이 많이 드는 계산의 경우도 useMemo가 훅으로 만들어진 또 다른 이유입니다. useMemo의 사용은 아래와 같은 장점이 있습니다.

const a = {b: props.b}

 

 

이것을 lazy하게 받아들입니다.

const a = React.useMemo(() => ({b: props.b}), [props.b])

위의 예시는 그렇게 유용하지는 않지만 동기적으로 복잡한 값을 계산하는 함수가 있다고 생각해봅시다. 

 

function RenderPrimes({ iterations, multiplier }) {
  const primes = calculatePrimes(iterations, multiplier);
  return <div>Primes! {primes}</div>;
}

 

iterations와 muliplier가 어떤 일을 하는지 봐서 아시겠지만 함수의 계산 속도는 꽤나 느릴 것입니다. 그리고 여기서 우리가 할 수 있는 일은 없어요. 하드웨어에 마법을 걸어 속도를 빠르게 만들지는 못하죠. 하지만 useMemo를 사용해 연속으로 같은 값을 다시 계산하지 않도록 만들어 속도를 향상시키는 방법은 있습니다. 

 

function RenderPrimes({ iterations, multiplier }) {
  const primes = React.useMemo(() => calculatePrimes(iterations, multiplier), [
    iterations,
    multiplier,
  ]);
  return <div>Primes! {primes}</div>;
}

 

이 방법이 먹히는 이유는 비록 컴포넌트가 매번 렌더링될 때마다 소수(primse)를 계산하는 함수를 정의했지만, 리액트는 소수의 값이 필요할 때만 해당 함수를 호출하기 때문입니다. 덧붙이자면 리액트는 또한 전에 입력되었던 값을 저장하고 있으며 같은 입력값에 한하여 같은 리턴값을 내보냅니다. 이렇게 메모이제이션은 동작합니다.

 

결론

저는 이 글을 "모든 추상화(그리고 성능 최적화)에는 비용이 든다"라고 말하며 마치겠습니다. 

분명히 말씀드리자면 useCallback과 useMemo를 사용함으로서

 

1. 동료가 보기에 코드가 더 복잡해질 수 있고

2. dependencies 배열을 잘못 사용할 수도 있으며

3. 내부 훅을 호출함으로써 성능상 안쓰느니 못하게 만들 수도 있고

4. dependency들과 memoized된 값들이 가비지 컬렉터가 안되게 만들수도 있습니다. 굳이 성능상 이점을 원한다면 위 비용들의 발생을 감수할 수도 있지만 손익분기 계산이 최우선이 되어야합니다.