이 글은 김민준(velopert)님의 리액트를 다루는 기술을 참조하였습니다.
목차
1. 많은 데이터 렌더링하기
2. Memorized Component
3. React.memo
4. useState Functional Update & useReducer
5. react-virtualized
1. 많은 데이터 렌더링하기
책에서 따라한 todo-app 기반으로 테스트를 해볼 것이다.
전체 코드는 괜히 올렸다가 저작권 관련으로 신고받고 싶지 않으니 따로 게시하지 않겠디.
function createBulkTodos() { // 데이터 때려넣기
const array = [];
for (let i = 1; i <= 2500; i++) {
array.push({
id: i,
text: `할 일 ${i}`,
checked: false
});
}
return array;
}
만들어둔 앱에 할 일 목록을 2500개 렌더링 시켜보자.
반응이 현저하게 느려졌다. 정확하게 몇 초 정도 느려졌을까?
Google에서 리액트팀에서 개발한 React Developer Tools를 설치하면 실제 렌더링 소요 시간을 확인할 수 있다.
좌측 상단의 녹화 버튼을 누르고 화면에서 몇 가지 작업을 해준 후에 중지를 누르면 분석이 완료되는데
우측의 Render Durations가 리렌더링에 소요된 시간이다. 상단의 화살표를 누르면 다른 작업도 확인할 수 있다.
평균적으로 300ms(0.3초) 정도의 시간이 걸렸는데, 세부적인 내용을 보려면 상단의 불꽃 아이콘 옆의
차트 아이콘을 누르면 확인할 수 있다.
TodoList야 그렇다쳐도 옆에 스크롤 바가 엄청 귀여워졌다.
뭐가 이렇게 많이 렌더링 된 걸까 싶어 각각 눌러보면 변화를 준 컴포넌트 이외에도 리렌더링이 되었음을 알 수 있다.
이게 바로 최적화가 필요한 시점이다.
(여담으로 처음 웹/앱 서비스를 개발할 때는 100명을 대상으로 시작해서 트래픽 문제를 신경쓰지 않았더라도 사용자가 늘면 문제를 개선할 수 있어야 한다. 내가 최근 구경하는 사이트는 유입 인원이 점점 많아지면서 렌더링 속도가 최근 바닥을 치고 있어 사용이 불편하다고 느낄 정도다.)
2. Memorized Component
컴포넌트의 리렌더링은 결국 상태의 변화에서 비롯한다.
- 전달받은 props 혹은 자신의 state의 변화
- 부모 컴포넌트 리렌더링
- forceUpdate 함수 실행
1장에서 언급했던 이야기를 다시 해보자.
React는 초기 렌더링에 하나의 HTML 파일과 JS 파일을 몽땅 브라우저에 때려넣고 시작한다.
그리고 참조하는 주소의 변화(불변성의 원칙에 근거하여)가 발생하면 리액트 조화(Reconciliation)가 일어난다.
그 때는 Virtual DOM을 다시 그리는 과정에서 memorized Component에 대한 내용을 건너 뛰었었다.
이 부분만 다시 보충 설명을 적어놓자면, 다음과 같다.
Render Phase단계에서 업데이트가 필요한 노드를 Fiber가 찾아내면
렌더가 필요한 시작점부터 beginWork() 함수를 실행하고, DOM Fiber를 그리는 것이 완료된 작업부터 compleWork()함수가 실행된다.
Diffing algorithm을 사용해서 effect list를 만드는 것인데 여기서 memorized Component의 경우엔 어떨까?
memorized Component란 직역 그대로 '기억된 노드'라고 해석해도 무방하다.
Memoization(메모이제이션)은 프로그래밍에서 동일 작업을 할 때, 그 작업 방법이나 계산 값들을 기억해두었다가 처리해두는 하나의 테크닉이다.
즉, 여기서 memorized Component는 사용자가 입력한 compare 함수나 리액트 자체적인 얕은 비교 함수로 컴포넌트를 리렌더링할 지 판단하다가 불필요하다고 생각되면 그대로 사용한다.
그런데 노드(I)처럼 변경이 발생한 컴포넌트를 제외하고, 변경이 없는 컴포넌트들과 memorized 컴포넌트는 어디서 차이가 나는 걸까?
바로 Commit Phase 단계에서 Alternate Tree를 만들 때 차이가 생긴다.
변화가 없더라도 current tree의 노드(E)와 Alternate tree의 노드(E)는 서로 다른 객체이기 때문에 렌더링이 발생한다.
하지만 노드(F)의 경우 서로 같은 포인터를 공유하기 때문에 같은 객체로 인식되어 리렌더링이 발생되지 않는다.
React.memo()같은 기능은 이러한 원리를 이용한 장치라고 생각하면 된다.
3. React.memo()
이전에 언급했었던 최적화 Hooks들을 사용할 것이다.
함수형 컴포넌트에선 원래 LifeCycle을 컨트롤하는 것이 불가능했지만 여러 기능들이 도입되면서 다양한 방법을 통해 비슷한 기능들을 구현해낼 수 있게 되었다.
React.memo도 그 중 하나인데 컴포넌트의 props가 바뀌지 않았다면, 리렌더링을 하지 않도록 하는 것이다.
사용법은 굉장히 간단하다. 컴포넌트를 만들고 감싸주면 끝이다.
import React from 'react';
export default React.memo(컴포넌트);
컴포넌트 내에 props를 조작하는 함수만 실행시키지 않는다면 리렌더링이 발생하지 않을 것이다.
React.memo를 사용할 때는 주의해야 할 점이 있는데, 사용해봤자 별 의미가 없을 때가 있다.
기본적으로 부모 컴포넌트의 변동에 의해 props의 값 변화가 없는 자식 컴포넌트도 변동되는 것을 방지하기 위한 장치이기 때문에
"memoization을 한다고 절대 바꾸지 않겠다!" 라는 선언이 아니다.
그렇다면 어떨 땐 리렌더링하고, 어떨 땐 가만히 둘거냐를 판단하기 위해 계속해서 해당 컴포넌트에 컴페어 함수가 돌아가는데 대부분의 경우에 props가 바뀌는 컴포넌트의 경우엔 false만 주구장창 리턴할 테니
굳이 해봐야 최적화의 효과를 볼 수 없다는 의미가 된다.
심지어 useCallback 함수같은 애들이랑 섞어쓸 때 함수의 동등성이라는 스스로의 논리적 오류로 인해
부모 컴포넌트가 자식 컴포넌트의 콜백 함수를 정의했을 때, 메모제이션이 막혀버릴 수도 있다.
(이 이야기는 또 나중에 다룰 수 있을 때 하도록 하자. 물론 해결책은 존재한다. 하지만 남용하진 말자.)
4. useState Functional Update & useReducer
React.memo로 컴포넌트 최적화가 완료되지는 않는다.
어쨌든 props의 값에 변화가 발생하면 리렌더링이 발생하는 것은 여전한데 이때 컴포넌트 내부의
onToggle, onRemove같은 함수들을 만들었다면, 이 함수들도 새로 만들어진다.
보통 이런 함수들은 배열 상태를 업데이트하는 과정에서 최신 상태의 데이터들을 참조하기 때문에 배열이 바뀌는 과정에서 함수도 덩달아 새로 만들어진다.
이를 방지하는 방법이 2가지 있는데 하나는 useState의 함수형 업데이트 기능을 사용하는 것이고
다른 하나는 useReducer Hook을 사용하는 것이다.
1. useState 함수형 업데이트
기존에 useState를 사용하면
const onInsert = useCallback(
text => {
const todo = {
id: nextId.current,
text,
checked: false,
}
setTodos(todos.concat(todo));
nextId.current += 1;
}, [todos])
setTodos 함수의 인자로 todos 객체를 넘김으로써 새로운 state를 받아왔어야 했는데,
이렇게 할 게 아니라 상태 업데이트를 어떻게 할 지 메뉴얼만 정의해주자는 이야기다.
setTodos((todos) => todos.concat(todo));
이게 대체 무슨 원리인가?
한 가지 상기시켜야 할 내용은 useState는 비동기 함수라는 점이다.
useState함수는 비동기적 특성을 지녀야만 성능 이슈에서 벗어날 수 있다.
만약 useState가 동기적 특성을 가진다면 리렌더링을 유발하는 setState가 동시다발적으로 일어났을 때, 몇 번의 리렌더링이 발생할까?
아래의 예시를 보자.
const [value, setValue] = useState(0);
const onClick = () => {
setValue(value + 1);
setValue(value + 1);
setValue(value + 1);
console.log(value);
}
<button onClick={onClick} >테스트</button>
이대로 리액트를 실행시키면 결과값이 무엇이 나올까? 놀랍게도 답은 3이 아니라 1이 나온다.
동일 state를 연속적으로 업데이트를 하는 경우, 리액트에서는 setState를 동기적 수행이 아닌 일괄(batch)처리 해버린다.
그런데 이런 비동기 작업을 회피해서 마치 동기적인 방식으로 작동하도록 만들 수 있는데
그것이 바로 함수형 업데이트, setState에 값을 전달하는 게 아니라 함수를 전달하는 방법이었다.
const [value, setValue] = useState(0);
const onClick = () => {
setValue(value => value + 1);
setValue(value => value + 1);
setValue(value => value + 1);
console.log(value);
}
<button onClick={onClick} >테스트</button>
이제 3씩 증가하는 것을 확인할 수 있을 것이다.
아니, 근데 한창 최적화 얘기하길래 살펴봤더니 비동기를 동기 작업으로 바꿔버리면 그만큼 성능이 떨어지는 거 아닌가?
사실 setState를 동기적으로 사용하는 것 자체가 최적화의 목적은 아니다.
진짜 목적은 컴포넌트의 useCallback 함수로부터 state의 의존성을 제거함으로써 리렌더링을 방지한다.
위에서 기존의 onInsert 함수에서 setState에 함수를 던졌을 때, 어떻게 변할까?
const onInsert = useCallback(
text => {
const todo = {
id: nextId.current,
text,
checked: false,
}
setTodos((todos) => todos.concat(todo));
nextId.current += 1;
}, [])
useCallback 함수가 state였던 todos에서 의존성이 제거되었음을 알 수 있다.
2. useReducer
useState 함수형 업데이트 대신 사용할 수 있는 방법이다.
고쳐야 하는 코드는 많지만 상태 업데이트 관련 로직을 분리시킴으로써 가독성과 효율을 증대시킬 수 있다.
성능은 둘이 비슷해서 편한 걸 사용하면 된다.
import {useReducer} from 'react';
(...)
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: "TOGGLE", id: 1}
return todos.map(todo =>
todo.id === action.id ? {...todo, checked: !todo.checked} : todo,
);
default:
return todos;
}
}
function App() {
const [todos, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);
const nextId = useRef(2501);
const onInsert = useCallback(
text => {
const todo = {
id: nextId.current,
text,
checked: false,
}
dispatchEvent({type:"INSERT", todo});
nextId.current += 1;
}, [])
}
useReducer도 원래 두 번째 인자에 초기값을 넣어주어야 하는데, 초기상태를 만들어주는 함수를 넣어버림으로써
초기 렌더링될 때만 함수를 호출시킨다.
5. react-virtualized
비록 todo-app에 2500개의 할 일 리스트가 있지만 한 번에 볼 수 있는 건 9개 정도밖에 없다.
그럼 나머지 2,491개의 컴포넌트는 스크롤하기 전까지 렌더링하지 않고 크기만 던져두게끔 하는 방법이다.
npm install react-virtualized --legacy-peer-deps * 버전 때문에 옵션을 붙여주어야 한다.
import React, {useCallback} from "react";
import {List} from 'react-virtualized';
import TodoListItem from "./TodoListItem";
import './scss/TodoList.scss';
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' }}
/>
);
}
export default React.memo(TodoList);
그러면 List 컴포넌트를 불러와 사용할 수 있게 된다.
해당 리스트의 전체 크기와 각 항목의 높이, 각 항목을 렌더링할 때 사용하는 함수, 그리고 배열을 props로 던져주면
List 컴포넌트가 전달받은 props를 사용해 자동으로 최적화 해준다.
그리고 각 리스트를 나타내는 TodoListItem.js를 수정해주어야 한다.
const TodoListItem = ({ todo, onRemove, onToggle, style }) => {
const { id, text, checked } = todo;
return (
<div className='TodoListItem-virtualized' style={style} >
(...)
</div>
)
}
export default React.memo(TodoListItem);
렌더링 속도가 300ms에서 6.4ms까지 떨어진 것을 확인할 수 있다..