WEB🔨/React

리액트를 다루는 기술 요약#6

최문경 블로그 2021. 6. 25. 15:52

해당 시리즈는 제목 그대로 김민준님의 '리액트를 다루는 기술'을 요약한 글입니다.

 

목차

11장 컴포넌트 성능 최적화

12장 immer를 사용하여 더 쉽게 불변성 유지하기

 

 

11장 컴포넌트 성능 최적화

10장에서 멋진 일정 관리 애플리케이션을 만들어보았습니다. 현재까지는 사용할 때 불편하지 않지만 데이터가 무수히 많아지면 애플리케이션이 느려지는 것을 체감할 수 있습니다

 

흐름

  1. 많은 데이터 렌더링하기
  2. 크롬 개발자 도구를 통한 성능 모니터링
  3. React.memo를 통한 컴포넌트 리렌더링 성능 최적화
  4. onToggle과 onRemove가 새로워지는 현상 방지하기
  5. react-virtualized를 사용한 렌더링 최적화

 

11.1 많은 데이터 렌더링하기

import React, { useCallback, useRef, useState } 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 App() {
  const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(2501);

  ( ... )
}

export default App;

여기서 주의할 점은 useState의 기본값에 함수를 넣어 주었다는 것입니다. 여기서 useState(createBulkTodos())라고 작성하면 리렌더링될 때마다 createBulkTodos 함수가 호출되지만, useState(createBulkTodos)처럼 파라미터를 함수 형태로 넣어 주면 컴포넌트가 처음 리렌더링될 때만 createBulkTodos 함수가 실행됩니다.

 

항목 중 하나를 체크했을 때 이전보다 느려진 것을 확인할 수 있습니다.

 

 

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

개발자 도구의 Performance 탭에서 녹화 버튼을 누른 뒤 항목을 체크하고 Stop을 누르세요.

거의 1초가 걸렸다는 것을 알 수 있습니다. 데이터가 2,500개밖에 안 되는데도 불구하고 1초나 걸린다는 것은 성능이 매우 나쁘다는 의미입니다.

 

 

11.3 느려지는 원인 분석

'할 일 1' 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링됩니다. 부모 컴포넌트가 리렌더링되었으니 TodoList 컴포넌트가 리렌더링되고 그 안의 모든 TodoListItem 컴포넌트들도 리렌더링됩니다.

 

'할 일 1' 항목은 리렌더링되어야 하는 것이 맞지만, '할 일2'부터 '할 일 2500'까지는 리렌더링을 안 해도 되는 상환인데 모두 리렌더링되고 있으므로 이렇게 느린 것입니다.

 

즉, 불필요한 리렌더링을 방지해주어야합니다.

 

 

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

컴포넌트의 리렌더링을 방지할 때는 7장에서 배운 shouldComponentUpdate라는 라이프사이클을 사용하면 됩니다. 그런데 함수형에서는 라이프사이클 메서드를 사용할 수 없습니다. 그 대신 React.memo라는 함수를 사용합니다. 컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정하여 성능을 최적화해 줄 수 있습니다.

 

React.memo의 사용법은 매우 간단합니다. 컴포넌트를 만들고 나서 감싸 주기만 하면 됩니다. TodoListItem 컴포넌트에 다음과 같이 React.memo를 적용해보세요.

 

import React from 'react';
import {
  MdCheckBoxOutlineBlank,
  MdCheckBox,
  MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
import '../styles/TodoListItem.scss';

const TodoListItem = ({ todo, onRemove, onToggle }) => {
  const { id, text, checked } = todo;
  return (
    <div className="TodoListItem">
      <div className={cn('checkbox', { checked })} onClick={() => onToggle(id)}>
        {checked ? <MdCheckBox /> : <MdCheckBoxOutlineBlank />}
        <div className="text">{text}</div>
      </div>
      <div className="remove" onClick={() => onRemove(id)}>
        <MdRemoveCircleOutline />
      </div>
    </div>
  );
};

export default React.memo(TodoListItem);

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

 

 

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

React.memo를 사용하는 것만으로 컴포넌트 최적화가 끝나지는 않습니다. todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀌기 때문입니다. 이렇게 함수가 계속 만들어지는 상황을 방지하는 방법은 두 가지입니다. 첫 번째 방법은 useState의 함수형 업데이트 기능을 사용하는 것이고, 두 번째 방법은 useReducer를 사용하는 것입니다.

 

useState의 함수형 업데이트

기존에 setTodos 함수를 사용할 때는 새로운 상태를 파라미터로 넣어 주었습니다. setTodos를 사용할 때 새로운 상태를 파라미터로 넣는 대신, 상태 업데이트를 어떻게 할지 정의해주는 업데이트 함수를 넣을 수도 있습니다. 이를 함수형 업데이트라고 부릅니다.

 

import React, { useCallback, useRef, useState } 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 App() {
  const [todos, setTodos] = useState(createBulkTodos);

  const nextId = useRef(2501);

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

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

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

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

export default App;

위 코드처럼 업데이트 함수를 넣어주면 useCallback을 사용할 때 두 번째 파라미터로 넣는 배열에 todos를 넣지 않아도 됩니다. 따라서, todo가 변경되어 todos에 변경이 생겨도 함수(onToggle, onRemove)를 재선언하지 않고 TodoListItem 중에서 todo가 변경된 TodoListItem만 리렌더링이 됩니다.

 

1초에서 0.055초로 성능이 훨씬 향상되었습니다!

 

 

useReducer 사용하기

함수형 업데이트를 사용하는 대신, useReducer를 사용해도 onToggle과 onRemove가 계속 새로워지는 문제를 해결할 수 있습니다.

 

import React, { useCallback, useRef, useReducer } 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;
  }
}

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,
    };
    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를 사용할 때는 원래 두 번째 파라미터에 초기 상태를 넣어 주어야 합니다. 지금은 그 대신 두 번째 파라미터에 undefined를 넣고, 세 번째 파라미터에 초기 상태를 만들어 주는 함수인 createBulkTodos를 넣어 주었는데요. 이렇게 하면 컴포넌트가 맨 처음 렌더링될 때만 createBulkTodos 함수가 호출됩니다.

 

조금 더 성능이 좋은 것 같다.

 

 

11.7 TodoList 컴포넌트 최적화하기

import React from 'react';
import TodoListItem from './TodoListItem';

const TodoList = ({ todos, onRemove, onToggle }) => {
  return (
    <div className="TodoList">
      {todos.map((todo) => (
        <TodoListItem
          todo={todo}
          key={todo.id}
          onRemove={onRemove}
          onToggle={onToggle}
        />
      ))}
    </div>
  );
};

export default React.memo(TodoList);

위 최적화 코드는 현재 프로젝트 성능에 전혀 영향을 주지 않습니다. 하지만 App 컴포넌트에 다른 state가 추가되어 해당 값들이 업데이트될 때는 불필요한 리렌더링을 할 수도 있습니다. 그렇기 때문에 미리 최적화해 준 것입니다.

 

리스트 관련 컴포넌트를 작성할 때는 리스트 아이템과 리스트, 이 두 가지 컴포넌트를 최적화해 주는 것을 잊지 마세요. 그러나 내부 데이터가 100개를 넘지 않거나 업데이트가 자주 발생하지 않는다면, 이런 최적화 작업을 반드시 해 줄 필요는 없습니다.

 

 

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

react-virtualized를 사용하면 리스트 컴포넌트에서 스크롤되기 전에 보이지 않는 컴포넌트는 렌더링하지 않고 크기만 차지하게끔 할 수 있습니다. 그리고 만약 스크롤되면 해당 스크롤 위치에서 보어 주어야 할 컴포넌트를 자연스럽게 렌더링시킵니다. 이 라이브러리를 사용하면 낭비되는 자원을 아주 쉽게 아낄 수 있습니다.

 

import React, { useCallback } from 'react';
import { List } from 'react-virtualized';
import TodoListItem from './TodoListItem';
import '../styles/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 컴포넌트를 사용하기 위해 rowRenderer라는 함수를 새로 작성해 주었습니다. 이 함수는 react-virtualized의 List 컴포넌트에서 각 TodoItem을 렌더링할 때 사용하며, 이 함수를 List 컴포넌트의 props로 설정해 주어야 합니다. 이 함수는 파라미터에 index, key, style 값을 객체 타입으로 받아 와서 사용합니다.

 

List 컴포넌트를 사용할 때는 해당 리스트의 전체 크기와 각 항목의 높이, 각 항목을 렌더링할 때 사용해야 하는 함수, 그리고 배열을 props로 넣어 주어야 합니다. 그러면 이 컴포넌트가 전달받은 props를 사용하여 자동으로 최적화해 줍니다.

 

0.006초까지 줄어들었습니다!

 

 

정리

리액트 컴포넌트의 렌더링은 기본적으로 빠르기 때문에 컴포넌트를 개발할 때 최적화 작업에 대해 너무 큰 스트레스를 받거나 모든 컴포넌트에 일일이 React.memo를 작성할 필요는 없습니다. 단, 리스트와 관련된 컴포넌트를 만들 때 보여줄 항목이 100개 이상이고 업데이트가 자주 발생한다면, 이 장에서 학습한 방식을 사용하여 꼭 최적화하길 바랍니다.

 

 

12장 imer를 사용하여 더 쉽게 불변성 유지하기

immer를 사용하지 않고 불변성 유지

import React, { useRef, useState } from 'react';

const App = () => {
  const nextId = useRef(1);
  const [form, setForm] = useState({ name: '', username: '' });
  const [data, setData] = useState({ array: [], uselessValue: null });

  const onChange = (e) => {
    setForm({ ...form, [e.target.name]: e.target.value });
  };
  const onSubmit = (e) => {
    e.preventDefault();
    const item = {
      id: nextId.current++,
      name: form.name,
      usename: form.username,
    };

    setData({ ...data, array: data.array.concat(item) });
    setForm({ name: '', username: '' });
  };
  const onRemove = (id) => {
    const removed = data.array.filter((info) => info.id !== id);
    setData({ ...data, array: removed });
  };

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input
          type="text"
          name="username"
          placeholder="아이디"
          value={form.username}
          onChange={onChange}
        />
        <input
          type="text"
          name="name"
          placeholder="이름"
          value={form.name}
          onChange={onChange}
        />
        <button type="submit">등록</button>
      </form>
      <div>
        <ul>
          {data.array.map((info) => (
            <li key={info.id} onClick={() => onRemove(info.id)}>
              {info.usename} ({info.name})
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default App;

 

 

immer 사용하기

// 예시코드
import produce from 'immer';
const nextState = produce(originalState, draft => {
	draft.somewhere.deep.inside = 5;
})

produce라는 함수는 두 가지 파라미터를 받습니다. 첫 번째 파라미터는 수정하고 싶은 상태이고, 두 번째 파라미터는 상태를 어떻게 업데이트할 지 정의하는 함수입니다.

 

이 라이브러리의 핵심은 '불변성에 신경 쓰지 않는 것처럼 코드를 작성하되 불변성 관리는 제대로 해주는 것'입니다.

 

import React, { useRef, useState } from 'react';
import produce from 'immer';

const App = () => {
  const nextId = useRef(1);
  const [form, setForm] = useState({ name: '', username: '' });
  const [data, setData] = useState({ array: [], uselessValue: null });

  const onChange = (e) => {
    // setForm({ ...form, [e.target.name]: e.target.value });
    setForm(
      produce(form, (draft) => {
        draft[e.target.name] = e.target.value;
      }),
    );
  };

  const onSubmit = (e) => {
    e.preventDefault();
    const item = {
      id: nextId.current++,
      name: form.name,
      usename: form.username,
    };

    // setData({ ...data, array: data.array.concat(item) });
    setData(
      produce(data, (draft) => {
        draft.array.push(item);
      }),
    );

    setForm({ name: '', username: '' });
  };
  const onRemove = (id) => {
    // setData({ ...data, array: data.array.filter((info) => info.id !== id) });
    setData(
      produce(data, (draft) => {
        draft.array.splice(
          draft.array.findIndex((info) => info.id === id),
          1,
        );
      }),
    );
  };

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input
          type="text"
          name="username"
          placeholder="아이디"
          value={form.username}
          onChange={onChange}
        />
        <input
          type="text"
          name="name"
          placeholder="이름"
          value={form.name}
          onChange={onChange}
        />
        <button type="submit">등록</button>
      </form>
      <div>
        <ul>
          {data.array.map((info) => (
            <li key={info.id} onClick={() => onRemove(info.id)}>
              {info.usename} ({info.name})
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default App;

immer를 사용한다고 해서 무조건 코드가 깔끔해지지는 않습니다. onRemove의 경우에는 filter를 사용하는 것이 더 깔끔하므로, 굳이 immer를 적용할 필요가 없습니다. immer는 불변성을 유지하는 코드가 복잡할 때만 사용해도 충분합니다.

 

immer에서 제공하는 produce 함수를 호출할 때, 첫 번째 파라미터가 함수 형태라면 업데이트 함수를 반환합니다. 이를 활용하면 코드를 더욱 깔끔하게 만들 수 있습니다.

 

import React, { useRef, useState } from 'react';
import produce from 'immer';

const App = () => {
  const nextId = useRef(1);
  const [form, setForm] = useState({ name: '', username: '' });
  const [data, setData] = useState({ array: [], uselessValue: null });

  const onChange = (e) => {
    // setForm({ ...form, [e.target.name]: e.target.value });
    setForm(
      produce((draft) => {
        draft[e.target.name] = e.target.value;
      }),
    );
  };

  const onSubmit = (e) => {
    e.preventDefault();
    const item = {
      id: nextId.current++,
      name: form.name,
      usename: form.username,
    };

    // setData({ ...data, array: data.array.concat(item) });
    setData(
      produce((draft) => {
        draft.array.push(item);
      }),
    );

    setForm({ name: '', username: '' });
  };
  const onRemove = (id) => {
    // setData({ ...data, array: data.array.filter((info) => info.id !== id) });
    setData(
      produce((draft) => {
        draft.array.splice(
          draft.array.findIndex((info) => info.id === id),
          1,
        );
      }),
    );
  };

  return (
    <div>
      <form onSubmit={onSubmit}>
        <input
          type="text"
          name="username"
          placeholder="아이디"
          value={form.username}
          onChange={onChange}
        />
        <input
          type="text"
          name="name"
          placeholder="이름"
          value={form.name}
          onChange={onChange}
        />
        <button type="submit">등록</button>
      </form>
      <div>
        <ul>
          {data.array.map((info) => (
            <li key={info.id} onClick={() => onRemove(info.id)}>
              {info.usename} ({info.name})
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};

export default App;

 

 

정리

immer 라이브러리는 편의를 위한 것으로 꼭 필요하지는 않지만, 사용한다면 생산성을 크게 높일 수 있습니다. 만약 immer를 사용하는 것이 오히려 불편하게 느껴진다면 사용하지 않아도 좋습니다.