Todo-list 예시 프로젝트 리뷰 및 최적화

nasagong·2023년 2월 20일
0

React

목록 보기
11/15
post-thumbnail

📚 들어가며

앞서 직접 만들어본 일정관리 앱과 교재의 코드를 비교해보며 개선점을 찾아보자. 디자인 부분은 최소한으로만 보고 가능한 컴포넌트 간 구조,상태관리,최적화 위주로 교재에선 왜 이런 코드를 썼는지 생각해가며 비교해보고자 한다. 코드 옮겨 적고 서버에서 돌려보기만 하면 실력이 늘지 않는다 !!


1. 예시 프로젝트

프로젝트 개요

App컴포넌트 제외 총 4개의 컴포넌트로 프로젝트가 구성된다. 각 컴포넌트의 역할을 먼저 알아본 후 코드를 읽어보자.

TodoTemplate : 템플릿 역할을 한다. 이 컴포넌트 태그 내에서 children으로 TodoInsert, TodoList가 렌더링된다. (곧 보게될 App.js 코드를 통해 직관적으로 확인 가능하다)

TodoInsert : input / submit이 포함된 컴포넌트다.

TodoList : TodoListItem컴포넌트를 사용해 일정을 보여준다.

TodoListItem : TodoList에서 받은 props를 사용해 컴포넌트 반복을 수행한다.

과정은 생략하고 바로 완성된 코드를 확인해보자

App

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

const App = () => {
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: 'TEST',
      checked: true,
    },
  ]);

  const nextId = useRef(2);

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

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

export default App;

useState를 통해 todos배열을 관리해주고 있다. todos는 여러 컴포넌트에 props로 전달될 예정이다.

이벤트 함수들은 일단 제쳐두고 return 부분만 확인해보자. 구조도로 그린 것처럼 TodoTemplate아래로 하위 컴포넌트들이 묶여있다. 이번엔 TodoTemplate 코드를 확인해보자.

TodoTemplate

// TodoTemplate.js
import React from 'react';
import './TodoTemplate.scss';

const TodoTemplate = ({ children }) => {
  return (
    <div className="TodoTemplate">
      <div className="app-title">일정 관리</div>
      <div className="content">{children}</div>
    </div>
  );
};

export default TodoTemplate;

이번 컴포넌트에서 직접적으로 렌더링하고 있는 건 app-title밖에 없다. app-title 아래로 children을 보여주는 형식이다. 여기서 children이란

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

당연히 TodoInsert와 TodoList를 의미한다. 그렇다면 이번엔 첫 번째 자식인 TodoInsert컴포넌트를 확인해보자

TodoInsert

// TodoInsert.js
import React, { useState, useCallback } from 'react';
import { MdAdd } from 'react-icons/md';
import './TodoInsert.scss';

const TodoInsert = ({ onInsert }) => {
  const [value, setValue] = useState('');

  const onChange = useCallback(e => {
    setValue(e.target.value);
  }, []);
// dependency array가 비어있다 == 렌더링 된 이후로 변화 없음
  const onSubmit = useCallback(
    e => {
      onInsert(value);
      setValue(''); // value 값 초기화

      e.preventDefault();
    },
    [onInsert, value],
  );

  return (
    <form className="TodoInsert" onSubmit={onSubmit}>
      <input
        placeholder="할 일을 입력하세요"
        value={value}
        onChange={onChange}
      />
      <button type="submit">
        <MdAdd />
      </button>
    </form>
  );
};

export default TodoInsert;

input창을 구현한 평범한 컴포넌트다. 특이한 점은 form으로 input을 감쌌으며, 버튼에 onClick이벤트를 설정하지 않고 form에 onSubmit이벤트를 걸어줬다.

form태그에 onSubmit을 사용하면 인풋에서 엔터키를 눌러도 submit이 이루어진다. 하던대로 버튼에 onClick을 설정해도 기능상으로는 문제가 없지만 엔터처리가 필요해지면 input에 onKeyPress이벤트를 또 설정해줘야 한다. 이런 상황이 발생한다면 form으로 인풋과 버튼을 묶어준 후 onSubmit을 사용하면 편리하다.

다만 onSubmit이벤트는 새로고침을 발생시킨다는 점을 염두에 두고 있어야 한다. 위 코드에서는 e.preventDefault()를 사용해 새로고침을 방지하고 있다.

submit된 값은 onInsert를 통해 App에서 만들어진 state인 todos에 반영된다.

TodoListItem

//TodoListItem.js
import React from 'react';
import {
  MdCheckBoxOutlineBlank,
  MdCheckBox,
  MdRemoveCircleOutline,
} from 'react-icons/md';
import cn from 'classnames';
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 TodoListItem;

TodoList를 보기 전에 TodoListItem을 먼저 보는 편이 흐름을 이해하기 편한듯 해 먼저 가져와봤다. props로 todo를 받고 있는데, 이는 곧 TodoList를 보면 알게 되겠지만 todos배열의 단일 값들을 의미한다.

구조분해를 통해 todo의 id, text, checked값을 바인딩한 후 여러 곳에 사용하고 있다. 제일 중요한 값은 id로, 삭제하거나 수정할 list의 id값을 이벤트 함수에 전달하여 원하는 형태로 모록을 관리할 수 있개 만든다.

TodoList

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

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

이벤트 함수들과 todos를 props로 받고있다. todos에 map을 돌려서 TosoListItem컴포넌트를 찍어내고 있는 것을 확인할 수 있다. <li>대신 그냥 생 컴포넌트를 디자인해서 사용하고 있다.


남의 코드 보는 게 쉬운 일은 아닌 것 같다.. 그래도 한번 주욱 읽어보면 나중엔 한 번에 눈에 들어온다! 이제 컴포넌트간 구조는 대략적으로 파악이 끝났다. 이번엔 이벤트 함수를 통해 여러 기능들을 파악해보자.

onInsert

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

특별한 건 없다. 불변성 유지를 위해 concat을 사용해 상태를 업데이트 해주고 있으며, 함수를 useCallback으로 감싸 todos에 변화가 없을 때 함수가 재생성되는 것을 예방하고 있다.

onRemove

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

역시나 특별한 부분은 없다. parameter로 사용되는 id는 아까 봤던대로 todo를 구조분해하여 값을 추출한 후 함수 호출 시 넘겨준다. 마찬가지로 useCallback을 사용해 todos의 변화에만 반응하도록 설정돼있다.

onToggle

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

이 친구는 디자인 쪽에 가깝긴 한데.. 인자로 받은 id를 사용해 특정 todo값의 checked값을 반전시켜준다. 다른 id를 가진 값들은 삼항연산을 통해 원래 상태를 유지 시켜주고 있기 때문에 불변성 유지에는 문제가 없다.


어느정도 코드는 다 봤다. 내가 무작정 만들었던 일정관리 앱과 비교해보자면

  1. 컴포넌트가 역할별로 잘 분할돼있다
  2. 1의 연장선으로, props를 적극 활용하고 있다.
  3. 디자인이 예쁘다
  4. 다양한 hook들을 사용하고 있다.

덕분에 막연하기만 했던 hook사용법들과 컴포넌트를 정리하는 법을 배울 수 있었다. scss와 classnames를 사용한 디자인도 개인적으로 어느정도 보긴 했지만 포스팅할 정도로 중요하다고 여겨지진 않아서.. 생각보다 css실력도 늘긴 했다. 대충만 알고 있었던 flex의 사용법을 익혔다.

2. 프로젝트 최적화

컴포넌트를 최적화하는 방법에 대해 알아보자. 일정관리 프로젝트에 많은 양의 데이터를 넣어 렌더링 시간을 늘린 후 코드를 수정해가며 직접 최적화 시켜볼 것이다.

(...)
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);
(...)
}
export default App;

createBulkTodos함수를 통해 2500개의 더미 일정을 추가했다. 얼마나 느려졌는지 확인해보자.

리스트 하나를 체크하는 데 체감상 1초 조금 안 되는 시간이 걸렸다. 크롬 React DevTools를 통해 소요 시간을 정확히 파악할 수 있으나 이건 일단 넘어가겠다. 바로 최적화를 시도해보자.

Lag의 원인

2500개의 리스트가 추가된 건 알겠다. 그래서 왜 느려지는 걸까? 리스트 하나가 수정될 때마다 엄청난 수의 나머지 리스트들이 함께 리렌더링되기 때문이다. 즉 TodoListItem컴포넌트가 불필요하게 렌더링되고 있다는 뜻이다.

하지만 지금까지 useCallbak이나 useMemo를 사용해 컴포넌트 내 요소가 불필요하게 리렌더링되는 걸 최적화해본 적은 있지만 컴포넌트 자체의 리렌더링을 관리해본 적은 없다..

React.memo 사용하기

컴포넌트의 리렌더링을 관리할 때는 라이프사이클메서드 중 하나인 shouldComponentUpdate를 사용할 수 있지만 함수형 컴포넌트에는 생명주기 메서드가 없다. 하지만 그 대신으로 React.memo()함수가 있다!

React.memo함수를 사용한 컴포넌트는 props의 값이 변하지 않는다면 리렌더링 하지 않도록 설정된다. 사용법은 그냥 함수로 컴포넌트를 감싸주면 된다. 코드로 확인해보자.

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

const TodoListItem = ({todo,onRemove,onToggle}) =>{
    (...)
}
export default React.memo(TodoListItem);

이렇게 React.memo로 감싸주기만 하면 TodoListItem은 더이상 props가 바뀌지 않는 한 리렌더링되지 않는다. 다시 한번 일정관리 앱에 들어가 리스트를 수정해보자. 좀 빨라졌을까?

예상과 달리 전혀 차이가 없다. 왜일까?

함수가 바뀌지 않게 하기

React.memo하나로는 최적화가 이루어질 수 없다. TodoListItem컴포넌트의 props인 onToggle함수와 onRemove함수는 todos에 의존하고 있기에 리스트가 수정될 경우 onToggle,onRemove 함수 또한 재생성된다. 이는 TodoListItem의 불필요한 리렌더링으로 이어진다..
이러한 상황을 방지하기 위해서 사용할 수 있는 방법은 두 가지다.

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

useState의 함수형 업데이트

ussState가 반환한 상태 업데이트 함수에는 보통 새로운 상태를 파라미터로 넣어줬다. 하지만 경우에 따라 상태를 어떻게 업데이트 할지 정의해주는 함수를 넣어주는 게 더 좋을 때도 있다. 예시를 확인해보자.

const [number,setNumber] = useState(0);
const onIncrease = useCallback(
  ()=> setNumber(prevNumber -> prevNumber+1),
  [],
);
/*
The onIncrease function in the provided code 
does not have any dependencies 
because it does not reference 
any variables or functions outside of its scope.
*/

setNumber(number+1) 대신 업데이트 과정을 담은 함수를 넣어줬다. 이런 경우엔 useCallback의 의존성 배열에 number를 추가해주지 않아도 된다. App컴포넌트의 함수들에도 이를 적용해보자.

const onInsert = useCallback(
    text =>{
      const todo = {
        id: nextId.current,
        text : 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),
      );
    },[]

전부 수정했다. 함수 내 setTodos의 파라미터를 수정하고도 의존성 배열을 비우지 않는다면 아래와 같은 경고가 뜬다.

쓸데없이 배열에 뭐 넣지 말라고 한다. 넵.

위 코드를 적용하면 실제로 속도가 매우 빨라진다. 최적화의 과정을 처음부터 끝까지 목격하다니 뭔가 신선했다.

useReducer 사용하기

import TodoInsert from "./components/TodoInsert";
import TodoTemplate from "./components/TodoTemplate";
import TodoList from "./components/TodoList";
import {useReducer,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;
}
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);
  const nextId = useRef(2501);
  const onInsert = useCallback(
    text =>{
      const todo = {
        id: nextId.current,
        text : 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를 사용하는 방법도 있다. 성능 자체는 useState의 함수형 업데이트와 큰 차이가 없지만 상태관리 로직을 한 곳에 묶어서 컴포넌트 밖에 둘 수 있다는 장점이 있다.

useReducer의 사용법을 알고 있다면 이해하기 어려운 코드는 아니지만 한 가지 낯선 부분이 있다.

const [todos,dispatch] = 
useReducer(todoReducer, undefined, createBulkTodos);

첫 번째 파라미터에 리듀서 함수가 들어가는 건 맞다. 그런데 분명 두 번째에는 초기 상태가 들어가는 걸로 기억하는데 뜬금 undefined가 있고 초기상태를 리턴하는 함수가 세번째에 들어가있다.

이는 createBulkTodos함수가 리렌더링될 때마다 호출되지 않게 하기 위해 조치를 취한 것이다. 교재에서는 짧게 설명돼있어 좀 와닿지 않을 수 있는데..

세번째 파라미터를 사용해 상태의 초기화를 지연할 수 있다고 한다. 자세한 건 docs 참고!

어차피 초기 상태값은 세번째에서 결정되기에

useReducer(todoReducer,Math.random(),createBulkodos);

이따구로 적어도 기능은 똑같이 한다.

react-virtualized

당장 보이지 않는 리스트들은 굳이 렌더링하지 않도록 도와주는 라이브러리다. 그냥 나중에 필요하지면 문서 보면서 공부하자. 일단 이런 친구가 있다는 것 정도만..

profile
잘쫌해

0개의 댓글