본문 바로가기
FE/React

3. 컴포넌트 성능 최적화

by aeyong-dev 2023. 7. 26.

우리는 이제 컴포넌트의 성능을 최적화 하는 방법을 알아볼 것입니다. 

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

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

3. 1 많은 데이터 렌더링

이전 실습 내용의 App.js를 다음과 같이 수정해봅시다.

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

function App() {
  const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(2501);
  
  ...

createBulkTodos 함수로 2500개의 더미데이터를 만들었습니다.

useState의 인자로는 createBulkTodos()가 아닌 createBulkTodos 라고 적어야만 합니다. 

createBulkTodos()라고 작성하게 된다면 리렌더링 될 때 마다 2500개의 더미데이터가 생성될 것입니다. 

createBulkTodos라고 작성하여 처음 렌더링 될 때만 실행되도록 해야합니다. 


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

성능 분석을 할 때는 렌더링하는데 걸리는 정확한 시간의 측정이 필요합니다. 

이 시간을 측정하기 위해 React dev tools를 사용합니다. 

개발자 도구를 켜서 profiler 탭을 열고, 파란색 녹화버튼을 누릅니다.

할일 1 항목을 체크하고, 화면에 변화가 생기면 녹화버튼을 한번 더 눌러 성능 분석 결과를 확인합니다.

React developer tool

Durations를 봅시다. Render: 264.9ms 라고 하네요. 렌더링하는데 264.9ms가 걸렸다고 합니다. 

그리고 파란색 불꽃모양 옆의 파란색 차트모양을 눌러보겠습니다.

리렌더링된 컴포넌트를 오래 걸린 순서대로 정렬하여 나열해줍니다. 클릭하면 어떤 컴포넌트인지 자세히 볼 수 있습니다. 

변화를 일으킨 컴포넌트(할일 1)와 관계없는 컴포넌트 까지 리렌더링 되었습니다. 

결코 성능이 좋다고 할 수 없습니다. 이제 이것을 함께 최적화해보겠습니다.


3. 3 느려지는 원인 분석

컴포넌트가 리렌더링 되는 상황들은 다음과 같습니다. 

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

현재 상황을 보겠습니다. 

할일1 항목을 체크하면, App 컴포넌트의 state가 변경되어 App이 리렌더링 됩니다. 

부모 컴포넌트가 리렌더링 되었으므로, 그 자식들 중 하나인 TodoList가 리렌더링 되고, 그 자식들 또한 전부 리렌더링 될 것입니다. 

할일1만 리렌더링 되면 되는건데, 나머지 할일들 모두 리렌더링 되니 이렇게 느린겁니다.

컴포넌트 갯수가 적다면 상관없지만, 이처럼 2000개 이상의 컴포넌트가 리렌더링 된다면 당연히 성능은 떨어질 것입니다. 

이때 컴포넌트 리렌더링 성능을 최적화 해야합니다. 

리렌더링이 불필요할 때는 리렌더링을 방지해야 하는데, 어떻게 하는건지 이제부터 알아보겠습니다.


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

리렌더링을 방지하려면 shouldComponentUpdate 라는 라이프사이클을 사용하면 된다고 합니다. 

shouldComponentUpdate란?

`shouldComponentUpdate`는 React 클래스 컴포넌트에서 사용되는 라이프사이클 메서드(Lifecycle Method) 중 하나입니다. 이 메서드를 사용하여 컴포넌트가 업데이트를 할지 여부를 결정할 수 있습니다. React 컴포넌트는 상태(state)나 속성(props)이 변경되거나, 부모 컴포넌트가 리렌더링되면 기본적으로 다시 렌더링됩니다. 그러나 `shouldComponentUpdate`를 구현하여 컴포넌트가 업데이트되는 조건을 세밀하게 제어할 수 있습니다. 이 메서드를 사용하면 불필요한 렌더링을 방지하여 성능을 최적화할 수 있습니다. `shouldComponentUpdate` 메서드의 기본 구조는 다음과 같습니다:
shouldComponentUpdate(nextProps, nextState) {
	// 업데이트를 할지 여부를 결정하는 조건을 작성 
	// nextProps: 다음 속성(props) 값 
	// nextState: 다음 상태(state) 값 
	// 반환 값: true(업데이트 수행), false(업데이트 무시) 
}​
`shouldComponentUpdate` 메서드는 `nextProps`와 `nextState`를 인수로 받으며, 현재 속성과 상태와 다음 속성과 상태 값을 비교하여 업데이트를 할지 여부를 결정합니다. 메서드가 `true`를 반환하면 컴포넌트가 업데이트되고, `false`를 반환하면 업데이트가 무시됩니다. 예를 들어, 특정 상태나 속성이 변경되었을 때에만 컴포넌트가 리렌더링되도록 하고자 할 때 `shouldComponentUpdate`를 사용할 수 있습니다. 이를 통해 불필요한 렌더링을 최소화하여 성능을 향상시킬 수 있습니다. 단, 주의할 점은 `shouldComponentUpdate` 메서드를 구현할 때 성능에 영향을 주지 않는 비교 로직을 잘 작성해야 한다는 점입니다.

하지만 함수형 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없습니다. 

대신, React.memo라는 함수를 사용합니다. 

컴포넌트의 props가 바뀌지 않았다면 리렌더링 되지 않도록 설정하여 최적화합니다.

사용법은 굉장히 간단합니다. 

export 시, 컴포넌트를 감싸주기만 하면 됩니다. 

TodoListItem.js를 다음과 같이 고칩니다.

import React from 'react';

...

const TodoListItem = ({ todo, onRemove, onToggle }) => {

  ...
  
};

export default React.memo(TodoListItem);

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

와 정말 쉽다!


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

하지만 우리의 프로젝트에서 React.memo만 사용해서는 최적화가 끝나지 않습니다. 

todos배열이 업데이트 되면 onRemove와 onToggle함수도 새롭게 바뀝니다. 

이 함수들은 최신 상태의 todos를 참조하기 때문에, todos배열이 업데이트되면 함수 또한 새로 만들어집니다. 

이와 같은 상황을 막기 위해서는 두가지 방법이 있습니다. 

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

3. 5. 1 useState의 함수형 업데이트

setTodos에서, 새로운 상태를 파라미터로 넣어줬습니다. 

새로운 상태 대신, 상태를 어떻게 업데이트 할지에 대한 함수를 파라미터로 넣으면 어떨까요?

이것을 함수형 업데이트 라고 합니다. 

예시를 보겠습니다.

const [numebr, setNumber] = useState(0);
const onIncrease = useCallback(
	() => setNumebr(prevNumber => prevNumber+1),
    [],
);

setNumebr(numebr+1) 대신, 어떻게 업데이트 할 것인지에 대해 정의해주는 업데이트 함수를 넣어줬습니다. 

이렇게 함으로써 useCallback을 사용할 때 두번째 파라미터로 넣는 배열에 number를 안넣어도 됩니다. 

이제 onToggle, onRemove 함수에서 useState의 함수형 업데이트를 사용해보겠습니다. 

onInsert도 수정해볼게요. App.js를 수정합시다.

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

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

function App() {
  const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(4);

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

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

export default App;

이 코드를 바탕으로 실행하고, 개발자 도구로 성능을 측정해봅시다. 

성능이 비약적으로 좋아진 것을 확인할 수 있습니다. 

저기서 빗금친 박스들은 React.memo를 통해 리렌더링 되지 않은 컴포넌트들 입니다. 

랭크 차트를 확인해보면, 리렌더링된 컴포넌트가 확 줄은 것을 볼 수 있습니다. 

3. 5. 2 useReducer 사용

useState의 함수형 업데이트를 사용하는 것 대신, useReducer를 사용해도 최적화를 할 수 있습니다. 

App.js를 다음과 같이 고치겠습니다. 

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

function createBulkTodos() {
  const array = [];
  for (let i = 0; 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;
  }
}

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

  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를 사용하면 코드를 많이 고쳐야하지만, 상태 업데이트 로직을 컴포넌트 바깥에 둘 수 있다는 장점이 있습니다. 

성능적인 면에서는 두 방법 모두 비슷하기 때문에 취향껏 하면 됩니다. 


3. 6 불변성의 중요성

먼저 불변성이란 무엇일까요?

불변성이란,

  1. 기존 데이터를 직접 수정하지 않고
  2. 새로운 배열을 만들고,
  3. 새로운 객체를 만들어서
  4. 필요한 부분을 교체하는 방식입니다.

리액트에서 이 불변성을 지키는 것은 아주아주 중요합니다.

(불변성을 지켜줌으로써,

업데이트가 필요한 곳에서 

아예 새로운 배열(혹은 객체)을 만들기 때문에)/

(React.memo를 사용했을 때

props가 바뀌는지 아닌지를 알아내서

리렌더링 성능을 최적화 해줄 수 있습니다.)

 

교재에 있는 글인데, 제가 읽기에 불편해서 이렇게 딱딱 끊어서 적었습니다 하하

 

잘 이해가 되지 않아 선생님에게 여쭤봤습니다.

React는 컴포넌트의 상태나 속성(props)이 변경되었을 때만 해당 컴포넌트를 리렌더링합니다.
이때, 불변성을 지키지 못하면 React는 이전 상태와 현재 상태를 올바르게 비교하지 못하여,
불필요한 리렌더링이 발생할 수 있습니다.
불변성을 지키지 못한다는 것은, 기존의 배열이나 객체를 직접 수정하는 것이 아니라 새로운 배열이나 객체를 생성하고 기존의 데이터를 복사하는 방식을 사용해야 한다는 의미입니다.
예를 들어, 배열에 새로운 항목을 추가할 때, 불변성을 지키지 못하면 다음과 같이 직접 수정하는 경우가 있을 수 있습니다.
// 불변성을 지키지 않는 예시
const myArray = [1, 2, 3];
myArray.push(4);
// 직접 수정하는 방식 (불변성 위반)
console.log(myArray);
// [1, 2, 3, 4]​

 

이렇게 직접 수정하면, React는 기존 배열의 참조를 그대로 가지고 있기 때문에 불필요한 리렌더링이 발생할 수 있습니다.
하지만 불변성을 지킨다면 새로운 배열을 생성하고 기존의 데이터를 복사하여 사용해야 합니다.
// 불변성을 지키는 예시
const myArray = [1, 2, 3];
const newArray = [...myArray, 4];
// 새로운 배열을 생성하여 기존 데이터를 복사 (불변성 유지)
console.log(newArray);
// [1, 2, 3, 4]​

 

React는 불변성을 지키는 경우, 이전 상태와 현재 상태를 정확하게 비교하여 어떤 부분이 변경되었는지를 판단하고, 변경된 부분만 리렌더링합니다.
이로 인해 불필요한 리렌더링을 최소화하여 성능을 최적화할 수 있습니다.
`React.memo`는 이러한 불변성을 지키는 컴포넌트에 대해 작동합니다.
즉, `React.memo`는 컴포넌트의 속성(props)이 변경되었을 때만 리렌더링을 방지하는데, 이때 불변성을 지키는 것이 중요한 역할을 합니다.
따라서 불변성을 지키지 못한다면 `React.memo`의 최적화 효과를 제대로 얻을 수 없을 수 있습니다.

보통 불변성을 지키기 위해 spread operation을 사용합니다.

const newArray = [...prevArray, 1, 2 ,3] 이런식으로요. 

이렇게 해서 불변성을 지킵니다. 

하지만 이것은 얕은 복사입니다. 

내부의 값이 완전히 복사되는 것이 아니라 가장 겉부분의 값만 복사돼요.

무슨말인지 모르겠죠? 예시를 들어 보겠습니다. 

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라는 라이브러리를 사용하는데, 이것은 다음 포스팅에서 공부할 것입니다. 


3. 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배열이 업데이트 될 때만 일어나기 때문입니다.

하지만 App 컴포넌트에 다른 state가 추가되고 그것들이 업데이트 될 때는 TodoList 컴포넌트가 불필요한 리렌더링을 할 수 있습니다.

그래서 지금 React.memo를 이용해 미리 최적화 한 것입니다. 

 

리스트와 관련된 컴포넌트를 수정할 때는 리스트 컴포넌트와 리스트의 아이템 컴포넌트 모두 최적화를 해줘야 합니다.

필수는 아니지만요. 내부 데이터가 100개가 넘지 않는 작은 상황이라면 굳이 할 필요는 없습니다.


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

우리의 TodoLIst에는 2500개의 항목이 있습니다. 

처음에는 9개의 항목만 보여요. 

그런데 내부적으로는 2500개 모두가 미리 렌더링 되어있습니다. 

어짜피 보이지 않는데 굳이 렌더링을 해야할까요? 자원의 낭비에요.

그래서 react-virtualized를 사용해서, 화면에 컴포넌트가 나타날 때 렌더링하는 식으로 바꿔보겠습니다. 그 전에는 크기만 차지하고요.

자원을 아주 효율적으로 사용할 수 있을거에요.

3. 8. 1 최적화 준비

다음과 같이 터미널에 입력하여 react-virtualized를 설치해줍시다.

$ yarn add react-virtualized

그리고 우리는 각 항목에 대한 픽셀단위 크기를 알아야합니다. 

개발자 도구로 크기를 알아봅시다. 

항목의 크기는 512*56px이네요. 

react-virtualized의 List 컴포넌트를 사용하여 코드를 수정해보겠습니다. 

3. 8. 2 TodoList 수정

이제 본격적으로 코드를 수정해보겠습니다. 

TodoList 컴포넌트를 다음과 같이 수정해주세요.

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

const TodoList = ({ todos, onRemove, onToggle }) => {

  const rowRerenderer = 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={512}                  //전체 크기
      height={513}                 //전체 높이  
      rowCount={todos.length}      //항목 개수
      rowHeight={56}              //항목 높이
      rowRenderer={rowRerenderer} //항목을 렌더링할 때 쓰는 함수
      list={todos}                //배열
      style={{outline:'none'}}    //List에 기본 적용되는 outline 스타일 제거
      />
  )
};

export default React.memo(TodoList);

List 컴포넌트를 활용하기 위해서 rowRenderer 함수를 작성했습ㄴ디ㅏ. 

react-virtualized의 List 컴포넌트에서 각각의 TodoItem을 렌더링할 때 사용합니다.

그리고 List 컴포넌트의 props로 설정해줘야 해요. 

이 함수는 파라미터의 값들을 객체로써 가져와 사용합니다. 

 

List 컴포넌트를 사용하려면 다음과 같은 정보들을 props로 넣어야 합니다.

  • 리스트의 전체 크기
  • 각 항목의 높이
  • 각 항목을 렌더링할 때 사용해야하는 함수
  • 배열

이렇게 해줌으로써 List 컴포넌트가 자동으로 최적화를 해줍니다. 

 

3. 8. 3 TodoListItem 수정

위 코드를 작성하고 실행해보면 아주 난리가 나 있습니다. 스타일이 엄청 깨져요. 

TodoListItem을 다음과 같이 수정해서 해결합시다. 

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

기존의 내용에서 div태그로 한번 더 감싸고, 클래스명을 TodoListItem-virtualized로 설정했습니다. 

-> 이는 컴포넌트 사이사이에 borderline을 제대로 그려주고, 항목의 칸에 색상을 입히기 위해서 입니다. 

그리고 props로 받아온 style을 적용했습니다. 

 

이제 TodoListItem.scss를 수정해봅시다. 최상단에 아래의 코드를 삽입할게요.

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

모든 작업이 완료되었습니다!

이제 다시 한번 성능을 측정해볼게요.

6.7ms까지 줄였습니다. 

 

이렇게 우리는 컴포넌트의 성능을 최적화 하는 방법에 대해 배웠습니다. 

작동하는 것에서 그치는 것이 아니라, 최적의 성능을 낼 줄 알아야 개발자겠죠?

앞으로 토이프로젝트를 많이 하다보면 잘 해낼 수 있으리라 생각합니다.