본문 바로가기
JavaScript/React

React) 컴포넌트 성능 최적화 - React.memo, useState의 함수형 업데이트, useReducer, 불변성의 중요성(얕은 복사)

by 박채니 2022. 11. 20.
안녕하세요, 코린이의 코딩 학습기 채니 입니다.
[리액트를 다루는 기술]의 책을 참고하여 포스팅한 개인 공부 내용입니다.

 

컴포넌트 성능 최적화

 

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

컴포넌트의 리렌더링을 방지하기 위해선 shouldComponentUpdate 라이프사이클을 사용하면 되지만,

함수 컴포넌트에선 라이프사이클을 이용할 수 없습니다.

대신! React.memo 함수를 사용합니다.

 

React.memo

- 컴포넌트의 props가 바뀌지 않았다면, 리렌더링 하지 않도록 함

- 함수 컴포넌트의 리렌더링 성능 최적화

 

TodoListItem.js

import React from 'react';
import cn from 'classnames'
import { MdCheckBox, MdRemoveCircleOutline, MdCheckBoxOutlineBlank } from 'react-icons/md';
import './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이 바뀌지 않으면 리렌더링 되지 않습니다.

 


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

 

App.js

  const onInsert = useCallback(text => {
    const todo = {
      id: nextId.current,
      text,
      checked: false
    };

    setTodos(todos.concat(todo));
    nextId.current += 1; // nextId 1씩 더해줌
  }, [todos]);

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

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

현재 코드를 살펴보면, todos 배열이 업데이트되면 onRemove와 onToggle함수도 새롭게 생성됩니다.

만일 할 일이 추가되었을 때, todos가 업데이트 되므로 onRemove와 onToggle 또한 새롭게 생성된다는 것이죠.

 

이렇게 함수가 계속 만들어지는 상황을 방지할 수 있습니다.

 

useState의 함수형 업데이트

 

기존 setTodos 함수를 사용할 땐, 새로운 상태를 파라미터로 넣어 처리하였습니다.

이 대신, 상태 업데이트를 어떻게 할지 정의해 주는 업데이트 함수를 넣을 수도 있습니다. → 함수형 업데이트

 

함수형 업데이트 예시

const [number, setNumber] = useState(0);

const onIncrease = useCallback(
	() => setNumber(prevNumber => prevNumber + 1),
	[]
);

setNumber(number + 1)이 아니라, 위처럼 어떻게 업데이트할지 정의해주는 업데이트 함수를 넣어주었습니다.

이렇게 되면, useCallback 사용 시 두 번째 파라미터로 넣는 배열에 number를 넣지 않아도 됩니다.

 

App.js

...
...

  const onInsert = useCallback(text => {
    const todo = {
      id: nextId.current,
      text,
      checked: false
    };

    setTodos(todos => todos.concat(todo)); // 함수형 업데이트로 변경 - 기존 : setTodos(todos.concat(todo))
    nextId.current += 1;
  }, []); // [todos]가 없어도 됨

  const onRemove = useCallback(id => {
    setTodos(todos => todos.filter(todo => todo.id !== id)); // 함수형 업데이트로 변경 - 기존 : setTodos(todos.filter(todo => todo.id !== id))
  }, []); // [todos]가 없어도 됨

  const onToggle = useCallback(id => {
    setTodos(todos => todos.map(todo => (
      todo.id === id ? { ...todo, checked: !todo.checked } : todo
    ))) // 함수형 업데이트로 변경 - 기존 : setTodos(todos.map(todo => (todo.id === id ? { ...todo, checked: !todo.checked } : todo)))
  }, []); // [todos]가 없어도 됨
  
  ...
  ...

이렇게 함수형 업데이트로 변경해줄 수 있습니다.

 

Profiler 개발자 도구에서 성능을 측정해보도록 하겠습니다.

이전 포스팅을 참고해보면, 하나의 할 일 목록 체크 시 337.9ms 소요되었습니다.

하지만, 현재 15.6ms로 확연히 줄어든 것을 확인할 수 있습니다.

또한, 왼쪽에서 회색 빗금이 그어져있는 박스는 React.memo를 통하여 리렌더링 되지 않은 컴포넌트를 나타내게 됩니다.

차트를 클릭하고 리렌더링 컴포넌트를 확인해보면 이전과 달리 몇 없는 것을 확인할 수 있습니다.

 

useReducer 사용하기

 

useState 함수형 업데이트를 사용할 수도 있지만,

useReducer를 사용해도 onRemove, onToggle 함수가 계속 생성되는 문제를 방지할 수 있습니다.

 

App.js

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

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 = () => {
  //const [ todos, setTodos ] = useState(createBulkTodos);
  const [todos, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);

  // 고윳값으로 사용될 id
  // ref를 사용해 변수 담기
  const nextId = useRef(2501);

  const onInsert = useCallback(text => {
    const todo = {
      id: nextId.current,
      text,
      checked: false
    };

    // useReducer 사용으로 변경
    dispatch({ type: 'INSERT', todo });
    nextId.current += 1;
  }, []); 

  const onRemove = useCallback(id => {
    // useReduce 사용으로 변경
    dispatch({ type: 'REMOVE', id });
  }, []); 

  const onToggle = useCallback(id => {
    // useReduce 사용으로 변경
    dispatch({ type: 'TOGGLE', id }); 
  }, []);

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

export default App;

※ useReducer 관련 포스팅

https://chanychu.tistory.com/444

 

React) Hooks - useReducer, useMemo

안녕하세요, 코린이의 코딩 학습기 채니 입니다. [리액트를 다루는 기술]의 책을 참고하여 포스팅한 개인 공부 내용입니다. Hooks - useReducer, useMemo useReducer - useState보다 더 다양한 컴포넌트 상황에

chanychu.tistory.com

 

이처럼 useReducer를 이용하여 처리해보았습니다.

이 때, 원래는 useReducer의 두 번째 파라미터에 초기 상태를 넣어주어야 했습니다.

하지만 지금은 undefined를 넣고, 세 번째 파라미터에 초기 상태를 만들어주는 함수 createBulkTodos를 넣어주었습니다.

이렇게 된다면 컴포넌트가 처음 렌더링될 때만 해당 함수를 호출하게 됩니다.

 


불변성의 중요성

리액트 컴포넌트에서 상태를 업데이트할 때 불변성을 지키는 것은 매우 중요!

 

App.js

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

기존 데이터를 수정할 때 직접 배열에 접근하여 수정하지 않고, 새로운 배열을 만든 후 새로운 객체를 만들어서 교체해주는 방식으로 구현했습니다.

따라서 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

만일 불변성이 지켜지지 않는다면, 객체 내부의 값이 새로워져도 바뀐 것을 감지하지 못하게 될 겁니다.

next~Bad처럼 내부 값이 바뀌어도 배열 혹은 객체 자체는 같은 배열 혹은 객체를 가리키게 되기 때문이죠.

 

위에서 사용한 전개 연산자 (...문법)를 사용해 객체나 배열 내부 값을 복사할 때는 얕은 복사가 됩니다.

내부의 값이 완전히 복사되는 것이 아닌 가장 바깥쪽에 있는 값만 복사!

즉, 내부의 값이 객체 혹은 배열이라면 내부의 값 또한 복사를 해줘야 한다는 것이죠

 

예시 코드

const todos = [{ id: 1, checked: true }, { id: 2, checked: true }];
const nextTodos = [...todos];

nextTodos[0].checked = false;
console.log(todos[0] === nextTodos[0]); // 같은 객체를 가리키므로 true

nextTodos[0] = {
	...nextTodos[0],
	checked: false
};
console.log(todos[0] === nextTodos[0]); // 새로운 객체를 할당해줬으므로 false

이처럼 내부의 값이 객체 혹은 배열이라면 내부의 값 또한! 복사를 해줘야 합니다.

 

만약 객체 안에 있는 객체라면 불변성을 지키며 새 값을 할당해야 하므로 아래와 같이 해줘야겠죠.

const nextComplexObject = {
	...complexObject,
	objectInside : {
    	...complexObject.objectInside,
        enabled: false
    }
};

console.log(complexObject === nextComplexObject); // false
console.log(complexObject.objectInside === nextComplexObject.objectInside); // false

만일 구조가 복잡해진다면, 불변성을 유지하면서 업데이트하는 것 또한 까다로워질 것입니다.

이 때 immer 라는 라이브러리를 사용할 수 있는데 추후 알아보겠습니다.

 


TodoList 컴포넌트 최적화하기

 

리스트 관련 컴포넌트를 최적화할 땐,

리스트 내부에서 사용하는 컴포넌트 뿐만 아니라 리스트로 사용되는 컴포넌트도 최적화해주는 것이 좋습니다.

 

TodoList.js

import React from 'react';
import './TodoList.scss';
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);

하지만 현재로썬 React.memo를 사용해도 성능에 영향을 주진 않을 것입니다.

그 이유는 부모 컴포넌트인 App이 현재 todos를 업데이트할 때만 리렌더링이 되기 때문입니다.

그럼에도 추후 다른 state가 추가될 상황을 대비하여 사전에 방지해주는 것이 좋겠죠!