11장 - 컴포넌트 성능 최적화

sh·2022년 9월 5일
0

많은 데이터 렌더링하기

앞 10장에서 만든 애플리케이션은 추가되어 있는 데이터가 적기 때문에 사용할 때 불편하지 않다. 하지만 데이터가 무수히 많아지면, 애플리케이션이 느려지는 것을 체감할 수 있을 정도로 지연이 발생한다.
App 컴포넌트에서 많은 데이터를 렌더링해 보자.

function createBulkTodos() {
  const array = [];
  for (let i = 1; i <= 2500; i++) {
    array.push({
      id: i,
      text: `할 일 ${i}`,
      checked: false,
    });
  }
  return array;
}
const App = () => {
  const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(2501);
 
  ...
  
};

여기서 useState의 기본값에 함수를 넣어 주었다는 것을 확인할 수 있는데, useState(createBulkTodos()) 라고 작성하면 리렌더링될 때마다 createBulkTodos 함수가 호출되지만, useState(createBulkTodos) 처럼 파라미터를 함수 형태로 넣어주면 컴포넌트가 처음 렌더링될 때만 createBulkTodos 함수가 실행된다.

크롬 개발자 도구를 통한 성능 모니터링

크롬 개발자 도구의 Performance 탭 사용해 측정

느려지는 원인 분석

컴포넌트 리렌더링이 발생하는 상황

  1. 자신이 전달받은 props 가 변경될 때
  2. 자신의 state가 바뀔 때
  3. 부모 컴포넌트가 리렌더링될 때
  4. forceUpdate 함수가 실행될 때

'할 일 1' 항목을 체크할 경우 App 컴포넌트의 state가 변경 -> App 컴포넌트 리렌더링 ->
자식 컴포넌트인 TodoList 컴포넌트 리렌더링 -> ... 그 안 무수한 컴포넌트들도 리렌더링

'할 일 1' 항목은 리렌더링되어야 하는 게 맞음, 근데 그 밑에 '할 일 2'부터 '할일 2500'까지는 리렌더링을 안 해도 되는데 모두 리렌더링 되고 있기 때문에 느린 것!

따라서 컴포넌트 리렌더링 성능을 최적화해 주는 작업이 필요한 것이다. 즉, 리렌더링이 불필요할 때는 리렌더링을 방지해 주어야 한다.


React.memo를 사용하여 컴포넌트 성능 최적화

컴포넌트의 리렌더링을 방지할 때는 7장에서 배운 shouldComponent 라이프사이클을 사용하면 되지만, 함수용 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없다.
대신 React.memo 라는 함수를 사용한다.

컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정하여 함수형 컴포넌트의 리렌더링 성능을 최적화해 줄 수 있다.

사용법은 간단하다! 컴포넌트를 만들고 나서 감싸 주기만 하면 된다.
TodoListItem에 React.memo를 적용해보자

// TodoListItem.js
...

export default React.memo(TodoListItem);

이제 TodoListItem 컴포넌트는 todo, onRemove, onToggle이 바뀌지 않으면 리렌더링을 하지 않는다.


onToggle, onRemove 함수가 바뀌지 않게 하기

React.memo를 사용하는 것만으로 컴포넌트 최적화가 끝나지 않는다.
현재 프로젝트에서는 todos 배열이 업데이트되면 onRemoveonToggle 함수도 새롭게 바뀌기 때문이다.
이 함수들은 배열 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어진다.

이렇게 함수가 계속 만들어지는 상황을 방지하는 방법은 두 가지가 있다.
첫 번째 방법: useState의 함수형 업데이트 기능을 사용하는 것
두 번째 방법: useReducer를 사용하는 것

1. useState의 함수형 업데이트

기존의 setTodos 함수를 사용할 때는 새로운 상태를 파라미터로 넣어 주었다.
setTodos를 사용할 때 새로운 상태를 파라미터로 넣는 대신, 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수도 있는데, 이를 함수형 업데이트라고 한다.

예시를 보자.

const [number, setNumber] = useState(0);
// prevNumbers는 현재 number 값을 가리킨다.
const onIncrease = useCallback(
  () => setNumber(prevNumber => prevNumber + 1),
  [],
);

setNumber(number+1) 을 하는 것이 아니라 , 위 코드처럼 어떻게 업데이트 할지 정의해 주는 업데이트 함수를 넣어준다. 그러면 useCallback을 사용할 때 두 번째 파라미터로 넣는 배열에 number를 넣지 않아도 된다.

이제 onToggle, onRemove 함수에서 useState의 함수형 업데이트를 사용해 보자!

// App.js
const App = () => {
  ... 
  
  const onInsert = useCallback((text) => {
    const todo = {
      id: nextId.current,
      text,
      checked: false,
    };
    setTodos((todos) => todos.concat(todo));
    nextId.current += 1;
  }, []);

  const onRemove = useCallback((id) => {
    setTodos((todos) => todos.filter((todo) => todo.id !== id));
  }, []);

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

  ...

setTodos를 사용할 때 그 안에 todos => 만 앞에 넣어 주면 된다.

2. useReducer 사용하기

import { useCallback, useReducer, useRef, useState } from 'react';
import './App.css';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
import TodoTemplate from './components/TodoTemplate';

function createBulkTodos() {
  const array = [];
  for (let i = 1; i <= 2500; i++) {
    array.push({
      id: i,
      text: `할 일 ${i}`,
      checked: false,
    });
  }
  return array;
}

function todoReducer(todos, action) {
  switch (action.type) {
    case 'INSERT':
      // {type: 'INSERT', todo: {id: 1, text: 'todo', checked: false}}
      return todos.concat(action.todo);
    case 'REMOVE':
      // {type:'REMOVE', id:1}
      return todos.filter((todo) => todo.id !== action.id);
    case 'TOGGLE':
      // {type:'TOGGLE', id:1}
      return todos.map((todo) =>
        todo.id === action.id ? { ...todo, checked: !todo.checked } : todo,
      );
    default:
      return todos;
  }
}

const App = () => {
  const [todos, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);

  // 고유값으로 사용될 id
  // ref를 사용하여 변수 담기
  const nextId = useRef(2501);

  const onInsert = useCallback((text) => {
    const todo = {
      id: nextId.current,
      text,
      checked: false,
    };
    dispatch({ type: 'INSERT', todo });
    nextId.current += 1;
  }, []);

  const onRemove = useCallback((id) => {
    dispatch({ type: 'REMOVE', id });
  }, []);

  const onToggle = useCallback((id) => {
    dispatch({ type: 'TOGGLE', id });
  }, []);

  return (
    <TodoTemplate>
      <TodoInsert onInsert={onInsert} />
      <TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} />
    </TodoTemplate>
  );
};

export default App;

useReducer를 사용할 때는 원래 두 번째 파라미터에 초기상태를 넣어 주어야 한다.

위 코드에서는 그 대신 두 번째 파라미터에 undefined를 넣고, 세 번째 파라미터에 초기 상태를 만들어 주는 함수인 createBulkTodos를 넣어주었다.

이렇게 하면 컴포넌트가 맨 처음 렌더링될 때만 createBulkTodos 함수가 호출된다.

useReducer를 사용하는 방법은 기존 코드를 많이 고쳐야 한다는 단점이 있지만, 상태를 업데이트하는 로직을 모아서 컴포넌트 바깥에 둘 수 있다는 장점이 있다.

성능상으로는 위 두 가지 방법이 비슷하다. 취향껏 골라 결정하자!


불변성의 중요성

리액트 컴포넌트에서 상태를 업데이트할 때 불변성을 지키는 것은 매우 중요하다.

앞에서 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
};

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

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

불변성이 지켜지지 않으면 객체 내부의 값이 새로워져도 바뀐 것을 감지하지 못한다.

추가로 전개 연산자(... 문법)를 사용해 객체나 배열 내부의 값을 복사할 때는 얕은 복사(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 nextComplexObject = {
  ...complexObject,
  objectInside: {
    ...complexObject.objectInside,
    enabled: false
  }
};
console.log(complexObject === nextComplexObject); //false
console.log(complexObject.objectInside === nextComplexObject.objectInside); //false

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


TodoList 컴포넌트 최적화하기

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

TodoList 컴포넌트도 React.memo로 감싸준다.

export default React.memo(TodoList);

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

리스트 관련 컴포넌트를 작성할 때는 리스트 아이템과 리스트, 이 두 가지 컴포넌트를 최적화해 주는 것을 잊지 말자.

(내부 데이터가 100개를 넘지 않거나 업데이트가 자주 발생하지 않으면 최적화 작업을 반드시 해 줄 필요는 없다.)


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

지금까지 리액트 컴포넌트 리렌더링 성능을 최적화하는 방법을 알아보았는데, 리렌더링 성능을 최적화할 때는 필요할 때만 리렌더링하도록 설정해 주었다.

일정 관리 앱에 초기 데이터가 2500개 등록되어 있는데 실제 화면에 나오는 항목은 9개 뿐이고 나머지는 스크롤해야 볼 수 있다.

현재 컴포넌트가 맨 처음 렌더링될 때 2500개 컴포넌트 중 2491개 컴포넌트는 스크롤하기 전에는 보이지 않음에도 불구하고 렌더링이 이루어진다. <- 비효율적!

나중에 todos 배열에 변동이 생길 때도 TodoList 컴포넌트 내부의 map 함수에서 배열의 처음부터 끝까지 컴포넌트로 변환해 주는데, 이 중에서 2491개는 보이지 않으므로 시스템 자원 낭비다.

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

최적화 준비

yarn을 사용해 설치
$ yarn add react-virtualized

react-virtualized에서 제공하는 List 컴포넌트를 사용해 TodoList 컴포넌트의 성능을 최적화 해보자.

최적화를 수행하려면 사전에 먼저 해야 하는 작업이 있다.
각 항목의 실제 크기를 px 단위로 알아내는 것!

이 때 두 번째 항목부터 확인해야 한다. 첫 번째 항목은 테두리가 없기 때문!

TodoList 수정

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

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}
        />
      );
    },
    [onRemove, onToggle, todos],
  );

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

export default React.memo(TodoList);

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

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


TodoListItem 수정

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

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);

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

TodoListItem의 스타일 파일에서 다음 코드를 최상단에 삽입하였다.

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

0개의 댓글