이번 포스팅에서는 리액트 컴포넌트 성능 최적화 방법들을 알아보고자 한다.
컴포넌트가 리렌더링되는 경우는 다음과 같다.
- 자신이 전달받은 props가 변경될 때
- 자신의 state가 바뀔 때
- 부모 컴포넌트가 리렌더링 될 때
- forceUpdate 함수가 실행이 될 때
매번 하나의 변화가 생길 때 마다 모든 요소들이 다시 불러와 지면 그 갯수가 적을 때는 큰 차이가 없지만, 수 백개, 수 천개 그리고 그 이상의 요소들을 불러올 때 성능이 느려지게 될 것이다. 따라서 리렌더링을 시키는 요소들을 어느정도 통제해 줄 필요가 생기게 되는데, 여기에서 많이 쓰이는 함수가 React.memo() 함수이다.
React.memo()
클래스형 컴포넌트에서는 shouldComponentUpdate라는 라이프사이클을 사용하면 되는데 함수형 컴포넌트에서는 라이프사이클 메서드를 쓸 수 없으므로 React.memo를 사용한다. 이 함수를 통해 컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정하여 함수형 컴포넌트의 리렌더링 성능을 최적화 해줄 수 있다.
TodoListItem 이라는 컴포넌트가 있을 때 다음과 같이 React.memo()로 컴포넌트를 감싸주게 되면, props인 { todo, onRemove, onToggle } 이 바뀌지 않는 이상 컴포넌트는 리렌더링되지 않는다.
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);
하지만 이렇게 최적화를 해 주어도 onRemove나 onToggle 함수가 불필요하게 바뀌는 경우가 생기면 성능이 떨어질 수 있다. 예를 들면 Todo-List 배열을 항상 최신 상태로 참조하는 함수일 경우, Todo-List가 매번 바뀔 때 마다 함수가 새로 만들어진다. 함수가 이와 같이 계속 만들어지는 상황을 방지하기 위해서는 useState나 useReducer와 같은 리액트 훅을 사용해야 한다.
useState의 함수형 업데이트
기존의 함수는 setState시에(아래 예제에서는 setTodos()) 새로운 상태를 파라미터로 넣어주었다. setState를 사용할 때 새로운 상태를 파라미터로 넣는 대신, 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수도 있다. 그렇게 되면 useCallback을 사용할 때 두 번째 파라미터로 넣는 배열에 값을 넣어주지 않아도 된다.
// 기존의 삭제 함수
const onRemove = useCallback(
id => {
setTodos(todos.filter(todo => todo.id !== id));
},
[todos],
);
// 함수형 업데이트 후
const onRemove = useCallback(id => {
setTodos(todos => todos.filter(todo => todo.id !== id));
}, []);
useReducer 사용하기
useReducer를 사용하면 리듀서를 만들어야 해서 앞에서 설명한 useState의 함수형 업데이트를 사용하는 방법보다 고쳐야 할 코드량이 많다. 하지만 상태를 업데이트하는 로직을 모아서 컴포넌트 바깥에 따로 둘 수 있다는 장점이 있다. 성능상의 차이는 useState와 useReducer를 사용하는 방법이 비슷하므로 목적과 취향에 맞게 적절하게 사용하면 좋을 것 같다.
// 기존 코드
const App = () => {
const [todos, setTodos] = useState(createBulkTodos);
const onRemove = useCallback(
id => {
setTodos(todos.filter(todo => todo.id !== id));
},
[todos],
);
// ...
}
// useReducer로 성능을 최적화 한 코드
function todoReducer(todos, action) {
switch (action, type) {
case 'REMOVE': // 제거
return todos.filter(todo => todo.id !== action.id);
// ...
default:
return todos;
}
}
const App = () => {
const [todos, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);
const onRemove = useCallback(id => {
dispatch({ type: 'REMOVE', id });
}, []);
// ...
}
불변성 유지
리액트 컴포넌트에서 상태를 업데이트할 때 불변성을 지키는 것은 굉장히 중요하다. 불변성을 지킨다는 말은 기존의 값을 수정하지 않으면서 새로운 값을 만들어내는 것을 의미한다. 불변성이 지켜지지 않을 경우, 객체 내부의 값이 새로워져도 바뀐 것을 감지하지 못해서 React.memo 등에서 비교해서 성능 최적화 하는 작업을 하지 못하게 되는 경우도 있다.
추가적으로 전개 연산자({ ... array }를 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사(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
객체의 구조가 복잡해지면 이렇게 불변성을 유지하면서 업데이트 하는 것이 어려워 진다. 따라서 이럴경우 라이브러리의 도움을 받기도 하는데 대표적인 라이브러리가 immer이다.
// ...
import produce from 'immer';
const App = () => {
const [data, setData] = useState({
array: [],
uselessValue: null
});
// ...
const onRemove = useCallback(
id => {
setData(
produce(data, draft => {
draft.array.splice(draft.array.findIndex(info => info.id === id), 1);
})
);
},
[data]
);
// ...
};
immer를 사용하는 가장 핵심적인 이유는 '불변성에 신경쓰지 않는 것처럼 코드를 작성하되 불변성 관리는 제대로 해주는 것'을 가능하게 해주기 때문이다. 단순하게 깊은 곳에 위치하는 값을 바꾸는 것 이외에 배열을 처리할 때도 쉽고 편하게 할 수 있다. 위의 예제에서 onRemove 함수는 immer를 적용하지 않고도 깔끔하게 작성할 수 있는데, immer를 쓰는 것이 상항 코드를 간결하게 만들어 주지는 않는다는 점을 명심하자.
참고자료
- 리액트 공식 문서
- 리액트를 다루는 기술, 김민준(Velopert) 저
'Web Frontend Developer' 카테고리의 다른 글
[리액트+TS] 우아한 테크러닝 3기 3주차 (0) | 2020.09.29 |
---|---|
[리액트+TS] 우아한 테크러닝 3기 2주차 (0) | 2020.09.21 |
[리액트+TS] 우아한 테크러닝 3기 1주차 (0) | 2020.09.11 |
[Webpack] 웹팩 개발 서버, API 연동 그리고 최적화 (0) | 2020.09.01 |
[React] 리액트 훅(Hook)에 대한 개념 정리 (0) | 2020.08.31 |