다라다라V
article thumbnail
728x90
반응형

지난 포스트에서 추가되는 데이터가 적어서 컴포넌트를 관리하는데 불편하지않았습니다. 그러나 데이터가 무수히 많아지면 애플리케이션이 느려지게 됩니다. 이때 사용되는 것이 컴포넌트 성능 최적화입니다.

실습은 다음과 같은 순서로 진행됩니다.

  1. 많은 데이터 렌더링하기
  2. 크롬 개발자 도구를 통한 성능 모니터링
  3. React.memo를 통한 컴포넌트 리렌더링 성능 최적화
  4. onToggle 과 onRemove 가 새로워지는 현상 방지하기
  5. react-virtualized 를 사용한 렌더링 최적화

이번 실습을 위해서는 일정관리 웹서버가 필요합니다. https://daradarav.tistory.com/64 의 주소를 참고하여 실습을 진행한 후 이 포스트를 봐주시길 바랍니다.

 

[React] 10. 일정 관리 웹 애플리케이션 만들기

우리는 이전의 단원들을 통해 리액트의 기본기부터 컴포넌트를 스타일링하는 방법까지를 배웠습니다. 지금까지 배운 내용을 활용하여 프런트 엔드를 공부할 때 자주 구현하는 일정관리 애플리

daradarav.tistory.com


📌 많은 데이터 렌더링하기

많은 데이터가 생성되면 랙(lag)이 발생합니다. 코드를 입력하여 많은 데이터를 렌더링해봅시다.

// App.js

import React, { useState, useRef, useCallback } from 'react';
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;
}

const App = () => {
  const [todos, setTodos] = useState(createBulkTodos);
  const nextId = useRef(2501);
  
  // onInsert, onRemove, onToggle, render 함수는 그대로
  ( ... )
}

export default App;
  • createBulkTodos 라는 함수를 만들어 데이터 2500개를 자동으로 생성했습니다.
  • useState 기본값에 함수를 넣었습니다. 이로 인해 처음 렌더링될때만 createBulkTodos 함수가 실행됩니다.

항목을 체크하면 이전보다 느려진 것을 체감할 수 있을 것입니다.


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

얼마나 느려졌는지 정확한 성능 분석을 하기 위해서는 React DevTools를 사용합니다. 리액트 v17 부터는 리액트 전용 개발자 도구 React DevTools를 통해 성능 분석을 자세히 할 수 있습니다.

 

10장에서는 Component  탭을 열어봤지만 이번에는 Profiler 라는 탭을 열어봅시다. 좌측 상단의 파란색 녹화 버튼을 눌러보세요. 이 버튼을 누르고 '할 일 1' 항목을 체크하고, 화면에 변화가 반영되도록 녹화 버튼을 누르면 성능 분석 결과가 나타납니다.

  • 우측의 Render duration 은 리렌더링에 걸린 시간을 말합니다. 변화를 화면에 반영하는데 254.2ms 가 걸렸습니다.

 

  • 탭 상단의 불꽃 모양 아이콘 우측의 랭크 차트 아이콘을 누르면 리렌덜이 컴포넌트를 오래 걸린 순으로 정렬할 수 있습니다.
  • 스크롤을 하면 많은 컴포넌트가 리렌더링 된 것을 확인할 수 있습니다.
  • 초록 색 박스들이 작어서 내용이 보이지 않는다면 클릭하여 크기를 늘일 수 있습니다.
  • 이 차트를 통해 변화와 관계 없는 컴포넌트들도 리렌더링 된 것을 확인할 수 잇습니다.

📌 느려지는 원인 분석

컴포넌트는 다음과 같은 상황에서 리렌더링 됩니다.  (https://daradarav.tistory.com/40)

  1. props가 바뀔 때
  2. state가 바뀔 때
  3. 부모 컴포넌트가 리렌더링될 때
  4. forceUpdate 함수가 실행될 때

'할 일 1' 항목을 체크할 경우 App 컴포넌트의 state 가 변경되면서 App 컴포넌트가 리렐더링 됩니다. 부모 컴포넌트가 리렌더링되엇으니 TodoList 컴포넌트가 리렌더링 되고 그 안의 무수한 리렌더링됩니다. '할 일 1'만 리렌더링되지만, '할 일 2' ~ '할 일 2500' 까지는 리렌더링이 필요없지만 되고 있어 느려진 것입니다.

컴포넌트 리렌더링 성능을 최적화해 주는 작업을 해야합니다. 즉, 리렌더링이 불필요할 때는 리렌더링을 방지하는 작업이 필요합니다


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

컴포넌트의 리렌더링을 방지할 때는 7장(https://daradarav.tistory.com/40)에서 배운 shouldComponentUpdate 라는 라이프사이클을 사용하면 됩니다. 그러나 함수 컴포넌트에서는 라이프 사이클 메서드를 사용할 수 없어 React.memo 라는 함수를 대신 사용해야합니다. 컴포넌트의 props가 바뀌지 않았다면 리렌더링하지 않도록 설정하여 함수 컴포넌트 리렌더링 성능을 최적화합니다.

 

 

React.memo 의 사용법은 컴포넌트를 만들고 감싸면 됩니다. TodoListItem 컴포넌트에 다음과 같이 React.memo를 적용해봅시다.

// 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 }) => {
  ( ... )
};

export default React.memo(TodoListItem); // 주목
  • 이제는 TodoListItem 컴포넌트는 todo, onRemove, onToggle 이 바뀌지 않으면 리렌더링하지 않습니다.

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

현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove, onToggle 함수도 새롭게 바뀌기 때문에 컴포넌트 최적화가 된 것은 아닙니다. onRemove, onToggle 함수는 배열 상태를 업데이트하는 과정에서 최신 상태의 todos 를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어집니다. 이렇게 함수가 계속 새로 만들어지는 것을 방지하기 위해서는

  1. useState 의 함수형 업데이트 기능을 사용하기
  2. useReducer 를 사용하기

와 같은 방법 2가지가 있습니다.


📚 useState의 함수형 업데이트

기존에 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

import React, { useState, useRef, useCallback } from 'react';
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;
}

const App = () => {
  const [todos, setTodos] = useState(createBulkTodos);
  const nextId = useRef(2501);
 
  const onInsert = useCallback(
    text => {
      const todo = {
        id: nextId.current,
        text,
        checked: false,
      };
      setTodos(todo => todo.concat(todo));
      nextId.current += 1; // nextId 1씩 더하기
    },
    [todos],
  );

  const onRemove = useCallback(
    id => {
      setTodos(todo => todo.filter(todo => todo.id !== id));
    },
    [todos],
  );
  
  const onToggle = useCallback(
    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;
  • setTodos를 사용할 때 그 안에 todos => 만 앞에 넣어두면 됩니다.

 

렌더링 소요시간이 13.4ms 로 줄은 것을 확인할 수 있습니다.


📚 useReducer 사용하기

useState의 함수형 업데이트 대신 useReducer를 사용해도 onToggle 과 onRemove가 새로워지는 문제를 해결할 수 있습니다.

// App.js

import React, { useReducer, useRef, useCallback } from 'react';
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': // 새로 추가
      // { 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: 'REMOVE', 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);
  const nextId = useRef(2501);

const onInsert = useCallback(text => {
    const todo = {
      id: nextId.current,
      text,
      checked: false,
    };
    dispatch({ type: 'INSERT', todo });
    nextId.current += 1; // nextId 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 함수를 호출합니다.
  • 이 코드는 상태를 업데이트 하는 로직을 모아서 컴포넌트 밖에 둘 수 있다는 장점이 있습니다.

📌 불변성의 중요성

리액트 컴포넌트에서 상태를 업데이트할 때는 불변성을 지키는 것이 주요합니다. 

 

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]; // 똑같은 배열을 가리킴
nextArrayBad[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) // 다른 객체기 때문에 true
  • 불변성이 지켜지지 않으면 객체 내부의 값이 바뀐 것을 감지할 수 없습니다.
  • 전개 연산자 (... 문법)을 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사(shallow copy)를 합니다.
  • 내부의 값이 완전히 새로 복사되는 것이 아니라 겉만 복사되기 때문에 배열이라면 내부의 배열도 따로 복사해야합니다.

 

객체의 내부 복사에 대한 코드를 봅시다.

const todos = [{ id: 1, checked: true }, { id: 2, checked: true }];
const nextId = [...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,
        enalbed: false
    }
};

console.log(complexObject === nextComplexObject); // false
console.log(complexObject.ObjectInside === nextComplexObject.ObjectInside); // flase
  • 배열 또는 객체의 구조가 복잡하다면 이렇게 불변성을 유지하면서 업데이트 하는 것도 까다로워집니다.
  • 복잡한 경우 immer 라는 라이브러리 도움을 받아 간단하게 작업할 수 있습니다. (이것은 다음 포스트에서 알아봅니다.)

📌 TodoList 컴포넌트 최적화하기

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

 

TodoList 컴포넌트를 다음과 같이 수정해봅시다.

// TodoList.js

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 배열이 업데이트 되기 때문이므로 불필요한 렌더링이 발생하지 않습니다.
  • App 컴포넌트에 다른 state 가 추가되어 해당 값이 업데이트될 때는 TodoList 컴포넌트가 불필요한 리렌더링을 할 수 있어, React.memo를 사용하여 미리 최적화 합니다.

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

리렌더링 성능을 필요할 때만 리렌더링하도록 하는 방법 외에도 최적화 방법은 또 있습니다. 일정 관리 애플리케이션에서 초기 데이터가 2,500개 등록되어 있지만 실제로 보이는 것은 9개 뿐입니다. 2,491개의 비효율적인 렌더링을 하는 것은 시스템 자원 낭비입니다.

react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하도록 합니다. 스크롤 되면 해당 스크롤 위치에서 보여 주어야할 컴포넌트를 자연스럽게 렌더링 시킵니다. 불필요한 자원을 쉽게 아낄 수 있습니다.


📚 최적화 준비

yarn 을 이용해 라이브러리를 설치합시다.

$ yarn add react-virtualized

react-virtualized 에서 제공하는 List 컴포넌트를 사용하여 TodoList 컴포넌트 성능을 최적화해봅시다.

이를 위해서는 각 항목의 실제 크기를 px 단위로 알아야합니다. 크롬 개발자 도구의 좌측 상단에 있는 아이콘을 눌러서 크기를 알고 싶은 항목에 커서를 대보면 크기를 알 수 있습니다.

  • 각 항목의 크기는 493px, 세로 57px입니다.
  • 두 번째 항목부터 테두리가 포함되기 때문에 두 번째 항목의 크기를 확인해야합니다.

📚 TodoList 수정

알아낸 크기를 바탕으로 TodoList를 수정해봅시다.

 // 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 = todo[index];
      return (
        <TodoListItem
          todo={todo}
          key={key}
          onRemove={onRemove}
          onToggle={onToggle}
          style={style}
        />
      );
    },
    [onRemove, onToggle, todos],
  );

  return (
    <List
      className='TodoList'
      width={493} // 전체 크기
      height={493} // 전체 높이
      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로 설정해야합니다.
  • List 컴포넌트를 사용할 때는 해당 리스트의 전체 크기와 각 항목의 크기, 각 항목의 렌더링할 때 사용해야하는 함수, 배열을 props로 넣어야합니다.
  • 이 컴포넌트가 전달받은 props를 사용하여 자동으로 최적화됩니다.

📚 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,
  (prevProps, nextProps) => prevProps.todo === nextProps.todo,
);
  • render 함수에서 기존에  보여주던 내용을 div 로 감쌌습니다.
  • 해당 div에는 TodoListItem-virtualized 라는 클래스 내임으로 props로 받아온 style을 적용시켰습니다.
  • TodoListItem-virtualized 클래스는 컴포넌트 사이사이에 테두리를 제대로 쳐주고 홀수/짝수 번째 항목에 다른 배경 색상을 지정하려고 하는 것입니다.

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

// TodoListItem.scss

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

( ... )

 

이 코드를 통해 홀수 번째, 짝수 번째 색상이 바뀐 것을 알아봤습니다.

렌더링 시간이 2.6ms 까지 줄어든 것을 확인할 수 있습니다.

반응형
profile

다라다라V

@DaraDaraV

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!