여태까지 실습한 애플리케이션들은 추가되어 있는 데이터가 매우 적기 때문에 사용할때 불편함을 느끼지 않았다. 그러나 데이터가 무수히 많아지면, 애플리케이션이 느려지는 것을 체감할 수 있을 정도로 지연이 발생한다.
실제로 랙(lag)을 경험할 수 있도록 많은 데이터를 렌더링해 보자.
이전보다 확연하게 느려진 것을 확인할 수 있다.
컴포넌트는 다음과 같은 상황에서 리렌더링이 발생한다.
자신이 전달받은 props가 변경될 때
자신의 state가 바뀔 때
부모 컴포넌트가 리렌더링될 때
forceUpdate 함수가 실행될 때
지금 상황을 분석해 보면, 특정 할일 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링되는데, 부모 컴포넌트가 리렌더링되었으니 TodoList 컴포넌트가 리렌더링되고 그 안의 무수한 컴포넌트들도 리렌더링된다.
해당 항목은 리렌더링되어야 하는 것이 맞지만, 나머지 할 일 컴포넌트들은 리렌더링을 안해도 되는 상황인데 모두 리렌더링되고 있으니 지연이 발생하는 것이다.
이럴 때는 컴포넌트 리렌더링 성능을 최적화해 주는 작업을 해 주어야 한다. 즉, 리렌더링이 불필요할때는 리렌더링을 방지해 주어야 한다.
컴포넌트의 리렌더링을 방지할 때는 shouldComponentUpdate라는 라이프 사이클을 사용하면 다. 하지만 함수형 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없기 때문에, 그 대신 React.memo라는 함수를 사용한다. 컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정하여 함수형 컾모넌트의 리렌더링 성능을 최적화해 줄 수 있다.
React.memo의 사용법은 매우 간단하다. 컴포넌트를 만들고 나서 감싸 주기만 하면 된다.
이제 TodoListItem 컴포넌트는 todo, onRemove, onToggle이 바뀌지 않으면 리렌더링을 하지 않는다.
React.memo를 사용하는 것만으로 컴포넌트 최적화가 끝나지는 않는다. 현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀌기 때문이다. onRemove와 onToggle 함수는 배열 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어진다. 이렇게 함수가 계속 만들어지는 상황을 방법은 두 가지이다. 첫 번째 방법은 useState의 함수형 업데이트 기능을 사용하는 것이고, 두 번째 방법은 useReducer를 사용하는 것이다.
기존에 setTodos 함수를 사용할 때는 새로운 상태를 파라미터로 넣어 주었지만, 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수도 있다. 이를 함수형 업데이트라고 부른다.
const [number, setNumber] = useState(0);
// prevNumbers는 현재 number 값을 가리킨다.
const onIncrease = useCallback(() => setNumber(prevNumber => prevNumber + 1), [], );
setNumber(number + 1)을 하는 것이 아니라, 어떻게 업데이트할지 정의해 주는 업데이트 함수를 넣어 준다. 그러면 useCallback을 사용할 때 두 번째 파라미터로 넣는 배열에 number를 넣지 않아도 된다.
useState의 함수형 업데이트를 사용하는 대신, useReducer를 사용해도 onToggle과 onRemove가 계속 새로워지는 문제를 해결할 수 있다.
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]; // 배열 내부의 값을 모두 복사한다.
nextArrayGood[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); // 다른 객체이기 때문에 false
불변성이 지켜지지 않으면 객체 내부의 값이 새로워져도 바뀐 것을 감지하지 못한다. 그러면 React.memo에서 서로 비교하여 최적화하는 것이 불가능하다.
추가로 전개 연산자(... 문법)을 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사(shallow copy)를 하게 된다. 즉, 내부의 값이 완전히 새로 복사되는 것이 아니라 가장 바깥쪽에 있는 값만 복사되기 때문에, 내부의 값이 객체 혹은 배열이라면 내부의 값 또한 따로 복사해 주어야 한다.
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
}
};
console.log(complexObject === nextComplexObject); // false
console.log(complexObject.objectInside === nextComplexObject.objectInside); // false
리스트에 관련된 컴포넌트를 최적화할 때는 리스트 내부에서 사용하는 컴포넌트도 최적화해야 하고, 리스트로 사용되는 컴포넌트 자체도 최적화해 주는 것이 좋다.
import React from 'react';
import TodoListItem from './TodoListItem';
import './TodoList.scss';
const TodoList = ({ todos, onRemove, onToggle }) => {
return (...);
};
export default React.memo(TodoList);
위 최적화 코드는 현재 프로젝트 성능에 전혀 영향을 주지 않는다. 왜냐하면, TodoList 컴포넌트의 부모 컴포넌트인 App 컴포넌트가 리렌더링되는 유일한 이유가 todos 배열이 업데이트될 때이기 때문이다. 즉, 지금 TodoList 컴포넌트는 불필요한 리렌더링이 발생하지 않는다. 하지만 App 컴포넌트에 다른 state가 추가되어 해당 값들이 업데이트될 때는 TodoList 컴포넌트가 불필요한 리렌더링을 할 수도 있기 때문에, React.memo를 사용해 미리 최적화해 준 것이다.
react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하게끔 할 수 있다. 그리고 만약 스크롤되면 해당 스크롤 위치에서 보여 주어야 할 컴포넌트를 자연스럽게 렌더링시키는데, 이 라이브러리를 사용하면 낭비되는 자원을 아주 쉽게 아낄 수 있다.