상세 컨텐츠

본문 제목

[리액트를 다루는 기술] 11장 컴포넌트 성능 최적화

21-22/21-22 리액트 스타터 -1

by Kimpeep 2022. 1. 17. 13:00

본문

728x90

10장에서 만든 일정 관리 애플리케이션은 데이터가 많지 않아 사용하는 데 불편하지 않았습니다. 그러나 데이터가 무수히 많아지면 애플리케이션이 느려지는 것을 체감할 수 있습니다. 이번 11장에서는 성능을 최적화하는 방법에 대해 알아보겠습니다.

 

11.1 많은 데이터 렌더링하기

먼저 실제로 랙(lag)을 경험할 수 있도록 많은 데이터를 렌더링해 보겠습니다. 데이터를 일일이 입력하지 않고 코드를 사용하여 추가하겠습니다.

App 컴포넌트를 다음과 같이 수정하세요.

// App.js
import { 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);

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

  (...)
};

export default App;

데이터를 직접 다 입력할 수는 없으므로 createBulkTodos라는 함수를 만들어 데이터 2,500개를 자동으로 생성했습니다. 여기서 주의할 점은 useState의 기본갑세 함수를 넣어 준 것입니다. 여기서 useState(createBulkTodos())라고 작성하면 리렌더링될 때마다 createBulkTodos 함수가 호출되지만, useState(createBulkTodos)처럼 파라미터를 함수 형태로 넣어 주면 컴포넌트가 처음 렌더링될 때만 createBulkTodos 함수가 실행될 것입니다.

많은 데이터 렌더링하기

데이터 2,500개가 렌더링되었으면 항목 하나를 체크해 보세요. 이전보다 느려진 것이 느껴지시나요?

 

 

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

성능을 분석해야 할 때는 느려졌다는 느낌으로 충분하지 않습니다. 정확히 몇 초가 걸리는지 확인해야 하는데, 이는 React DevTools를 사용하여 측정하면 됩니다. 리액트 v17부터는 리액트 전용 개발자 도구인 React DevTools를 사용해야 성능 분석을 자세하게 할 수 있습니다. 10장에서는 개발자 도구의 Components 탭을 열어봤었는데, 그 우측에 있는 Profiler라는 탭을 열어보세요. 이 탭을 열면 녹화 버튼이 보입니다.

빨간색 사각형 버튼을 누르고 '할 일 1' 항목을 체크한 다음, 화면에 변화가 반영되면 녹화 버튼을 한번 더 누르세요. 그러면 성능 분석 결과가 나옵니다.

성능 분석 결과

우측에 있는 Render duration은 리렌더링에 소요된 시간을 의미합니다. 변화를 화면에 반영하는 데 271.3ms가 걸렸다는 의미입니다. (1ms는 0.001초입니다.)

 

Profiler 탭의 상단에 있는 불꽃 모양 아이콘 오른쪽에 있는 랭크 차트 아이콘을 눌러보세요.

이 화면에서는 리렌더링된 컴포넌트를 오래 걸린 순으로 정렬하여 나열해줍니다.

 

 

11.3 느려지는 원인 분석

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

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

현재 상황을 분석해 보면, '할 일 1' 항목을 체크할 때 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링됩니다. 부모 컴포넌트가 리렌더링 되었으니 TodoList 컴포넌트가 리렌더링되고 그 안의 무수한 컴포넌트들도 리렌더링됩니다.

'할 일 1' 항목만 리렌더링이 되고, 나머지는 리렌더링하지 않아도 되는 상황인데 모두 리렌더링 중이라 느린 것입니다. 이럴 때는 컴포넌트 리렌더링 성능을 최적화해 주는 작업을 해 주어야 합니다. 리렌더링이 불필요할 때는 리렌더링을 방지해 주어야 하는데, 지금부터 이걸 알아보겠습니다.

 

 

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

컴포넌트의 리렌더링을 방지할 때는 7장에서 배운 shouldComponentUpdate라는 라이프사이클을 사용하면 됩니다. 그러나 함수 컴포넌트에서는 라이프사이클을 사용할 수 없습니다. 대신 React.memo라는 함수를 사용합니다. 컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정해 리렌더링 성능을 최적화해 줄 수 있습니다.

React.memo는 컴포넌트를 만들고 나서 감싸 주기만 하면 됩니다. 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 }) => {
    const {id, text, checked} = todo;

    return (
        <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>
    );
};

export default React.memo(TodoListItem);

맨 첫 줄이 추가되고, 맨 마지막 줄에서 TodoListItem이 React.memo로 감싸졌습니다. 이제 TodoListItem 컴포넌트는 todo, onRemove, onToggle이 바뀌지 않으면 리렌더링을 하지 않습니다.

 

 

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

React.memo를 사용하는 것만으로 컴포넌트 최적화가 끝나지는 않습니다. 현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새로 만들어집니다. 이렇게 함수가 계속 만들어지는 상황을 방지하는 방법은 2가지가 있습니다. 첫 번째는 useState의 함수형 업데이트 기능을 사용하는 것, 두 번째는 useReducer를 사용하는 것입니다.

 

11.5.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의 함수형 업데이트를 사용해 보겠습니다. 이 과정에서 onInsert 함수도 수정하겠습니다.

// App.js
import { 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(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;

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

코드를 저장하고, 다시 Profiler를 보면 아까보다 성능이 훨씬 향상된 것을 확인할 수 있습니다.

 

11.5.2 useReducer 사용하기

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

// App.js
import { useState, useRef, useCallback, useReducer } 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;
    }, [],
  );

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

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

  (...)

export default App;

useReducer를 사용할 때는 원래 두 번째 파라미터에 초기 상태를 넣어주어야 합니다. 그러나 지금은 그것 대신 두 번째 파라미터에 undefined를 넣고, 세 번째 파라미터에 초기 상태를 만들어 주는 함수인 createBulkTodos를 넣어 주었습니다. 그러면 컴포넌트가 맨 처음 렌더링될 때만 createBulkTodos 함수가 호출됩니다.

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

 

 

11.6 불변성의 중요성

리액트 컴포넌트에서 상태를 업데이트할 때 불변성을 지키는 것은 중요합니다. 기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는 것을 '불변성을 지킨다'고 합니다. 다음 예시 코드에서는 어떻게 불변성을 지키는 지 생각해보세요.

// 예시
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
};

const nextObjectBad = object; // 객체가 복사되지 않고, 똑같은 객체를 가리킵니다.
nextObjectBad.value = nextObjectBad.value + 1;
console.log(object === nextObjectBad); // 같은 객체이기 때문에 true

const nextObjectGood = {
  ...object,  // 기존에 있던 내용을 모두 복사해서 넣습니다.
  value: object.value + 1
};
console.log(object === nextObjectGood); // 다른 객체이기 때문에 false

불변성이 지켜지지 않으면 객체 내부의 값이 새로워져도 바뀐 것을 감지하지 못합니다. 그러면 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

만약 객체 안에 있는 객체라면 불변성을 지키면서 새 값을 할당해야 하므로 다음과 코드와 같이 해주어야 합니다.

// 예시
const nextComplexObject = {
  ...complexObject,
  objectInside: {
    ...complexObject.objectInside,
    enabled: false
  }
};
console.log(complexObject === nextComplexObject) // false
console.log(complexObject.objectInside === nextComplexObject.objectInside); // false

배열이나 객체의 구조가 복잡해진다면 불변성을 유지하면서 업데이트 하는 것이 까다로워집니다. 이럴 경우 immer라는 라이브러리의 도움을 받으면 편하게 작업할 수 있는데, 이는 12장에서 알아보겠습니다.

 

 

11.7 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 배열이 업데이트될 때이기 때문입니다. 그래서 지금은 TodoList 컴포넌트는 불필요한 리렌더링이 발생하지 않습니다. 그러나 다른 state가 추가되어 업데이트될 때 불필요한 리렌더링이 발생할 수 있기 때문에 미리 최적화해 준 것입니다.

 

 

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

현재 화면에 나오는 리스트의 개수는 9개 뿐이고, 나머지는 스크롤해야 볼 수 있습니다. 그러나 9개를 제외한 나머지 컴포넌트는 스크롤하기 전에는 보이지 않음에도 렌더링이 이루어집니다. 따라서 이번 절에서 배우는 react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하며, 스크롤되면 렌더링시켜 낭비되는 자원을 아낄 수 있습니다.

 

11.8.1 최적화 준비

$ yarn add react-virtualized

 

먼저 yarn을 이용해 react-virtualized를 설치합니다. 최적화 수행 전에 각 항목의 실제 크기를 px 단위로 알아냅니다.

크기를 알아낼 때는 두 번째 항목을 확인해야 합니다. 두 번째 항목부터는 테두리가 포함되어 있기 때문입니다.

 

11.8.2 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 = 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={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 컴포넌트를 수정하면 됩니다.

// TodoListItem.js - render
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);

render 함수에서 기존에 보여주던 내용을 div로 한 번 감싸고, 해당 div에는 TodoListItem-virtualized라는 className을 설정하고, props로 받아 온 style을 적용시켰습니다. TodoListItem-virtualized라는 클래스를 만든 것은 컴포넌트 사이에 테두리를 제대로 쳐 주고, 홀수와 짝수 번째 항목에 다른 배경 색상을 설정하기 위해서입니다.

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

(...)

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

 

최적화 작업이 끝나고 다시 성능을 측정해보면 그 전보다 더 줄었습니다.

 

 

 

11장 Quiz

1. createBulkTodos 함수를 useState에 넣을 때, 컴포넌트가 처음 렌더링될 때만 실행되려면 어떤 형태로 넣어야 할까요?

  1. useState(createBulkTodos())
  2. useState(createBulkTodos)

2. 함수 컴포넌트에서는 라이프사이클을 사용할 수 없기 때문에 컴포넌트의 리렌더링을 방지할 때는 _____________라는 함수를 사용합니다. 그 함수는 무엇일까요?

3. 함수가 계속 만들어지는 상황을 방지하는 방법은 2가지가 있는데, 첫 번째는 ㅇㅇㅇㅇㅇㅇㅇㅇ의 함수형 업데이트 기능을 사용하는 것, 두 번째는 ㅇㅇㅇㅇㅇㅇㅇㅇㅇㅇ를 사용하는 것입니다.

4. setTodos를 사용할 때 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수도 있는데, 이를 ㅇㅇㅇ ㅇㅇㅇㅇ라고 부릅니다.

5. 기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는 것을 'ㅇㅇㅇ을 지킨다'고 합니다.

 

 

더보기

1. 2번

2. React.memo

3. useState, useReducer

4. 함수형 업데이트

5. 불변성

 

 


 

 

                                                                                                                          Corner React Starter #1
                                                                                                                                          Editor 알밤

728x90

관련글 더보기