[React] 컴포넌트의 불필요한 리렌더링 방지

js43o·2021년 11월 3일
4

1. 리액트에서 컴포넌트는 다음과 같은 상황에 리렌더링된다.

  1. 자신의 state가 바뀔 때
  2. 부모 컴포넌트로부터 받아오는 props가 바뀔 때
  3. 부모 컴포넌트가 리렌더링될 때
  4. forceUpdate를 통한 강제 업데이트 시

앱을 구성하는 컴포넌트가 많아질수록, 또 그것들이 자주 리렌더링될수록 성능 최적화를 고려하는 것이 좋다.
간단한 일정 관리 앱의 구조를 가정하자. 상위 컴포넌트부터 차례대로...

  • TodoApp: todo 객체들로 이루어진 배열 todos를 자신의 state로 가지며, 이에 대한 추가/수정/삭제 함수를 정의하고 자식 컴포넌트인 TodoList에게 props로 전달한다.
  • TodoList: props로 받은 todos를 상응하는 TodoItem 컴포넌트들로 렌더링하며, 수정/삭제 함수를 props로 전달한다.
  • TodoItem: props로 받은 todo의 정보를 표시하고 수정/삭제 함수를 이벤트 리스너에 등록한다.

만약 우리가 새로운 일정을 todos에 추가한다면, 이것을 state로 가지는 TodoApp이 먼저 리렌더링되고 그 자식들도 연달아 리렌더링될 것이다.
문제는 굳이 리렌더링될 필요가 없음에도 위의 규칙에 따라 리렌더링이 발생하는 컴포넌트가 존재한다는 것이다. (예: 새로 추가된 TodoItem 외 모든 TodoItem이 리렌더링됨)

2. 컴포넌트 최적화를 위한 도구에는 크게 세 가지가 있다.

먼저, 고차 컴포넌트 React.memo()이다. 다른 컴포넌트를 인수로 받으며, 해당 컴포넌트가 React.memo()로 감싸지고 나면 이 컴포넌트가 받는 props가 바뀌지 않는 한 리렌더링되지 않는다.

고차 컴포넌트: 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수

다음은 useCallback() hook이다. 주로 자식 컴포넌트에게 props로 전달하려는 함수에 적용한다. 첫 번째 인자는 함수, 두 번째 인자는 의존 배열(deps)로, 이 안에 포함된 값이 바뀌면 해당 함수를 새롭게 생성하고, 그렇지 않다면 이전에 생성한 함수를 기억해두고 그대로 사용한다.

// TodoApp.js
const TodoApp () => {
  const onRemove = useCallback((id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  }, [todos]); 
  ...
  return <TodoList onRemove={onRemove}, ... />
};
// TodoItem.js
const TodoItem = ({ onRemove, ... }) => {
  ...
}

export default React.memo(TodoItem);

이 둘은 함께 쓰일 때 그 효력을 발휘한다. 우선 자식 컴포넌트인 TodoItemReact.memo()를 적용하여 props가 변하지 않을 때 리렌더링을 막는다. (= 부모의 리렌더링에 의한 단순 리렌더링을 방지)

그리고 상위 컴포넌트인 TodoApp에 정의된 수정/삭제 함수에 useCallback을 적용하면, (deps에 포함된 todos가 변하지 않는 한) TodoApp이 리렌더링되더라도 이 함수를 새로 생성하지 않게 된다. 즉, TodoItem에 전달하는 props이 바뀌지 않는다는 뜻이다.

중간 정리

useCallback(): (의존 배열이 변하지 않는 한) 기존 함수를 재생성하지 않고 그대로 props로 내려보냄.
React.memo(): props이 바뀌지 않으면 해당 컴포넌트를 리렌더링되지 않게 함.
➡️ 즉, 내려받는 함수 == props이 바뀌지 않으므로 컴포넌트가 리렌더링되지 않음!

그리고 마지막 도구는 useState의 함수형 업데이트이다.

useCallback을 적용하더라도 deps에 포함된 state가 변한다면 해당 함수는 재생성된다. todos를 직접 변화시키는 수정/삭제 함수가 그렇다.
그러나 만약 함수 내에서 직접 state에 접근하여 작업을 처리하는 로직이 있다면, useState의 함수형 업데이트를 이용하여 deps에서 해당 state를 제외할 수 있다.

const onRemove = useCallback((id) => {
  setTodos(todos.filter((todo) => todo.id !== id));
}, [todos]); // 일반적인 setter

const onRemove = useCallback((id) => {
  setTodos((todos) => todos.filter((todo) => todo.id !== id));
}, []); // '함수형 업데이트' 이용

단순히 Setter의 인자로 새로운 state 값 자체가 아닌 'state를 업데이트 하는 방법을 정의하는 함수'를 넣어줬을 뿐인데, 의존 배열 내 todos가 불필요해졌다.
이것이 가능한 이유는 Setter 내에서 todos를 직접 참조할 수 있게 되었기 때문이다. 따라서 이제 useCallback은 현재의 todos 값을 기억하지 않아도 된다.

또한, 가장 처음 state를 정의할 때 useState 대신 useReducerdispatch()를 사용하여 state 관련 로직을 컴포넌트 바깥으로 분리함으로써 동일한 효과를 얻을 수 있다.

const onRemove = useCallback((id) => {
    dispatch({ type: 'REMOVE', payload: id })
  }, []); // state를 deps에 포함시키지 않아도 된다

추가로 리스트 관련 컴포넌트를 구성할 때 react-virtualized 등의 최적화 라이브러리도 사용해보면 좋을 것 같다.

profile
공부용 블로그

0개의 댓글