[React] 렌더링 최적화, 리팩토링으로 성능향상🎯 시켜보자

HongBeen Lee·2022년 6월 26일
7
post-thumbnail

프로젝트로 만든 "칭찬이 필요해" 웹사이트의 첫 페이지에서 여러 사람의 글을 모아볼 수 있다.

그리고 이렇게 맘에 드는 기록에 칭찬버튼을 누를 수 있다.

그런데 한번 누를때마다 너무 느려서 Profiler를 통해 성능을 측정해보았다.

리팩토링 전 성능측정

크롬 익스텐션인 Profiler를 사용하여 측정한다.

새로운 칭찬을 반영하는데 총 75.7ms가 걸렸다....


부드러운 웹 어플리케이션을 위해서 60fps가 권장되는데,
이는 한 동작에 약 16~17ms가 소요되어야 한다.
지금은... 느리게 느껴질만하다.

그리고 칭찬을 교체하는데에는 무려 139.4ms가 소요되었다....

프로파일러를 보면,
하나의 버튼을 누르는데도,
버튼이 눌리지 않은 요소들까지 모두 리렌더링 시키는 것이 근본적인 문제이다.

버튼을 누르지 않은 요소들은 업데이트되지 않아도 되므로,
React Memo를 사용하여 리렌더링에 조건을 걸어주는 방식으로 최적화
를 진행할 것이다.

그리고 이렇게 최적화하기에 앞서,
컴포넌트를 제대로 설계하지 못하였음을 깨닫고....
유지보수에 편하도록 리팩토링을 먼저 진행하기로 했다.

리팩토링 시작

현재 컴포넌트는 다음과 같다.

FeedPublic 컴포넌트에서 map 함수를 통해 순회하며 FeedItem 컴포넌트를 생성해주는 방식이다.

우선 하나로 뭉쳐져있는 컴포넌트를 역할별로 분리하여 로직을 깔끔히 하고,
최적화를 하기로 했다.

응집도있는 컴포넌트에 관한 영상을 봐서,
한 컴포넌트는 한가지의 역할만 하도록 리팩토링해보고 싶었다. ^-^

컴포넌트 분리 - 한 컴포넌트는 한가지 역할만!

1. 컴포넌트가 여러역할을 한다면?

기존의 FeedItem 컴포넌트는 보여줘야할 정보를 props로 받아서 한번에 보여주었다.

하나의 컴포넌트가 너무 다양한 정보를 다루고 있기 때문에
새로운 기능의 추가가 어렵고, 에러 발생 시 추적이 쉽지 않았다.
코드가 읽기 어렵게 되어 가독성이 좋지않다.

2. 분리하기

따라서 다음과 같이 작은 컴포넌트로 분리하여 하나의 역할 을 담당하도록 했다.

왼쪽부터 차례로

  1. goal 데이터를 받아 goal.name, goal.color를 보여주는 컴포넌트

  2. task 데이터를 받아 task.title을 보여주는 컴포넌트

  3. compliments 데이터, onEmojiClick 핸들러, 클릭된 타입을 알려주는clickedType을 받아 보여주는 컴포넌트

  4. task 데이터를 받아 task.createAt을 보여주는 컴포넌트
    이렇게 나누었다.

3. 더 작게 쪼갤 필요가 있다.

여기서 가장 중요한 컴포넌트는 3번 FeedItemCompliment 컴포넌트이다.

칭찬버튼이 눌릴때마다 직접적으로 인터렉션을 담당하고,
빠르게 변경사항을 반영해야 하기 때문이다.

따라서 그림과 같이 컴포넌트를 더 작게 나누어 리팩토링한다.

  1. 추가 기능 확장을 쉽게 하도록, 개수를 보여주는 부분을 별도의 ComplimentCounter 컴포넌트로 관리한다.

  2. 그리고 내부적으로 4개의 버튼이 존재하기 때문에, 버튼을 ComplimentButton 컴포넌트로 만들어 관리한다.
    버튼 타입 배열 [👏🏻 , 🎉 ,❤️ ,👍🏻] 을 map으로 순회하며 ComplimentButton 컴포넌트를 생성한다.
    이렇게 하면, 새로운 버튼 타입이 추가되거나 수정되어도 버튼 타입 배열만 수정해주면 쉽게 반영할 수 있다.

4. 응집도있는 버튼 컴포넌트

이 코드는 ComplimentButton 컴포넌트의 props이다.

type ComplimentButtonProps = {
  type: ComplimentData["type"],
  clicked: boolean,
  onClick: (type: ComplimentData["type"]) => void;
}
  1. type
    • 자신이 표현해야 할 타입
    • 👏🏻 , 🎉 ,❤️ ,👍🏻 중 하나를 의미
  2. clicked
    • 클릭되었는지 아닌지 여부
    • 버튼 내부적으로 어떻게 표시할지는 알아서 하고, 부모 컴포넌트는 클릭 여부만 전달한다.
    • 상위 컴포넌트에서 useMemo를 사용하여 계산하여 전달한다.
  3. onClick
    • 버튼을 눌렀을 때 동작할 콜백함수

이렇게 하면 버튼 역할만! 하는 컴포넌트가 된다.

  1. 버튼컴포넌트가 clicked에 대해 내부적으로 알아서 표시
  2. onClick 핸들러에 자신에게 할당된 type을 전달

즉, 주어진 데이터 사이의 연관관계가 강한 응집도있는 컴포넌트가 되었다!

React memo 적용 - 리렌더링이 필요없는 컴포넌트를 골라내자!

1. memo란?

리액트는 렌더링한 뒤, 이전 렌더결과와 비교하여 DOM업데이트를 결정한다.
이전 렌더결과와 다르다면 DOM업데이트를 진행한다.

리액트는 가상돔을 가지고 있으므로 빠르다.
하지만 특정 상황에서 이 속도를 더 높일 수 있다!

React memo를 사용하면 된다.

memo는 기존 컴포넌트를 한번더 감싸는 HOC패턴으로,
memo가 적용된 컴포넌트의 props가 변경되지 않는다면, 다음 렌더링에 메모이징된 렌더결과를 그대로 사용한다.

memo를 적용하면 다음과 같이 동작한다.

  1. 이전 props와 비교를 위해 비교함수를 실행한다.
  2. 비교함수에서 true를 반환한다면, 메모이징된 이전 렌더결과를 재사용한다.✨
  3. 비교함수에서 false를 반환한다면, 새로 렌더링하고 이전 렌더결과와 다음 렌더결과를 비교하여 DOM업데이트를 결정한다.

메모이징된 렌더결과를 재사용함으로서 리액트에서 리렌더링할 때,
가상돔에서 달라진 부분을 비교할 필요가 없으므로 속도를 높일 수 있다.

2. memo를 사용하는 경우

memo를 효과적으로 적용할 수 있는 경우는
같은 props로 자주 렌더링이 일어나는 컴포넌트로 예상될 때 이다.

  • 부모컴포넌트에 의해 자식컴포넌트가 같은 props로 자주 리렌더링 되는 경우
  • 혹은 무겁고 비용이 큰 연산이 있는 경우

다만, 메모를 적용해도 내부 state가 변경되면 당연히 리렌더링되므로
내부 state가 자주 변경되는 경우는 memo가 필요없을 가능성이 높다.

주의할 점은 props를 비교할 때 얕은(shallow)비교 를 한다는 점이다.

따라서 객체형태로 주어지는 props에서 실제값을 비교하길 원한다면,
memo의 두번째 인자로 비교 함수를 직접 작성하여 넣어줄 수 있다.
(아래에 나의 코드예시가 있다.)

3. 적절한 컴포넌트에 memo를 적용하기!

  1. Header
    우선 props가 존재하지 않는다.
    오른쪽 사이드바를 열고 닫는 용도의 boolean값 하나만을 state로 가지고 있다.
    따라서 사이드바를 열고닫는 동작이 아니라면 memo를 적용하여 메모이징을 적용하도록 한다.

  2. feedItem
    칭찬 버튼이 눌린 컴포넌트만 제외하고 나머지 전부 memo로 재사용한다.
    렌더링시간을 줄이면, 버튼을 눌렀다는 표시를 더 빠르게 적용시킬 수 있다.
    (gif로 설명: ❤️를 클릭해서, 표시가 👍🏻에서 ❤️로 바뀜)

    중요한 핵심 로직은
    칭찬버튼을 누르면 칭찬 type이 변경된다 는 것 이다.

  • 새로 칭찬하면?
    undefined에서 👏🏻 , 🎉 ,❤️ ,👍🏻 중 하나로 type이 바뀜
  • 삭제하면?
    👏🏻 , 🎉 ,❤️ ,👍🏻 중 하나에서 undefined로 바뀜
  • 교체하면?
    👏🏻 , 🎉 ,❤️ ,👍🏻 중 하나에서 다른 type으로 바뀜

이렇게 DB에 저장된 칭찬 타입이 변경될 것이다.

이 점을 활용하여, memo의 두번째 비교함수에 적용했다.

//feedItem.tsx
const FeedItem = ()=>{
	//component..
}

FeedItem.displayName="FeedItem"

const areEqual = (prevProps:FeedItemProps,nextProps:FeedItemProps)=>{
  // 1.현재 로그인 사용자가 같다
  if(prevProps.loggedInUserId !== nextProps.loggedInUserId) return false;

  const {loggedInUserId}=nextProps;
  
  // 2.현재 로그인 사용자가 눌렀던 칭찬버튼 `type`이 같다
  if(prevProps.task.compliments.find(compliment => compliment.author === loggedInUserId)?.type === nextProps.task.compliments.find(compliment => compliment.author === loggedInUserId)?.type){
    return true;
  }
  return false;
}

export default memo(FeedItem,areEqual)

//index.ts
export {default as FeedItem} from "./feedItem.tsx";

이 예시는 내가 적용한 코드이다.

  1. 현재 로그인 사용자가 같고,
  2. 현재 로그인 사용자가 눌렀던 칭찬버튼 type이 같으면,

true를 반환한다!
즉, 새로 렌더링할 필요가 없다.

전,후 성능 비교 🎉

메모이징이 적용된 모습을 Profiler에서 확인할 수 있다.
보는것처럼 memo가 의도한대로 적용되었다.

버튼이 눌린 컴포넌트만 재사용하지 않고,
나머지 컴포넌트는 memo가 적용되어 리렌더링하지 않고 있다!

Header, FeedItem 두 컴포넌트에 memo를 적용한 결과,
동일한 작업에 대해 렌더링 시간이 18.4ms로 줄었다.

리팩토링 전 139ms에 비해 120.6ms가 줄었다.
렌더링 시간이 86.762% 감소 🎉 한 것을 알 수 있다.

조금만 더...? 추가 리팩토링

약 87% 렌더링 시간 감소!
정말 뿌듯하다.
하지만 보다보니 영 아쉬운점이 보인다.

전체 렌더링시간은 확연히 줄었고, 그만큼 빠르게 리렌더링된다.

(gif: 👏🏻누르고, 딜레이 후에 👏🏻에 표시되는 모습....)
하지만 서버와 통신 후 반영이 되는거라
통신하는 시간만큼 화면에 보여지는데에 딜레이가 생기게 된다.


Profiler를 보고 계산해보니, 대략 클릭하고 12ms 후에 변경된 내용이 화면에 그려진다.

통신 상태에 따라서 더~ 느려질 수도 있을 것 이다.

통신이 어떻든지간에,
유저입장에서 누른 버튼이 당장❗️ 반영되었다고 느껴지도록 만들고 싶다.

렌더링시간보다 중요한건 사용자 경험!

useState에 당장 변경된 내용을 저장하여 이 내용을 반영시키도록 리팩토링을 했다.

렌더링시간이 조금 늘어나더라도,
유저에게 바로 변화를 보여주는 방식을 택했다!

	<ComplimentButton 
          key={type}
          type={type}
          clicked={clickedComplimentType ? clickedComplimentType===type : false}
          onClick={handleClickedEmoji}>	
	</ComplimentButton>

각 칭찬 버튼에서는 현재 클릭된 clickedComplimentType 을 가지고 클릭된지 아닌지를 판단한다.

이 점을 이용하여,

  1. 버튼의 상위 컴포넌트에서 clickedComplimentTypeuseState로 저장하여 사용한다.
    counter 컴포넌트에도 똑같이 적용하려고 complimentsCountuseState로 저장한다.
const clicked = useMemo(()=>{
    return compliments.find(compliment => compliment.author === loggedInUserId);
  },[compliments,loggedInUserId])

const [clickedComplimentType, setClickedComplimentType] = useState(clicked?.type);
const [complimentsCount, setComplimentsCount] = useState(compliments.length);
  1. 클릭하자마자 클릭된 버튼타입을 clickedComplimentType에 저장하고 이 값을 버튼에게 전달한다.
    그리고 삭제,추가 될 때 complimentsCount도 변경해준다!

  const handleClickedEmoji = useCallback((emoji:ComplimentData["type"])=>{
    if(!loggedInUserId) {
      handleSnackbarShow();
      return;
    }
    
    if(clickedComplimentType === undefined){
      // 새로 생성할 때
      setComplimentsCount((state)=>state+1);
      handleCreate(emoji);
    }else if(clickedComplimentType !== emoji){
      // 지우고 새로만든다. 즉, 교체할 때
      handleDelete();
      handleCreate(emoji);
    }else{
      // 삭제할 때
      setComplimentsCount((state)=>state-1);
      handleDelete();
    }
    
  },[clickedComplimentType,handleSnackbarShow,loggedInUserId,handleDelete,handleCreate]) 

지금 누른 버튼타입을 일단 화면에 보여준 후에,
디비에 저장된 내용을 가져와 뒤늦게 반영하는 방식이다.

(gif: 누르자마자 표시되는 모습!! 카운트도 바로 반영!!! )

이렇게 하면,
유저입장에서는 당장❗️ 반영되는 것 같지만
사실 실제 저장된 데이터는 여전히 조금 딜레이된 후에 적용된다.

하지만 성공적으로 저장되었다면 이미 적용된 화면에 변화가 없을것이다!! 후후후

저장에 실패했다면 다시 누르기 전 화면으로 돌아갈 것이다.

🎯 최종 렌더링 시간 비교와 회고

이런 방식으로 바꿈으로서,
기존의 memo를 사용하여 18ms로 줄였던 렌더링시간이 다시 22ms로 늘어났다.
(그래도 84.172% 감소이다! 지독했던 이전 속도 🤣)
하지만 숫자보다 중요한건 UX라고 들었다.

기존방식은 렌더링 시간은 더 짧지만, 유저가 바뀐 화면을 보기까지 약 12ms가 걸렸다.
하지만 지금 방식은 약 0.5ms만에 볼 수 있다.
누르자마자 변경되었다고 느껴진다.

프론트엔드 개발자로서 숫자보다 유저의 더 나은 경험이 중요하다❗️

따라서 렌더링시간이 좀 늘어나고, 컴포넌트가 조금 복잡해졌지만
유저는 매우 빠른 사이트라고 경험할 것이다!

추가 리팩토링을 안했다면,
숫자에 집중하여 얼마나 전체 렌더링 시간을 감소시켰는지만 집착(?)했을 것이다.

관점을 조금 바꿔 유저경험을 위주로 생각하니, 이런 좋은 결과가 나왔
다고 생각한다.
뿌듯하다!


쓰다보니 글이 예상보다 너무 길어졌다....🙃...

profile
🏃🏻‍♀️💨

1개의 댓글

comment-user-thumbnail
2022년 6월 29일

저는 memo 써서 좀 더 나아졌던 적은 거의 없었는데 여기서는 확 보이네요. 멋진홍홍 짱 👍

답글 달기