티스토리 뷰

지금까지 학습한 리액트 기초 개념을 가지고 직전에 Todo List App을 만들어 보았다. 일반적인 상황에서 할 일은 10개를 넘어가지 않으니, 이 App을 사용할 때 큰 불편함 없이 사용할 수 있다. 

 

만약, 데이터가 1000개 이상으로 많아진다면 어떨까? 이벤트를 핸들링할 때마다 1000개 이상의 컴포넌트를 모두 리렌더링하는 것은 분명히 부담이 될 것이며, 속도 또한 체감이 가능할 정도로 느려질 것이다.

 

Todo를 2500개 정도 만들어서 한번 부하를 줘보자.

 

◎App.js

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

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);

    // ref 를 사용하여 변수 담기
    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.filter(todo => todo.id !== id));
        }, [todos]
    );

    const onToggle = useCallback(
        id => {
            setTodos(
                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;

 

데이터를 하나하나 직접 입력할 수는 없으므로, createBulkTodos 라는 함수를 만들어 데이터를 자동으로 생성했다. 

 

위 코드에서 주의 깊게 봐야할 점은 useState의 기본값에 들어가는 createBulkTodos 함수의 형태이다. createBulkTodos()의 형태로 넣어준다면 useState가 리렌더링될 때마다 createBulkTodos()가 호출되므로, 괄호를 뺀 함수 형태로 넣어 컴포넌트가 처음 렌더링될 때만 createBulkTodos가 실행되도록 해줘야 한다.

2500개의 데이터가 잘 들어갔다. 클릭하여 Toggle 이벤트를 걸면, 딱 느껴지기에도 1초 이상의 딜레이가 생긴다. 

 

개발자 도구에서 recording을 따보면, 

렌더링에 0.4초 정도가 소요됐다. Ranked List를 쭉 내려보면, 하나의 Todo만 클릭하여 Toggle해줬을 뿐인데도 불구하고 2500개의 todo가 모두 렌더링된 것을 확인할 수 있다.

 

즉, '할 일 1' 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링된다. 부모 컴포넌트가 리렌더링되었으니 TodoList 컴포넌트가 리렌더링되면서 그 안에 있던 수 많은 Todo 컴포넌트들이 리렌더링되는 것이다.


그럼 어떻게 이 성능을 최적화할 수 있을까?

 

우선, 컴포넌트가 어떤 상황에서 리렌더링이 발생하는 지를 알아야 한다.

 

1. 자신이 전달받은 props가 변경될 때

2. 자신의 state가 바뀔 때

3. 부모 컴포넌트가 리렌더링될 때

4. forceUpdate 함수가 실행될 때

 

리렌더링하지 않아도 되는 컴포넌트들까지 "굳이" 리렌더링을 할 필요는 없다. 

 

불필요한 리렌더링을 어떻게 방지할 수 있는 지 한번 알아보자.


1. React.memo를 사용

 컴포넌트의 리렌더링을 방지하기 위해서는 이전에 배운 shouldComponentUpdate라는 라이프사이클을 사용하면 된다. 그런데 함수 컴포넌트에서는 라이프사이클 메서드를 사용할 수 없으므로 React.memo라는 함수를 사용한다. 

 

이는 컴포넌트의 props가 바뀌지 않았다면, 리렌더링하지 않도록 설정하여 함수 컴포넌트의 리렌더링 성능을 최적화할 수 있다.

 

React.memo의 사용은 매우 간단하다. TodoListItem을 React.memo로 감싸주기만 하면 된다.

 

◎TodoListItem.js

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

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);

이렇게만 해주면, memo로 감싸준 컴포넌트는 todo, onRemove, onToggle 속성이 바뀌지 않는 한 리렌더링을 하지 않는다.


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

React.memo를 사용하는 것만으로 컴포넌트 최적화가 끝나지는 않는다. 현재 프로젝트에서는 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀐다.

 

즉, App.js에서 아래와 같이 작성이 된 함수이기 때문에 todos라는 배열의 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어지는 것이다.

 

◎App.js

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.filter(todo => todo.id !== id));
    }, [todos]
);

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

 

이렇게 함수가 계속 만들어지는 상황을 방지하는 방법은 두 가지이다. 

 

 1) useState의 함수형 업데이트 기능을 사용. 

기존에 setTodos 함수를 사용할 때는 새로운 상태를 파라미터로 넣어 줬다. setTodos를 사용할 때마다 새로운 상태를 파라미터로 넣는 대신에, 아래와 같이 상태 업데이트를 어떻게 할 지 정의해주는 업데이트 함수를 넣을 수도 있다.

 

◎App.js

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

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
            )
        );
    }, []
);

이렇게 하면, 굳이 useCallback 함수의 두 번째 인자로 참조할 상태를 넣지 않아도 된다. 이를 함수형 업데이트라고 한다.

 

 2) useReducer 사용

useState의 함수형 업데이트를 사용하는 대신에 useReducer를 사용해도 함수가 계속 새롭게 작성되는 것을 해결할 수 있다.

 

◎App.js

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

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, dispatch] = useReducer(todoReducer, undefined, createBulkTodos);

    // ref 를 사용하여 변수 담기
    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가 호출된다. 

 

useReducer를 사용하는 방법은 기존 코드를 많이 고쳐야 한다는 단점이 있지만, 상태를 업데이트하는 로직을 모아서 컴포넌트 바깥에 둘 수 있다는 장점이 있다.


3. 불변성의 중요성

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

 

앞에서 useState를 사용해 만든 todos 배열과 setTodos 함수를 사용하는 onToggle 함수를 다시 확인해보자.

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

기존 데이터를 수정할 때 직접 수정하지 않고, 새로운 배열을 만든 다음에 새로운 객체를 만들어서 필요한 부분을 교체해주는 방식으로 구현했다. 업데이트가 필요한 곳에서는 아예 새로운 배열 혹은 새로운 객체를 만들기 때문에, React.memo를 사용했을 때 props가 바뀌었는지 혹은 바뀌지 않았는지를 알아내서 리렌더링 성능을 최적화할 수 있다.

 

이런 전개 연산자(... 문법)를 사용하여 객체나 배열 내부의 값을 복사할 때는 얕은 복사를 한다. 

 

즉, 내부의 값이 완전히 새로 복사되는 것이 아니라 가장 바깥쪽에 있는 값만 복사된다. 따라서 내부의 값이 객체 혹은 배열이라면 내부의 값 또한 따로 복사해 줘야 한다. 

 

예시를 확인해보자.

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 라이브러리를 사용하여 편리하게 처리하는데, 이는 다음 포스팅에서 알아보자.


4. TodoList 컴포넌트 최적화

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

 

TodoList 컴포넌트도 React.memo로 최적화해주자.

 

◎TodoList.js

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

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-virtualized를 사용한 렌더링 최적화

Todo List에서는 초기 데이터가 2,500개 등록되어 있는데, 실제로 화면에 나오는 항목은 아홉개이다. 나머지는 스크롤해야 볼 수 있다. 

 

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

 

이 라이브러리를 사용하면 낭비되는 자원을 아주 쉽게 아낄 수 있다.

 

npm install react-virtualized 를 입력하여 일단 패키지를 설치하자.

 

최적화를 수행하려면 사전에 먼저 해야하는 작업이 있는데, 바로 각 항목의 실제 크기를 px 단위로 알아내는 것이다. 

 

현재 내 브라우저의 경우, width: 495에 height: 56 이다.

 

이제, TodoList를 아래와 같이 수정하자.

 

◎TodoList.js

import TodoListItem from './TodoListItem';
import './TodoList.scss';
import React from 'react';
import {useCallback} from "react";
import {List} from 'react-virtualized';

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={495} // 전체 크기
            height={513} // 전체 높이
            rowCount={todos.length} // 항목 개수
            rowHeight={57} // 항목 높이
            rowRenderer={rowRenderer} // 항목을 랜더링할 때 사용할 함수
            list={todos} // 배열
            style={{outline: 'none'}} // List에 기본 적용되는 outline 스타일 제거
        />
    );
};

export default React.memo(TodoList);

 

div 대신 List 컴포넌트를 적용했기 때문에 CSS가 좀 깨진다. TodoListItem.js 를 아래와 같이 수정해주자.

 

◎TodoListItem.js

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

const TodoListItem = ({todo, onRemove, onToggle, style}) => {
    const {id, text, checked} = todo;

    return(
        <div className="TodoListItem-virtualized" style={style}>
            <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>
        </div>
    );
};

export default React.memo(TodoListItem);

 

◎TodoListItem.scss

.TodoListItem-virtualized {
  // 엘리먼트 사이사이에 테두리를 넣어줌
  & + & {
    border-top: 1px solid #dee2e6;
  }

  &:nth-child(even) {
    background: #f8f9fa;
  }
}

react-virtualized 까지 적용하고 나니까 아래와 같이 성능이 매우 향상된 것을 알 수 있다.

리액트 컴포넌트의 렌더링은 기본적으로 매우 빠르기 때문에 컴포넌트를 개발할 때 최적화 작업에 대하여 너무 큰 스트레스를 받거나 모든 컴포넌트에 memo를 작성할 필요는 없다. 

 

하지만 리스트와 관련된 컴포넌트를 만들 때 데이터가 아주 많다면, 이러한 방식으로 성능 최적화가 필수적이니 반드시 알아두자.

'WEB > React' 카테고리의 다른 글

[React] Routing: React Router  (0) 2023.03.30
[React] 불변성 유지: immer  (0) 2023.03.29
[React] First Project: Todo List  (0) 2023.03.28
[React] 컴포넌트 스타일링  (0) 2023.03.27
[React] Hooks  (0) 2023.03.27
Comments