7c. 컴포넌트 업데이트 최적화
리액트에선 부모 컴포넌트가 업데이트 되면, 버추얼 돔에 일단 새로 그립니다. 버추올 돔에 그리는 것 자체는 실제 브라우저 렌더링 엔진이랑 관계없이, 자바스크립트만 실행 시키는 것이기 때문에 브라우저를 크게 과부하 시키지는 않지만, 렌더링 될 컴포넌트가 엄청나게 많다면, 잠재적인 렉 현상을 초래시킬 수도 있습니다.
한번, 그 과부하 현상을 직접 경험해봅시다.
App.js
import React, { Component } from 'react';
import CreateForm from './components/CreateForm';
import TodoList from './components/TodoList';
import './App.css';
// 함수를 선언후 바로 호출하는, IIFE 패턴
const bulkTodos = (() => {
const array = [];
for (let i = 0; i < 5000; i++) {
array.push({
id: i,
text: `Todo #${i}`,
checked: false
});
}
return array;
})();
class App extends Component {
id = 5000;
state = {
todos: bulkTodos
};
handleCreate = text => {
const todoData = {
id: this.id,
text,
checked: false
};
this.id += 1;
this.setState({
todos: this.state.todos.concat(todoData)
});
};
handleCheck = id => {
this.setState(({ todos }) => ({
todos: todos.map(
todo => (todo.id === id ? { ...todo, checked: !todo.checked } : todo)
)
}));
};
handleRemove = id => {
this.setState(({ todos }) => ({
todos: todos.filter(todo => todo.id !== id)
}));
};
render() {
return (
<div className="App">
<div className="header">
<h1>오늘 뭐할까?</h1>
</div>
<CreateForm onSubmit={this.handleCreate} />
<div className="white-box">
<TodoList
todos={this.state.todos}
onCheck={this.handleCheck}
onRemove={this.handleRemove}
/>
</div>
</div>
);
}
}
export default App;
기본 값을 5000 개로 만들었습니다.
한번 아이템중 아무거나 눌러보세요. 좀 느릴 것입니다.
리액트 개발모드에서는, 개발자 도구를 통해 성능을 직접 확인 할 수도 있습니다.
개발자도구에서 Performance 를 누르세요. 그리고 좌측 상단의 녹화 버튼을 누른다음에 투두 아이템을 토글하고 다시 녹화버튼을 누릅니다. 그러면, 이렇게 결과를 볼 수 있습니다.
리액트 관련 성능은, User Timing 부분에서 볼 수있는데, 하나 업데이트하는데 자바스크립트 부분에 429ms 가 소요됩니다.
현재 개발 모드이기때문에 429ms 나 소요 된 것이고, 프로덕션에서는 실제로는 43ms 정도 소요됩니다.
이렇게 수많은 데이터를 렌더링 할 일이 발생한다면, shouldComponentUpdate 를 통하여 최적화를 해줘야합니다.
우선 TodoItemList 에서 TodoItem 에 todo 값을 통째로 props 로 전달하세요.
사실 지금의 상황에선 단순히 checked 값만 바뀌었는지 확인하면 되긴 하지만, 실제로 여러분이 비슷한 작업을 하게 될 때에는 여러 값을 비교해야 될 지도 모릅니다. 그러한 상황에서는 전달받은 props 를 모두 비교를 하는게 아니라, 우리가 불변성을 유지하면서 데이터를 업데이트하기에, 해당 컴포넌트에서 값을 받아오는 객체 자체를 비교하는게 효율적입니다.
components/TodoList.js
import React, { Fragment } from 'react';
import TodoItem from './TodoItem';
const TodoList = ({ todos, onCheck, onRemove }) => {
const todoList = todos.map(todo => (
<TodoItem
key={todo.id}
id={todo.id}
checked={todo.checked}
text={todo.text}
onCheck={onCheck}
onRemove={onRemove}
todo={todo}
/>
));
return todoList;
};
export default TodoList;
그리고, 기존의 TodoItem 컴포넌트는 라이프사이클 API 를 사용하기 위하여, 함수형 컴포넌트에서 클래스형 컴포넌트로 변환해주세요. 그리고 shouldComponentUpdate 를 다음과 같이 구현합니다.
components/TodoItem.js
import React, { Component } from 'react';
import './TodoItem.css';
class TodoItem extends Component {
shouldComponentUpdate(nextProps, nextState) {
return nextProps.todo !== this.props.todo;
}
render() {
const { checked, text, id, onCheck, onRemove } = this.props;
return (
<div
className={`TodoItem ${checked && 'active'}`}
onClick={() => onCheck(id)}
>
<div className="check">✔</div>
<div className="text">{text}</div>
<div
className="remove"
onClick={e => {
e.stopPropagation();
onRemove(id);
}}
>
[지우기]
</div>
</div>
);
}
}
export default TodoItem;
이렇게 최적화를 하고 나면 약 290ms 가 걸립니다.
지금은 최적화를 통해 대략 32% 정도의 성능 개선이 일어났는데요, 만약에 데이터의 수가 더 많고, 프로덕션 모드에서 실행된다면 더욱 큰 차이가 발생합니다.
15000 개의 데이터 최적화 전후:
전: https://pumped-behavior.surge.sh
후: https://stereotyped-division.surge.sh
85% 정도의 성능 개선이 이뤄졌습니다.
정리
리액트에서는 부모 컴포넌트가 리렌더링 되면 내부 컴포넌트들도 리렌더링 됩니다. 비록 이 작업이 버추얼 돔 에서 이뤄진다고 해도, 그 컴포넌트의 갯수가 많으면 자바스크립트 처리에 불필요한 자원을 낭비하게 될 수도 있습니다. 수많은 컴포넌트를 렌더링해야 하는 경우 이런식으로 shouldComponentUpdate 를 사용하시면, 보여지는 데이터 그리고 발생하는 업데이트의 수가 많아도, 앱의 성능을 지켜낼 수 있을 것입니다.