지난 장에서 학습한 내용을 활용해 애플리케이션을 만들었습니다. 데이터가 적을 때 사용하는 것은 불편하지 않지만, 데이터가 무수히 많아지면 지연이 발생하여 애플리케이션이 느려질 것입니다. 이번 실습에서는 이를 해결하는 과정을 다룹니다. 이번 장에서는 많은 데이터를 렌더링하고, 크롬 개발자 도구로 성능을 모니터링하고, React.memo를 통해 컴포넌트 리렌더링 성능을 최적화하고, onToggle과 onRemove가 새로워지는 현상을 방지하고, react-virtualized를 사용하여 렌더링을 최적화하겠습니다.
우선 지연을 발생시키도록 많은 데이터를 렌더링하겠습니다. 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);
const nextId = useRef(2501);
const onInsert = useCallback(
text => {
const todo = {
id: nextId.current,
text,
checked: false,
};
setTodos(todos.concat(todo));
nextId.current += 1;
},
[todos],
);
const onRemove = useCallback(
id => {
setTodos(todos.filter(todo => todo.id !== id));
},
[todos],
);
const onToggle = useCallback(
id => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, checked: !todo.checked } : todo,
),
);
},
[todos],
);
return (
<TodoTemplate>
<TodoInsert onInsert={onInsert} />
<TodoList todos={todos} onRemove={onRemove} onToggle={onToggle}/>
</TodoTemplate>
);
};
export default App;
createBulkTodos 함수를 만들어 2500개의 일정을 생성합니다. 이 상태에서 체크하고 삭제할 때 이전보다 느려진 것이 느껴집니다.
이렇게 느려진 느낌을 정확히 몇 초가 걸리는지 React DevTools를 사용하여 측정할 수 있습니다. 리액트 v17 전에는 브라우저에 내장된 성능 측정 도구의 User Timing API를 사용했으나, v17부터는 리액트 전용 개발자 도구인 React DevTools를 사용하여 성능 분석을 자세하게 할 수 있습니다. 리액트 개발자 도구의 Profiler라는 탭을 열면 좌측 상단에 파란색 녹화 버튼이 보입니다.
이 버튼을 누르고 항목을 체크한 다음, 화면에 변화가 반영되면 녹화 버튼을 누르세요. 그러면 다음과 같이 성능 분석 결과가 나타납니다.
우측의 Render duration은 리렌더링에 소요된 시간을 의미합니다. 변화를 화면에 반영하는데 358.4ms가 걸렸다는 의미입니다. 이 소요 시간은 컴퓨터 환경에 따라 다르게 나타납니다. 이제 Profiler 탭의 상단에 있는 불꽃 아이콘의 우측에 있는 랭크 차트 아이콘을 눌러보면 이러한 화면이 나타납니다.
이 화면에서는 리렌더링된 컴포넌트를 오래 걸린 순으로 정렬하여 나열합니다. 아래로 내려보면 많은 컴포넌트가 리렌더링된 것을 확인할 수 있습니다. 작은 박스들은 클릭하면 크기가 늘어나 내용을 확인할 수 있습니다. 이를 보면 변화를 일으킨 컴포넌트와 관련없는 컴포넌트들도 리렌더링된 것을 확인할 수 있습니다. 이는 좋지 못한 성능을 불러옵니다. 이를 최적화하는 방법을 알아볼 것입니다.
컴포넌트는 자신이 전달받은 props가 변경될 때, 자신의 state가 바뀔 때, 부모 컴포넌트가 리렌더링될 때, forceUpdate 함수가 실행될 때 리렌더링이 발생합니다.
지금 상황을 분석하면, 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링됩니다. 부모 컴포넌트가 리렌더링되었으니 자식 컴포넌트인 TodoList 컴포넌트가 리렌더링되고, 그 안의 모든 컴포넌트들이 리렌더링된 것입니다. 하나의 항목을 체크한 것인데 나머지 2499개의 항목이 모두 리렌더링되었기 때문에 애플리케이션에 지연이 발생합니다. 이럴 때 컴포넌트 리렌더링 성능을 최적화하는 작업을 해야합니다. 즉, 리렌더링이 불필요할 때 리렌더링을 방지하는 것입니다.
컴포넌트의 리렌더링을 방지할 때는 7장에서 배운 shouldComponentUpdate라는 라이프사이클을 사용하면 됩니다. 함수형 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없기 때문에 React.memo라는 함수를 사용합니다. 컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정하여 함수형 컴포넌트의 리렌더링 성능을 최적화할 수도 있습니다.
React.memo의 사용법은 컴포넌트를 만들고 나서 감싸면 됩니다. TodoListItem 컴포넌트에 다음과 같이 React.memo를 적용합시다. 적용 시 todo, onRemove, onToggle이 바뀔 때만 리렌더링합니다.
//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 TodoListItem;
export default React.memo(TodoListItem);
React.memo를 사용하는 것만으로 컴포넌트 최적화가 끝나지 않습니다. 현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀌기 때문입니다. onRemove와 onToggle 함수는 배열 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조합니다. 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어집니다. 이렇게 함수가 계속 만들어지는 상황을 방지하는 방법은 두 가지입니다. 첫 번째 방법은 useState의 함수형 업데이트 기능을 사용하는 것이고, 두 번째 방법은 useReducer를 사용하는 것입니다.
기존에 setTodos 함수를 사용할 때는 새로운 상태를 파라미터로 넣었습니다. setTodos를 사용할 때 새로운 상태를 파라미터로 넣는 대신 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수 있습니다. 이를 함수형 업데이트라고 부릅니다. onRemove와 onToggle, onIsert 함수에서 useState의 함수형 업데이트를 사용해 봅시다.
//기존 App.js
const onInsert = useCallback(
text => {
const todo = {
id: nextId.current,
text,
checked: false,
};
setTodos(todos.concat(todo));
nextId.current += 1;
},
[todos],
);
//수정한 App.js
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.filter(todo => todo.id !== id));
},[ todos],);
//수정
const onRemove = useCallback(
id => {
setTodos(todos=>todos.filter(todo => todo.id !== id));
},[]);
//기존
const onToggle = useCallback(
id => {
setTodos(
todos.map(todo =>
todo.id === id ? { ...todo, checked: !todo.checked } : todo,
),
);
},
[todos],
);
//수정
const onToggle = useCallback(
id => {
setTodos(todo =>
todos.map(todo =>
todo.id === id ? { ...todo, checked: !todo.checked } : todo,
),
);
},[]);
setTodos 안에 todos =>를 앞에 넣으면 됩니다. 이 코드를 저장하고 Profiler 개발자 도구로 성능을 측정합시다. 렌더링 소요시간이 358.4ms에서 20.7ms로 줄었습니다. 회색 빗금으로 그어져 있는 박스는 리렌더링되지 않은 컴포넌트를 나타냅니다. 차트 아이콘을 누르면 리렌더링된 컴포넌트의 수가 줄어든 것을 확인할 수 있습니다.
useState의 함수형 업데이트를 사용하는 대신에 useReducer를 사용해도 onToggle과 onRemove가 새로워지는 문제를 해결할 수 있습니다. 이번에도 App.js 파일을 수정해 봅시다.
//import문 추가 작성
import { useReducer, useRef, useCallback } from 'react';
//App.js todoReducer 함수 작성
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);
const nextId = useRef(2501);
const onInsert = useCallback(
text => {
const todo = {
id: nextId.current,
text,
checked: false,
};
dispatch({ type: 'INSERT', todo });
//setTodos(todos=>todos.concat(todo));
nextId.current += 1;
},[]);
const onRemove = useCallback(
id => {
dispatch({ type: 'REMOVE', id });
//setTodos(todos=>todos.filter(todo => todo.id !== id));
},[]);
const onToggle = useCallback(
id => {
dispatch({ type: 'TOGGLE', id });
//setTodos(todo =>
// todos.map(todo =>
// todo.id === id ? { ...todo, checked: !todo.checked } : todo,
// ),
//);
},[]);
useReducer를 사용하면 두 번째 파라미터에 초기 상태를 넣어야 합니다. 지금은 그 대신 undefined를 넣고 세 번째 파라미터에 초기 상태를 만드는 함수 createBulkTodos를 넣었습니다. 이러면 컴포넌트가 맨 처음 렌더링될 때만 createBulkTodos 함수가 호출됩니다.
이 코드를 저장하고 Profiler 개발자 도구로 성능을 측정합시다. 렌더링 소요시간이 358.4ms에서 11.6ms로 줄었습니다.
useReducer를 사용하면 기존 코드를 많이 고쳐야 하는 단점이 있으나 상태를 업데이트하는 로직을 모아서 컴포넌트 밖에 둘 수 있는 장점이 있습니다. 성능상으로는 useState의 함수형 업데이트를 사용하는 방법과 useReducer를 사용하는 방법이 비슷하기 때문에 취향에 따라 결정하면 됩니다.
리액트 컴포넌트에서 상태를 업데이트할 때 불변성을 지키는 것이 매우 중요합니다. 이전에는 기존 데이터를 수정할 때 직접 수정하지 않고, 새로운 배열을 만든 후 새로운 객체를 만들어 필요한 부분을 교체하는 방식으로 구현했습니다.
const onToggle = useCallback(
id => {
setTodos(todo =>
todos.map(todo =>
todo.id === id ? { ...todo, checked: !todo.checked } : todo,
),
);
},[]);
업데이트가 필요한 곳에서는 아예 새로운 배열 혹은 새로운 객체를 만들기 때문에, props가 바뀌었는지를 알아내어 리렌더링 성능을 최적화하였습니다. 이렇게 기존의 값을 수정하지 않고 새로운 값을 만드는 것을 불변성을 지킨다라고 합니다. 불변성이 지켜지지 않으면 객체 내부의 값이 새로워져도 바뀐 것을 감지하지 못합니다. 그러면 React.memo에서 비교하여 최적화하는 것 또한 불가능합니다.
전개 연산자(…문법)를 사용하여 내부의 값을 복사하면 얕은 복사를 하게 됩니다. 즉, 내부의 값이 새로 복사되는 것이 아니라 가장 바깥쪽에 있는 값만 복사되기 때문에 내부의 값이 객체, 배열이라면 내부의 값 또한 복사해야 합니다. 때문에 배열, 객체 구조가 아주 복잡해진다면 불변성을 유지하면서 업데이트하는 것이 까다로워집니다. 이렇게 복잡한 상황일 경우 immer라는 라이브러리의 도움을 받으면 편하게 작업할 수 있습니다. 이는 12장에서 다룹니다.
리스트에 관련된 컴포넌트를 최적화할 때는 리스트 내부에서 사용하는 컴포넌트를 최적화해야 하며, 리스트로 사용되는 컴포넌트 자체 또한 최적화하는 것이 좋습니다. TodoList.js 파일을 수정합시다.
// 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 컴포넌트는 불필요한 리렌더링이 발생하지 않습니다. 그러나 App 컴포넌트에서 state가 추가되어 해당 값이 업데이트될 때 TodoList 컴포넌트가 불필요한 리렌더링을 할 수도 있습니다. 이를 방지하기 위하여 React.memo를 사용하여 미리 최적화한 것입니다.
리스트 관련 컴포넌트를 작성할 때에는 리스트 아이템과 리스트, 두 가지 컴포넌트를 최적화해야 합니다.
지금까지 리액트 컴포넌트 리렌더링 성능을 최적화하는 방법을 알아보았습니다. 필요할 때만 리렌더링하도록 최적화하였는데, 이번에는 다른 렌더링 최적화 방법을 알아보겠습니다. 현재 일정 2500개가 등록되어 있습니다. 그런데 화면 로딩 시 나오는 항목은 9개입니다. 나머지 2491개는 스크롤을 해야 볼 수 있는데도 렌더링이 이루어집니다. react-virtualized를 사용하면 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하도록 합니다. 스크롤 시에 해당 위치에서 보여지는 컴포넌트만 렌더링 시키는 겁니다. 이를 통해 낭비되는 자원을 아낄 수 있습니다.
우선 이 라이브러리를 설치합시다.
$ yarn add react-virtualized
이제 react-virtualized를 사용하여 최적화해 봅시다.에서 제공하는 List 컴포넌트를 사용하여 TodoList 컴포넌트의 성능을 최적화할 것입니다. 최적화를 수행하려면 우선 각 항목의 실제 크기를 px 단위로 알아내야 합니다. 이 값은 크롬 개발자 도구의 좌측 상단에 있는 아이콘을 눌러 확인할 수 있습니다.
각 항목의 크기는 가로 512px, 세로 57px입니다.
크기를 알아냈다면 이제 TodoList 컴포넌트를 다음과 같이 수정합시다.
//TodoList.js
import React, {useCallback} from 'react';
import TodoListItem from './TodoListItem';
import './TodoList.scss';
import { List } from 'react-virtualized';
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를 사용하여 자동으로 최적화합니다.
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는 className으로 TodoListItem-virtualized를 설정하였고, props로 받은 style을 적용하였습니다. 이는 홀수 짝수 항목에 대해 다른 배경 색상을 설정하기 위해서입니다.
그다음에는 TodoListItem.scss 파일에서 최하단에 있던 & + &를 사용한 코드를 지우고, 코드 최상단에 .TodoListItem-virtualized 코드를 삽입합시다.
/*TodoListItem.scss*/
.TodoListItem-virtualized {
& + & {
border-top: 1px solid #dee2e6;
}
&:nth-child(even) {
background: #f8f9fa;
}
}
작업이 끝났으니 성능을 측정합시다. 4.3ms로 줄은 것을 확인할 수 있습니다.
이번 장에서는 리액트 애플리케이션에 많은 데이터를 렌더링하는 리스트를 만들어서 지연을 유발하고 이를 최적화하는 방법을 알아봤습니다. 리액트 컴포넌트의 렌더링은 기본적으로 빠르기 때문에 일일이 React.memo를 작성할 필요는 없으나 보일 항목이 100개 이상이고 업데이트가 자주 발생한다면 최적화가 필요합니다.
Quiz
1. 지연시간을 확인할 때 정확히 몇 초가 걸리는지 (________)로 측정할 수 있다.
React DevTools
2. 컴포넌트에서 리렌더링이 발생하는 경우는 자신이 전달받은 (________)가 변경될 때, 자신의 (________)가 바뀔 때, (________)가 리렌더링될 때, (________) 함수가 실행될 때이다.
props, state, 부모 컴포넌트, forceUpdate
3. 함수형 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없기 때문에 (________) 함수를 사용한다.
React.memo
4. 함수가 계속 만들어지는 상황을 방지하기 위하여 useState의 (________) 기능을 사용한다.
함수형 업데이트
5. 함수가 계속 만들어지는 상황을 방지하기 위하여 (________)를 사용한다.
useReducer
6. useReducer를 사용할 때 두 번째 파라미터에 (________)를 넣어야 한다.
초기 상태
7. 기존의 값을 수정하지 않고 새로운 값을 만드는 것을 (________)을 지킨다라고 합니다
불변성
8. 많은 데이터를 생성하는 반복문 함수의 빈칸을 채워라.
function createBulkTodos(){
const array = [];
___ (let i = 1; i <= 2500; i++) {
_____.____({
id: i,
text: `할 일 ${i}`,
checked: false,
});
}
//정답
function createBulkTodos(){
const array = [];
for (let i = 1; i <= 2500; i++) {
array.push({
id: i,
text: `할 일 ${i}`,
checked: false,
});
}
9. 최적화되지 않은 onRemove를 함수형 업데이트 방법과 useReducer 방법을 각각 사용하여 최적화하여라.
const onRemove = useCallback(
id => {
setTodos(todos.filter(todo => todo.id !== id));
},[ todos],);
//정답
//useState의 함수형 업데이트 사용
const onRemove = useCallback(
id => {
setTodos(todos=>todos.filter(todo => todo.id !== id));
},[]);
//useReducer 사용
const onRemove = useCallback(
id => {
dispatch({ type: 'REMOVE', id });
},[]);
Corner React1
Editor: 동동
[리액트 스타터1] 13장. 리액트 라우터로 SPA 개발하기 (0) | 2023.01.05 |
---|---|
[리액트 스타터1] 12장. immer를 사용하여 더 쉽게 불변성 유지하기 (1) | 2022.12.29 |
[리액트 스타터1] 10장 : 일정 관리 웹 애플리케이션 만들기 (0) | 2022.12.22 |
[리액트 스타터1] 9장. 컴포넌트 스타일링 (0) | 2022.12.01 |
[리엑트 스타터1] 8장. Hooks (0) | 2022.11.24 |