10장에서 만들었던 todo-app
에서 데이터가 폭증하면 앱이 느려지겠지? 성능 최적화 전에 실제로 랙(lag
)을 경험할 수 있도록 많은 데이터를 렌더링해보자.
App.js
를 다음과 같이 수정해 데이터 2500개를 생성하는 함수를 통해 todos
를 설정했다.
주의
useState(createBulkTodos())
리렌더링될 때마다 함수 호출
useState(createBulkTodos)
처음 렌더링될 때만 함수 호출
//...
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(
//...
확실히 느려진 것이 보인다. 근데 원인이 뭘까?
컴포넌트는 다음과 같은 상황에서 리렌더링이 발생한다.
1. 자신이 전달받은props
가 변경될 때
2. 자신의state
가 바뀔 때
3. 부모 컴포넌트가 리렌더링될 때
4.forceUpdate
함수가 실행될 때
이 상황에서는 항목을 체크할 경우 state
가 변경되면서 App
컴포넌트가 리렌더링되기 때문에 자식 컴포넌트인 TodoList
컴포넌트 또한 리렌더링되고 그 안의 무수한 컴포넌트들도 리렌더링 된다. 체크되지 않는 컴포넌트들까지 리렌더링 되는 것은 불필요하기 때문에 이를 방지하기 위해 최적화가 필요한 것이다.
컴포넌트의 리렌더링을 방지할 때는 shouldComponentUpdate
라는 라이프사이클을 사용하면 된다. 하지만 이를 사용할 수 없는 함수형에서는 어떻게 해야 할까?
그럴 때 쓸 수 있는 것이 바로 React.memo
함수이다. 컴포넌트의 props
가 바뀌지 않았다면 리렌더링하지 않도록 설정하여 함수형 컴포넌트의 리렌더링 성능의 최적화를 돕는다.
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);
TodoListItem
컴포넌트를 React.memo()
로 감싸줌으로써 todo
, onRemove
, onToggle
이 바뀌지 않으면 리렌더링을 하지 않도록 만들었다. 하지만 현재 프로젝트에서는 todos
배열이 업데이트되면 onRemove
와 onToggle
함수도 새롭게 바뀌기 때문에 React.memo
로는 부족하다.
1. useState
의 함수형 업데이트
setTodos
를 사용할 때 새로운 상태를 파라미터로 넣는 대신, 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수도 있는데, 이를 함수형 업데이트라고 한다.
// 사용 예시
const [number, setNumber] = useState(0);
const onIncrease = useCallback(
() => setNumber(prevNumber => prevNumber + 1),
[],
);
setNumber(number+1)
을 하는 것이 아니라, 위 코드처럼 어떻게 업데이트할지 정의해 주는 업데이트 함수를 넣어 주면 useCallback
안에서 이를 사용할 때 두 번째 파라미터인 배열에 number
를 넣지 않아도 된다. 이와 같이 코드를 바꾸어 주자.
2. useReducer
사용하기
함수형 업데이트 대신 useReducer
를 사용해도 onToggle
과 onRemove
가 계속 새로워지는 문제를 해결할 수 있다.
App.js
import React, { useReducer, useRef, useCallback }from 'react';
...
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 });
}, []);
return(
...
useReducer
를 사용할 때는 원래 두 번째 파라미터에 초기 상태를 넣어 주어야 한다. 지금은 두 번째 파라미터에 undefined
를 넣고, 세 번째 파라미터에 초기 상태를 만들어 주는 함수인 createBulkTodos
를 넣어 준다. 이렇게 하면 컴포넌트가 맨 처음 렌더링될 때만 createBulkTodos
함수가 호출됩니다.
useReducer
를 사용하는 방법은 기존 코드를 많이 고쳐야 한다는 단점이 있지만, 상태를 업데이트하는 로직을 모아서 컴포넌트 바깥에 둘 수 있다는 장점이 있다.
초기 데이터를 2,500개 등록했는데, 실제 화면에 나오는 항목은 아홉 개뿐이고 나머지는 스크롤해야만 볼 수 있다. 스크롤하기 전에는 보이지도 않을 컴포넌트들이 미리 렌더링 되는 것은 비효율적이라고 할 수 있다.
react-virtualized
를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하게끔 할 수 있다.
react-virtualized
에서 제공하는 List
컴포넌트를 사용하여 최적화를 진행하는데, 사전에 각 항목의 실제 크기를 px 단위로 알아내야 한다. 이는 개발자 도구에서 확인할 수 있다. (할 일 1은 위쪽 테두리가 없기 때문에 2 아래로 재야 한다)
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
를 사용하여 자동으로 최적화해 준다.
TodoListItem.js
...
const TodoListItem = ({ todo, onRemove, onToggle, style }) => {
const { id, text, checked } = todo;
return (
<div className="TodoListItem-virtualized" style={style}>
<div className="TodoListItem">
...
</div>
</div>
);
};
...
render
함수에서 기존에 보여 주던 내용을 div
로 한 번 감싸고, 해당 div
에는 TodoListItem-virtualized
라는 className
을 설정하고 props
로 받아 온 style
을 적용시킨다.
TodoListItem.scss
.TodoListItem-virtualized {
& + & {
border-top: 1px solid #dee2e6;
}
&:nth-child(even) {
background: #f8f9fa;
}
}