관리 메뉴

거니의 velog

(3) 컴포넌트 성능 최적화 3 본문

React_리액트 응용

(3) 컴포넌트 성능 최적화 3

Unlimited00 2023. 12. 7. 09:59

6. 불변성의 중요성

* 리액트 컴포넌트에서 상태를 업데이트 할 때 불변성을 지키는 것은 매우 중요하다. 앞에서 useState를 사용해 만든 todos 배열과 setTodos 함수를 사용하는 onToggle 함수를 다시 확인해 보자.

  const onToggle = useCallback(id => {
     setTodos((todos) =>
        todos.map((todo) =>
          todo.id === id ? { ...todo, checked: !todo.checked } : todo,
        ),
      );
  }, []);

* 기존 데이터를 수정할 때 직접 수정하지 않고, 새로운 배열을 만든 다음에 새로운 객체를 만들어서 필요한 부분을 교체해 주는 방식으로 구현했다. 업데이트가 필요한 곳에서는 아예 새로운 배열 혹은 새로운 객체를 만들기 때문에, React.memo를 사용했을 때 props가 바뀌었는지 혹은 바뀌지 않았는지를 알아내서 리렌더링 성능을 최적화해 줄 수 있다.

* 이렇게 기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는 것을 '불변성을 지킨다'고 한다. 다음 예시 코드에서는 불변성을 어떻게 지키고 있는지 생각해 보자.

const array = [1, 2, 3, 4, 5];

const nextArrayBad = array; // 배열을 복사하는 것이 아니라 똑같은 배열을 가리킨다.
nextArrayBad[0] = 100;
console.log(array === nextArrayBad); // 완전히 같은 배열이기 때문에 true

const nextArrayGood = [...array]; // 배열 내부의 값을 모두 복사한다.
nextArrayGood[0] = 100;
console.log(array === nextArrayGood); // 다른 배열이기 때문에 false

//////////////////////////////////////////////////////////////////////////////////////

const object = {
    foo: 'bar',
    value: 1
};

const nextObjectBad = object; // 객체가 복사되지 않고, 똑같은 객체를 가리킨다.
nextObjectBad.value = nextObjectBad.value + 1;
console.log(object === nextObjectBad); // 같은 객체이기 때문에 true

const nextObjectGood = {
    ...object, // 기존에 있던 내용을 모두 복사해서 넣는다.
    value: object.value + 1 // 새로운 값을 덮어 쓴다.
}
console.log(object === nextObjectGood); // 다른 객체이기 때문에 false

* 불변성이 지켜지지 않으면 객체 내부의 값이 새로워져도 바뀐 것을 감지하지 못한다. 그러면 React.memo에서 서로 비교하여 최적화하는 것이 불가능할 것이다.

* 추가로 전개 연산자(spread 문법, ... 문법)를 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사(shallow copy)를 하게 된다. 즉, 내부의 값이 완전히 새로 복사되는 것이 아니라 가장 바깥쪽에 있는 값만 복사된다. 따라서 내부의 값이 객체 혹은 배열이라면 내부의 값 또한 따로 복사해 주어야 한다. 다음 코드를 읽어 보면 쉽게 이해될 것이다.

const todos = [{ id: 1, checked: true }, { id: 2, checked: true }];
const nextTodos = [...todos];

nextTodos[0].checked = false;
console.log(todos[0] === nextTodos[0]); // 아직까지는 똑같은 객체를 가리키고 있기 때문에 true

nextTodos[0] = {
    ...nextTodos[0],
    checked: false
};
console.log(todos[0] === nextTodos[0]); // 새로운 객체를 할당해 주었기에 false

* 만약 객체 안에 있는 객체라면 불변성을 지키면서 새 값을 할당해야 하므로 다음과 같이 해 주어야 한다.

const complexObject = {
    id: 1,
    enabled: true
}

const nextComplexObject = {
    ...complexObject,
    objectInside: {
        ...complexObject.objectInside,
        enabled: false
    }
};
console.log(complexObject === nextComplexObject); // false
console.log(complexObject.objectInside === nextComplexObject.objectInside); // false

* 배열 혹은 객체의 구조가 정말 복잡해진다면 이렇게 불변성을 유지하면서 업데이트하는 것도 까다로워진다. 이렇게 복잡한 상황일 경우 immer라는 라이브러리의 도움을 받으면 정말 편하게 작업할 수 있는데, 이는 다음에 알아보도록 하겠다.


7. TodoList 컴포넌트 최적화하기

* 리스트에 관련된 컴포넌트를 최적화할 때는 리스트 내부에서 사용하는 컴포넌트도 최적화해야 하고, 리스트로 사용되는 컴포넌트 자체도 최적화해 주는 것이 좋다.

* TodoList 컴포넌트를 다음과 같이 수정해 보자.

import React from 'react';
import TodoListItem from './TodoListItem';
import './TodoList.scss';

const TodoList = ({ todos, onRemove, onToggle }) => {
  return (
    <div className="TodoList">
      {todos.map((todo) => (
        <TodoListItem
          todo={todo}
          key={todo.id}
          onRemove={onRemove}
          onToggle={onToggle}
        />
      ))}
    </div>
  );
};

export default React.memo(TodoList);

* 위 최적화 코드는 현재 프로젝트 성능에 전혀 영향을 주지 않는다. 왜냐하면 TodoList 컴포넌트의 부모 컴포넌트인 App 컴포넌트가 리렌더링되는 유일한 이유가 todos 배열이 업데이트될 때이기 때문이다. 즉, 지금 TodoList의 컴포넌트는 불필요한 리렌더링이 발생하지 않는다. 하지만 App 컴포넌트에 다른 state가 추가되어 해당 값들이 업데이트될 때는 TodoList 컴포넌트가 불필요한 리렌더링을 할 수도 있다. 그렇기 때문에 지금 React.memo를 사용해서 미리 최적화 해 준 것이다.

* 리스트 관련 컴포넌트를 작성할 때는 리스트 아이템과 리스트, 이 두 가지 컴포넌트를 최적화해 주는 것을 잊지 말자. 그러나 내부 데이터가 100개를 넘지 않거나 업데이트가 자주 발생하지 않는다면, 이런 최적화 작업을 반드시 해 줄 필요는 없다.


8. react-virtualized 를 사용한 렌더링 최적화

* 지금까지 리액트 컴포넌트 리렌더링 성능을 최적화하는 방법을 알아 보았다. 리렌더링 성능을 최적화할 때는 필요한 때만 리렌더링하도록 설정해 주었다. 이번에는 또 다른 렌더링 성능 최적화 방법을 알아보자. 일정 관리 애플리케이션에 초기 데이터가 2,500개 등록되어 있는데, 실제 화면에 나오는 항목은 아홉 개 뿐이다. 나머지는 스크롤해야 볼 수 있다.

* 현재 컴포넌트가 맨 처음 렌더링될 때 2,500개의 컴포넌트 중 2,491개 컴포넌트는 스크롤하기 전에는 보이지 않음에도 불구하고 렌더링이 이루어 진다. 꽤 비효율적이다. 그리고 나중에 todos 배열에 변동이 생길 때도 TodoList 컴포넌트 내부의 map 함수에서 배열의 처음부터 끝까지 컴포넌트로 변환해 주는데, 이 중에서 2,491개는 보이지 않으므로 시스템 자원 낭비이다.

* 이번 절에 배울 react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하게끔 할 수 있다. 그리고 만약 스크롤되면 해당 스크롤 위치에서 보여 주어야 할 컴포넌트를 자연스럽게 렌더링시킨다. 이 라이브러리를 사용하면 낭비되는 자원을 아주 쉽게 아낄 수 있다.


(1) 최적화 준비

* 우선 yarn을 사용하여 설치해 주자.

$ yarn add react-virtualized

* 이제 본격적으로 react-virtualized를 사용하여 최적화 해보자! react-virtualized에서 제공하는 List 컴포넌트를 사용하여 TodoList 컴포넌트의 성능을 최적화할 것이다.

* 최적화를 수행하려면 사전에 먼저 해야 하는 작업이 있는데, 바로 각 항목의 실제 크기를 px 단위로 알아내는 것이다. 이 값은 우리가 작성한 CSS를 확인해서 직접 계산해도 되지만, 이보다 훨씬 더 편리하게 알아낼 수 있다. 크롬 개발자 도구의 좌측 상단에 있는 아이콘을 눌러 크기를 알고 싶은 항목에 커서를 대 보자.

* 각 항목의 크기는 가로 512px, 세로 57px이다. 이는 브라우저 버전에 따라 값이 달라질 수 있으니 개별적으로 확인하기 바란다. 크기를 알아낼 때 두 번째 항목을 확인해야 하는데, 두 번째 항목부터 테두리가 포함되어 있기 때문이다(첫 번째 항목은 테두리가 없기 때문에 56px이 된다).


(2) TodoList 수정

* 크기를 알아냈다면 이제 TodoList 컴포넌트를 다음과 같이 수정해 보자.

(필자는 495*57이므로 여기에 맞춰 작성하겠다)

[TodoList.js]

import React, { useCallback } from 'react';
import { List } from 'react-virtualized';
import TodoListItem from './TodoListItem';
import './TodoList.scss';

const TodoList = ({ todos, onRemove, onToggle }) => {
  const rowRenderer = useCallback(({ index, key, style }) => {
    const todo = todos[index];
    return (
      <TodoListItem
        todo={todo}
        key={key}
        onRemove={onRemove}
        onToggle={onToggle}
        style={style}
      />
    );
  });

  return (
    <List
      className="TodoList"
      width={495} // 전체 넓이
      height={513} // 전체 높이
      rowCount={todos.length} // 항목 개수
      rowHeight={57} // 항목 높이
      rowRenderer={rowRenderer} // 항목을 렌더링할 때 쓰는 함수
      list={todos} // 배열
      style={{ outline: 'none' }} // List에 기본 적용되는 outline 스타일 제거
    />
  );
};

export default React.memo(TodoList);

* List 컴포넌트를 사용하기 위해 rowRenderer라는 함수를 따로 작성해 주었다. 이 함수는 react-virtualized의 List 컴포넌트에서 각 TodoItem을 렌더링할 때 사용되며, 이 함수를 List 컴포넌트의 props로 설정해 주어야 한다. 이 함수는 파라미터에 index, key, style 값을 객체 타입으로 받아 와서 사용한다.

* List 컴포넌트를 사용할 때는 해당 리스트의 전체 크기와 각 항목의 높이, 각 항목을 렌더링할 때 사용해야 하는 함수, 그리고 배열을 props로 넣어 주어야 한다. 그러면 이 컴포넌트가 전달받은 props를 사용하여 자동으로 최적화해 준다.


(3) TodoListItem 수정

* TodoList를 저장하고 나면 스타일이 깨져서 나타날 텐데 TodoListItem 컴포넌트를 다음과 같이 수정하면 해결된다.

[TodoListItem.js]

import React from 'react';
import {
  MdCheckBoxOutlineBlank,
  MdCheckBox,
  MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
import './TodoListItem.scss';

const TodoListItem = ({ todo, onRemove, onToggle, style }) => {
  const { id, text, checked } = todo;

  return (
    <div className="TodoListItem-virtualized" style={style}>
      <div className="TodoListItem">
        <div
          className={cn('checkbox', { checked })}
          onClick={() => onToggle(id)}
        >
          {checked ? <MdCheckBox /> : <MdCheckBoxOutlineBlank />}
          <div className="text">{text}</div>
        </div>
        <div className="remove" onClick={() => onRemove(id)}>
          <MdRemoveCircleOutline />
        </div>
      </div>
    </div>
  );
};

export default React.memo(TodoListItem);

* render 함수에서 기존에 보여 주던 내용을 div로 한 번 감싸고, 해당 div에는 TodoListItem-virtualized라는 className을 설정하고, props로 받아온 style을 적용시켜 주었다.

* 여기서 TodoListItem-virtualized라는 클래스를 만든 것은 컴포넌트 사이사이에 테두리를 제대로 쳐 주고, 홀수 번째/짝수 번째 항목에 다른 배경 색상을 설정하기 위해서이다.

* 그 다음에는 TodoListItem의 스타일 파일에서 최하단에 있던 & + &을 사용하여 .TodoListItem 사이사이에 테두리를 설정했던 코드와 &:nth-of-type(even)을 사용하여 다른 배경 색상을 주는 코드를 지우고, 코드 최상단에 다음 코드를 삽입해 주자.

[TodoListItem.scss]

.TodoListItem-virtualized {
    & + & {
        border-top: 1px solid #dee2e6;
    }
    &:nth-of-type(even) {
        background-color: #f8f9fa;
    }
}

* 작업이 모두 끝났다! 리스트가 잘 나타나는가? 다시 한 번 성능을 측정해 보자.

* React.memo를 통해 0.059초까지 줄었는데, 이번에는 반응속도가 0.005초로 줄었다!


9. 정리

* 이 장에서는 리액트 애플리케이션에 많은 데이터를 렌더링하는 리스트를 만들어 지연을 유발해 보고, 이를 해결하는 방법을 알아보았다. 리액트 컴포넌트 렌더링은 기본적으로 빠르기 때문에 컴포넌트를 최적화할 때 최적화 작업에 대해 너무 큰 스트레스를 받거나 모든 컴포넌트에 일일이 React.memo를 작성할 필요는 없다. 단, 리스트와 관련된 컴포넌트를 만들 때 보여 줄 항목이 100개 이상이고 업데이트가 자주 발생한다면, 이 장에서 학습한 방식을 사용해 꼭 최적화하길 바란다.