React.memo 소개 및 예제

김종식·2022년 2월 28일
1

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전에 굳이 한번 더 연산을 거쳐야하는 성능상의 불이익을 받을 수 있기에 사용 전에 고민을 한번 더 해보고 사용하는것을 추천한다.

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

위의 페이지는 가장 바깥쪽 Div의 ref와 바인딩된 useMouse hook의 사용으로 인해서 마우스를 움직일때마다 매 번 re-render가 발생하게되는 페이지이다. 그리고 React.memo테스트를 위해 이를 사용한 버튼과 일반적인 버튼 Component 두개를 render 시켜놓았으며 해당 버튼이 re-render 될 경우 console을 출력하도록 만들어놓았다.
일반적인 Component를 사용한 페이지의 경우 해당 두 개의 버튼 모두 re-render가 발생하게되어 console이 출력되게된다.

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

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


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

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

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을 확인해보자


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

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

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


위 이미지처럼 더이상 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을 확인할 수 있다.

여기서 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;
profile
웹개발자 / 잘못된 정보에 대한 피드백은 언제나 환영입니다. ^^

0개의 댓글