이벤트 리스너 캐시를 이용한 React 성능 향상


저자 : Charles Stover / 웹사이트 : charlesstover.com / LinkedIn : https://www.linkedin.com/in/charles-stover / Twitter : https://twitter.com/CharlesStover
역자 : 박정환(FE개발랩)
원문 : https://medium.com/@Charles_Stover/cache-your-react-event-listeners-to-improve-performance-14f635a62e15

자바스크립트의 객체와 함수가 참조형이라는 개념은 React의 성능에 직접적인 영향을 끼침에도 불구하고 많이 다뤄지지 않고 있다. 예를들어, 내용이 완전히 같은 함수를 두 개 만들더라도 두 함수는 절대 같지 않다. 다음과 같이 작성해보자.

const functionOne = function() { alert('Hello world!'); };
const functionTwo = function() { alert('Hello world!'); };
functionOne === functionTwo; // false

하지만, 아래 코드처럼 이미 만들어진 함수를 변수에 할당한 결과는 다를 것이다.

const functionThree = function() { alert('Hello world!'); };
const functionFour = functionThree;
functionThree === functionFour; // true

객체에서 또한 동일하게 동작한다.

const object1 = {};
const object2 = {};
const object3 = object1;
object1 === object2; // false
object1 === object3; // true

다른 언어들을 사용해본 경험이 있다면 포인터라는 개념이 친숙할 것이다. 객체를 만들거나 기기의 메모리에 무언가를 할당할 때 마다 어떤 일이 일어나는 것일까? object1 ={}에 대해 먼저 말해보면, 조금 전에 우리는 object1을 위한 한 덩어리의 바이트를 사용자의 RAM에 생성했다. object1을 RAM의 key-value 쌍을 가리키는 주소라고 생각하면 이해하기 쉬울 것이다. 그리고 object2 = {}에 대해 얘기해보면 object2를 위한 한 덩어리의 또 다른 바이트를 사용자의 RAM에 만들었다. 그렇다면 object1주소object2와 같을까? 아니다. 이것이 두 변수의 동일 비교에서 false 가 반환되는 이유이다. 각각의 key-value 쌍 내용은 정확히 같지만, 그 메모리의 주소는 다르다.

object3 = object1를 이용해서 할당하게 되면 object3object1의 주소를 할당하게 된다. 다시 말해 obejct3는 새로운 객체가 아니다. object1과 같은 메모리에 있다. 다음과 같이 작성해서 확인해보자.

const object1 = { x: true };
const object3 = object1;
object3.x = false;
object1.x; // false

이번 예제에서는 메모리에 객체를 생성해서 그 객체를 object1에 할당했다. 그리고 object3에 그 객체를 할당했다. object3를 변경하게 되면, 메모리상의 값을 변경하는 것이기 때문에 메모리 주소를 참조하는 모든 곳에서 영향을 받는다. object1 또한 같은 메모리를 가리키므로 값이 변경된 걸 확인할 수 있다.

이 오류는 초급 개발자들 사이에서 흔히 일어나며, 이 오류만으로도 심층적인 튜토리얼을 만들 수 있을 것이다. 하지만 이 글은 React 성능에 대한 내용만을 다루고 있으며, 더 숙련된 개발자들도 변수의 참조 값에 의한 영향을 고려하지 못해 이 부분에서 오류를 범하기도 한다.

이것이 React와 무슨 관련이 있을까? React는 성능을 끌어올리기 위해 똑똑한 방법으로 연산을 줄인다. 컴포넌트의 propsstate가 변경되지 않으면 render의 출력 또한 변경되지 않을 것이라고 가정한다. 다시 말해, 모든 것들이 전과 같다면 변경되는 것 또한 없다는 말이다. 변경이 없다면, render가 반환하는 출력 또한 같아야 한다. 그러므로 render를 수행하는 귀찮은 일은 건너뛴다. 이것이 React를 더 빨라지게 한다. 컴포넌트는 필요할 때만 다시 그려진다.

React는 자바스크립트와 같은 방법으로 propsstate가 같은지 판단한다. 단순히 ==연산자로 확인한다. React는 객체가 같은지 확인하기 위해 깊게 비교하지 않는다. 깊은 비교라는 용어는 메모리 주소만 비교하는 것과는 반대로, 각 객체의 key-value 쌍을 모두 비교하는 것을 말한다. React는 객체의 참조가 같은지 확인하는 얕은 비교라고 불리는 방법을 사용한다.

만약 컴포넌트의 prop = { x: 1 } 을 새로운 객체인 { x: 1 }로 바꾼다면, React는 컴포넌트를 다시 그릴 것이다. 왜냐하면 두 객체의 참조가 가리키는 메모리가 다르기 때문이다. 만약 컴포넌트의 prop을 위 코드의 object1에서 object3로 바꾸면 React는 컴포넌트를 다시 그리지 않는다. 마찬가지로, 두 객체가 같은 참조이기 때문이다.

자바스크립트의 함수도 같은 방법으로 취급된다. React가 같은 구현에 다른 메모리에 있는 함수를 받는다면, 컴포넌트는 다시 그려질 것이다. 만약 반대로 React가 같은 참조의 함수를 받는다면, 다시 그려지지 않을 것이다.

아래의 코드는 안타깝게도 코드리뷰에서 많이 볼 수 있는 시나리오다.

class SomeComponent extends React.PureComponent {

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={() => alert('!')} />
      </div>
    );
  }
}

이 컴포넌트는 아주 간결하다. Button 컴포넌트가 있고, 그것이 클릭 되었을 때 경고창이 뜬다. 설명서에 나와 있지 않더라도 사용자는 클릭할 것이다. 그리고 SomeComponentdo={true}do={false} prop으로 제어된다.

SomeComponenttruefalse로 토글되면 다시 그려지게 되고, 버튼 컴포넌트도 다시 그려진다! onClick 핸들러 함수는 같더라도 매 render함수 호출마다 새로 생성된다. 이는 render함수 내부에서 핸들러를 생성하기 때문에 매 render마다 새로운 메모리에 함수가 생성된다. 새로운 메모리 주소의 참조가 <Button />으로 넘어가고, 아무런 변경이 없지만 Button은 다시 그려진다.

해결책


함수가 해당 컴포넌트에 의존하지 않는다면(this 컨텍스트를 사용하지 않음), 컴포넌트의 외부에 함수를 정의할 수 있다. 함수가 사용되는 조건이 같다면, 컴포넌트의 모든 인스턴스는 같은 함수 참조를 사용할 것이다.

const createAlertBox = () => alert('!');

class SomeComponent extends React.PureComponent {

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={createAlertBox} />
      </div>
    );
  }
}

이전 예제와는 반대로 createAlertBox는 매 render마다 같은 메모리에 있는 참조를 반환한다. 그러므로 Button 컴포넌트는 절대로 다시 그려지지 않는다.

Button컴포넌트는 작은 컴포넌트라서 그리는 시간이 오래 걸리지 않는다. 하지만 이런 인라인 함수가 크고 복잡하고 그리는 데 오래 걸리는 컴포넌트에 있다면, React 애플리케이션을 아주 느리게 만들 수 있다. 이런 함수들은 컴포넌트의 render 함수에서 정의하지 않는 것이 좋다.

반대로 함수가 컴포넌트에 의존한다면, 컴포넌트 밖에 함수를 정의할 수 없다. 이럴 땐 컴포넌트의 메서드로 만들어 이벤트 핸들러로 넘겨주면 된다.

class SomeComponent extends React.PureComponent {

  createAlertBox = () => {
    alert(this.props.message);
  };

  get instructions() {
    if (this.props.do) {
      return 'Click the button: ';
    }
    return 'Do NOT click the button: ';
  }

  render() {
    return (
      <div>
        {this.instructions}
        <Button onClick={this.createAlertBox} />
      </div>
    );
  }
}

이런 경우, SomeComponent의 모든 인스턴스는 각기 다른 경고창을 가질 것이다. Button 컴포넌트의 클릭 이벤트 리스너는 SomeComponent 에서 고유해야 한다. createAlertBox 메서드를 넘기므로, SomeComponent가 다시 그려지더라도 Button 컴포넌트와는 상관없다. 심지어 message가 바뀌더라도 상관이 없다. createAlertBox의 메모리 주소가 변하지 않으면, Button 컴포넌트도 다시 그려지지 않는다. 이렇게 애플리케이션의 다시 그려지는 속도가 빨라지게 된다.

하지만 사용하는 함수가 동적으로 변경된다면 어떻게 할까?

해결책 (고급)


아래 코드는 매우 보편적인 사례이다. 한 개의 컴포넌트에서 각기 다른 다수의 동적 이벤트 리스너가 있는 상황이다. 그중에서도 배열을 매핑하는 상황이다.

class SomeComponent extends React.PureComponent {
  render() {
    return (
      <ul>
        {this.props.list.map(listItem =>
          <li key={listItem.text}>
            <Button onClick={() => alert(listItem.text)} />
          </li>
        )}
      </ul>
    );
  }
}

이런 경우, 버튼 컴포넌트 숫자에 맞는 이벤트 리스너들 변수들이 있다. 그리고 SomeComponent가 만들어질 때는 각 함수가 무엇인지 알 수 없을 수도 있다. 이런 수수께끼를 어떻게 해결할까?

메모이제이션(memoization), 아니 더 쉬운 말로 캐싱을 사용하자. 각 고유한 값마다 함수를 생성하고 캐싱하자. 다시 그려질 때 필요한 참조 값은 이전에 캐싱한 함수의 참조를 그대로 이용한다.

아래 코드는 위의 설명에 대한 구현이다.

class SomeComponent extends React.PureComponent {

  // SomeComponent의 각 인스턴스는 인스턴스마다 고유한 클릭 핸들러들을 캐싱한다.
  clickHandlers = {};

  // 고유 식별자에 따른 클릭 핸들러를 반환한다. 없다면 생성하고 반환한다.
  getClickHandler(key) {

    // 고유 식별자에 대한 이벤트 핸들러를 만들지 않았다면, 새로 생성한다.
    if (!Object.prototype.hasOwnProperty.call(this.clickHandlers, key)) {
      this.clickHandlers[key] = () => alert(key);
    }
    return this.clickHandlers[key];
  }

  render() {
    return (
      <ul>
        {this.props.list.map(listItem =>
          <li key={listItem.text}>
            <Button onClick={this.getClickHandler(listItem.text)} />
          </li>
        )}
      </ul>
    );
  }
}

배열에 있는 각 아이템은 getClickHandler 메서드를 통해 이벤트 리스너를 받는다. 이 메서드는 처음 실행 시엔 고윳값에 해당하는 함수가 있는지 확인한 후, 함수가 없다면 함수를 새로 생성한 후 해당 고윳값을 키로 삼고 함수를 값으로 저장한다. 그리고 저장한 함수를 반환한다. 이후에 이 메서드를 같은 고윳값으로 실행하면 새로운 함수를 생성하지 않는다. 그 대신 메모리상에 미리 생성해둔 함수의 참조를 반환한다.

그 결과, SomeComponent가 다시 그려질 때 Button도 같이 그려지는 일은 일어나지 않는다. 그리고 prop.list에 새로운 아이템을 추가하면 동적으로 새로운 버튼에 대한 이벤트 리스너를 생성한다.

만약 한 개 이상의 변수로 각 이벤트 핸들러가 결정되기 위한 고유 식별자를 만들려면 조금 고민을 해야 할 것이다. 그렇다고 해도, 매핑된 각 JSX 객체에 고유한 키 prop을 생성하는 일은 그리 어렵지 않다.

식별자로 배열의 인덱스를 사용하는 것은 위험할 수 있다. 배열의 인덱스를 식별자로 사용한다면, 리스트의 순서를 변경하거나 아이템을 제거하는 경우 잘못된 결과를 얻을 수 있다. 만약 ['soda', 'pizza'] 배열을 ['pizza']로 바꾼다면 캐싱한 이벤트 리스너가 listeners[0] = () => alert('soda) 이므로, 사용자가 0번째 인덱스의 아이템을 클릭했을 때 'soda'라고 경고창이 뜰 것이다. 이것이 React가 배열의 인덱스를 key prop으로 사용하지 말라고 권고하는 이유다.

마치며


이 글이 좋다면 박수를 쳐주길 바란다(역: 원 글이 작성된 Medium 서비스의 좋아요 버튼 같은 기능이다). 두 번 쳐주는 것도 좋다. 쉽고, 빠르며 심지어 공짜다! 그리고 관련된 질문이나 조언이 있다면 아래에 댓글로 남겨주길 바란다.

필자의 다른 칼럼을 읽고 싶다면 LinkedIn, Twitter를 팔로우하거나, 필자의 포트폴리오인 CharlesStover.com를 확인해보길 바란다.