React.memo

otter·2022년 9월 15일
1

사실 react에서 성능 최적화를 위한 훅으로 react.memouseCallback 이 있다는 것을 안지는 생각보다 오래되었다. 그런데, 이 부분 항상 공부해보고 써봐야겠다고 생각하고 있었지만 미뤄두고 있었다. memo, callback 을 공부할 수록 어렵게 다가왔던 부분이 잘쓰기가 힘들다는 부분이었기 때문이다. 잘못된 사용은 오히려 이전의 상태와 현재를 비교하는 알고리즘만 작동되어 성능을 더 좋지 않게 만드는 부작용을 초래할 수 있다는 부분이 조금 무서웠다. 그런데, 얼마전 내가 진행하고 있는 블로그 프로젝트가 성능상에서 좋지않은 점수가 나오기도 했고 이 부분 꼭 한번 짚고 넘어가려고 했던 부분이라 이번에 공부하고 적용해보고자 한다.

React.memo 알아보기

공식문서에 따르면, React.memo 는 고차 컴포넌트이다. 고차 컴포넌트는, 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수인데 간단히 forwardRef 를 생각하면 될 것 같다. (memo 도 이처럼 사용하는구나 ! 생각하고 넘어가자) 또, React.memo 를 사용함으로써 결과를 메모이징하도록 래핑해 성능의 향상을 누릴 수 있다고 한다. 즉, 이전에 렌더링한 결과와 현재가 같다면 새롭게 렌더링하지 않는다는 것이다.

다만, React.memoprops 변화에만 영향을 준다. React.memo 로 감싸진 함수 컴포넌트에서 상태나, context 가 있을 경우 상태와 context 가 변할때마다 렌더링 된다.

그런데 이거 언제 써야할까?

위의 React.memo 가 영향을 받는 조건을 다시 보자.

  1. React.memoprops 변화에만 영향을 준다.
  2. props 가 같을 경우에만 - 달라진 부분이 없는 경우에만 - 불필요한 리렌더링을 건너 뛴다.

이 두가지를 종합해보면 다음과 같지 않을까?

동일한 props 를 계속해서 받는 경우!

쓰지 말아야할 경우

이는 위를 다시보자. 일단 계속해서 다른 props 를 받아야하는 경우에는 절대로 사용하지 말아야 할 것이다. (props 변화가 계속해서 이루어지므로 메모이제이션이 작동할 일이 없지 않나)

또, 상태변화 context 변화가 큰 부분에서도 memo 의 사용은 큰 의미가 없어보인다. 상태가 변할 경우, 또는 context 가 변할 경우에 리렌더링이 일어나는 것은 불가피하기 떄문이다.

그러면 한번 써보자.

계속해서 진행하고 있는 블로그 프로젝트에서 React.memo 를 적용할만한 부분을 찾았다. props 가 잘 변하지 않고, 메모이제이션이 충분히 가능할만한 부분을 말이다. 그나마, 여기였다.

일반적으로 사용하는 이 Header 부분이었는데 Header 부분은 constansts 에서 따로 props 를 불러와 렌더링된다. 살짝 아쉬운 부분은 왼쪽의 로고부분을 제외한 오른쪽 부분은 다크모드의 적용 및, 활성화 되어있는 탭을 표시해주기 위한 효과를 주려는 부분, 모바일 뷰 버전에서 dropdown 을 펼치는 부분 때문에 상태가 너무 많았다. 그래서 잘 적용이 될 지는 모르겠지만, 한번 해보자.

일단, 예상으로는 왼쪽의 OTTER-LOG 부분은 무조건 메모이제이션이 될 것같다. 오른쪽의 Nav 는 위와같은 이유로 조금 힘들 수 있을 것 같다.

리액트 프로파일러로 변한 부분을 확인해봤다.

실행 조건은 무조건 동일하게 메인페이지가 렌더링된 상태에서 블로그 버튼을 누르고, 블로그 페이지로 이동하는 과정에서의 리렌더링을 파악했다.

정확히 이렇게!

이는 memo 를 사용하지 않았을 때의 결과다. Header 컴포넌트만 보면, 0.6ms 가 걸렸고 아래 하위 항목을 보면, otter-log 로고와 navList 가 모두 리렌더링 된다.

조그맣게 /projects 로 보이는 부분이 projects 버튼이고 아래 버튼들이 search 버튼들이다. 사실 지금 props 를 전달받긴 하는 nav 부분이 이전과 똑같더라도 - projects 버톤과 otter-log 로고 등의 부분 - 이 모두 리렌더링 되는구나를 알 수 있다.

import Link from "next/link";
import React from "react";
import Nav from "./nav";

const Header = () => {
  return (
    <header className='fixed z-50 flex h-20 w-full items-center justify-between bg-white px-5 dark:bg-black md:max-w-[1080px]'>
      <h1>
        <Link href='/'>
          <a className='text-3xl font-extrabold italic'>OTTER-LOG</a>
        </Link>
      </h1>
      <Nav />
    </header>
  );
};

export default React.memo(Header);

Header.displayName = "Header";

이런 방식으로, Header 컴포넌트 전체에 memo 를 걸어버렸다.

그리고 렌더링이 어떻게 바뀌었는지 확인해보았다.

Header 부분과 Nav 부분은 메모이제이션에 성공해 리렌더링이 되지 않았다. 전체 레이아웃이 렌더링되는 속도를 체크해보면 memo 이전 6.1ms 에서 3.3ms 로 성능을 조금 더 높일 수 있었다. 3ms 라고 생각하면 아주 작지만 이런 티끌만한 것들이 모여서 아주 큰 것을 만들어 낸다는 걸 생각하자. 게다가, 비율로따지면 50%가 준거다. (이거 오류가 아닐까? 싶을정도로 내 생각보다 너무 크게 달라졌다.)

조금만 더 고민해보기

그런데, 위 부분 일단 뭐가 뭔지 모르니까 Header 컴포넌트 전체에 걸었다. 이제 조금 세세하게 들어가보자.

  1. NavList 컴포넌트가 계속해서 re-render 되고 있는데 한번 걸어보면 어떨까?

  2. Otter-Log 로고는 왜 자꾸 리렌더링 되는걸까? 이부분은 절대 달라지지 않는다!

이 두가지를 생각해보자.

우선, NavList 컴포넌트에도 memo 를 걸어보자.

import Link from "next/link";
import { useRouter } from "next/router";
import React from "react";

type props = {
  query: string;
  title: string;
};

const NavList: React.FC<props> = ({ query, title, onClickHandler }) => {
  const router = useRouter();

  return (
    <li>
      <Link href={query}>
        <a
          className={`
            ${
              router.pathname.includes(query) &&
              "text-blue-900 dark:text-yellow-200"
            } 
          font-semibold hover:text-blue-500 dark:hover:text-yellow-300 md:text-xl`}
          onClick={onClickHandler}
        >
          {title}
        </a>
      </Link>
    </li>
  );
};

export default React.memo(NavList);

NavList.displayName = "NavList";

위에, navList 들이 계속해서 리렌더링되는게 거슬렸던 나는 그냥 아무생각없이 이렇게 memo 를 걸었다. (참, 혹시 몰라 props로 받는 onClickHandler은 useCallback으로 wrapping 했다.)

Header, Nav 는 위의 상태와 같지만 놀랍게도 Layout 컴포넌트의 렌더링 시간이 증가해버렸다.

이 부분은 예상했던데로 상태를 계속해서 사용, 감지하고 있는 부분이여서 memo 가 생각처럼 먹히지 않았다. 그리고 무언가 좋은 예시처럼, 잘못 memo 를 걸었을 때 성능이 떨어지는 모습을 볼 수 있었다.

역시 함부로 쓰면 안돼… 😅

그러면, 두번째 문제로 들어가자. 위의 otter-log 는 왜 자꾸 리렌더링 되는거지?

저 부분은 달라지는 게 없는 부분인데 거슬린다.

import Link from "next/link";
import React, { memo } from "react";
import Logo from "./logo";
import Nav from "./nav";

const Header = () => {
  return (
    <header className='fixed z-50 flex h-20 w-full items-center justify-between bg-white px-5 dark:bg-black md:max-w-[1080px]'>
      {/* <h1>
        <Link href='/'>
          <a className='text-3xl font-extrabold italic'>OTTER-LOG</a>
        </Link>
      </h1> */}
      <Logo />
      <Nav />
    </header>
  );
};

export default memo(Header);

Header.displayName = "Header";

사실 이문제 답을 알고 있었다. 얼마전, virtual DOM 에 깊숙이 들어가다 diff 알고리즘에 대해 공부한 적이 있었다. (요즘 그를 너무 과신하고 있다) 그런데, otter-log 부분이 계속해서 리렌더링 되고 있는 부분의 코드를 살펴보면 이는 컴포넌트로 만들어져 있지 않다. 컴포넌트로 만들어져 있지 않으므로, 이는 diff 알고리즘에 적용될 수 없다. 따라서, 이 부분은 원래 memo를 전혀 사용하지 않아도 알아서 memo가 되어야 하는 부분인데 계속해서 리렌더링 되고 있었던 것이다.

이게 컴포넌트를 작게 만드는 이유였다. (이거 얼마전에 고민하고 있었다)

단순히, 컴포넌트를 나눠주자 마자 저 부분은 아래처럼 리렌더링되지않고 메모이제이션 되었음을 확인할 수 있었다.

한번 더 해보자.

의미없이 반복되는 컴포넌트가 하나 더 있다. Footer 컴포넌트다.

위 부분을 말하는데, 위 부분은 다크모드가 아닌이상 절대 바뀌지 않는다. 어떤 props 을 받지도, 또 상태가 변경되지 않는다. 그런데 리렌더링 된다.

이 부분은 메인페이지에서 → Projects 를 눌렀을때 측정했다. (Blog페이지는 무한스크롤로 첫 렌더링때 이 부분이 보여지지 않아 다른 페이지로 측정했다. )

이 부분을 memo 로 수정해보자.

import { memo } from "react";

const Footer = () => {
  return (
    <footer className='mt-16 flex w-full flex-col items-center justify-center'>
      <div>
        <a href='https://github.com/otterp012'>
          <svg
            fill='black'
            xmlns='http://www.w3.org/2000/svg'
            viewBox='0 0 30 30'
            width='30px'
            height='30px'
            className='dark:fill-white'
          >
            <path d='M15,3C8.373,3,3,8.373,3,15c0,5.623,3.872,10.328,9.092,11.63C12.036,26.468,12,26.28,12,26.047v-2.051 c-0.487,0-1.303,0-1.508,0c-0.821,0-1.551-0.353-1.905-1.009c-0.393-0.729-0.461-1.844-1.435-2.526 c-0.289-0.227-0.069-0.486,0.264-0.451c0.615,0.174,1.125,0.596,1.605,1.222c0.478,0.627,0.703,0.769,1.596,0.769 c0.433,0,1.081-0.025,1.691-0.121c0.328-0.833,0.895-1.6,1.588-1.962c-3.996-0.411-5.903-2.399-5.903-5.098 c0-1.162,0.495-2.286,1.336-3.233C9.053,10.647,8.706,8.73,9.435,8c1.798,0,2.885,1.166,3.146,1.481C13.477,9.174,14.461,9,15.495,9 c1.036,0,2.024,0.174,2.922,0.483C18.675,9.17,19.763,8,21.565,8c0.732,0.731,0.381,2.656,0.102,3.594 c0.836,0.945,1.328,2.066,1.328,3.226c0,2.697-1.904,4.684-5.894,5.097C18.199,20.49,19,22.1,19,23.313v2.734 c0,0.104-0.023,0.179-0.035,0.268C23.641,24.676,27,20.236,27,15C27,8.373,21.627,3,15,3z' />
          </svg>
        </a>
      </div>
      <div>
        <span>Copyright ⓒ. All rights reserved</span>
      </div>
    </footer>
  );
};

export default memo(Footer);

메모만 넣어주었을 뿐인데 바로 메모이제이션 되었다. 이부분은 아주 쉬운 부분이였기 때문에 바로 가능했던 것 같다. props, state, context 가 모두 없는 부분이니까!

(그런데, 개인적인 궁금증으로 이거 diff 알고리즘에 따라서 자동적으로 memo되어야 하는 부분아닌가..? 싶은데 모르겠다.)

결론은, 진짜 좋은데..

진짜 좋은데… 이거 맨날맨날 쓰고 싶은데 조심할 필요가 있는 것 같다. 특히, 중간에 잘못 쓴 부분에 대해서 생각보다 막대한 피해를 입은 것 처럼 느껴졌다. (안써도 될 부분이었으니까) 다만, 이런식으로 실제로 시간을 재보기도 하고 어떤 상황에 좋을지도 고민해보니까 또 써볼만 한 것 같기도 하고 그렇다. 아니 이거 써야만 할 것 같다. 이렇게 조금만 신경써도 많이 좋아졌는데? 싶으니까

아쉬운점은 이번에 useCallBack 관련해선 할 수 있는게 따로 없었다. 흠 시간을 재 보거나 할 부분이 없었고 함수를 props로 받는 컴포넌트가 딱 두개 있었는데 하필 둘다 상태값도 같이 props로 받아왔다. callback으로 묶어주던지 묶어주지 않던지 메모이제이션이 되지 않았다. 좀 아쉬웠다. 이번에 callBack도 같이 적용해보려고 했는데..! callBack은 다음에 눈으로 내가 성능을 측정해서 바뀌는 부분이 있을때 새로운 포스트를 쓰는걸로 해야겠다.

profile
http://otter-log.world 로 이사했어요!

0개의 댓글