프론트엔드/리액트를 다루는 기술

리액트를 다루는 기술, 컴포넌트 성능 최적화

Hyeon_E 2023. 6. 30. 20:09

[ 11.1 많은 데이터 렌더링하기 ]

import React, { useRef, useState } from "react";
function createBulkTodos() {
  const array = [];
  for (let i = 1; i <= 2500; i++) {
    array.push({
      id: i,
      text: `할일 ${i}`,
      checked: false,
    });
  }
  return array;
}
function App() {
  const [todos, setTodos] = useState(createBulkTodos);
  const nextId = useRef(2501);
  return <div>todos</div>;
}
export default App;

 

여기서 주의할 점은 useState의 기본값에 함수를 넣어 주었다는 것

여기서 useState(createBulkTodos())라고 작성하면 리렌더링될 때마다 createBulkTodos 함수가 호출되지만 useState(createBulkTodos)처럼 파라미터를 함수 형태로 넣어주면 컴포넌트가 처름 렌더링될 때만 createBulkTods 함수가 실행됨. 실행해 보면 이전보다 느려진 것이 느껴짐

 

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

성능을 분석할때 정확히 몇 초가 걸리는지 확인해야 하는데 크롬 개발자 도구의 Performance탭을 사용하여 측정

크롬 개발자 도구의 Performance 탭을 열면 다음과 같이 녹화 버튼이 나타남

● 버튼을 누르고 이것저것 실행한 다음 화면에 변화가 반영된 다음 ● 버튼을 누르면 성능 분석 결과가 나타남

성능분석 결과에 나타난 Timings 를 열어보면 각 시간대에 컴포넌트의 어떤 작업이 처리되었는지 확인할 수 있음

 

[ 11.3 느려지는 원인 분석 ]

컴포넌트는 다음과 같은 상황에서 리렌더링이 발생

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

코드를 분석해 보면 '할일' 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링 됨

부모 컴포넌트가 리렌더링 되었으니 TodoList 컴포넌트가 리렌더링 되고 그 안의 무수한 컴포넌트들도 리렌더링 됨

결국에는 해당 '할일' 항목만 리렌더링되어야 하는 것이 맞는데 모든 '할일'이 리렌더링 되고 있어서 느린것

컴포넌트의 개수가 많지 않다면 모든 컴포넌트를 리렌더링해도 느려지지 않지만 너무 많으면 성능히 저하됨

이럴경우에는 컴포넌트 리렌더링 성능을 최적화해 주는 작업을 해 주어야 함

불필요한 리렌더링을 방지해주어야 하는 것

 

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

컴포넌트의 리렌더링을 방지할 때 shouldComponentUpdate라는 라이프사이클을 사용하면 됨

그런데 함수형 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없음

그 대신 React.memo라는 함수를 사용컴포넌트의 props가 바뀌지 않았다면 리렌더링하지 않도록 설정하여 함수형 컴포넌트의 리렌더링 성능을 최적화해 줄 수 있음

React.memo의 사용법은 매우 간단함 컴포넌트를 만들고 나서 감싸 주기만 하면 됨

 

export default React.memo(TodoListItem);

 

이렇게 감싸주면 TodoListItem 컴포넌트는 todo, onRemove, onToggle이 바뀌지 않으면 리렌더링을 하지 않음

 

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

React.memo를 사용하는 것만으로 컴포넌트 최적화는 끝나지 않음

현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀌기 때문

onRemove와 onToggle 함수는 배열 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어짐 이렇게 함수가 계속 만들어지는 상황을 방지하는 방법이 두가지가 있음

 

▶ 11.5.1 useState의 함수형 업데이트

setTodos를 사용할 때 새로운 상태를 파라미터로 넣는 대신 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수 있음 이를 함수형 업데이트라고 부름

 

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

 

setNumber(number+1)을 하는 것이 아니라 어떻게 업데이트할지 정의해주는 업데이트 함수를 넣어줌

그러면 useCallback을 사용할 때 두번째 파라미터로 넣는 배열에 number를 넣지 않아도 됨

 

  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,
      ),
    );
  }, []);

 

성능을 확인해보면 훨씬 향상된 것을 확인할 수 있음

 

▶ 11.5.2 Reducer 사용하기 

useState 함수형 업데이트 대신 useReducer를 사용해도 함수가 계속 새로워지는 문제를 해결할 수 있음

 

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

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

  const nextId = useRef(4);

  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 });
  }, []);

 

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

지금은 그 대신 두번째 파라미터에 undefined를 넣고 세번째 파라미터에 초기 상태를 만들어 주는 함수인 createBlukTodos를 넣어 주었음 이렇게 하면 컴포넌트가 맨 처음 렌더링 될때만 createBulkTodos 함수가 호출

 

useReducer를 사용하는 방법은 기존 코드를 많이 고쳐야 한다는 단점이 있지만 상태를 업데이트 하는 로직을 모아서 컴포넌트 바깥에 둘 수 있다는 장점이 있음 성능상으론느 두가지 방법이 비슷하기 때문에 어떤 방법을 선택할지는 본인선택임

 

[ 11.6 불변성의 중요성 ]

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

 

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

 

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

 

기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는 것을 '불변성을 지킨다'라고 함

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

그러면 React.memo에서 서로 비교하여 최적화하는 것이 불가능

추가로 전개 연산자( ... 문법 )를 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사를 하게 됨

즉 내부의 값이 완전히 새로 복사되는 것이 아니라 가장 바깥쪽에 있는 값만 복사

따라서 내부의 값이 객체 혹은 배열이라면 내부의 값 또한 따로 복사해 주어야 함

 

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

 

만약 객체 안에 있는 객체라면 불변성을 지키면서 새 값을 할당해야 함 배열 혹은 객체의 구조가 복잡해진다면 불변성을 유지하면서 업데이트하는 것도 까다로워짐 이렇게 복잡한 상황일 경우 immer라는 라이브러리의 도움을 받으면 정말 편하게 작업할 수 있음

 

[ 11.7 TodoList 컴포넌트 최적화 하기 ]

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

 

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

function TodoList({ todos, onRemove, onToggle }) {
  return (...);
}

export default React.memo(TodoList);

 

위에 최적화 코드는 현재 프로젝트 성능에 전혀 영향을 주지 않음

왜냐하면 TodoList 컴포넌트의 부모 컴포넌트인 App 컴포넌트가 리렌더링 되는 유일한 이유가 todos 배열이 업데이트될 때이기 때문 즉 현재 TodoList 컴포넌트는 불필요한 리렌더링이 발생하지 않음

하지만 App 컴포넌트에 다른 state가 추가되어 해당 값들이 업데이트될 때는 TodoList 컴포넌트가 불필요한 리렌더링을 할 수도 있음. 그렇기 때문에 지금 React.memo를 사용해서 미리 최적화해 준 것

 

리스트 관련 컴포넌트를 작성할 때는 리스트 아이템과 리스트 이 두가지 컴포넌트를 최적화 해주어야 함

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

 

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

일정관리 애플리케이션에 초기 데이터가 2500개 등록되어 있는데 실제 화면에 나오는 항목은 아홉 개뿐 나머지는 스크롤해야만 볼 수 있음 현재 컴포넌트중 9개를 뺀 2491개 컴포넌트는 스크롤하기 전에는 보이지 않음에도 불구하고 렌더링이 이루어지기 때문에 비효율적

 

react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링 하지 않고 크기만 차지하게끔 할 수 있음 그리고 스크롤되면 해당 스크롤 위치에서 보여 주어야 할 컴포넌트를 자연스럽게 렌더링시킴

이 라이브러리를 사용하면 낭비되는 자원을 아주 쉽게 아낄 수 있음

 

▶ 11.8.1 최적화 준비

 라이브러리 설치

 

yarn add react-virtualized

 

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

확인해보면 TodoListdp  각 항목 크기는 가로 512px, 세로 56px 이지만 두번째 항목부터는 테두리가 포함되므로 세로 57px

 

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

function TodoList({ todos, onRemove, onToggle }) {
  const rowRenderer = useCallback(
    ({ index, key, style }) => {
      const todo = todos[index];
      return (
        <TodoListItem
          todo={todo}
          key={todo.id}
          onRemove={onRemove}
          onToggle={onToggle}
          style={style}
        />
      );
    },
    [onRemove, onToggle, todos],
  );
  return (
    <List
      className="TodoList"
      width={512} //전체 크기
      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 사용하여 자동으로 최적화해 줌

 

▶ 11.8.3 TodoListItem 수정

TodoList를 저장하고 나면 스타일이 깨져서 나타날 텐데 TodoListItem 컴포넌트를 수정해주면 됨

 

function 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라는 className을 설정하고 props로 받아 온 style을 적용시켜줌

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

 

[ 11.9 정리 ]

리액트 컴포넌트의 렌더링은 기본적으로 빠르기 때문에 컴포넌트를 개발할 때 최적화 작업에 대해 너무 큰 스트레스를 받거나 모든 컴포넌트에 일일이 React.memo를 작성할 필요는 없음

단 리스트와 관련된 컴포넌트를 만들 때 보여 줄 항목이 100개 이상이고 업데이트가 자주 발생한다면 최적화를 해야함