2020. 2. 17. 18:17

출처 : https://yzzzzun.tistory.com/41?category=836401

 

리액트 컴포넌트 성능 최적화

지금까지는 리액트의 기본적인 내용에 대해서 공부했습니다. 리액트의 장점이 변경된 부분에 대해서 렌더링하기때문에 빠른속도를 자랑한다고 했는데 과연 우리가 만들어가는 코드가 빠른속도를 자랑할까요..? 리엑트를 다루는 기술의 ToDo 리스트를 구현해보았고 많은 데이터를 추가해봤습니다.

2500개의 array를 코드로 생성하여 todo 리스트를 만들었고 초기 4개의 데이터만 있었을 때보다 확실히 느려진걸 체감할 수 있습니다. 우선 느려진걸 체감했다면 왜? 어디서? 속도가 느려지는지 확인할 수 있어야 합니다.

크롬 개발자 도구(F12)를 실행하고 performance 탭을 켭니다. 탭을 켜고 왼쪽 상단 record 버튼을 눌러 사용자가 액션을 녹화하도록 합니다. 중간중간 막대그래프 형태로 컴포넌트의 업데이트 시간을 확인할 수 있습니다.

 

App컴포넌트를 업데이트하는데 1초라는 시간이 걸렸습니다... 만약 서비스를 하는데 리스트 아이템 하나를 변경하는 시간이 1초가 걸리면 사용자들이 난리가 나겠죠..?

이를통해 우리는 느려지는 원인을 파악할 수 있습니다. 앞서 컴포넌트가 렌더링 되는 상황을 다시 한번 복습하고 넘어가죠

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

우리가 녹화한 액션을 따라가보면 데이터를 하나 변경하는 순간 App 컴포넌트의 state가 변경됩니다. 그러면 App 컴포넌트가 리렌더링이 시작됩니다. 그러면 App컴포넌트 즉, 부모 컴포넌트가 리렌더링 되었으니 자식컴포넌트가 줄줄이 리렌더링 됩니다. 그럼 데이터 하나를 변경하는 순간 2500개의 컴포넌트들이 리렌더링 되는거죠...

우린 이런상황을 원하지 않습니다. 그저 변경한 하나의 리스트 아이템만 리렌더링 되도록 하고싶은거죠..

React.memo 를 사용해 컴포넌트 최적화

위에서 우린 컴포넌트 렌더링이 느려지는 이유를 확인하는 방법과 왜 느려지는지 원인을 파악했습니다. 컴포넌틀 리렌더링을 방지하는 방법은 shouldComponentUpdate라는 라이프 사이클 메서드를 사용해 이전과 현재의 props를 비교해 리렌더링을 막는 방법이 있었습니다.

하지만 위의 코드는 함수형 컴포넌트를 사용했고 라이프사이클 메서드를 사용할 수 없습니다. 대신 React.memo 함수를 사용해 props가 바뀌지 않으면 리렌더링 하지 않도록 설정하여 함수형 컴포넌트의 리렌더링을 최적화 할 수 있습니다.

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

const TodoListItem = ({ todo, onRemove, onToggle }) => {
  const { id, text, checked } = todo;
  return <div>.....</div>;
};

export default React.memo(TodoListItem);

적용방법은 간단합니다. 이렇게 수정하면 todo, onRemove, onToggle props가 변경되지 않으면 리렌더링되지 않습니다. 하지만 깃헙에 있는 코드는 todos 배열이 업데이트 된다면 onRemove, onToggle 함수도 새롭게 바뀝니다. 결국 리렌더링 됩니다... 그렇다면 props로 넘어오는 함수가 변경되지 않도록 하려면 어떻게해야할까요??

useState를 사용하는 방법

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

보통 useState에서 setTodos를 통해 새로운 todos state를 파라미터로 넣는데, 대신 상태 업데이트를 어떻게 할지 정의하는 업데이트 함수를 파라미터로 전달할 수 있습니다. 이를 함수형 업데이트라고 합니다.

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

이렇게 함수형 업데이트를 사용하면 todos가 변경되어도 props로 전달되는 onInsert 함수는 변하지 않습니다.

useReducer를 사용하는 방법

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

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':
      return todos.concat(action.todo);
    case 'REMOVE':
      return todos.filter(todo => todo.id !== action.id);
    case 'TOGGLE':
      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);
  // const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(2501);

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

  const onRemove = useCallback(id => {
    dispatch({ type: 'REMOVE', id });
    // setTodos(todos => todos.filter(todo => todo.id !== id));
  }, []);

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

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

export default App;

useState를 사용하던 부분을 모두 주석처리하고 useReducer를 사용한 코드입니다. useReducer의 두번째 파라미터는 초기상태를 넣어주어야 합니다. undefined를 넣어주고 세번째 파라미터에 함수를 전달하면 컴포넌트의 맨처음 렌더링될때만 함수가 호출되어 state가 초기화 됩니다.

useReducer의 단점은 기존코드를 많이 고쳐야하는 단점이 있지만 컴포넌트 밖으로 상태 업데이트 로직을 빼낼 수 있는 장점이 있습니다. 편한쪽으로 선택하도록 합시다.

불변성의 중요성

리엑트 컴포넌트의 상태 업데이트에서 불변성을 지키는것이 아주 중요합니다. 컴포넌트의 상태가 업데이트 될때 리엑트 컴포넌트가 리렌더링 되는데 만약 불변성을 유지하지 않고 값을 바꿔버리면 바뀐값을 감지하지 못해 리렌더링이 필요한 시점에 이루어지지 않을 수 있습니다.

보통 전개연산자(spread 연산자)로 객체나 배열의 내부 값을 shallow copy 해서 사용하고 있습니다. 만약 배열이 객체로 구성되어있다면 배열의 원소까지 복사해서 사용해야하기때문에 복잡해 지지만 다음 포스팅에서 정리할 immer 라이브러리를 사용하면 되기때문에 걱정하지 않아도 됩니다. 우리는 불변성을 지켜서 업데이트해야한다! 이것만 기억합시다

react-virtualized

현재 코드는 화면에 몇개 보이지 않지만 2500개의 데이터를 초기에 렌더링을 하게됩니다. React-virtualized 는 눈에 보이지 않는 컴포넌트를 렌더링하지 않고 크기만 차지하고 있다가 스크롤을 통해 보여지면 그때 렌더링해 자원의 낭비를 막을 수 있는 라이브러리 입니다.

yarn add react-virtualized

해당 라이브러리를 추가했으면 TodoList와 TodoListItem 컴포넌트를 수정해야 합니다.

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}
        />
      );
    },
    [todos, onRemove, onToggle],
  );
  return (
    <List
      className="TodoList"
      width={512}
      height={513}
      rowCount={todos.length}
      rowHeight={51}
      rowRenderer={rowRenderer}
      list={todos}
      style={{ outline: 'none' }}
    />
  );
};

export default React.memo(TodoList);

react-virtualized의 List컴포넌트를 사용합니다. List컴포넌트를 사용할때는 리렌더링할 리스트의 크기, 항목의 높이와 리렌더링해주는 함수를 props로 설정해줘야 합니다. 그러면 해당 props로 최적화하여 컴포넌트를 렌더링해줍니다.

TodoListItem에 props로 style을 추가했으니 컴포넌트도 수정해줘야합니다.

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,
  (prevProps, nextProps) => prevProps.todo === nextProps.todo,
);

크롬 개발자 도구로 performance 측정을 해보면 렌더링 속도가 더 빨라진것을 확인할 수 있습니다.

컴포넌트 최적화 작업에 너무 목숨걸 필요는 없습니다 왜냐면 렌더링 속도자체가 기본적으로 빠르기 때문입니다. 만약 리스트의 항목이 100개 이상넘어가고 업데이트가 자주 이뤄지는 컴포넌트에 대해서만 최적화를 신경써주면 될것같습니다.

'Javascript > React' 카테고리의 다른 글

#10 SPA 와 Routing  (0) 2020.02.24
#9 immer를 사용한 불변성(Immutability) 유지  (0) 2020.02.24
#8 React todo-app 1  (0) 2020.02.17
#7 Component Styling  (0) 2020.02.17
#6 React Hooks  (0) 2020.02.14
Posted by yongminLEE