컴포넌트 성능 최적화

Posted by yunki kim on July 6, 2021

  아래의 내용은 벨로포트의 todo app의 내용을 가지고 진행한다. 10장에서 todo app만들기는 내용을 보지 않고 독자적으로 만들었기에 내 코드를 사용하지 않았다.

https://github.com/velopert/learning-react/tree/master/10/todo-app/src

 

velopert/learning-react

[길벗] 리액트를 다루는 기술 서적에서 사용되는 코드. Contribute to velopert/learning-react development by creating an account on GitHub.

github.com

많은 데이터 렌더링 하기

  우선 App.js의 코드를 다음과 같이 수정해 보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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++) { //todo 2500개 생성
        array.push({
            id: i,
            text: `to do ${i}`,
            checked: false,
        });
    }
    return array;
}
 
const App = () => {
//useState(createBulkTodos())를 하면 리렌더링 마다 호출
//useState(createBulkTodos)를 하면 처음 렌더링 될때만 호출
  const [todos, setTodos] = useState(createBulkTodos);
 
  // 고유 값으로 사용 될 id
  // ref 를 사용하여 변수 담기
  const nextId = useRef(4);
    //...;
};
 
export default App;
 
cs

그 후 checkbox하나를 체크 하면서 크롬 개발자 툴에서 Performance를 통해 속도를 모니터링 해 보면 444.1ms 정도 걸린것을 볼 수 있다.

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

    1. 자신이 전달받은 props가 변경 되었을 때

    2. 자신의 state가 바뀔 때

    3. 부모 컴포넌트가 리렌더링 될때

    4. forceUpdate함수가 실행될 때. 

  위 코드의 App컴포넌트의 state가 변결되어 리렌더링 되는데 이에 따라 App컴포넌트의 자식 컴포넌트까지 싹 다 리렌더링 된다. 즉, 바뀐것은 리스트 하나의 체크박스인데 나머리 리스트까지 싹다 리렌더린 된다. 

  위 문제점을 해결하기 위해서는 shouldComponentUpdate라는 메서드를 사용하면 되지만 함수형 컴포넌트에서는 라이프 사이클 메서드를 사용할 수 없으니 그 대신 React.memo라는 함수를 사용하면 된다. 컴포넌트의 porps가 바뀌지 않았다면 리렌더링을 하지 않도록 하면 된다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//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);
 
cs

  하지만 이렇게 한다 해도 onToggle, onRemove함수는 배열 상태를 업데이트 하는 과정에서 최신 상태의 todos를 참조해야 되기 때문에 매번 새로운 함수가 만들어 진다. 이를 해결하는 방식은 두가지가 있다. 1. useState의 함수형 업데이트기능, 2. useReducer사용

  useState의 함수형 업데이트

    기존 코드에서는 useState의 파라미터로 새로운 상태를 넣어 주었다. 새로운 상태를 파라미터로 넣는 대신에 어떤식으로 업데이트 할지를 정해주는 업데이트 함수를 넣을 수 도 있다. 이와같은 방식으로 사용할 경우 useCallback에서 두번째 인자를 빈 배열로 해도 된다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//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 App = () => {
 //..
 
  const onInsert = useCallback(
      text => {
        const todo = {
          id: nextId.current,
          text,
          checked: false,
        };
        setTodos(todos => todos.concat(todo));
        nextId.current += 1// nextId 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 (
      //..
  );
};
 
export default App;
 
cs

    동일 동작을 했을때 확실이 이전에 비해 속도가 향상되었다.

  useReducer

   useReducer를 사용해도 위와 같은 효과를 얻을 수 있다. 이를 사용할려면 useReducer를 사용할때 초기 상태를 넣는 두번쨰 파라미터에 undfined를 넣고 세번쨰 파라미터에 초기상태를 만들어 주는 함수인 createBulkTools를 넣어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import React, { useState, useRef, useCallback, useReducer } from 'react';
import TodoTemplate from './components/TodoTemplate';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';
 
function createBulkTodos() {
    //...
}
 
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);
  // 고유 값으로 사용 될 id
  // ref 를 사용하여 변수 담기
  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 (
      //...
  );
};
 
export default App;
 
cs

    위 결과에서 볼 수 있듯이 useState를 사용한 것과 큰 차이는 없다. 하지만 상태를 업데이트 하는 로직을 모아서 컴포넌트 바깥에 둘 수 있다는 장접이 있기 떄문에 어떤것을 선택할지는 본인이 판단해야 한다.

 

불변의 중요성

  리엑트 컴포넌트에서 불변성을 지키는것은 매우 중요한 일이다. 위 코드 중 React.memo를 사용했을 떄 props가 바뀌었는지를 판별해 리렌더링 성능을 최적화할  수 있는 것은 업데이트가 필요한 곳에 아예 새로운 배열 또는 객체를 만들기 때문이다. 만약 불변성이 지켜지지 않는다면 객체 내부의 값이 바뀌어도 바뀐것을 감지하지 못한다.

  ...연산자를 사용하면 얕은 복사를 하게 되기 때문에 내부의 값이 객체, 배열이면 내부의 값을 따로 복사해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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
cs

  객체를 불변성을 지키면서 새 값을 할당해야 할떄에는 다음과 같이 해야 한다.

1
2
3
4
5
6
7
const nextComplexObject = {
    ...complexObject,
    objectInside: {
        ...complexObject.objectInside,
        enable: false,
    }
}
cs

 

  ToDoList 예제 좀 더 최적화 하기

    리스트에 관한 컴포넌트를 최적화 할떄에는 리스트 내부에서 사용하는 컴포넌트, 리스트로 사용되는 컴포넌트 모두를 최적화 하는 것이 좋다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
//TodoList.js
import React from 'react';
import TodoListItem from './TodoListItem';
import './TodoList.scss';
 
const TodoList = ({ todos, onRemove, onToggle }) => {
  return (
    //..
  );
};
 
export default React.memo(TodoList);
 
cs

  사실 위 최적화를 통해 프로젝트 성능 향상을 기대하기는 어렵다. ToDoList컴포넌트의 부모 컴포넌트인 App은 오직 todos배열리 업데이트 될때만 리렌더링을 하기 때문이다. 하지만 추가적으로 다른 state가 추가 된다면. 불필요한 리렌더링이 발생할 수 있다.

 

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

  페이지를 들어가 보면 일부 데이터의 경우 스크롤을 해야 볼 수 있다. 이때 스크롤을 해야 볼 수 있는 데이터를 그냥 렌더링 되면 비효율 적이므로 해당 데이터들을 스크롤되기 전에는 렌더링을 하지 않고 크기만 차지하게 하면 자원 낭비를 줄일 수 있다. 이는 react-virualized라는 라이브러리를 사용하면 된다.

  react-virtualized를 사용하기 위해서는 각 항목의 실제 크기를 px단위로 알아내야 한다. 이는 크롬 개발자 도구에서 좌측 상단 첫번째 버튼을 클릭하고 크기를 알고싶은 항목에 같다 대면 된다.

  크기를 알았다면 다음과같이 코드를 수정하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//TodoList.js
import React, {useCallback} from 'react';
import {List} from 'react-virtualized'
import TodoListItem from './TodoListItem';
import './TodoList.scss';
 
const TodoList = ({ todos, onRemove, onToggle }) => {
//react-Virtualed의 List컴포넌트에서 각 TodoItem을 렌터링 할때 사용한다.
//List컴포넌트의 props로 설정해야 한다.
  const rowRender = 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={rowRender}//항목을 렌더링 할때 쓰는 함수
        list={todos}//배열
        style=//List에 기본 적용되는 outline스타일 제거
    />
  );
};
 
export default React.memo(TodoList);
 
cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//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"} styled={style}>
          <div className="TodoListItem">
              //...
          </div>
      </div>
  );
};
 
export default React.memo(TodoListItem);
 
cs

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/*TodoListItem.scss*/
.TodoListItem {
  padding: 1rem;
  display: flex;
  align-items: center; // 세로 중앙 정렬
  .checkbox {
    cursor: pointer;
    flex: 1; // 차지할 수 있는 영역 모두 차지
    display: flex;
    align-items: center; // 세로 중앙 정렬
    svg {
      // 아이콘
      font-size: 1.5rem;
    }
    .text {
      margin-left: 0.5rem;
      flex: 1; // 차지할 수 있는 영역 모두 차지
    }
    // 체크되었을 때 보여줄 스타일
    &.checked {
      svg {
        color: #22b8cf;
      }
      .text {
        color: #adb5bd;
        text-decoration: line-through;
      }
    }
  }
  .remove {
    display: flex;
    align-items: center;
    font-size: 1.5rem;
    color: #ff6b6b;
    cursor: pointer;
    &:hover {
      color: #ff8787;
    }
  }
 
  .TodoListItem-virtualized {
    & + & {
      border-top: 1px solid #dee2e6;
    }
    &:nth-child(even) {
      background: #f8f9fa;
    }
  }
}
 
cs