지난 포스트에서 추가되는 데이터가 적어서 컴포넌트를 관리하는데 불편하지않았습니다. 그러나 데이터가 무수히 많아지면 애플리케이션이 느려지게 됩니다. 이때 사용되는 것이 컴포넌트 성능 최적화입니다.
실습은 다음과 같은 순서로 진행됩니다.
- 많은 데이터 렌더링하기
- 크롬 개발자 도구를 통한 성능 모니터링
- React.memo를 통한 컴포넌트 리렌더링 성능 최적화
- onToggle 과 onRemove 가 새로워지는 현상 방지하기
- react-virtualized 를 사용한 렌더링 최적화
이번 실습을 위해서는 일정관리 웹서버가 필요합니다. https://daradarav.tistory.com/64 의 주소를 참고하여 실습을 진행한 후 이 포스트를 봐주시길 바랍니다.
📌 많은 데이터 렌더링하기
많은 데이터가 생성되면 랙(lag)이 발생합니다. 코드를 입력하여 많은 데이터를 렌더링해봅시다.
// 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);
const nextId = useRef(2501);
// onInsert, onRemove, onToggle, render 함수는 그대로
( ... )
}
export default App;
- createBulkTodos 라는 함수를 만들어 데이터 2500개를 자동으로 생성했습니다.
- useState 기본값에 함수를 넣었습니다. 이로 인해 처음 렌더링될때만 createBulkTodos 함수가 실행됩니다.
항목을 체크하면 이전보다 느려진 것을 체감할 수 있을 것입니다.
📌 크롬 개발자 도구를 통한 성능 모니터링
얼마나 느려졌는지 정확한 성능 분석을 하기 위해서는 React DevTools를 사용합니다. 리액트 v17 부터는 리액트 전용 개발자 도구 React DevTools를 통해 성능 분석을 자세히 할 수 있습니다.
10장에서는 Component 탭을 열어봤지만 이번에는 Profiler 라는 탭을 열어봅시다. 좌측 상단의 파란색 녹화 버튼을 눌러보세요. 이 버튼을 누르고 '할 일 1' 항목을 체크하고, 화면에 변화가 반영되도록 녹화 버튼을 누르면 성능 분석 결과가 나타납니다.
- 우측의 Render duration 은 리렌더링에 걸린 시간을 말합니다. 변화를 화면에 반영하는데 254.2ms 가 걸렸습니다.
- 탭 상단의 불꽃 모양 아이콘 우측의 랭크 차트 아이콘을 누르면 리렌덜이 컴포넌트를 오래 걸린 순으로 정렬할 수 있습니다.
- 스크롤을 하면 많은 컴포넌트가 리렌더링 된 것을 확인할 수 있습니다.
- 초록 색 박스들이 작어서 내용이 보이지 않는다면 클릭하여 크기를 늘일 수 있습니다.
- 이 차트를 통해 변화와 관계 없는 컴포넌트들도 리렌더링 된 것을 확인할 수 잇습니다.
📌 느려지는 원인 분석
컴포넌트는 다음과 같은 상황에서 리렌더링 됩니다. (https://daradarav.tistory.com/40)
- props가 바뀔 때
- state가 바뀔 때
- 부모 컴포넌트가 리렌더링될 때
- forceUpdate 함수가 실행될 때
'할 일 1' 항목을 체크할 경우 App 컴포넌트의 state 가 변경되면서 App 컴포넌트가 리렐더링 됩니다. 부모 컴포넌트가 리렌더링되엇으니 TodoList 컴포넌트가 리렌더링 되고 그 안의 무수한 리렌더링됩니다. '할 일 1'만 리렌더링되지만, '할 일 2' ~ '할 일 2500' 까지는 리렌더링이 필요없지만 되고 있어 느려진 것입니다.
컴포넌트 리렌더링 성능을 최적화해 주는 작업을 해야합니다. 즉, 리렌더링이 불필요할 때는 리렌더링을 방지하는 작업이 필요합니다
📌 React.memo 를 사용하여 컴포넌트 성능 최적화
컴포넌트의 리렌더링을 방지할 때는 7장(https://daradarav.tistory.com/40)에서 배운 shouldComponentUpdate 라는 라이프사이클을 사용하면 됩니다. 그러나 함수 컴포넌트에서는 라이프 사이클 메서드를 사용할 수 없어 React.memo 라는 함수를 대신 사용해야합니다. 컴포넌트의 props가 바뀌지 않았다면 리렌더링하지 않도록 설정하여 함수 컴포넌트 리렌더링 성능을 최적화합니다.
React.memo 의 사용법은 컴포넌트를 만들고 감싸면 됩니다. TodoListItem 컴포넌트에 다음과 같이 React.memo를 적용해봅시다.
// 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 컴포넌트는 todo, onRemove, onToggle 이 바뀌지 않으면 리렌더링하지 않습니다.
📌 onToggle, onRemove 함수가 바뀌지 않게 하기
현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove, onToggle 함수도 새롭게 바뀌기 때문에 컴포넌트 최적화가 된 것은 아닙니다. onRemove, onToggle 함수는 배열 상태를 업데이트하는 과정에서 최신 상태의 todos 를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어집니다. 이렇게 함수가 계속 새로 만들어지는 것을 방지하기 위해서는
- useState 의 함수형 업데이트 기능을 사용하기
- useReducer 를 사용하기
와 같은 방법 2가지가 있습니다.
📚 useState의 함수형 업데이트
기존에 setTodos 함수를 사용할 때는 새로운 상태를 파라미터로 넣어주었습니다. 이 대신 상태 업데이트를 어떻게 할지를 정의해주는 업데이트 함수를 넣을 수 있습니다. 이를 함수형 업데이트라고 합니다.
함수형 업데이트를 코드로 확인해봅시다.
const [number, setNumber] = useState(0);
// prevNumbers는 현재 number 값을 가리킵니다.
const onIncrease = useCallback(
() => setNumber(prevNumber => prevNumber + 1),
[],
);
- setNumber(number+1) 을 하는 것이 아니라, 위 코드처럼 어떻게 업데이트를 할지를 정의해주는 함수를 작성합니다.
- 이렇게 코드를 작성하면 useCallback 을 사용할 때 두 번째 파라키터로 넣는 배열에 number를 넣지 않아도 됩니다.
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);
const nextId = useRef(2501);
const onInsert = useCallback(
text => {
const todo = {
id: nextId.current,
text,
checked: false,
};
setTodos(todo => todo.concat(todo));
nextId.current += 1; // nextId 1씩 더하기
},
[todos],
);
const onRemove = useCallback(
id => {
setTodos(todo => todo.filter(todo => todo.id !== id));
},
[todos],
);
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 => 만 앞에 넣어두면 됩니다.
렌더링 소요시간이 13.4ms 로 줄은 것을 확인할 수 있습니다.
📚 useReducer 사용하기
useState의 함수형 업데이트 대신 useReducer를 사용해도 onToggle 과 onRemove가 새로워지는 문제를 해결할 수 있습니다.
// App.js
import React, { useReducer, 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;
}
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; // nextId 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 함수를 호출합니다.
- 이 코드는 상태를 업데이트 하는 로직을 모아서 컴포넌트 밖에 둘 수 있다는 장점이 있습니다.
📌 불변성의 중요성
리액트 컴포넌트에서 상태를 업데이트할 때는 불변성을 지키는 것이 주요합니다.
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]; // 똑같은 배열을 가리킴
nextArrayBad[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) // 다른 객체기 때문에 true
- 불변성이 지켜지지 않으면 객체 내부의 값이 바뀐 것을 감지할 수 없습니다.
- 전개 연산자 (... 문법)을 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사(shallow copy)를 합니다.
- 내부의 값이 완전히 새로 복사되는 것이 아니라 겉만 복사되기 때문에 배열이라면 내부의 배열도 따로 복사해야합니다.
객체의 내부 복사에 대한 코드를 봅시다.
const todos = [{ id: 1, checked: true }, { id: 2, checked: true }];
const nextId = [...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,
enalbed: false
}
};
console.log(complexObject === nextComplexObject); // false
console.log(complexObject.ObjectInside === nextComplexObject.ObjectInside); // flase
- 배열 또는 객체의 구조가 복잡하다면 이렇게 불변성을 유지하면서 업데이트 하는 것도 까다로워집니다.
- 복잡한 경우 immer 라는 라이브러리 도움을 받아 간단하게 작업할 수 있습니다. (이것은 다음 포스트에서 알아봅니다.)
📌 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 배열이 업데이트 되기 때문이므로 불필요한 렌더링이 발생하지 않습니다.
- App 컴포넌트에 다른 state 가 추가되어 해당 값이 업데이트될 때는 TodoList 컴포넌트가 불필요한 리렌더링을 할 수 있어, React.memo를 사용하여 미리 최적화 합니다.
📌 react-virtualized 를 사용한 렌더링 최적화
리렌더링 성능을 필요할 때만 리렌더링하도록 하는 방법 외에도 최적화 방법은 또 있습니다. 일정 관리 애플리케이션에서 초기 데이터가 2,500개 등록되어 있지만 실제로 보이는 것은 9개 뿐입니다. 2,491개의 비효율적인 렌더링을 하는 것은 시스템 자원 낭비입니다.
react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하도록 합니다. 스크롤 되면 해당 스크롤 위치에서 보여 주어야할 컴포넌트를 자연스럽게 렌더링 시킵니다. 불필요한 자원을 쉽게 아낄 수 있습니다.
📚 최적화 준비
yarn 을 이용해 라이브러리를 설치합시다.
$ yarn add react-virtualized
react-virtualized 에서 제공하는 List 컴포넌트를 사용하여 TodoList 컴포넌트 성능을 최적화해봅시다.
이를 위해서는 각 항목의 실제 크기를 px 단위로 알아야합니다. 크롬 개발자 도구의 좌측 상단에 있는 아이콘을 눌러서 크기를 알고 싶은 항목에 커서를 대보면 크기를 알 수 있습니다.
- 각 항목의 크기는 493px, 세로 57px입니다.
- 두 번째 항목부터 테두리가 포함되기 때문에 두 번째 항목의 크기를 확인해야합니다.
📚 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 = todo[index];
return (
<TodoListItem
todo={todo}
key={key}
onRemove={onRemove}
onToggle={onToggle}
style={style}
/>
);
},
[onRemove, onToggle, todos],
);
return (
<List
className='TodoList'
width={493} // 전체 크기
height={493} // 전체 높이
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로 설정해야합니다.
- List 컴포넌트를 사용할 때는 해당 리스트의 전체 크기와 각 항목의 크기, 각 항목의 렌더링할 때 사용해야하는 함수, 배열을 props로 넣어야합니다.
- 이 컴포넌트가 전달받은 props를 사용하여 자동으로 최적화됩니다.
📚 TodoListItem 수정
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,
(prevProps, nextProps) => prevProps.todo === nextProps.todo,
);
- render 함수에서 기존에 보여주던 내용을 div 로 감쌌습니다.
- 해당 div에는 TodoListItem-virtualized 라는 클래스 내임으로 props로 받아온 style을 적용시켰습니다.
- TodoListItem-virtualized 클래스는 컴포넌트 사이사이에 테두리를 제대로 쳐주고 홀수/짝수 번째 항목에 다른 배경 색상을 지정하려고 하는 것입니다.
TodoListItem 스타일 파일에서 최하단에 있는 & + & 를 사용하여 .TodoListItem 사이사이에 테두리를 설정했던 코드와 &:nth-child(even)을 사용하여 다른 배경 색상을 주는 코드를 지우고 최상단에 코드를 이 삽입합시다.
// TodoListItem.scss
.TodoListItem-virtualized {
& + & {
border-top: 1px solid #dee2e6;
}
&:nth-child(even) {
background: #f8f9fa;
}
}
( ... )
이 코드를 통해 홀수 번째, 짝수 번째 색상이 바뀐 것을 알아봤습니다.
렌더링 시간이 2.6ms 까지 줄어든 것을 확인할 수 있습니다.
'프레임워크 > REACT' 카테고리의 다른 글
[React] 13. 리액트 라우터로 SPA 개발하기 (0) | 2023.01.04 |
---|---|
[React] 10. 일정 관리 웹 애플리케이션 만들기 (0) | 2022.12.19 |
[React] 9장. 컴포넌트 스타일링 (0) | 2022.12.17 |
[React] 8장. Hooks (0) | 2022.12.12 |
[React] 07. 컴포넌트의 라이프사이클 메서드 (0) | 2022.11.14 |