sbyeol3 / articles

Learn.. Run.. 🏃
34 stars 1 forks source link

[번역] `useMemo`와 `useCallback`을 사용해야 할 때 #2

Open sbyeol3 opened 3 years ago

sbyeol3 commented 3 years ago

<원문 : When to useMemo and useCallback>

성능최적화는 언제나 비용이 들지만 항상 좋은 건 아닙니다. 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))
}

이 상황에서 원래 코드와 변경된 코드 중 어떤 것이 성능에 더 좋을까요? 여러분의 생각을 알려주세요.

역 : 원문에는 독자가 선택할 수 있는 버튼이 있지만 생략합니다.

정답은 '원본 코드' 입니다.

useCallback을 쓰는게 더 안좋은가요?

성능을 향상시키고 "인라인 함수는 성능에 문제가 있다"고 하기 때문에 React.useCallback을 사용해야 한다고 많이 들으셨을 겁니다. 그런데 왜 useCallback을 사용하지 않는게 왜 더 좋은 코드일까요?

아까 봤던 예시에서 한 발짝 물러나 React 코드를 작성할 때도 실행되는 코드의 모든 라인은 비용이 든다는 점을 고려하세요. 이제 useCallback 예시를 명확하게 설명하고자 좀 더 살펴보겠습니다. (실제 변화는 없지만 약간의 이동만 합니다)

const dispense = candy => {
  setCandies(allCandies => allCandies.filter(c => c !== candy))
}
const dispenseCallback = React.useCallback(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, [])

맞습니다. useCallback 버전이 좀 더 많은 일을 한다는 것 빼고 두 코드는 정확히 같습니다. 함수를 정의해야 하는 것 뿐만 아니라 배열을 정의하고 프로퍼티를 설정하고 논리적인 표현을 통해 실행되는 React.useCallback을 호출해야 합니다.

따라서 두 가지 경우 모두 JavaScript는 렌더링 때마다 함수 정의에 대한 메모리를 할당해야 하고 어떻게 useCallback이 구현되는지에 따라 함수 정의에 대해 더 많은 할당이 필요할 수 있습니다. (이 경우에 해당하지는 않지만 제 말의 요점은 여전합니다) 제가 트위터 투표를 통해 전달하고자 했던 내용은 다음과 같습니다.

정답을 알고 있지만 제가 말을 서툴게 해서 틀린 답을 선택하셨다면 죄송합니다.

컴포넌트의 두 번째 렌더링 때 원래 있던 dispense 함수는 가비지 콜렉터가 수집하고(메모리 공간도 풀리죠) 새로운 함수가 생성될 것입니다. 그러나 useCallback를 사용하게 되면 원래 있던 dispense 함수는 수집되지 않고 새로운 함수가 생성되므로 메모리 측면에서도 좋지 않다는 것을 말씀드리고 싶었습니다.

관련해서 useCallback의 dependency가 있는 경우 React가 이전 함수에 대한 참조로 그 값들을 계속 가지고 있을 겁니다. 메모이제이션은 이전에 주어졌던 dependency 값들이 동일한 경우 이전 값들을 그대로 리턴해주기 때문입니다.특히 눈치가 빠른 분들이라면 React가 dependency 값들의 동일성 체크를 위해 해당 값들의 참조를 가지고 있어야 한다는 것을 알아차리셨을 겁니다.

그렇다면 useMemo는 어떻게 다른가요?

useMemo는 함수 뿐만 아니라 어떤 타입의 값이든 메모이제이션을 할 수 있다는 것 외에 useCallback과 유사합니다. 값을 반환하는 함수를 받으면 함수는 값이 반환되어야 할 때만 호출됩니다. (보통 렌더 시 dependency 배열 내 값들이 변경될 때마다 한번 발생합니다.)

그래서 렌더링 때마다 initialCandies 배열을 다시 만들기 싫다면 이렇게 코드를 바꾸어야 합니다.

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

그러면 그 문제는 피할 수 있겠지만, 저장된 값들이 너무 작아서 코드를 복잡하게 하는 것만큼의 가치가 있지 않습니다. 사실 이렇게 useMemo를 사용하면 함수를 호출하면서 프로퍼티 할당 등을 해야 하기 때문에 좋지 않습니다.

이 예시에서는 이렇게 코드를 바꾸는 것이 좋습니다.

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

그러나 값들이 props로 넘어오거나 함수 바디에서 초기화되는 다른 값들이 필요하면 이렇게 작성할 수 없을 겁니다.

제가 말하고자 하는 바는 결국 코드를 최적화할 때 얻는 베네핏이 매우 미미하기 때문에 어떻게 프로젝트를 개선할 수 있을지를 생각하는 것이 더 낫다는 것입니다.

그래서 요점이 뭔가요?

결국 이 글의 요점을 말씀드리자면, 성능 최적화는 공짜가 아닙니다. 성능 최적화는 언제나 비용을 동반하지만 항상 비용만큼의 베네핏을 가져다주는 것은 아니라는 겁니다.

그러니 책임감을 갖고 최적화하세요.

그럼 언제 useMemouseCallback을 사용해야 하나요?

두 hook이 React에 내장되어 있는 분명한 이유가 있습니다.

  1. 참조 동일성 (Referential equality)
  2. 비용이 많이 드는 연산 (Computationally expensive calculations)

참조 동일성 (Referential equality)

여러분이 JavaScript나 프로그래밍 초보라 할지라도, 왜 이런 결과가 나오는지 금방 알 겁니다.

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 ===

이 코드에 대해 깊게 얘기하지는 않게지만, 여러분이 React 함수형 컴포넌트 내부에서 객체를 선언할 때 같은 객체가 정의된 마지막 시간과 참조가 동일하지 않을 겁니다. (동일한 프로퍼티과 값을 가지고 있다고 해도 말이죠.)

React에서 참조 동일성이 중요한 두 가지 상황이 있는데 하나씩 살펴봅시다.

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에 대한 참조 동일성 확인을 해야 하고 JavaScript의 동작 방식에 의해 options는 모든 시점에서 새로운 참조를 갖기 때문입니다. 그러면 결국 React는 항상 options가 변경되었다고 평가할 겁니다. 그렇다면 useEffect의 콜백함수는 barbaz가 변경될 때만 호출되는 것이 아니라 매 렌더링 때마다 호출됩니다.

이 문제를 해결하는 두 가지가 있습니다.

// 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>
}

이 방법은 아주 좋습니다. 실제로 제가 해결한 방법이죠.

그러나 이 방법으로 해결할 수 없는 한 가지 상황이 있습니다. barbaz가 원시값이 아닌 객체/배열/함수 등일 때입니다.

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

이런 경우가 useCallbackuseMemo의 존재 이유를 보여주는 때입니다. 어떻게 수정할까요?

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 배열에 동일하게 적용됩니다.

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의 상태값은 변경되어 리렌더링이 발생하여 CountButton 컴포넌트들도 리렌더링됩니다. 그러나 실제로는 클릭된 버튼만 다시 렌더링이 되어야 하지 않을까요? 첫 번째 버튼을 클릭했을 때, 두 번째 버튼이 다시 렌더링되는데 변경되는 것은 없습니다. 우리는 이런 상황을 "불필요한 리렌더"라고 합니다.

불필요한 렌더링을 최적화하는데 너무 많은 시간을 쓰지 마세요. React는 굉장히 빠르기 때문에 이것을 최적화하는 것보다 더 중요한 일들이 많습니다. 실제로 Paypal 제품을 작업하는 3년동안 말 그대로 3년동안, 그리고 React로 작업한 기간동안 할 필요가 없었답니다.

그러나 인터랙티브한 그래프, 차트, 애니메이션 등 렌더링에 많은 시간이 소요되는 경우가 있습니다. 다행히 React의 실용주의적인 특성 덕분에 비상구가 존재합니다.

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

이제 React는 props가 변경될 때 CountButton만을 리렌더링 할 겁니다! 그러나 아직 할 일이 남았습니다. 아까 얘기했던 참조 동일성을 기억하시나요? DualCounter 컴포넌트에서 increment1increment2 함수를 정의했는데, DualCounter가 리렌더링될 때마다 이 함수들은 새로 생성되어 CountButton들을 다시 렌더링하게 될 겁니다.

이런 경우가 useCallbackuseMemo가 도움이 되는 또 다른 상황입니다.

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} />
    </>
  )
}

이제 우리는 CountButton 컴포넌트의 "불필요한 리렌더링"을 피할 수 있게 됐습니다.

다시 말하지만 저는 React.memo(나 비슷한 PureComponent, ShouldComponentUpdate)를 기준없이 사용하지 않는 것을 권고합니다. 최적화를 했을 때의 비용과 얻을 수 있는 이점이 있는지 확인하는 것이 필요합니다. 위에서 살펴본 것처럼 최적화를 했을 때 얻는 이점이 없을 수도 있습니다.

비용이 많이 드는 연산

React에서 useMemo가 내장된 또 다른 이유가 있습니다.(useCallback은 해당되지 않습니다.) 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>
}

iterationsmultiplier를 생각해보면 연산 속도가 꽤 느릴 거라는 것을 알 수 있습니다. 이 문제에 대해 할 것은 딱히 없고, 하드웨어를 자동적으로 빠르게 만들 순 없습니다. 그러나 같은 값을 두 번 계산하지 않도록 할 수는 있습니다.

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

이 방법이 효과적인 이유는 모든 렌더링 때마다 소수를 계산하는 함수를 정의했다고 하더라도 React는 필요한 값이 있을 때만 함수를 호출한다는 겁니다. React는 이전의 값들을 저장하고 같은 입력값이 들어오면 이전 값들을 반환합니다. 메모이제이션이 동작하는 방식입니다.

결론

모든 추상화(와 성능 최적화)는 비용이 든다는 사실을 말씀드리면서 글을 마무리하고자 합니다. AHA 프로그래밍 원칙을 적용하고 추상화/최적화가 정말 필요할 때만 적용시킨다면 이점이 없이 비용만 드는 상황에서 벗어날 수 있을 겁니다.

특히 useCallbackuseMemo는 여러분의 코드를 복잡하게 만드는 비용을 가집니다. 또한 dependencies 배열에 실수를 한다면 여러분은 내장된 hooks을 호출하거나 가비지 콜렉터가 값을 수집하지 못하게 하여 성능을 나쁘게 만들 수도 있습니다. 얻고자 하는 이익이 있을 때 발생하는 비용은 괜찮지만, 먼저 확인해보는 것이 중요합니다.

관련되어 읽어볼만한 것들입니다.

추신. 여러분이 hook으로 옮기는 것과 클래스로 선언하던 것들을 함수형 컴포넌트로 정의해야 하는 거에 걱정하고 있는 분 중에 한 명이라면 메소드를 render 단계에서 정의하고 있었다는 사실을 고려해보시길 권합니다.

class FavoriteNumbers extends React.Component {
  render() {
    return (
      <ul>
        {this.props.favoriteNumbers.map(number => (
          // TADA! This is a function defined in the render method!
          // Hooks did not introduce this concept.
          // We've been doing this all along.
          <li key={number}>{number}</li>
        ))}
      </ul>
    )
  }
}