이번 글 부터는 평소 궁금해하고 깊게 다뤄보고자 하였던 Rendering Time
과 관련된 이야기를 써보고자 한다.
관련된 내용은 벨로퍼트님의 리액트를 다루는 기술에서 상당 부분을 가져왔고, 일부 추가적으로 궁금한 내용들은 구글링을 통해 해결하였다.
시작하기 전에 React의 Hooks인 Memo, useCallback에 대한 충분한 이해가 필요하다!
기본적인 공부 방법들이 모두 궁금증에서 시작되기 때문에, 이 글 또한 렌더링 최적화를 왜 하는것인지에 대한 이야기에서 시작해보고자 한다.
시간이 지날수록 더 업그레이드 되고 세분화된 정보를 다루다 보면 애플리케이션에서 다루고자 하는 데이터의 용량이 거대해지는 것은 시간문제일 것이다. 이러한 경우 애플리케이션의 렌더링 속도가 느려지는 것은 당연하고, 이는 곧 UX
에 치명적인 문제점으로 다가오기 때문에 렌더링 최적화를 진행하는 것은 필수적이라고 할 수 있다.
책에서는 크롬 개발자 도구
를 활용한 성능 모니터링을 진행했는데, 본래 데이터의 크기가 10개 이하일 때의 렌더링 속도와 2500개일 때의 렌더링 속도는 확연히 차이가 남을 알 수 있었다.
책에서 다룬 느려짐의 원인을 알아보도록 하자.
React의(물론 다른 프레임워크도 마찬가지) 컴포넌트들은 다음과 같은 네 가지 상황에서 리렌더링이 발생한다.
-자신이 전달받은
props
가 변경될 때
-자신의state
가 바뀔 때
-부모 컴포넌트가 리렌더링될 때
-forceUpdate
함수가 실행될 때(자세한 설명은 안 하겠다. 강제 render()함수를 실행하게 하는 친구라고 하자)
특히 map과 같은 함수로 렌더링을 실행하면 배열의 길이에 따라 쉽게 컴포넌트의 개수가 증가하기 때문에 이런 상황에서 렌더링 최적화는 필수적이다.
이럴 때에는 state
의 변화에 영향을 받는 컴포넌트를 제외한 나머지 컴포넌트들의 리렌더링을 방지해 주어야 하는데, 책에서는 이를 다양한 방법으로 소개하고 있다.
React
내장 함수인 React.memo
를 사용하면 props
가 바뀌지 않은 컴포넌트는 리렌더링하지 않도록 설정할 수 있다!
사용법은 그렇게 어렵지 않다. 간단한 예시를 살펴보자.
component.js
import React from 'react';
const Component = () => {
return(...)
}
export default React.memo(Component);
위와 같이 React.memo
함수로 컴포넌트를 감싸주기만 하면 간단하게 리렌더링 방지를 구현할 수 있다.
안타깝게도 React.memo
를 사용하는 것만으로는 state
가 업데이트될 시 이를 재참조하는 함수들의 리렌더링을 방지하지는 못한다.
이렇게 함수가 매번 만들어지는 상황을 방지하기 위한 첫 번째 방법으로는 useState
의 함수형 업데이트 기능을 사용하는 것이 있다.
함수형 업데이트?
기존에 setState
함수를 사용하는 경우 새로운 상태 자체를 파라미터로 넣는 방법이 있었겠지만, 이 방법 대신 상태 업데이트 방법을 정의해주는 함수를 파라미터로 넣을 수 있다(실제로 권장되는 방법이다).
예시로 확인해 보자
component.js
const[ number, setNumber ] = useState(0);
const onIncrease = useCallback(
() => setNumber(prevNumber => prevNumber + 1),
[]
);
위의 경우처럼 setNumber(number + 1)
을 하는 것이 아니라 어떻게 업데이트할지 정의해주는 업데이트 함수를 넣는 방법이다. 이처럼 하면 number
를 뭐로 바꿀지를 판단하는 것이 아닌 어떻게 바꿀지를 판단하기 때문에 어떤 number
가 와도 상관이 없다. 따라서 state
가 업데이트된다고 함수까지 리렌더링 될 이유가 없어지는 것이다(어떤 state
가 와도 변경 로직을 알고 있기 때문이다)!
useReducer
를 사용해도 비슷한 성능으로 최적화가 가능하다.
다음 예제에서는 onToggle, onRemove 함수를 최적화한다. 그 과정을 살펴보자.
App.js
import { useReducer, useRef, useCallback } 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: '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);
(...)
dispatch({ type: 'INSERT', todo });
(...)
}, []);
cosnt onRemove = useCallback(id => {
dispatch({ type: 'REMOVE', id });
}, []);
cosnt onToggle = useCallback(id => {
dispatch({ type: 'TOGGLE', id });
}, []);
return (...)
export default App;
위의 경우처럼 따로 action
마다의 함수를 케이스별로 나눠주면 그 함수를 useCallback
을 이용하여 첫 렌더링에서만 정의하고 추후의 재정의를 방지할 수 있게 된다.
위의 케이스에서 useReducer
의 두 번째 파라미터에는 본래 초기 상태를 넣어주어야 한다. 지금은 그 대신 두 번째 파라미터에 undefined
를 넣고, 세 번째 파라미터에 초기 상태를 만들어주는 함수인 createBulktodos
를 넣어주었다. 이렇게 하면 컴포넌트가 맨 처음 렌더링될 때만 createBulktodos
함수가 호출된다.
useReducer
를 사용하는 방법은 기존 코드를 많이 고치고 switch
를 사용할 함수를 새로 정의해주어야한다는 단점이 있지만, 상태 업데이트 로직들을 모아서 따로 두고 관리하기 좋다는 장점이 있다.
함수 자체를 따로 모아 관리하는 것은 좋은 습관이라고 생각한다!
이쯤에서 내용을 마무리하고 다음 글에서 불변성에 대한 내용과 react-virtualized
를 이용한 최적화 등을 다루려고 한다.
useMemo
,useState
의 함수 파라미터 대입,useReducer
를 활용한 관리용 함수 제작 모두 장단점을 기억하도록 하자!