그래서 메모이제이션이 왜 필요한데? 의도하지 않은 값의 갱신을 막는 리액트의 특약처방, 메모이제이션

Jihan·2024년 9월 2일
1
post-thumbnail

의도하지 않은 값의 갱신

React는 컴포넌트가 값의 변화를 감지하고 알아서 리렌더링된다는 컨셉을 가지고 있다.

다시 말해 값이 갱신되었을 때 자동으로 화면도 갱신되도록 한다는 것인데, 이를 통해 우리가 값의 갱신DOM의 갱신을 각각 다루지 않아도 되게 해준다. 이러한 편의성과 함수형 패턴을 통한 선언적 프로그래밍이 가능하게 하도록 한 점들은 React가 가장 인기있는 프론트엔드 렌더링 도구가 되도록 만들어줬다.

하지만 "값이 갱신되면 자동으로 리렌더링"된다는 React가 가진 강점이 반대로 React로 만들어진 페이지들의 성능을 저하시킬 때가 있다.

바로 의도하지 않은 값의 갱신이 일어나는 경우이다.

의도하지 않은 값의 갱신은 크게 두 가지를 꼽을 수 있는데, 의도하지 않은 리렌더링의도하지 않은 재할당이라고 이야기할 수 있을 것 같다.

의도하지 않은 리렌더링

React 컴포넌트는 그 컴포넌트를 포함하고 있는 부모가 리렌더링되거나, 그 컴포넌트가 의존하고 있는 state 또는 props가 변경되었을 때 리렌더링된다. 즉 React 컴포넌트의 렌더링은 그 컴포넌트의 상태와 부모 컴포넌트에 의존성을 갖는다.

나는 이러한 React 환경에서 개발자가 예상하지 못한 의존성의 변경으로 인하여 컴포넌트가 리렌더링되는 현상을 의도하지 않은 리렌더링이라 부른다.

의도하지 않은 재할당

React 컴포넌트는 JavaScript 함수이며, 곧 객체이다. 따라서 React 컴포넌트 내부에 작성된 코드는 컴포넌트가 호출될 때마다 새로이 호출된다.

const Component = () => {
  const maVar = 'Hello, World!';
  return <div>{myVar}</div>;
};

위와 같은 컴포넌트가 렌더링되어 있는데, 어떤 이유로 인하여 리렌더링될 때를 생각해보자. 함수 코드 블럭이 그대로 새로 호출되면서, myVar는 새로운 메모리 영역에서 선언될 것이다.

지금이야 아주 작은 string 값이니까 상관이 없겠지만, 만약에 저 변수가 엄청나게 긴 연산의 결괏값이라면 어떻게 될까? 당연히 컴포넌트가 리렌더링될 때마다 그 연산을 새로이 하게 될 것이다.

컴포넌트의 리렌더링은 의도되었을 수 있지만, 그 안의 모든 값들이 새로 할당되고 연산이 새로 이루어지는 것은 의도되지 않을 때가 많다. 나는 이것을 의도하지 않은 재할당이라 부른다.

어딘가에서는 싸이클을 끊어야 한다.

일반적인 경우에 React의 편의성을 느껴보기 위한 작은 사이즈의 프로젝트에서는 이러한 현상이 치명적이지 못하다고 느낀다. 하지만 위의 두 현상은 심각할 경우 서로를 재귀호출하는 구조로 발생하여 많은 불필요한 연산을 발생시키고, 우리가 만든 앱이 움직이지 않도록 만들 수도 있다.

특정 컴포넌트가 렌더링되면서 아주 긴 비동기 연산이 시작되고, 그 연산이 끝나면서 컴포넌트의 상태가 갱신되어 컴포넌트가 리렌더링되고, 다시 컴포넌트가 렌더링되면서 또 그 끔찍한 연산이 시작되고, ... 하는 구조를 생각해볼 수 있겠다.

해결하기 위한 이상적인 방향을 생각해보자.

만약 모든 자식을 가진 컴포넌트의 렌더링과 상태값의 관리를 완벽하게 할 수 있다면, 의도하지 않은 리렌더링은 발생하지 않을 것이다.

일반적인 프로젝트에서 상태값과 컴포넌트는 수 십, 많으면 수 천 개에 달한다. 또한 그 사이에서 생겨나는 의존성의 개수는 그보다 훨씬 많을 수 있으며, 이렇게 수많은 논리적 흐름을 단번에 파악하고 모든 렌더링과 상태값의 관리를 완벽하게 하는 것은 불가능에 가깝다.

또한 이렇게 리렌더링을 불가능에 가까운 방법으로 모두 의도하여 발생시킨다고 하더라도, 리렌더링이 일어났을 때 그 안의 값들이 휘발되어 새로 계산되어야 하는 의도하지 않은 값의 재할당을 막을 방법은 되지 않는다.

따라서 우리는 이러한 현상들을 해결할 또다른 방법을 모색해야 하는데, 다행히도 React에서 이미 이러한 가이드를 탄탄하게 제공하고 있다. 바로 메모이제이션(Memoization)이다.

메모이제이션(Memoization)이란?

메모이제이션은 컴퓨터 공학에서 사용되는 용어로 특정 연산 결과를 메모(Memoize)하여 다시 그 연산을 호출하였을 때, 연산 과정을 거치지 않고 그 연산 결과만을 반환하도록 하는 일종의 캐싱(caching) 기법이다.

또한 알고리즘에서도 등장하는 개념인데, 메모이제이션은 특정 연산을 하기 위해서 그 전 연산을 거쳐야 값을 도출해낼 수 있는 형태의 연산에 대해 연산의 시간 복잡도를 공간 복잡도로 trade off하는 다이나믹 프로그래밍(Dynamic Programming)의 핵심 기법이다.

React는 앞서 소개한 두 가지 의도하지 않은 값의 갱신들에 대하여 메모이제이션을 통한 극복을 제안한다. 간단하게 설명해보자면 아래와 같다.

먼저 컴포넌트가 렌더링되었을 때, 그 안에서 선언된 특정 값의 참조를 컴포넌트 외부에서 붙잡는다.

그리고 컴포넌트가 리렌더링되면서 다시 그 안의 값이 호출되면, 앞서서 붙잡아뒀던 값의 참조를 반환하여 컴포넌트는 변경되어도 내부 값의 참조는 유지되도록 하는 것이다. 실제 패턴과는 차이가 있지만 코드로 보자면 아래와 같다.

const myVar = 'Hello, World!';

const Component = () => {
  return <div>{myVar}</div>;
};

위의 일관된 방식을 통해서 우리는 앞서 이야기했던 두 가지 유형에 대하여 모두 대응할 수 있다.

먼저 의도하지 않은 리렌더링은, 부모 컴포넌트가 리렌더링되기 전에 자식 컴포넌트를 메모이제이션하고, 부모 컴포넌트가 리렌더링되어도 앞서 메모이제이션해둔 참조값으로 자식 컴포넌트를 유지시키게 한다.

그리고 의도하지 않은 재할당은, 컴포넌트가 리렌더링되기 전에 내부 값을 메모이제이션하고, 컴포넌트가 리렌더링되어도 앞서 메모이제이션해둔 참조값으로 내부 값을 유지시키게 한다.

React는 이러한 두 가지 메모이제이션 유형에 대하여 아래와 같은 API들을 각각 제공한다.

의도하지 않은 리렌더링 방지, React.memo()

React.memo()는 컴포넌트를 파라미터로 입력받아, 컴포넌트를 반환하는 함수이다. ⛓️React.memo

React.memo()로 반환된 컴포넌트는 렌더링 결과를 메모이제이션하여 재호출되었을 때 중 props가 변경되지 않은 경우에는 메모이제이션된 렌더링 결과를 반환하도록 한다. 동작을 제대로 이해하기 위해서 예제를 한 번 살펴보자.

To-be

const Parent = () => {
  const [, rerender] = useState({});
  const [myState, setMyState] = useState('Hello, World!');
  
  return (
    <div>
      <Child state={myState}/>
      <button onClick={() => rerender({})}>
      	rerender
      </button>
    </div>
  )
    
};

const Child = ({state}) => {
  console.count('rerender');
  return <div>{state}</div>;
};

위의 코드에서 렌더링된 Parentrerender 버튼을 누를 때마다 값의 재할당으로 인해 리렌더링된다. 그로 인하여 그 자식 컴포넌트도 리렌더링되고, 자식 컴포넌트 내부의 console문이 버튼을 클릭한 횟수만큼 호출되어 카운트를 증가시킨다.

이 경우 부모 컴포넌트가 리렌더링될 때마다 자식 컴포넌트를 리렌더링시키기 때문에, 자식 컴포넌트가 포함하고 있는 렌더링 로직이 복잡하고, 리렌더링마다 로직을 실행할 필요가 없는 경우 렌더링 로직의 처리 시간만큼 불필요한 연산 속도가 반복적으로 발생한다.

이럴 때 우리는 React.memo()를 적용하여 렌더링 결과를 메모이제이션할 수 있다.

As-is

const Parent = () => {
  const [, rerender] = useState({});
  const [myState, setMyState] = useState('Hello, World!');
  
  return (
    <div>
      <MemoizedChild state={myState}/>
      <button onClick={() => rerender({})}>
      	rerender
      </button>
    </div>
  )
    
};

const Child = ({state}) => {
  console.count('rerender');
  return <div>{state}</div>;
};

const MemoizedChild = React.memo(Child)

ChildReact.memo()의 파라미터로 입력하여 반환된 MemoizedChild라는 컴포넌트로 Parent의 렌더링부에서 호출해주었다. 이 결과 MemoizedChildrerender 버튼onclick 이벤트로는 리렌더링되지 않게 된다. rerender 버튼을 눌러서 발생하는 부모의 리렌더링이 MemoizedChild가 전달받는 propstate가 변경되도록 하지 않기 때문이다.

원래 React에서는 아래 두 가지 이유로 컴포넌트 리렌더링이 발생한다.

1. 컴포넌트 내부 state 혹은 전달받는 props의 갱신
2. 부모 컴포넌트의 리렌더링

React.memo()는 위에서 살펴봤던 것처럼 이 중에서 2번, 부모 컴포넌트의 리렌더링에 대한 렌더링 의존성을 제거해준다.

물론 1번 케이스의 리렌더링 의존성은 그대로 유지된다. 이러한 이유로 React.memo()가 의도한 대로 동작하지 않는 경우가 간혹 있다. 부모 컴포넌트의 리렌더링 로직이 Memoized Component에 전달되는 props이 변경되도록 하거나, Memoized Component 내부의 state를 변경되도록 하는 경우이다.

부모 컴포넌트의 리렌더링 자체의 의존성(2)을 제거해도, 부모 컴포넌트의 리렌더링으로 인해 props가 갱신되고, 갱신된 props로 리렌더링되는 경우(1)인 것이다.

일반적으로 부모 컴포넌트에서 내려주는 props는 부모 컴포넌트가 리렌더링될 경우 그 참조값이 갱신되기 때문에, Memoized Component는 부모 컴포넌트에게 부모 컴포넌트의 리렌더링으로 변경되는 props를 전달받지 않는 순수 컴포넌트일 경우에 그 의도가 명확히 반영된다.
⛓️ 컴포넌트 순수하게 유지하기

정리하자면, React.memo()는 순수 컴포넌트를 메모이제이션하여 부모의 리렌더링에도 리렌더링되지 않는 컴포넌트를 만드는 함수인 것이다.

의도하지 않은 재할당 방지, useMemo(), useCallback()

이번에는 의도하지 않은 재할당을 막아보자. 컴포넌트 내부에서 재할당되는 객체들은 다양하다. 그 대상이 값을 담은 변수일 수도 있고, 함수일 수도 있을 것이다. 그래서 React에서는 변수와 함수에 대해서 각각 useMemo(), useCallback()이라는 메모이제이션 훅을 제공한다.

⛓️ useMemo
⛓️ useCallback

둘은 거의 같은 형태로 동작한다. 컴포넌트 내부에 useMemo 혹은 useCallback을 통해 선언된 변수 혹은 함수는, 컴포넌트 마운트시에 그 내용을 연산하여 메모이제이션하고 리렌더링시에 메모이제이션된 변수 혹은 함수를 반환하도록 한다. 각 훅은 두 번째 파라미터로 의존성 배열을 입력받고, 그 의존성 배열 내부의 값이 변경되었을 때 새롭게 연산을 수행하여 메모이제이션을 최신화하도록 한다.

간단한 예제를 통해 useMemo의 작동 방식을 살펴보자.

To-be

const Parent = () => {
  const [, rerender] = useState({});

  return (
    <div>
      <Child />
      <button
        onClick={() => {
          console.log("rerender");
          rerender({});
        }}
      >
        rerender
      </button>
    </div>
  );
};

const Child = () => {
  const start = performance.now(); // 측정 시작

  const myVar = (() => {
    let a = 0;
    for (let i = 0; i < 100000000; i++) a++;

    return a;
  })();

  const end = performance.now(); // 측정 종료
  console.log(`time: ${end - start}`); // 실행 시간 출력

  return <div>Hello, World!</div>;
};

이전 예제와 거의 비슷한 코드이다. 다만 Child 컴포넌트 내에 컴포넌트가 렌더링될 때마다 그 내부에 특정 값을 반환하는 익명 함수를 호출시켰고, 컴포넌트 렌더링에 소요된 시간을 콘솔에 출력하도록 수정하였다.

Child는 렌더링마다 myVar를 계산하기 위한 함수 때문에 렌더링 병목을 발생시킬 것이다.

rerender 버튼을 누를 때마다 100ms에 가까운 연산 시간을 필요로 하여 리렌더링되었다는 것을 콘솔에서 확인할 수 있다. 이제 useMemo를 활용해서 이러한 렌더링 병목을 해결해보자.

As-is

const Parent = () => {
  const [, rerender] = useState({});

  return (
    <div>
      <Child />
      <button
        onClick={() => {
          console.log("rerender");
          rerender({});
        }}
      >
        rerender
      </button>
    </div>
  );
};

const Child = () => {
  const start = performance.now(); // 측정 시작

  const myVar = useMemo(() => {
    let a = 0;
    for (let i = 0; i < 100000000; i++) a++;

    return a;
  }, []);

  const end = performance.now(); // 측정 종료
  console.log(`time: ${end - start}`); // 실행 시간 출력

  return <div>Hello, World!</div>;
};

myVar에 값을 할당해주는 익명 함수useMemoWrapping하여 메모이제이션해주었다.

Child는 마운트하면서 myVar의 값을 할당해주는 콜백함수를 메모이제이션한다. 그리고 부모의 리렌더링으로 인해서 Child가 리렌더링되어도, myVar의 콜백함수를 재실행하여 그 값을 새로 할당하는 것이 아니라, 앞서 메모이제이션한 값을 그대로 불러와 할당한다.

그 결과 첫 렌더링에서는 앞서와 같이 100ms에 가까운 연산 시간을 필요로 했지만, rerender 버튼을 눌러서 발생한 컴포넌트 리렌더링에서는 로그는 출력이 되지만 useMemo 내부 로직이 재실행되지 않고 메모이제이션된 결괏값을 지속적으로 참조하여, 연산 시간이 0으로 줄어든 것을 확인할 수 있다.

예제에는 없지만 useCallback의 경우 같은 메모이제이션을 함수에 대해서 수행한다. useMemo는 콜백함수의 반환값을 메모이제이션하지만, useCallback은 콜백함수를 그대로 메모이제이션한다고 보면 된다. useCallback은 함수를 생성하는 것 자체에 큰 연산이 필요한 경우에 사용할 수도 있고, 의존성 배열을 토대로 함수의 갱신을 원하는 때에만 하도록 하여 메모이제이션 시점별로 상태를 상수화한 콜백함수를 활용하는 데에도 사용할 수 있을 것이다.

메모이제이션 남발에 대한 이야기

앞서 얘기했던 것처럼 렌더링 의존성은 한 프로젝트 내에서도 수 백, 수 천 가닥의 논리적 인과로 엮여있고, 메모이제이션은 이 한 가닥 한 가닥을 컨트롤하는 거기 때문에 잘못된 메모이제이션 활용은 버그로 이어질 확률이 높다. 따라서 우리는 적절한 타이밍에만 메모이제이션을 적용하여야 한다.

하지만 메모이제이션을 필요한 곳에만 활용하고, 적절하게 적용하는 것은 쉬운 일이 아니다.

특히 필요한 부분에서만 메모이제이션을 사용하는 것은, 코드 레벨에서 메모이제이션이 적용되어 있는 곳과 적용되지 않은 곳의 구분에 대해 일관성을 깨뜨리는 것처럼 보이기도 한다.

그냥 모든 곳에 메모이제이션을 하면 안되나..?

클래스형 컴포넌트 패턴으로 개발할 경우, 모르면 그냥 PureComponent로 선언해라.라는 말이 있었다. 요즘에는 메모이제이션도 이런 얘기가 많다.

모르면 그냥 useMemo, useCallback으로 선언해라.

과연 어떨까? 그냥 무지성 메모이제이션이 렌더링 성능 저하를 방지하는 간결하면서 효과적인 방법으로 활용될 수 있을까?

개발자가 원하는 부분만 리렌더링되도록 해야한다.는 최종 목표에는 두 가지 도달 방법이 있다.

A. 모든 것이 리렌더링되도록 하고 필요한 부분에서 리렌더링이 되지 않도록 하는 것과,

B. 모든 것이 리렌더링되지 않도록 하고 필요한 부분에서 리렌더링이 되도록 하는 것.

이 두 가지 중 어느 방향으로 문제를 해결할 것이냐는 선택사항으로 볼 수 있다. 다만 나는 React의 설계 의도를 유지하는 방식이 A에 가깝다고 느낀다.

어떤 방향을 선택하느냐에 관련 없이, 우리가 도달해야하는 결론은 쉽지만 어렵다.

필요할 때만 적절하게 메모이제이션을 적용하자.

profile
DIVIDE AND CONQUER

0개의 댓글

관련 채용 정보