frontend

React.memo 소개 및 예제

하리하링웹 2023. 1. 14. 03:57

React.memo는 특정한 몇 가지의 경우를 제외하고는 실제로 사용할 일이 많이 없기에 React.memo는 생소하다고 느끼는 사람들이 많을 것 같다.

 

또한 React.memo는 사용하더라도 성능상의 큰 이득을 가져오지 못할수도 있으며 잘못 사용할경우 찾기 힘든 버그를 발생시키거나 오히려 페이지의 성능이 저하되는 경우도 있을수도 있기에 사용할 때 주의해서 사용해야하는 함수이다.

 

React.memo는 Component를 memo라는 함수로 감싸는것으로 사용할 수 있으며 memo로 감싸져있는 Component를 render할때에 render 이전에 현재 render되어 있는 component의 Props와 다음에 render될 Props를 비교하는 연산을 한 번 거친다. 이후 두 개의 Props가 모두 일치한다면 해당 Component를 다시 render시키지 않아 성능상의 이득을 보게 만들어준다.


하지만 Props를 비교하는 연산또한 성능의 저하를 일으킬수 있기에 해당 Component가 정말로 Props가 변하지 않음에도 계속해서 re-render를 만들어내는 Component인지를 잘 판단해서 사용해야하며 이를 고민하지 않고 사용할경우 Props가 자주 변경되는 Component임에도 불구하고 re-render전에 굳이 한번 더 연산을 거쳐야하는 성능상의 불이익을 받을 수 있기에 사용 전에 고민을 한번 더 해보고 사용하는것을 추천한다.

간단하게 예시를 들어 설명해보겠다.

page image

위의 페이지는 가장 바깥쪽 Div의 ref와 바인딩된 useMouse hook의 사용으로 인해서 마우스를 움직일때마다 매 번 re-render가 발생하게되는 페이지이다.

그리고 React.memo테스트를 위해 이를 사용한 버튼과 일반적인 버튼 Component 두개를 render 시켜놓았으며 해당 버튼이 re-render 될 경우 console을 출력하도록 만들어놓았다.
일반적인 Component를 사용한 페이지의 경우 해당 두 개의 버튼 모두 re-render가 발생하게되어 console이 출력되게된다.

하지만 해당 페이지의 console을 확인해보면

console


위의 이미지처럼 초기에 두 버튼을 render 시킨 뒤 Memo로 감싸지 않은 버튼만 계속해서 re-render가 발생하는것을 확인할 수 있다.

code


Memo Component는 다음과 같은 구조로 이루어져있는데 여기서 children Props는 변하지 않기에 re-render가 일어나지 않게 되는것이다.

추가적인 예시로 페이지 하단에 아래와 같은 modal component가 한 개 선언되어있다고 가정해보자

page modal

import { Dialog, Transition } from '@headlessui/react';
import { ExclamationIcon } from '@heroicons/react/outline';
import { Fragment, memo, useRef } from 'react';

interface Props {
  open: boolean;
  onClose: () => void;
}

function TestModal({ open, onClose }: Props) {
  console.log('modal re-rendered');

  const cancelButtonRef = useRef(null);

  return (
    <Transition.Root show={open} as={Fragment}>
      <Dialog
        as="div"
        className="fixed z-10 inset-0 overflow-y-auto"
        initialFocus={cancelButtonRef}
        onClose={onClose}
      >
        <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0"
            enterTo="opacity-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100"
            leaveTo="opacity-0"
          >
            <Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
          </Transition.Child>

          {/* This element is to trick the browser into centering the modal contents. */}
          <span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
            &#8203;
          </span>
          <Transition.Child
            as={Fragment}
            enter="ease-out duration-300"
            enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
            enterTo="opacity-100 translate-y-0 sm:scale-100"
            leave="ease-in duration-200"
            leaveFrom="opacity-100 translate-y-0 sm:scale-100"
            leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
          >
            <div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
              <div className="sm:flex sm:items-start">
                <div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
                  <ExclamationIcon className="h-6 w-6 text-red-600" aria-hidden="true" />
                </div>
                <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
                  <Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
                    Deactivate account
                  </Dialog.Title>
                  <div className="mt-2">
                    <p className="text-sm text-gray-500">
                      Are you sure you want to deactivate your account? All of your data will be
                      permanently removed from our servers forever. This action cannot be undone.
                    </p>
                  </div>
                </div>
              </div>
              <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
                <button
                  type="button"
                  className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
                  onClick={onClose}
                >
                  Deactivate
                </button>
                <button
                  type="button"
                  className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
                  onClick={onClose}
                  ref={cancelButtonRef}
                >
                  Cancel
                </button>
              </div>
            </div>
          </Transition.Child>
        </div>
      </Dialog>
    </Transition.Root>
  );
}

const MemoTestModal = memo(TestModal);
export default MemoTestModal;

이 modal은 코드의 맨 아레에서 보이는것처럼 memo로 감싸져있어서 re-render가 일어나지 않는다고 생각할 수 있다. 하지만 이는 잘못된 생각이다. console을 확인해보자

console2


이 modal은 위의 이미지에서 보이는것처럼 매 번 re-render가 일어나고 있다.

 

먼저 memo의 두 번째 인자로는 이전 Props와 현재 Props의 값을 사용하여 해당 Props들의 특정 값들을 비교하여 re-render를 결정할 수 있는 추가적인 옵션을 넣어줄 수 있다. 예를 들어 방금과 같은 Modal에서 두번째 인자에 아래의 코드와 같이 넣어 Momoized해준다면

const MemoTestModal = memo(TestModal, ()=>true);
export default MemoTestModal;

console3


위 이미지처럼 더이상 modal이 re-render되지않는다.

이는 두 번째 옵션이 true이기때문에 Props값이 변하더라도 절대로 re-render가 일어나지 않으며 이 부분에서 두 번째 옵션을 잘못 설정하게 된다면 Props가 변했음에도 re-render가 발생하지 않는 잘못된 component가 만들어 질 수 있기에 조심해서 사용하여야 한다.

 import { useRef, useState } from 'react';
import { useMouse } from 'react-use';

import { Button, MemoTestButton, TestButton, TestModal } from '@components/ui';

export default function ReactMemoPage() {
  const [openModal, setOpenModal] = useState(false);
  const divRef = useRef<HTMLDivElement>(null);

  useMouse(divRef);

  return (
    <div ref={divRef} className="text-center pt-20 text-xl">
      <p>This page is re-rendering everytime</p>
      <div className="mt-4 space-x-2">
        <MemoTestButton>Memo Button</MemoTestButton>
        <TestButton>Normal Button</TestButton>
        <Button onClick={() => setOpenModal(true)}>Open Modal</Button>
        <TestModal open={openModal} onClose={() => setOpenModal(false)} />
      </div>
    </div>
  );
}

위의 코드는 Page의 코드이다.


여기서 한 가지 의문이 들 수 있다. 여기에서 modal은 분명 open, onClose Props만 사용하며 이는 변하지 않을텐데 왜 Modal component에서 re-render가 일어나는것일까? 이는 onClose가 함수이기 때문에 발생하는 문제이다.

 

 const MemoTestModal = memo(TestModal, ({ onClose: prev }, { onClose: next }) => {
  console.log('check areEqual', prev === next);
  return prev === next;
});
export default MemoTestModal;

위의 코드를 실행시켜보면 아래와 같은 console을 확인할 수 있다.

console4

여기서 prev,next는 같은 동작을 하는 함수이지만 이는 다른 주소값에 저장이 되게되며 이로 인해 비교연산의 결과는 false값을 리턴하게되며 이를 memo가 다른 Props로 인식하게되어 re-render를 발생시키게되는것이다.

 

따라서 이를 해결하기 위해서는 주소값으로 비교하는 것이 아니라 함수를 비교해야하며 이는 다음과 같이 해결할 수 있게된다.

 const MemoTestModal = memo(
  TestModal,
  ({ onClose: prev }, { onClose: next }) => prev.toString() === next.toString(),
);
export default MemoTestModal;

이와 같이 함수를 toString()을 통해 변환시켜서 비교를 하게되면 두 함수를 비교할 수 있게되며 결과적으로 true가 return되게되어 re-render가 발생하지 않게된다.

 

하지만 이 부분에서 open이 변경되어도 re-render가 발생하지 않기에 버그가 발생하게된다. 따라서 onClose는 같은 Props인것이 명확한 상황이며 open에 따라 re-render를 일으키는것이 올바른 동작이므로 아래 코드와 같이 memo부분을 변경하여 정상적으로 동작하며 성능상으로도 이득을 보는 modal component가 완성되게된다.

const MemoTestModal = memo(
  TestModal,
  ({ open: prevOpen }, { open: nextOpen }) => prevOpen === nextOpen,
);
export default MemoTestModal;

2022년 2월 28일에 작성된 글입니다.