React

[React] 오픈소스와 함께 알아보는 React.memo, useMemo, useCallback

Chae-ri🍒 2025. 2. 9. 18:16

프론트엔드 기술세미나를 준비하면서 React.memo, useMemo, useCallback 이 3가지에 대해 꼭 블로그에 따로 정리를 해둬야지 싶었다. 오픈소스를 직접 뜯어보며 이 세 가지가 어떤 원리로 돌아가는지 알아간 것을 정리해보겠다.

 

먼저 브라우저 및 리액트 렌더링 과정에 대해 알아볼 필요가 있다.

 

[React] 브라우저 + 리액트 렌더링 과정에 대해 알아보자

기술세미나로 리액트 렌더링 최적화라는 주제에 대해 준비하기로 하였다.렌더링 최적화 방법을 소개하기 전에 브라우저와 리액트가 각각 어떤 과정을 통해 렌더링이 되는지 알아볼 필요가 있

yonyoni824.tistory.com

 

아무리 리액트에서 변경된 사항을 모아서 한 번에 반영한다고 해도 불필요한 리렌더링이 계속 일어난다면 이는 브라우저에게 심각한 부담을 주고 ux 저하에 이르게 될 것이다. 

 

실제 서비스를 개발하면서 불필요한 리렌더링을 마주한 적이 많을 것이다. 백엔드와 통신하면서 무거운 데이터를 받을 때, 불안정한 네트워크 상황에서 데이터를 띄워줘야할 때 등 이런 상황에서 불필요한 리렌더링을 놔두게 된다면 이는 좋은 웹사이트가 되지 못할 것이다. 그렇다면 이런 불필요한 렌더링을 줄여주어 최적화할 수 있는 기법에는 무엇이 있을까?

 

여러 기법 중에 필자는 헷갈릴 만한 React.memo와 useMemo, useCallback 세 가지 기법에 대해 소개해볼 것이다.

 

이 세 가지 기법은 기본적으로 메모이제이션(Memoization) 기반으로 한다.

메모이제이션이란, 이미 계산한 결과를 저장해두었다가, 같은 입력 및 조건으로 다시 함수를 호출 할 때 저장된 결과를 재활용하는 기법을 말한다.

 

필자가 준비한 실습코드(참고자료: https://youtu.be/oqUgcxwrnSY?si=MBul9MTeTZdsmTPS)와

직접 뜯어본 React 구현체와 함께 알아보도록 하자.

React.memo

React.memo는 렌더링에 최적화된 컴포넌트로 변환해주는 고차함수이다.

 

실습 코드는 리렌더링 과정을 확실하게 알아보기 위해 부모와 자식 간의 실습코드를 준비해보았다.

다음처럼 부모 컴포넌트와 자식 컴포넌트가 놓여져 있다.

React.memo 사용 전

자식 컴포넌트에 이상한 코드가 하나 놓여 있는 걸 볼 수 있다.

for (let i = 0; i < 2_000_000_000; i++) {}

 

이는 불필요한 리렌더링 과정을 몸소 느껴보기 위해 일부러 필자가 무거운 연산을 넣어준 것이다.

 

그렇다면 부모의 나이를 증가시켰을 때 각 부모와 자식 컴포넌트는 어떤 일이 일어날까?

React.memo 사용 전

 

확인해보면 부모의 나이만을 증가시키는데 자식 컴포넌트까지 같이 렌더링이 되면서 부모의 나이가 늦게 업데이트되는 것을 확인할 수 있다. 부모 컴포넌트가 리렌더링이 되면서 자식이 같이 렌더링이 되어버리는 것이다.

 

이를 해결해주기 위해 React.memo를 활용해볼 것이다.

다음처럼 Child 컴포넌트를 바꿔보자.

import { memo } from "react";

const Child = ({ name, age }) => {
  for (let i = 0; i < 2_000_000_000; i++) {}
  console.log("자식 컴포넌트가 렌더링 되었습니다");

  return (
    <div style={{ margin: "10px", padding: "10px" }}>
      <h2>👶🏻자식</h2>
      <p>name: {name}</p>
      <p>age: {age}</p>
    </div>
  );
};

export default memo(Child);

위처럼 Child 컴포넌트를 memo로 감싸주면 이 자식 컴포넌트는 렌더링에 최적화된 컴포넌트가 된다.

즉, 자식 컴포넌트에 들어온 props 값의 변경 여부를 판단하여 렌더링 여부를 판단해주는 컴포넌트가 된 것이다.

 

위 코드로 변경 후 렌더링이 어떻게 되는지 확인해보자.

React.memo 사용 후

자식 컴포넌트가 같이 렌더링되지 않은 것을 확인할 수 있다. 같이 렌더링되지 않아 부모의 나이가 바로바로 업데이트되는 것을 볼 수 있다.

 

그렇다면 React.memo는 어떤 원리로 이루어져 있는 것일까?

 

다음 링크에서 실제 React 오픈소스 구현 코드를 확인해볼 수 있다.

https://github.dev/facebook/react/blob/main/packages

 

https://github.dev/facebook/react/blob/main/packages

Setting up your web editor eyJzZXJ2ZXJDb3JyZWxhdGlvbklkIjoiM2YxNTBmNGMtOGM2Ni00NzMyLTlkMDctZDJhZWMwY2JiYmU4Iiwid29ya2JlbmNoVHlwZSI6ImVkaXRvciIsIndvcmtiZW5jaENvbmZpZyI6eyJ2c2NvZGVWZXJzaW9uSW5mbyI6eyJpbnNpZGVyIjp7ImNvbW1pdCI6IjExNWJhYTE1ZTc2MjRmMDcwOWMxNTE2N

github.dev

 

📁 파일 위치 -> /packages/react/src/ReactMemo.js

import {REACT_MEMO_TYPE} from 'shared/ReactSymbols';
import isValidElementType from 'shared/isValidElementType';

export function memo<Props>(
  type: React$ElementType,
  compare?: (oldProps: Props, newProps: Props) => boolean,
) {
  if (__DEV__) {
    if (!isValidElementType(type)) {
      console.error(
        'memo: The first argument must be a component. Instead ' +
          'received: %s',
        type === null ? 'null' : typeof type,
      );
    }
  }

  const elementType = {
    $$typeof: REACT_MEMO_TYPE,
    type,
    compare: compare === undefined ? null : compare,
  };

  return elementType;
}

 

여기서 elementType을 확인해보자.

  const elementType = {
    $$typeof: REACT_MEMO_TYPE,
    type,
    compare: compare === undefined ? null : compare,
  };

  return elementType;
}

 

- $$typeof : REACT_MEMO_TYPE 을 통해 이 해당 컴포넌트가 memo로 감싸져 있는지 아닌지를 판별하여 메모이제이션된 컴포넌트인지 아닌지 확인을 한다.

- type : 원래 컴포넌트의 타입을 말한다.(function 또는 class)

- compare : 사용자 정의 비교 함수이다. 사용자가 직접 커스텀 compare 함수를 사용하면 된다. 하지만 직접 커스텀을 하지 않으면 React.memo는 기본적으로 얕은 비교를 하게 된다.

 

실습 코드와 함께 보면!

 

여기서 memo로 감싸져 있는지 아닌지를 $$typeof를 통해 확인하고

compare 함수(또는 얕은 비교)를 통해 Child 컴포넌트 props가 변경됐는지 아닌지의 여부를 판단해 렌더링 여부를 React.memo가 결정하는 것이다!!

 

React.memo 사용 전후 렌더링 속도 비교

렌더링 속도 비교를 React Profiler를 통해 확인해보았다.

사용 전 : ReactMemo 부모 컴포넌트를 볼 때 자기 자신만 렌더링 될 때 시간이 0.7ms임에도 불구하고 전체 렌더링 시간이 1206ms인 것을 확인할 수 있다. 이는 자식 컴포넌트 때문에 부모컴포넌트 또한 같이 불필요하게 늦게 렌더링되는 것을 확인할 수 있다.

사용 후 : 자식 컴포넌트가 렌더링이 되지 않아 부모컴포넌트가 빠르게 렌더링된 것을 확인할 수 있다.

 

useMemo

다음은 useMemo에 대해 알아볼 것이다.

 

useMemo객체 및 배열(원시 타입도 할 수 있다. 왜 객체와 배열만을 이야기 했는지는 뒤에서 설명하겠다.)을 메모이제이션 해주는 React 훅이다. 비용이 많이 드는 계산을 캐싱하기 위해 사용되는 훅이라고 보면 된다.

 

실습 코드와 함께 알아보도록 하겠다.

useMemo 사용 전

React.memo와 크게 다른 점은 Child 컴포넌트에 넘겨진 props다. props로 name이라는 객체를 넘기고 있다.

const name = {
  lastName: "woori",
  firstName: "fisa",
};

과연 이 상태에서 부모의 나이를 증가시킬 때 어떤 일이 일어나는 지 확인해보자.

React.memo 사용 전처럼 부모 컴포넌트가 자식 컴포넌트와 같이 렌더링되어 업데이트가 늦어지는 것을 확인할 수 있다.

 

이를 해결하기 위해 다음처럼 name 객체를 useMemo로 감싸주었다.

  const name = useMemo(() => {
    return {
      lastName: "woori",
      firstName: "fisa",
    };
  }, []);

 

이렇게 감싸준 상태에서 렌더링이 어떻게 되는지 확인해보자.

바로바로 부모의 나이가 업데이트 된 것을 확인할 수 있다.

 

여기서 궁금한 점이 있었다.

왜 객체 및 배열만 되고 원시 타입일 때는 사용을 잘 안 할까?🤔

 

궁금해서 원시 타입으로 props를 넘겨주었다.

const name = "fisa";

 

원시 타입을 넘겨주었을 때 렌더링이 어떻게 되는지 확인해보자.

자식 컴포넌트가 리렌더링이 되지 않는 것을 확인할 수 있다. React.memo가 name props를 같은 값으로 판단하고 자식 컴포넌트를 렌더링시키지 않는 것이다.

 

이는 React.memo의 얕은 비교 때문이다. 

얕은 비교를 통해 다른 변수여도 같은 값이면 같은 값으로 판단하는 것이다.

즉, "fisa"라는 같은 값으로 들어가기 때문에 "이전 name === 리렌더링 된 후 name"으로 React.memo가 판단했기 때문에 리렌더링이 일어나지 않은 것!!!!!

 

그렇담 객체 및 배열은???

반면, 객체 타입은 리렌더링될 때마다 객체의 참조값이 바뀌기 때문에 React.memo가 다른 값으로 인식하는 것이다!!

(+ 변수에 객체 및 배열은 값이 아닌 참조값 및 주소값으로 들어가게 된다.)

 

그렇기 때문에 useMemo는 객체의 props를 메모이제이션할 때 더 효율적으로 쓰일 수 있다!

 

그렇다면 useMemo는 어떤 원리로 이루어져 있을까?

 

📁 파일 위치 -> /packages/react/src/ReactHooks.js

export function useMemo<T>(
  create: () => T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useMemo(create, deps);
}

 

구현체를 보면 인자값으로 어떠한 T라는 값을 리턴하는 콜백 함수와 의존성 배열을 받고,

resolveDispatcher라는 것을 dispatcher에 넣어 useMemo를 부르는 것을 확인할 수 있다.

 

resolveDispatcher는 현재 실행되고 있는 React Dispatcher를 호출하는 역할이라고 보면 된다.

이 dispatcher의 useMemo 구현체를 기반으로 메모이제이션된 값을 반환한다.

 

그렇다면 이 dispatcher는 어떻게 이루어져 있을까?

 

📁 파일 위치 -> /packages/react-reconciler/src/ReactFiberHooks.js

function mountMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();

  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}{

function updateMemo<T>(nextCreate: () => T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;

  if (nextDeps !== null) {
    const prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0]; // 이전 값 재사용
    }
  }

  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

mountMemo와 updateMemo 이렇게 두 개로 크게 디스패처가 이루어져 있다.

 

mountMemo: 처음 useMemo 실행될 때 

updateMemo: 이후 useMemo가 실행될 때

 

로 사용된다.

먼저 처음 실행될 때 mountMemo가 실행이 되는데,

useMemo의 첫번째 인자인 콜백 함수의 반환값의 참조값이 nextValue에 저장이되고 nextDeps에는 의존성 배열이 들어가게 된다.

 

여기서 중요한 점은

useMemo는 의존성 배열만을 비교해서 이 참조값을 메모이제이션할 것인지 아닌지를 판단한다.

 

오른쪽 우리의 실습코드를 봤을 때 빈배열이기 때문에 첫 렌더링 후에는 계속해서 같은 빈배열을 내놓게 된다. 변함이 없으므로 useMemo는 계속해서 메모이제이션한 이전 값을 재사용할 것이다.

 

=> 즉, 참조값 변경으로 인한 React.memo의 불필요한 리렌더링이 일어나지 않게 된다.

 

useCallback

다음은 useCallback에 대해 알아볼 것이다.

 

useCallback리렌더링 간에 함수 정의를 캐싱해주는 React 훅이다. useMemo와 로직이 정말 비슷한데 여기서 차이점은 useMemo는 값이라면 useCallbackdms 함수를 메모이제이션 하기 위한 것이라고 생각하면 된다.

 

실습 코드와 함께 알아보도록 하겠다.

useCallback 사용 전

여기서는 props로 callChild이라는 함수를 넘기고 있다.

const callChild = () => {
  console.log("사랑한다 내아들");
};

과연 이 상태에서 부모의 나이를 증가시킬 때 어떤 일이 일어나는 지 확인해보자.

부모의 나이가 증가할 때 자식 컴포넌트가 불필요하게 리렌더링 되는 것을 확인할 수 있다.

 

이를 해결하기 위해 다음처럼 useCallback을 감싸주었다. useMemo와 같이 감싸주면 된다.

  const callChild = useCallback(() => {
    console.log("사랑한다 내아들");
  }, []);

 

감싸주었을 때 렌더링을 확인해보자.

빠르게 부모의 나이가 바로바로 업데이트 되는 것을 확인할 수 있다.

 

그렇다면 useCallback은 어떤 원리로 이루어져 있을까?

사실 useCallback은 useMemo와 너무나도 비슷한 로직으로 짜여져 있다.

 

📁 파일 위치 -> /packages/react/src/ReactHooks.js

export function useCallback<T>(
  callback: T,
  deps: Array<mixed> | void | null,
): T {
  const dispatcher = resolveDispatcher();
  return dispatcher.useCallback(callback, deps);
}

단, 차이점은 useMemo는 인자 값으로 T라는 값을 반환하는 함수를 인자로 받는다면, useCallback은 T라는 함수를 인자로 받는다.

 

 

 

📁 파일 위치 -> /packages/react-reconciler/src/ReactFiberHooks.js

여기서도 차이점은 useMemo는 값을 반환했다면, useCallback은 함수를 반환한다는 점이다.

 

useMemo useCallback 사용 전후 렌더링 속도 비교

React Profiler를 통해 렌더링 속도를 비교했는데 메모이제이션을 통해 개선이 확실히 된 것을 확인할 수 있다.

 

그렇다면 얘네들을 많이 사용하면 좋은 걸까?

그렇지 않다. 이 기법들은 메모이제이션을 기반으로 하는 것이기에 무분별하게 사용한다면 메모리에 큰 부담을 줄 것이다.

 

사용하면 좋은 경우와 안좋은 경우를 정리해보았다.

 

결론: 정말 필요할 때 사용하고 먼저 근본적인 코드로 개선하여 작성하려고 하자...

728x90