React : 불필요한 리렌더링을 줄여 성능을 개선해보자 (Memo, useCallback)

Maru·2022년 12월 2일
26
post-thumbnail

[오픈SW플랫폼] 기술블로그 팁/디버깅 에 해당하는 포스트입니다.

[오픈SW플랫폼]을 수강하면서, 이화여대 맛집 사이트 오랭(Orang)을 개발했다.

큰 어려움 없이 개발은 완료했으나, 여기서 마무리하기엔 아쉽다는 생각이 들었다.
그래서 이번 기회에 그동안 해보지 못했던 성능 개선에 도전해보고자 했다.

유저들은 반응이 빠른 UI를 선호한다.
100ms 미만의 UI 응답 지연은 유저들이 즉시 느낄 수 있고, 100ms에서 300ms가 지연되면 유저들은 상당한 지연으로 느낀다는 연구 결과가 있다.

이번에 나는 리액트 컴포넌트의 불필요한 리렌더링을 줄이기 위해 hooks 기반의 성능 최적화를 해보았고,
그리고 그 과정에서 리액트 성능 측정을 위해 'React Dev Tools'를 사용하였다.




React Developer Tools 로 성능 측정하기


React Developer Tools는 React로 만들어진 웹 사이트를 분석하거나, 자신이 만든 React 웹 사이트의 Component를 쉽게 탐색할 수 있도록 돕는 chrome 확장 프로그램이다.


React로 개발 할 때 이용하면 매우 유용하기 때문에, 난 React 개발자라면 반드시 다운 받아야하는 필수 tool이라고 생각한다.

설치 방법

chrome 웹 브라우저의 웹 스토어에서 react developer tools를 탐색해 다운 받으면 된다.

아래 링크에서 바로 다운 받을 수도 있다.
다운 받으러 가기

Component의 렌더링 확인하는 법


chrome에 잘 설치가 되었다면, dev tools에 component 탭이 생기게 된다. 해당 탭은 각 컴포넌트에서 관리되는 상태, 함수 등을 확인 할 수 있다.


톱니 바퀴를 누르면 설정창이 뜬다.

위 이미지 처럼 설정해주면, 렌더링 되는 컴포넌트에 깜박거리는 Highlight 효과가 실행된다.

사이트의 React 사용 여부 확인하는 기능

또 브라우저 우측 상단에 이렇게 리액트 로고가 표시된다.
이는 해당 사이트가 react로 만들어졌다는 사실을 알려주는 것이다.
만약 저 로고가 회색, 또는 빨간색이라면 리액트로 만들어진 사이트가 아닐 수 있다.

렌더링 확인하기 - (1) 메인페이지

Compoenent 탭을 활성화 시키고 프로젝트를 열면, 위 처럼 컴포넌트가 재렌더링 될 때 반짝거리게 된다. 메인 페이지 말고도 여러 페이지들을 확인해보았다.

렌더링 확인하기 - (2) 리스트페이지

렌더링 확인하기 - (3) 마이페이지

렌더링 확인하기 - (4) 상세페이지

🚨 여기서 주목해야 할 점은,

클릭을 할 때 마다 불필요하게 리렌더링 되는 부분이 많다는 것이다.
상세페이지의 경우, [메뉴, 리뷰, 정보] 탭을 눌렀을 때 내요이 바뀌는 하단 내용만 리렌더링 될 것이라 생각했는데,
실제로 확인해보니 버튼을 누를 때 마다 페이지 전체가 리렌더링 되고 있었다.


렌더링 속도 측정

상세 페이지에 대하여 렌더링 소요 시간도 측정해보았다.
Profiler 탭에 들어가 파란색 동그라미 녹화 버튼을 누르면 측정이 시작된다.


상세 페이지의 경우 전체 렌더링 시간 중 detail header가 많은 부분을 차지 하고 있었다.
detail header는 맛집 정보가 있는 상단 부분이다.



원인

왜 이런 일이 발생했을까?

먼저, 컴포넌트가 리렌더링 되는 조건은 다음과 같다.

  1. 부모에서 전달받은 props가 변경될때
  2. 부모 컴포넌트가 리렌더링 될 때
  3. 자신의 state가 변경 될 때

현재 DetailPage의 코드를 살펴보자면,

DetailHeaderNavigate 컴포넌트의 부모 컴포넌트인 DetailPage에서 TabContainer의 isTab이라는 상태(state)를 가지고 있다.
그리고 이 상태는 [메뉴, 리뷰, 정보]를 누를 때 마다 변경된다.

DetailPage.js

const DetailPage = () => {
  const Nav = useNavigate();
  let { id } = useParams();

  const [isTab, setIsTab] = useState({
    menu: true,
    review: false,
    info: false,
  });

그렇기 때문에 [메뉴, 리뷰, 정보] 를 클릭하면 그 상태가 변경되어서 DetailPage의 하위 요소는 모두! 리렌더링 되고 있는 것이다.

짧게 요약하자면, React는 변경되는 state를 가진 컴포넌트를 다시 그리는데,
이때 이 state를 가진 컴포넌트의 하위 자식 요소까지 모두! 리렌더링 한다.

아무튼, 지금 불필요하게 렌더링 되고 있는 부분들의 리렌더링을 방지하여 렌더링 속도를 줄일 수 있다면 좋을 것 같다.




React.Memo와 usecallback

1. React.Memo

컴포넌트가 동일한 props에 대해 동일한 UI를 그린다면, props가 바뀌지 않았을 땐 다시 그릴 필요가 없을 것이다. 이럴 때 React.memo를 사용 할 수 있다.

React.memo는 메모이제이션 기법으로 동작하는 고차 컴포넌트(Higher Order Component, HOC)로, props가 바뀌지 않았다면 마지막으로 렌더링된 결과를 재사용할 수 있게 해준다. useMemo와 굉장히 비슷한 기능이다.


DetailPage.js

//수정 전
<MainImg  src={
              rest.image === ''
                ? mainimg
                : `http://127.0.0.1:5000/${rest.image}`
            }
          />

        <BackButton onClick={() => Nav('/list')}>
          <Back src={back} />
        </BackButton>

        <DetailHeader rId={id} />

        <Divider /> 

~~

본래 코드는 위와 같았다.
rest.image 변수와 id 변수를 사용하고 있고, rest와 id가 변하지 않더라도 계속 리렌더링 되고 있는 부분이다.

rest.image와 id가 바뀌지 않는 이상 리렌더링 되지 않도록 이 부분을 memo 처리해주면 된다.


컴포넌트로 수정

//수정 후
<InfoBox rest={rest} id={id} />

memo를 적용하기 편하도록 따로 컴포넌트화 시켰다.


InfoBox 컴포넌트

import React from 'react';

~ 생략 ~

const InfoBox = ({ rest, id }) => {
  const Nav = useNavigate();

  return (
    <>
      <MainImage>
        <MainImg
          src={
            rest.image === '' ? mainimg : `http://127.0.0.1:5000/${rest.image}`
          }
        />
      </MainImage>

      <BackButton onClick={() => Nav('/list')}>
        <Back src={back} />
      </BackButton>
      <DetailHeader rId={id} />
      <Divider />
    </>
  );
};

// 컴포넌트를 memo로 감싸준다.
export default React.memo(InfoBox);

React.memo는 props에 대해 얕은 비교를 수행하므로, 만약 컴포넌트의 props가 객체라면 두 번째 인자로 별도의 비교 함수를 제공하면 된다.


결과

버튼을 눌러도 맛집 정보에 해당하는 위 부분은 리렌더링 되지 않는다! 👍



성능 개선 평가

이제 성능이 얼마나 개선되었는지 측정해보자.

렌더링 시간이 줄어든 것을 확인할 수 있었다.
memo 처리한 InfoBox 컴포넌트의 렌더링을 막았고, 그 결과 DetailPage의 렌더링 소요시간이 4.5ms에서 3.8ms로 줄었다.

절대적으로 정말 작은 시간이긴 하지만... 대략 26퍼센트 정도 줄인 것이니, 만약 매우 무거운 컴포넌트였다면 유의미한 성능 향상일 것이다.




✨ 그 외 페이지도 Memo 적용

Mypage.js


export default function MyPage() {
  var currentUserInfo = JSON.parse(localStorage.getItem('id'));
  const [myreviews, setMyreviews] = useState([]);
  const [bookmarks, setBookmarks] = useState([]);

  
  // ~생략~

  const LogOut = () => {
    window.localStorage.clear();
    alert('로그아웃 하시겠습니까?');
    navigate('/');
  };

  const [activeBtn, setActiveBtn] = useState([
    { id: 1, name: '저장한 맛집', active: true },
    { id: 2, name: '내가 쓴 리뷰', active: false },
  ]);

  return (
    <div style={{ paddingBottom: '100px' }}>
    
      <GoBackBar TopBarName="마이페이지" center path="/" />

      <Layout.Profile>
        <img src={Profil} className="profile" />
        <p className="username">{userName}</p>
      </Layout.Profile>

      <GrayBtn onClick={() => LogOut()}>로그아웃</GrayBtn>

      <Com.Hr />

      <Layout.SelectBox>
     // ~생략 ~
      </Layout.SelectBox>

    // ~생략~
      <BottomNavigateBar />
    </div>
  );
}

수정 하기 전 코드이다.
코드가 길어서, 리렌더링을 막고자 하는 부분만 남겼고 나머지는 생략했다.

✨ Memo 적용

BottomNavigateBar.js

import React from 'react';

const BottomNavigateBar = () => {
  return (
    <Bottom.Rectangle>
      생략
    </Bottom.Rectangle>
  );
};

export default React.memo(BottomNavigateBar);

GoBackBar.js

import React from 'react';

const GoBackBar = ({ TopBarName, center, path }) => {

  return (
    <B.Rectangle>
    생략
    </B.Rectangle>
  );
};

export default React.memo(GoBackBar);

userInfo.js

import React from 'react';
import { Layout } from './style';
import Profil from '../../assets/Profile/Profile.svg';

const userInfo = ({ userName }) => {
  return (
    <Layout.Profile>
      <img src={Profil} className="profile" />
      <p className="username">{userName}</p>
    </Layout.Profile>
  );
};

export default React.memo(userInfo);

GrayBtn.js

import styled from 'styled-components';
import React from 'react';

const GrayBtn = ({ children, onClick }) => {
  return <Button onClick={onClick}>{children}</Button>;
};

const Button = styled.div`
~~
`;

export default React.memo(GrayBtn);

최종


import UserInfo from './userInfo';

export default function MyPage() {
 
  // ~생략~

  const LogOut = () => {
    window.localStorage.clear();
    alert('로그아웃 하시겠습니까?');
    navigate('/');
  };


  return (
    <div style={{ paddingBottom: '100px' }}>
    
      <GoBackBar TopBarName="마이페이지" center path="/" />

         <UserInfo userName={userName}></UserInfo>

      <GrayBtn onClick={() => LogOut()}>로그아웃</GrayBtn>

      <Com.Hr />

      <Layout.SelectBox>
     // ~생략 ~
      </Layout.SelectBox>

    // ~생략~
      <BottomNavigateBar />
    </div>
  );
}

결과

상단 네비게이션바, 하단 네비게이션바, 유저 정보 등은 리렌더링을 막았다.

🚨 하지만, 로그아웃 버튼이 Memo를 적용하였음에도 불구하고 리렌더링이 되고 있었다.




2. useCallback

앞서 사용한 useMemo는 리턴되는 값(컴포넌트)를 memoization 시켜주었는데, useCallback함수 선언을 memoize 하는데 사용된다.

지금 로그아웃 버튼은 Mypage로 부터 onClick 함수를 전달 받고 있다.

Button 컴포넌트는 onClick props를 함수로 받고 있는데, 언제든 Mypage가 리렌더링 될 때 Button에게 전달되는 onClick props가 동일한지 체크한 후 동일하다면 리렌더링 되지 않아야 한다.

하지만 이 경우에 Button 컴포넌트도 같이 리렌더링 되는 문제가 발생되는데, 이 상황에선 Button 컴포넌트에 memo로 감싸도 소용이 없다.

그 이유는 함수는 객체이고, 매번 새로 생성된 함수는 다른 참조 값을 가지기 때문에 Button 입장에서는 새로 생성된 함수를 받을 때 props가 변한 것으로 인지하기 때문이다.
(이는 React가 얕은 비교를 하기 때문에 일어나는 현상이다.)

이럴 때 useCallback을 쓰면 된다.

useCallback으로 함수를 선언해주면, 종속 변수들이 변하지 않는 이상 굳이 함수를 재생성하지 않고 이전에 있던 참조 변수를 그대로 하위 컴포넌트에 props로 전달하며, 하위 컴포넌트도 props가 변경되지 않았다고 인지하게 되어 하위 컴포넌트의 리렌더링을 방지할 수 있다.




✨ useCallback 적용하기

import {~, useCallback } from 'react';

  const LogOut = useCallback(() => {
    
    window.localStorage.clear();
    alert('로그아웃 하시겠습니까?');
    navigate('/');
    
  }, []);
  

  ~
  ~
  

  <GrayBtn onClick={LogOut}>로그아웃</GrayBtn>

결과

로그아웃 버튼의 리렌더링을 막는데 성공했다.

하단바와 상단 네비게이션 바에 대해서도 react.memo를 적용해 모든 페이지들의 불필요한 리렌더링을 막았다.




마무리

  1. 프로젝트 규모가 작아서, 렌더링 시간도 10ms 이내로 매우 짧았다. 그로인해 최적화를 하나 안하나, 유저 입장에선 차이를 느낄 수 없는 수준이었다. 하지만 직접 성능 개선을 시도했다는 것에 큰 의미가 있었던 것 같고 그 과정이 매우 재미있었다.

  2. 성능을 개선하는 방법도 중요하지만 성능을 측정하고 어떻게 해결할지 고민해본 경험도 유의미하다는 생각이 들었다.

  3. 무턱대고 useMemo, useCallback 훅을 적용하는 것보다, 어떤 부분이 느린지, 어떤 부분을 개선하는 것이 가장 효과적일지 판단하고, 얼마나 개선됐는지 측정해서 효과를 검증 해보는 게 정말 중요한 것 같다.

profile
함께 일하고 싶은 개발자

1개의 댓글

comment-user-thumbnail
2023년 3월 5일

최근 글부터 아주 양질의 글 잘 봤습니다 감사드립니다~!

답글 달기