끄적끄적 코딩
article thumbnail
Published 2023. 6. 27. 01:58
[React] 컴포넌트 스타일링 React

많은 데이터 렌더링하기

아래의 코드를 작성하여 랙(lag)경험할 수 있도록 많은 데이터를 렌더링해봅시다.

function createBulkTodos() {
  const array = [];
  for (let i = 1; i <= 2500; i++) {
    array.push({
      id: i,
      text: `할 일 ${i}`,
      checked: false,
    });
  }
  return array;
}

const App = () => {
  const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(2501);

(...)
};
  • 이 때, 해당 코드에서 주의할 점은 useState의 기본값에 함수를 넣어 주었다는 것입니다.useState(createBulkTodos)라 작성하면 컴포넌트가 처음 렌더링될 때만 함수가 실행됩니다.
  • useState(createBulkTodos())라 작성하면 리렌더링될 때마다 함수가 호출되지만 useState(createBulkTodos)라 작성하면 컴포넌트가 처음 렌더링될 때만 함수가 실행됩니다.


크롬 개발자 도구를 통한 성능 모니터링

  • 성능을 분석할 때 React DevTools를 사용하여 측정.
  • 개발자 도구의 Profiler 탭을 열어 녹화 버튼을 누른 후, 컴포넌트에 변화를 주고 변화가 반영되면 녹화 버튼을 다시 누릅니다. 그러면 아래와 같은 분석 결과가 나옵니다.

  • Durations의 Render는 리렌더링에 소요된 시간을 의미하고 즉, 위에선 리렌더링 되는데 781.2ms가 걸렸다는 것을 의미합니다.(컴퓨터 환경에 따라 다를 수 있음)

  • 상단의 랭크 차트 아이콘을 누르면 리렌더링된 컴포넌트를 오래 걸린 순으로 정렬하여 나열해줍니다.
  • 해당 랭크 차트를 보면 성능을 분석하기 위해 변화를 일으킨 컴포넌트랑 관계없는 컴포넌트들이 모두 리렌더링된 것을 확인할 수 있습니다.
  • 하나의 항목을 업데이트하는 데 렌더링 쇼요시간이 이렇게 오래 걸리는 것은 결코 좋지 못한 성능으로 최적화가 필요!


느려지는 원인 분석

리렌더링이 발생하는 상황

  1. 자신이 전달받은 props가 변경될 때
  2. 자신의 state가 바뀔 때
  3. 부모 컴포넌트가 리렌더링될 때
  4. forceUpdate 함수가 실행될 때

지금 상황을 분석해 보면

  1. “할 일 1” 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링.
  2. 부모 컴포넌트가 리렌더링되었으므로 TodoList 컴포넌트가 리렌더링.
  3. 그 안의 무수한 컴포넌트가 리렌더링.

이렇게 컴포넌트 하나의 리렌더링이 연쇄적인 리렌더링을 발생시킴.

원하지 않는 컴포넌트까지 리렌더링되기 때문에 성능 저하가 발생하였고 이럴 때는 불필요한 리렌더링을 방지해줌으로써 리렌더링 성능을 최적화해 주는 작업이 필요.


React.memo를 사용하여 컴포넌트 성능 최적화

컴포넌트의 리렌더링을 방지할 때, 

클래스형 컴포넌트 : shouldComponentUpdate라는 라이프 사이클
함수 컴포넌트 : React.memo

를 사용하여 컴포넌트의 props가 바뀌지 않았다면 리렌더링하지 않도록 설정합니다.

React.memo의 사용법은 매우 간단합니다.

	(...)
import React from 'react';

const TodoListItem = ({ todo, onRemove, onToggle }) => {
	(...)
};

export default React.memo(TodoListItem);

이 간단한 설정으로 이제 TodoListItem 컴포넌트는 todo, onRemove, onToggle이 바뀌지 않으면 리렌더링을 하지 않습니다.


onToggle, onRemove 함수가 바뀌지 않게 하기

React.memo를 사용하는 것만으로 최적화가 끝나진 않습니다. 현재의 프로젝트에서 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀌기 때문입니다.

→ 배열 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새롭게 만들어짐.

이렇게 함수가 계속 만들어지는 상황을 방지하는 방법은 다음과 같습니다.

  1. useState의 함수형 업데이트 기능 사용.
  2. useReducer 사용.



useState의 함수형 업데이트

함수형 업데이트란 useState의 setState를 사용할 때, 새로운 상태를 파라미터로 넣는 대신, 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣어주는 것을 말합니다.

예를 들어,

const [number, setNumber] = useState(0);
// prevNumbers는 현재 number 값을 가리킵니다.
const onIncrease = useCallback(
	() => setNumber(prevNumber => prevNumber + 1),
	[],
);

setNumber(number+1)을 해주는 것이 아니라 어떻게 업데이트할지 정의해주는 업데이트 함수를 넣어줌

→ 이러면 useCallback을 사용할 때 두 번째 파라미터로 넣는 배열에 number를 넣지 않고 빈 배열을 넣어도 됨. (왜일까요…?)

함수형 업데이트를 사용하여 다시 구현해보면

import { useState, useRef, useCallback } from 'react';
import TodoTemplate from './components/TodoTemplate';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';

function createBulkTodos() {
  const array = [];
  for (let i = 1; i <= 2500; i++) {
    array.push({
      id: i,
      text: `할 일 ${i}`,
      checked: false,
    });
  }
  return array;
}

const App = () => {
	const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(2501);

  const onInsert = useCallback(
    (text) => {
      const todo = {
        id: nextId.current,
        text,
        checked: false,
      };
      setTodos((todos) => todos.concat(todo));
      nextId.current += 1;
    },
    [todos],
  );

  const onRemove = useCallback(
    (id) => {
      setTodos((todos) => todos.filter((todo) => todo.id !== id));
    },
    [todos],
  );

  const onToggle = useCallback(
    (id) => {
      setTodos((todos) =>
        todos.map((todo) =>
          todo.id === id ? { ...todo, checked: !todo.checked } : todo,
        ),
      );
    },
    [todos],
  );

  return (
    <TodoTemplate>
      <TodoInsert onInsert={onInsert} />
      <TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} />
    </TodoTemplate>
  );
};

export default App;


useReducer 사용하기

import { useState, useReducer, useRef, useCallback } from 'react';
import TodoTemplate from './components/TodoTemplate';
import TodoInsert from './components/TodoInsert';
import TodoList from './components/TodoList';

function createBulkTodos() {
  const array = [];
  for (let i = 1; i <= 2500; i++) {
    array.push({
      id: i,
      text: `할 일 ${i}`,
      checked: false,
    });
  }
  return array;
}

function todoReducer(todos, action) {
  switch (action.type) {
    case 'INSERT':
      return todos.concat(action.todo);
    case 'REMOVE':
      return todos.filter((todo) => todo.id !== action.id);
    case 'TOGGLE':
      return todos.map((todo) =>
        todo.id === action.id ? { ...todo, checked: !todo.checked } : todo,
      );
    default:
      return todos;
  }
}

const App = () => {
	// useReducer(reducer 함수, 초기 상태, 초기 상태를 만들어 주는 함수);
  const [todos, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);

  const nextId = useRef(2501);

  const onInsert = useCallback((text) => {
    const todo = {
      id: nextId.current,
      text,
      checked: false,
    };
    dispatch({ type: 'INSERT', todo });
    nextId.current += 1;
  }, []);

  const onRemove = useCallback((id) => {
    dispatch({ type: 'REMOVE', id });
  }, []);

  const onToggle = useCallback((id) => {
    dispatch({ type: 'TOGGLE', id });
  }, []);

  return (
    <TodoTemplate>
      <TodoInsert onInsert={onInsert} />
      <TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} />
    </TodoTemplate>
  );
};

export default App;

  • 엄청난 성능 향상!
  • useReducer의 세 번째 파라미터에 초기 상태를 만들어주는 함수를 넣어주면 컴포넌트가 맨 처음 렌더링될 때만 함수가 호출됨.
  • useReducer를 사용하는 방법
    • 단점 : 기존 코드를 많이 고쳐야 함.
    • 장점 : 상태를 업데이트하는 로직을 모아서 컴포넌트 바깥에 둘 수 있음.

 

불변성의 중요성

기존의 값을 직접 수정하지 않으면서 새로운 값을 만들어 내는 것을 ‘불변성을 지킨다’고 함.

리액트 컴포넌트에서 상태를 업데이트할 때, 불변성을 유지해주는 것이 매우 중요.

업데이트가 필요한 곳에서는 아예 새로운 배열 혹은 새로운 객체를 만들기 때문에 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.

리액트는 상태 값을 업데이트할 때 참조 값만을 비교하여 상태 변화를 감지하는 얕은 비교 수행.

→ 불변성 지키지 않으면 객체 내부의 값이 새로워져도 변화를 감지하지 못함.

따라서 배열이나 객체를 새로 생성해서 새로운 참조값을 만들어서 상태를 업데이트해야 함.

예시에선 전개 연산자(… 문법)을 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사를 하게 됨.

얕은 복사란?
내부 값이 완전히 새로 복사되는 것이 아니라 가장 바깥 쪽에 있는 값만 복사하는 것을 의미. → 두 개의 객체가 다른 주소를 가지고 있지만 객체 안 프로퍼티는 동일한 주소를 지님. 내부의 값이 객체 혹은 배열이라면 내부의 값 또한 따로 복사해야 함.

배열 혹은 객체의 구조가 복잡해지면 불변성을 유지하면서 업데이트하는 것이 까다로워짐.

→ immer 라이브러리의 도움을 받아 편하게 작업.

 

TodoList 컴포넌트 최적화하기

리스트 관련 컴포넌트를 최적화할 때는 리스트 내부에서 사용하는 컴포넌트와 리스트로 사용되는 컴포넌트 자체를 둘 다 최적화해 주는 것이 좋음.

따라서 TodoList.js 또한 React.memo를 추가해줘야 함.

 

react-virtualized를 사용한 렌더링 최적화

 

이미지에서 보이듯이 항목은 2500개를 생성하였지만 실제 화면에 출력되는 항목은 9개 뿐.

→ 스크롤하기 전에 보이지 않음에도 불구하고 렌더링을 하는 건 비효율적.

react-virtualized를 사용하여 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링 되지 않고 크기만 차지하게끔할 수 있음.

 

최적화 준비

yarn add react-virtualized

명령어를 통해 설치하고 react-virtualized가 제공하는 List 컴포넌트를 사용하여 TodoList의 성능을 최적화해보자!

사전 작업

  • 최적화를 수행하기 전, 각 항목의 실제 크기를 px 단위로 알아내야 함.

  • 직접 계산할 필요없이 크롬 개발자 도구로 알아내면 되고 두 번째 항목부터 테두리가 포함되어 있기 때문에 두 번째 항목의 크기를 확인.

'React' 카테고리의 다른 글

[React] 리액트 컴포넌트에서 API를 연동  (0) 2023.06.28
[React] immer  (0) 2023.06.28
[React] 컴포넌트 스타일링  (0) 2023.06.22
[React] Hooks  (0) 2023.06.22
[React] 컴포넌트의 라이프사이클 메서드  (0) 2023.06.22

검색 태그