avatar
Published on

리액트와 메모이제이션

Author
  • avatar
    Name
    yceffort

메모이제이션

https://ko.wikipedia.org/wiki/%EB%A9%94%EB%AA%A8%EC%9D%B4%EC%A0%9C%EC%9D%B4%EC%85%98

메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술이다. 동적 계획법의 핵심이 되는 기술이다.

함수형 프로그래밍의 기본원칙을 잘 지켰다면, (어떠한 외부 부수 효과에 영향을 받지 않는다면) 어떤 input이 들어가도 그 input에 대한 output은 동일 할 것이고, 따라서 동일한 input이 들어온다면 미리 전에 계산해두었던 output을 그대로 돌려줘도 될 것이다.

const cache = {}
function addTwo(input) {
  if (!cache[input]) {
    console.log('계산 중..')
    cache[input] = input + 2
  } else {
    console.log('계산된 값을 그대로 돌려드립니다.')
  }
  return cache[input]
}
addTwo(2) // 계산 중..
4
addTwo(3) // 계산 중..
5
addTwo(2) // 계산된 값을 그대로 돌려드립니다.
4

예제의 연산 자체는 간단했지만, 연산이 복잡하다면 메모이제이션으로 분명히 이득을 볼 수가 있다.

또 한가지 메모이제이션의 이점은, 정말로 동일한 결과가 리턴된다는 것이다. 결과값이 원시값이 아닌경우, 주소를 비교하기 때문에 ===이 성립하지 않는데, 메모이제이션을 한다면 정말로 똑같은 값을 리턴할 것이다. (기존에 가지고 있던 값을 그대로 돌려줄 것이므로)

리액트의 메모이제이션

리액트는 메모이제이션을 위한 세개의 api를 제공한다.

리액트 메모이제이션에서는 주목해야 할 부분이 있다. https://ko.reactjs.org/docs/hooks-faq.html#how-to-memoize-calculations

The useMemo Hook lets you cache calculations between multiple renders by "remembering" the previous computation:

바로 이전의 값만 메모이제이션 한다는 것이다.

const Memoized = React.memo(Component)
<!-- 새롭게 렌더링 -->
<Memoized num="{1}" />
<!-- 직전 elements를 사용 -->
<Memoized num="{1}" />
<!-- 새롭게 렌더링 -->
<Memoized num="{2}" />
<!-- 새롭게 렌더링 -->
<Memoized num="{1}" />

useMemo useCallback도 마찬가지로, 직전의 값만 메모이제이션한다. 코드로 풀면 이런 느낌의 메모이제이션일 것이다.,

let prevInput;
let prevResult;

function someFunction(input) {
  if (input !== prevInput) {
    prevResult = doSomethingHeavyJob....
  }

  prevInput = input
  return prevResult
}

메모이제이션의 이유

메모이제이션은 아래 두가지 이유때문에 한다고 볼 수 있다.

  1. 비싼 연산을 반복하는 것을 피하여 성능을 향상시킨다
  2. 안정된 값 제공

1번에 대해서는 모든 리액트 개발자들이 공감하고 있을 것이기 때문에 생략하고, 2번에 대해서 이야기 해보자.

function App() {
  const [body, setBody] = useState()
  const fetchOptions = {
    method: 'POST',
    body,
    headers: { 'content-type': 'application/json' },
  }

  const callApi = () => (body ? fetch('/url', fetchOptions) : null)

  useEffect(() => {
    const result = callApi()
    if (!result) return
  }, [callApi])

  return <>...</>
}

리액트에서 흔히 볼 수 있는 코드다. useEffectdeps에 변경이 있을 때마다 실행된다. 여기에서는 callApi가 있으므로, callApi는 컴포넌트 내에서 매번 새롭게 렌더링 될 때마다 계속해서 만들어질 것이다.

따라서 이 값을 안정시키기 위해서 memoization을, 정확히는 useCallback을 사용해야 한다.

const callApi = useCallback(
  () => (body ? fetch('/url', fetchOptions) : null),
  [body, fetchOptions],
)

그러나 fetchOptions역시 컴포넌트가 렌더링 될 때마다 새롭게 생성될 것이므로, fetchOptions도 memoization을 거쳐야 한다.

const fetchOptions = useMemo(() => {
  return {
    method: 'POST',
    body,
    headers: { 'content-type': 'application/json' },
  }
}, [body])

fetchOptionscallApi를 오로지 body의 값이 변경될 때만 다시 연산하게 함으로써 값을 안정시킬 수 있다.

원하는 값을 memoization하기 위해서 중요한 것은 memoization에 필요한 값들을 안정화 시키는 것이다. fetchOptionsuseMemo를 사용하여 callApi에서 하고자하는 memoization을 안정적으로 달성할 수 있게 되었다.

물론, 위의 예제를 제대로 작성하기 위해선 아래와 같이 해야할 것이다.

useEffect(() => {
  if (!body) return

  const fetchOptions = {
    method: 'POST',
    body,
    headers: { 'content-type': 'application/json' },
  }

  fetch('/url', fetchOptions)
}, [body])

굳이 memoization을 하지 않더라도, useEffectbody의 값의 변화에만 트리거하도록 바꾸면 (이것도 리액트의 직전 값만 비교하는 memoization전략과 일치한다고 볼 수 있다.) 쉽게 원하는 바를 달성할 수 있다.