10장에서 만든 일정 관리 애플리케이션은 추가되어 있는 데이터가 매우 적기 때문에 아직은 사용하는 데 불편함이 없습니다. 그러나 데이터가 무수히 많아지면, 애플리케이션이 느려지는 것을 체감할 수 있을 정도로 지연이 발생합니다. 따라서 11장에서는 이와 같은 흐름으로 컴포넌트 성능 최적화를 위한 실습을 진행합니다.
우선 실제로 랙(lag)을 경험해보기 위해 많은 데이터를 렌더링해 보겠습니다. createBulkTodos 함수를 만들어서 데이터 2500개를 자동으로 생성했습니다.
//일정관리 애플리케이션 App.js에 createBulkTodos 함수 추가
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;
여기서 주의할 점은 useState의 기본값에 함수를 넣어 주는 것입니다. 여기서 useState(createBulkTodos())라고 작성하면 리렌더링될 때마다 createBulkTodos 함수가 호출되지만, useState(createBulkTodos)처럼 매개변수를 함수 형태로 넣어주면 컴포넌트가 처음 렌더링될 때만 함수가 실행됩니다.
애플리케이션을 실행해보면, 이전보다 느려진 것을 느낄 수 있습니다.
성능을 분석할 때는 React DevTools - Profiler 탭을 사용하여 정확히 몇 초가 걸리는지 확인할 수 있습니다.
좌측 상단에 파란색 녹화 버튼을 누르고 '할 일1'항목을 체크한 다음, 화면에 변화가 반영되면 녹화 버튼을 누르면 다음과 같이 성능 분석 결과가 나타납니다.
우측의 Render Duration은 리렌더링에 소요된 시간을 의미합니다. 변화를 화면에 반영하는 데 403.5ms가 걸렸다는 의미입니다.
Profiler 탭의 상단에 있는 불꽃 모양 아이콘 우측의 랭크 차트 아이콘을 눌러보겠습니다.
이 화면에서는 리렌더링된 컴포넌트를 오래 걸린 순으로 정렬하여 나열해줍니다. 이를 보면, 이번에 변화를 일으킨 컴포넌트랑 관계없는 컴포넌트들도 리렌더링된 것을 확인할 수 있습니다. 이는 결코 좋지 못한 성능이므로, 이제 이를 최적화하는 방법에 대해 알아보겠습니다.
컴포넌트는 다음과 같은 상황에서 리렌더링이 발생합니다.
앞선 상황에서, '할 일 1' 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링됩니다. 부모 컴포넌트가 리렌더링되었으니 TodoList 컴포넌트가 리렌더링되고 그 안의 무수한 컴포넌트들도 리렌더링됩니다. '할 일 1' 항목은 리렌더링되어야 하는 것이 맞지만, '할 일 2'부터는 리렌더링을 안 해도 되는 상황임에도 모두 리렌더링되므로 느려지는 것입니다. 이럴 때 컴포넌트 리렌더링 성능을 최적화해 주는 작업이 필요합니다. 즉, 리렌더링이 불필요할 때는 리렌더링을 방지해 주어야 합니다.
컴포넌트의 리렌더링을 방지할 때는 7장에서 배운 shouldComponentUpdate라는 라이프사이클메서드를 사용하면 됩니다. 그러나 함수 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없어, React.memo라는 함수를 대신 사용합니다. 컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정하여 함수 컴포넌트의 리렌더링 성능을 최적화해 줄 수 있습니다.
React.memo의 사용법은 컴포넌트를 만들고 나서 감싸 주기만 하면 됩니다.
//TodoListItem.js
import React from 'react';
import
(...)
const TodoListItem = ({ todo, onRemove, onToggle }) =>
{
(...)
};
export default React.memo(TodoListItem);
이제 TodoListItem 컴포넌트는 todo, onRemove, onToggle이 바뀌지 않으면 리렌더링을 하지 않습니다.
React.memo를 사용하는 것만으로 컴포넌트 최적화가 끝나지는 않습니다. 현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀌기 때문입니다. 이 두 함수는 배열 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어집니다. 이런 상황을 방지하는 방법은 두 가지가 있습니다.
기존에 setTodos 함수를 사용할 때는 새로운 상태를 파라미터로 넣어 주었습니다. 이 대신, 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수도 있습니다. 이를 함수형 업데이트라고 부릅니다.
예시를 확인해 봅시다.
const [number, setNumber] = useState(0);
//prevNumbers는 현재 number 값을 가리킵니다.
const onIncrease = useCallback(
() => setNumber(prevNumber => prevNumber + 1),
[],
);
setNumber(number+1)을 하는 것이 아니라, 위 코드처럼 어떻게 업데이트할지 정의해 주는 업데이트 함수를 넣어줍니다. 그러면 useCallback을 사용할 때 두 번째 파라미터로 넣는 배열에 number를 넣지 않아도 됩니다.
onToggle, onRemove 함수에서 useState의 함수형 업데이트를 사용해 봅시다. 이 과정에서 onInsert 함수도 함께 수정하겠습니다. setTodos를 사용할 때 그 안에 todos =>만 앞에 넣어 주면 됩니다.
//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);
//고유값으로 사용될 id
//ref 사용해 변수담기
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;
Profiler 개발자 도구를 열고 성능을 측정해 봅시다.
렌더링 소요시간이 403.5ms -> 12.5ms로 줄어, 성능이 훨씬 향상된 것을 확인할 수 있습니다. 회색 빗금이 그어져 있는 박스들은 React.memo를 통해 리렌더링되지 않은 컴포넌트를 나타냅니다.
useReducer를 사용해도 onToggle과 onRemove가 계속 새로워지는 문제를 해결할 수 있습니다. App.js 코드를 다음과 같이 고쳐보겠습니다.
//App.js
import React, { useReducer, useRef, useCallback } from 'react';
import
(...)
function createBulkTodos() {
(...)
}
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);
//고유값으로 사용될 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;
}, []);
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 함수가 호출됩니다.
useReducer를 사용하는 방법은 기존 코드를 많이 고쳐야되는 단점이 있지만, 상태를 업데이트하는 로직을 모아서 컴포넌트 바깥에 둘 수 있다는 장점이 있습니다.
리액트 컴포넌트에서 상태를 업데이트할 때 불변성을 지키는 것은 매우 중요합니다. 앞에서 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]; //배열 내부의 값을 모두 복사
nextArrayGood[0] = 100;
console.log(array === nextArrayGood); //다른 배열이기 때문에 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
}
};
배열 혹은 객체의 구조가 정말 복잡해진다면 이렇게 불변성을 유지하면서 업데이트하는 것도 까다로워집니다. 이때 immer라는 라이브러리의 도움을 받으면 정말 편하게 작업할 수 있는데, 이는 12장에서 알아보겠습니다.
리스트에 관련된 컴포넌트를 최적화할 때는 리스트 내부에서 사용하는 컴포넌트와 리스트로 사용되는 컴포넌트 자체도 최적화해 주는 것이 좋습니다. 그러나 내부 데이터가 100개를 넘지 않거나 업데이트가 자주 발생하지 않는다면, 이런 최적화 작업을 반드시 할 필요는 없습니다.
//TodoList.js
import React from "react";
import TodoListItem from "./TodoListItem";
import './TodoList.scss';
// todos를 TodoList의 props로 전달
const TodoList = ({ todos, onRemove, onToggle }) => {
return (...);
};
export default React.memo(TodoList);
App 컴포넌트에 다른 state가 추가되어 해당 값들이 업데이트될 때는 TodoList 컴포넌트가 불필요한 리렌더링을 할 수 있기 때문에 React.memo를 사용해 최적화해 줍니다.
이번에는 또 다른 렌더링 성능 최적화 방법을 알아보겠습니다.
일정 관리 애플리케이션에 초기 데이터가 2500개 등록되어 있는데, 실제 화면에 나오는 항목은 아홉 개뿐이며 나머지는 스크롤을 해야 볼 수 있습니다. 현재 컴포넌트가 맨 처음 렌더링될 때 2500개 컴포넌트 중 2491개는 스크롤하기 전에는 보이지 않음에도 불구하고 렌더링이 이루어집니다. react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하게끔 할 수 있습니다. 스크롤이 되었을 때, 해당 위치에서 보여 주어야 할 컴포넌트를 자연스럽게 렌더링시켜줍니다.
yarn을 사용해 설치
$ yarn add react-virtualized
react-virtualized에서 제공하는 List 컴포넌트를 사용하여 TodoList 컴포넌트의 성능을 최적화해 봅시다.
최적화를 수행하려면 사전에 먼저 각 항목의 실제 크기를 px 단위로 알아내는 작업이 필요합니다. 크롬 개발자 도구를 사용합니다. 각 항목의 크기는 가로 512px, 세로 57px입니다.
크기를 알아냈다면 이제 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={619.965} //전체 높이
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를 사용해 자동으로 최적화해 줍니다.
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);
render 함수에서 기존에 보여 주던 내용을 div로 한 번 감싸고, 해당 div에는 TodoListItem-virtualized라는 className을 설정하고, props로 받아 온 style을 적용시켜 주었습니다.
다시 한 번 성능을 측정해 보겠습니다.
React.memo를 통해 12.5ms까지 줄였는데, 이번에는 4.3ms로 줄었습니다.
리액트 애플리케이션에 많은 데이터를 렌더링하는 리스트를 만들어 지연을 유발해 보고, 이를 해결하는 방법을 알아보았습니다. 모든 컴포넌트에 일일이 React.memo를 작성할 필요는 없으나, 리스트와 관련된 컴포넌트를 만들 때 보여 줄 항목이 100개 이상이고 업데이트가 자주 발생한다면, 앞선 방법들을 사용해 최적화를 해야 합니다.
다음 빈칸에 들어갈 단어는?
8. 다음 TodoListItem.js에 React.memo 함수를 적용한 코드를 작성해 주세요.
//TodoListItem.js
import {
MdCheckBoxOutlineBlank,
MdCheckBox,
MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
import './TodoListItem.scss';
const TodoListItem = ({ todo, onRemove, onToggle }) => {
return (...);
};
export default TodoListItem;
//Answer
import React from 'react';
import (...)
const TodoListItem = ({ todo, onRemove, onToggle }) =>
{
(...)
};
export default React.memo(TodoListItem);
9. 다음은 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);
//고유값으로 사용될 id
//ref 사용해 변수담기
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 => {
/* 빈칸 1 */
}, []);
const onToggle = useCallback(
id => {
/* 빈칸 2 */
}, []);
return (
<TodoTemplate>
<TodoInsert onInsert={onInsert} />
<TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} />
</TodoTemplate>
);
};
export default App;
//Answer
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,
),
);
}, []);
Corner React3
Editor: 머핀
[리액트 스타터 3] 13장. 리액트 라우터로 SPA 개발하기 (0) | 2023.01.05 |
---|---|
[리액트 스타터 3] 12장. immer를 사용하여 더 쉽게 불변성 유지하기 (0) | 2022.12.29 |
[리액트 스타터3] 10장. 일정 관리 웹 애플리케이션 만들기 (0) | 2022.12.22 |
[리액트 스타터 3] 9장. 컴포넌트 스타일링 (0) | 2022.12.01 |
[리액트 스타터 3] 8장. Hooks (0) | 2022.11.24 |