redux를 이용한 컴포넌트 렌더링 최적화

April 21, 2021

회사 미디엄 블로그에 작성한 글을 옮겼습니다. 원본 보러가기

unsplash
Photo by Austin Distel on Unsplash

원티드의 이력서 화면이 업데이트되었습니다. (앞으로 더 많은 업데이트가 있을 예정입니다. 😀)
다음처럼 글자 수를 체크해 이력서를 분석해 주는 기능이 생겼습니다.

resume
아래 ProgessBar에서 이력서 길이를 체크하고 있습니다.

클래스 컴포넌트로 위와 같은 화면을 만들 때 생길 수 있는 퍼포먼스 관련 이슈와 이를 해결 할 수 있는 방법에 대해서 공유하고자 합니다.

리액트 렌더링의 이해

Redux나 Context API를 제외하고 클래스 컴포넌트가 언제 render 메소드를 호출하는지 알아보겠습니다.

  • 컴포넌트가 최초에 Mount 되었을 때
  • this.setState() 를 통해 컴포넌트의 state를 변경할 때
  • 상위 컴포넌트로부터 새로운 props 을 전달 받을 때
  • this.forceUpdate() 를 통해 강제로 렌더링을 할 때

리액트 컴포넌트의 렌더링은 브라우저에 화면을 다시 그리게(Paint) 하는 큰 비용이기 때문에 최대한 불필요한 렌더링을 줄이는 것이 최적화의 중요한 요소입니다.

다음처럼 state를 변경하면 어떨까요?

  • Initial state
this.state = {
  someVar: true,
};
  • 같은 값으로 setState()를 호출
this.setState({
  someVar: true,
});

someVar를 true 에서 true 로(같은 값) 바꿨지만 리액트는 render 메소드를 호출합니다.
위 상황에서 불필요한 렌더링을 막으려면 다음과 같은 작업을 할 수 있습니다.

1.shouldComponentUpdate 라이프 사이클을 이용

shouldComponentUpdate(nextProps, nextState) {
  if(this.state.someVar === nextState.someVar) {
    return false;
  }
  return true;
}

익숙한 방법이죠?

여기서 state의 불변성을 유지하는 것(immutability) 이 왜 중요한지 강조 할 수 있습니다. 만일 최적화를 위해 값을 비교해야 되는데 비교가 되는 두 객체가 immutable 한 객체가 아니라 다음처럼 비교적 복잡한 depth를 가지고 있다면 모든 스칼라 값을 비교해야 합니다.

const prevState = {
  title: "shopping items",
  items: [
    {
      id: 0,
      name: "iMac"
    },
    {
      id: 1,
      name: "iPhone"
    },
    ...
  ],
  ...
};

const nextState = {
  title: "shopping items",
  items: [
    {
      id: 0,
      name: "iMac"
    },
    {
      id: 1,
      name: "iPad" // 1번 아이템이 iPhone에서 iPad로 변경 되었습니다.
    },
    ...
  ],
  ...
};

여기서 items를 모두 비교해서 모두 같으면 렌더링을 하지 말라고 로직을 추가하는 것은 오히려 리액트의 성능을 저하 시킬 수 있을 뿐만 아니라 개발자에게 복잡한 일입니다.

하지만 두 객체가 다른 주소 값을 가지고 있다면 즉, immutable 한 객체라면

if (prevState !== nextState) return true;

를 통해 간단하게 원하는 결과를 얻을 수 있습니다.

  1. PureComponent를 상속합니다.
class MyComponent extends React.PureComponent {}

PureComponent를 상속하게 된다면 자동으로 shouldComopnentUpdate 에서 변경되는 데이터간 shallow compare를 실행한 뒤 render 메소드를 호출할지 안 할지 결정합니다.

그렇다고 모든 컴포넌트를 PureComponent로 만들어서 shouldComponentUpdate 에서 shallow compare를 시키는 것은 오히려 리액트에게 무거운 임무를 주는 것과 같습니다. (참고: Tweet from Dan Abramov)

react-redux와 클래스 컴포넌트 렌더링

리덕스 스토어와 클래스 컴포넌트는 다음과 같이 연결됩니다.

import React from "react";
import { connect } from "react-redux";

class Counter extends React.Component {
  render() {
    return <h1>VALUE: {this.props.value}</h1>;
  }
}

let mapStateToProps = (state) => {
  return {
    value: state.counter.value,
  };
};
export default connect(mapStateToProps)(Counter);

컴포넌트와 연결된 리듀서(스토어)의 값이 바뀌면 새로운 props를 내려주어 re-render 되는 구조를 갖고 있는 react-redux는 어떤 방법으로 최적화를 구현하고 있을까요?

위 예제코드를 보면 connect 함수의 첫번째 argument로 mapStateToProps를 넣어주고 있는데, 안에 정의 된 객체가 component안에 props로 추가 되는 데이터이고 해당객체의 변화가 있을 때만 실질적으로 props로 전달되어 컴포넌트의 렌더링이 일어납니다.

이 때 PureComponent 를 상속해서 최적화를 하는 방법과 동일하게 객체간의 shallow compare를 실행해 새로운 props을 내려주고 re-render를 유발합니다. 불변성을 무시한 채 스토어를 업데이트하면 렌더링이 되지 않는 문제가 생길 수 있기 때문에 react-redux 에서 또한 불변성을 강조하고 있고 관련한 라이브러리가 많이 사용되고 있습니다. (immer.js, immutable.js 가 대표적입니다.)

react-redux를 이용한 컴포넌트 렌더링 최적화

위와 같은 react-redux의 최적화 방법을 잘 사용한다면 다음과 같은 상황에서 불필요한 렌더링을 막을 수 있습니다.

최적화를 하기 전 원티드의 이력서 페이지는 대략적으로 다음과 같은 구조로 구성되어 있었습니다.

class ResumePage extends React.Component {
  render() {
   <Layout>
     <ResumeBasicInfo>
     <ResumeInput>
     <ResumeInput>
     <ResumeInput>
     <ResumeInput>
     <BottomPannel>
      <GuideProgressBar resumeLength={this.state.resumeLength} />
      <ButtonContainer>
        <Button />
        <Button />
      <ButtonContainer>
     </BottomPannel>
   </Layout>
  }
}
const mapStateToProps = (state) => ({
   detail: state.resumeDetail, // 여기서는 resumeDetail reducer를 참조
    ...,
});
export default connect(mapStateToProps)(ResumePage);

그리고 유저가 작성하는 이력서의 길이는 다음처럼 체크되고 있습니다.

class ResumePage extends React.Component {
  componentDidMount() {
    window.addEventListener("keyup", this.setResumeLength);
  }
  setResumeLength = () => {
    const inputElems = this.container.querySelectorAll(".resume-input");
    const inputs = [];
    for (let i = 1; i < inputElems.length; i++) {
      inputs[i] = _.trim(inputElems[i].value);
    }
    this.setState({ resumeLength: inputs.join("").length });
  };
}

보시는대로 타이핑을 한번 할때마다 컨테이너 컴포넌트와 안에 존재하는 모든 컴포넌트가 다시 렌더링 되어서 성능측면에서 굉장히 비효율적인 컴포넌트입니다.

성능 개선을 위해 GuideProgressBar만 re-render 되는 모습을 구현하는 것이 바람직해 보였는데 컨테이너 컴포넌트의 로직을 GuideProgressBar안에 모두 넣는 것이 불가능했고, 결국 컨테이너 컴포넌트에서 체크한 글자 수(resumeLength)를 Store로 보내고 GuideProgressBar가 resumeLength를 subscribe하는 방법으로 해당 컴포넌트만 렌더링 되도록 다음처럼 수정했습니다.

  • setState 대신에 redux store에 resumeLength를 전달
setResumeLength = () => {
  const inputElems = this.container.querySelectorAll(".resume-input");
  const inputs = [];
  for (let i = 1; i < inputElems.length; i++) {
    inputs[i] = _.trim(inputElems[i].value);
  }
  this.props.dispatch({ resumeLength: inputs.join("").length });
};
  • resumeMetaData reducer를 새로 만들어서 resumLength의 변화를 감지합니다.
import React from 'react';
import { connect } from 'react-redux';
class GuideProgressBar extends React.Component {
 return (
   <span>{this.props.resumeLength}</span>
  );
}
const mapStateToProps = (state) => ({
  resumeLength: state.resumeMetaData.resumeLength, // 여기서는 resumeMetaData reducer를 참조
});
export default connect(mapStateToProps)(GuideProgressBar);

이런 식이라면 컨테이너 컴포넌트(ResumePage)는 더 이상 resumeLength를 알 필요가 없고, 타이핑할 때마다 모든 자식 컴포넌트를 다시 그릴 필요가 없습니다.

같은 리듀서를 참조하지 않도록 분리 하는 작업도 중요합니다. 컨테이너 컴포넌트는 resumeDetail 리듀서를 연결하고있고, GuideProgressBar 컴포넌트는 resumeMetaData 리듀서를 연결하고 있어 스토어의 변화에 서로 관여하지 않습니다.


위에서 말한 방법 말고도 reselect (input이 같다면 결과값을 기억하고 있다가 return하는 방법을 사용) 와 같은 라이브러리를 이용해서 좀 더 고도화 된 방법으로 리덕스를 최적화 할 수도 있습니다.

➡️ reselect를 설명하고 있는 블로그 글

참고

이전 - Lerna와 Yarn workspaces를 활용한 패키지 관리