지난 글에서 만들었던 일정 관리 웹 애플리케이션을 통해 성능 최적화를 도모해보고자 한다.
많은 데이터 렌더링
일정 관리 애플리케이션 코드에서 랙을 경험할 수 있도록 많은 데이터를 렌더링해보자.
import "./App.css";
import TodoTemplate from "./TodoTemplate";
import TodoInsert from "./TodoInsert";
import TodoList from "./TodoList";
import { useCallback, useRef, useState } from "react";
function createBulkTodos() {
const array = [];
for (let i = 1; i <= 2500; i++) {
array.push({
id: i,
text: `할 일 ${i}`,
checked: false,
});
}
return array;
}
export default function App() {
const [todos, setTodos] = useState(createBulkTodos);
const nextId = useRef(4);
const onInsert = useCallback(
(text: string) => {
const todo = {
id: nextId.current,
text,
checked: false,
};
setTodos(todos.concat(todo));
nextId.current += 1;
},
[todos]
);
const onRemove = useCallback(
(id: number) => {
setTodos(todos.filter((todo) => todo.id !== id));
},
[todos]
);
const onToggle = useCallback(
(id: number) => {
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>
);
}
이럴 경우, 항목 체크를 해보면 조금 느려진 것이 느껴질 것이다.
느려지는 원인
컴포넌트는 다음과 같은 상황에서 리렌더링이 발생한다.
- 자신이 전달받은 props가 변경될 때
- 자신의 state가 바뀔 때
- 부모 컴포넌트가 리렌더링될 때
- forceUpdate 함수가 실행될 때
위의 상황은 항목을 체크할 경우 App 컴포넌트의 state가 변경되면서 App 컴포넌트가 리렌더링 된다. 부모 컴포넌트가 리렌더링 되었으니 TodoList 컴포넌트가 리렌러링되고 그 안의 무수한 컴포넌트들도 리렌더링된다.
체크된 하나의 항목은 리렌더링되어야하겠지만, 나머지 항목들은 리렌더링될 필요가 없음에도 불구하고 현재 리렌더링을 해줘서 느려지게 된 것이다. 이럴 경우를 대비해 컴포넌트 리렌더링 성능을 최적화해주는 작업이 필요하다.
React.memo 를 사용한 컴포넌트 성능 최적화
React.memo 함수를 통해서 리렌더링을 방지할 수 있다. React.memo는 함수형 컴포넌트에서 성능 최적화를 위해 사용되는 Higher Order Component로 이전 결과를 메모이제이션해서 현재 렌더링 결과와 동일한 경우 렌더링을 스킵하고 이전 결과를 재사용함으로써 성능을 개선한다.
사용법은 간단하다. 컴포넌트를 만들고나서 감싸주기만 하면 된다. TodoListItem 컴포넌트에 React.memo를 적용해보자.
import {
MdCheckBoxOutlineBlank,
MdCheckBox,
MdRemoveCircleOutline,
} from "react-icons/md";
import "./TodoListItem.scss";
import cn from "classnames";
import React from "react";
interface Todo {
id: number;
text: string;
checked: boolean;
}
interface Props {
todo: Todo;
onRemove: (id: number) => void;
onToggle: (id: number) => void;
}
function TodoListItem({ todo, onRemove, onToggle }: Props) {
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);
이후 렌더링해보면 성능의 차이를 느낄 수 있을 것이다.
리렌더링 시 함수가 바뀌지 않게 하기
React.memo만으로 컴포넌트 최적화가 끝나지는 않는다. 현재 프로젝트에서 todos 배열이 업데이트되면 onRemove와 onToggle 함수도 새롭게 바뀌기 때문이다. 이 두 함수는 배열 상태를 업데이트하는 과정에서 최신 상태의 todos를 참조하기 때문에 todos 배열이 바뀔 때마다 함수가 새로 만들어진다.
함수가 계속 새로 만들어지는 상황을 방지하는 방법은 두 가지가 있다. useState의 함수형 업데이트 기능 사용과 useReducer를 사용하는 것이다.
useState
기존 useState에서는 새로운 상태를 파라미터로 넣어주었다. setTodos를 사용할 때 새로운 상태를 파라미터로 넣는 대신, 상태 업데이트를 어떻게 할지 정의해주는 함수를 넣을 수 있다. 이를 함수형 업데이트라고 한다.
이를 직접 사용해보기 위해 기존 코드를 수정해보자.
수정 전
import "./App.css";
import TodoTemplate from "./TodoTemplate";
import TodoInsert from "./TodoInsert";
import TodoList from "./TodoList";
import { useCallback, useRef, useState } from "react";
function createBulkTodos() {
const array = [];
for (let i = 1; i <= 2500; i++) {
array.push({
id: i,
text: `할 일 ${i}`,
checked: false,
});
}
return array;
}
export default function App() {
const [todos, setTodos] = useState(createBulkTodos);
const nextId = useRef(4);
const onInsert = useCallback(
(text: string) => {
const todo = {
id: nextId.current,
text,
checked: false,
};
setTodos(todos.concat(todo));
nextId.current += 1;
},
[todos]
);
const onRemove = useCallback(
(id: number) => {
setTodos(todos.filter((todo) => todo.id !== id));
},
[todos]
);
const onToggle = useCallback(
(id: number) => {
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>
);
}
수정 후
import "./App.css";
import TodoTemplate from "./TodoTemplate";
import TodoInsert from "./TodoInsert";
import TodoList from "./TodoList";
import { useCallback, useRef, useState } from "react";
function createBulkTodos() {
const array = [];
for (let i = 1; i <= 2500; i++) {
array.push({
id: i,
text: `할 일 ${i}`,
checked: false,
});
}
return array;
}
export default function App() {
const [todos, setTodos] = useState(createBulkTodos);
const nextId = useRef(4);
const onInsert = useCallback((text: string) => {
const todo = {
id: nextId.current,
text,
checked: false,
};
setTodos((todos) => todos.concat(todo));
nextId.current += 1;
}, []);
const onRemove = useCallback((id: number) => {
setTodos((todos) => todos.filter((todo) => todo.id !== id));
}, []);
const onToggle = useCallback((id: number) => {
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>
);
}
setTodos를 사용할 때 그 안의 todos => 만 앞에 넣어주는 것 만으로 성능 개선에 큰 역할을 해준다.
useReducer 사용
useReducer 는 복잡한 상태 로직을 관리하기 위해 사용된다. useState와 유사하지만 useReducer는 상태와 관련된 로직을 분리할 수 있으므로, 컴포넌트가 더욱 가독성이 좋아진다.
useReducer는 상태 객체와 상태를 업데이트하기 위한 액션 객체를 받아서, 새로운 상태 값을 반환하는 리듀서(reducer) 함수를 호출한다. 리듀서 함수는 이전 상태 값과 액션 객체를 받아서, 새로운 상태 값을 계산한다. 그리고, dispatch 함수를 사용해 액션 객체를 전달하면, useReducer는 새로운 상태 값을 계산하고, 컴포넌트를 다시 렌더링한다.
이전의 App 컴포넌트를 useReducer로 다시 고쳐보자.
import "./App.css";
import TodoTemplate from "./TodoTemplate";
import TodoInsert from "./TodoInsert";
import TodoList from "./TodoList";
import { useCallback, useReducer } from "react";
interface Todo {
id: number;
text: string;
checked: boolean;
}
type TodoAction =
| { type: "INSERT"; text: string }
| { type: "REMOVE"; id: number }
| { type: "TOGGLE"; id: number };
interface TodoState {
todos: Todo[];
nextId: number;
}
function createBulkTodos(): Todo[] {
const array = [];
for (let i = 1; i <= 2500; i++) {
array.push({
id: i,
text: `할 일 ${i}`,
checked: false,
});
}
return array;
}
function todoReducer(state: TodoState, action: TodoAction): TodoState {
switch (action.type) {
case "INSERT":
const todo = {
id: state.nextId,
text: action.text,
checked: false,
};
return {
todos: state.todos.concat(todo),
nextId: state.nextId + 1,
};
case "REMOVE":
return {
todos: state.todos.filter((todo) => todo.id !== action.id),
nextId: state.nextId,
};
case "TOGGLE":
return {
todos: state.todos.map((todo) =>
todo.id === action.id ? { ...todo, checked: !todo.checked } : todo
),
nextId: state.nextId,
};
default:
return state;
}
}
export default function App() {
const [{ todos }, dispatch] = useReducer(todoReducer, {
todos: createBulkTodos(),
nextId: 2501,
});
const onInsert = useCallback((text: string) => {
dispatch({ type: "INSERT", text });
}, []);
const onRemove = useCallback((id: number) => {
dispatch({ type: "REMOVE", id });
}, []);
const onToggle = useCallback((id: number) => {
dispatch({ type: "TOGGLE", id });
}, []);
return (
<TodoTemplate>
<TodoInsert onInsert={onInsert} />
<TodoList todos={todos} onRemove={onRemove} onToggle={onToggle} />
</TodoTemplate>
);
}
useReducer를 사용하기 위해 새로운 타입을 선언해주었다. TodoAction이라는 타입인데, 각각이 지니는 요소는 다음과 같다.
- INSERT : 새로운 Todo 객체를 추가하며, text 필드를 전달받는다.
- REMOVE : 주어진 id를 가진 Todo 객체를 삭제한다.
- TOGGLE : 주어진 id를 가진 Todo 객체의 checked 값을 토글한다.
todoReducer 함수는 현재 TodoState와 TodoAction을 인자로 받아 새로운 TodoState를 반환한다. 액션의 타입에 따라 적절한 처리를 수행한다.
useReducer hook을 사용하여 state와 dispatch 함수를 받아오며, state는 TodoState 객체의 todos 필드만을 참조한다.
onInsert, onRemove, onToggle 함수는 각각 INSERT, REMOVE, TOGGLE 액션을 dispatch합니다.
여기까지 두 가지의 방법을 알아보았는데, 성능상으로는 두 가지 방법이 비슷하다. 하지만 useReducer의 경우, 기존 코드를 많이 고쳐야한다는 단점이 있다. 그럼에도 상태를 업데이트하는 로직을 모아서 컴포넌트 밖에 둘 수 있다는 장점도 가지고 있어서 가독성 측면에서는 더 좋을 수 있다.
불변성
리액트 컴포넌트에서 상태를 업데이트할 때 불변성을 지키는 것은 매우 중요하다. 불변성이란 상태를 변경하는 작업에서 기존 상태를 직접적으로 수정하지 않고, 새로운 객체나 배열을 생성해서 변경하는 것을 말한다.
기존 객체를 직접 수정하거나 배열에 push() 등의 메서드를 이용해 요소를 추가하면, 해당 객체나 배열의 참조값이 변경된다. 이렇게 참조값이 변경된 객체나 배열은 메모리에서 새로운 공간을 차지하게 되고, 컴포넌트들은 이를 인지하지 못하고 업데이트 되지 않는다. 따라서 컴포넌트들은 변경되지 않은 상태를 보여주고, 예기치 않은 동작을 일으킬 수 있다.
따라서 리액트에서는 불변성을 유지하는 것이 중요하다. 불변성을 유지하면 컴포넌트의 성능을 향상시킬 수 있다. 이유는 불변성을 유지하면, 기존 객체나 배열이 변경되었는지 여부를 쉽게 알 수 있기 때문이다. 이에 따라 리액트는 Virtual DOM을 이용하여 변경된 부분만 업데이트하게 되므로, 불필요한 리렌더링을 줄일 수 있다.
리액트에서 객체나 배열을 변경할 때, spread 연산자나 배열/객체의 메서드(slice(), concat() 등)를 이용하여 새로운 객체나 배열을 생성하고 변경해야 한다. 이렇게 새로운 객체나 배열을 생성하면, 새로운 참조값이 생성되어 불변성이 유지된다.
정리
기본적으로 리액트에서 컴포넌트 렌더링은 빠르게 동작하기 때문에 최적화를 매우 잘 할 필요는 거의 없다. 하지만, 보여줄 리스트가 100개 이상인데 업데이트가 자주 발생할 경우, 최적화를 고민해보는것이 좋다.
'WEB > React' 카테고리의 다른 글
리액트 라우터 써보기 [react/router/typescript] (0) | 2023.03.19 |
---|---|
리액트 불변성 유지하기 [react/immer/typescript] (0) | 2023.03.18 |
리액트 일정관리 웹 만들기 [react/typescript] (0) | 2023.03.17 |
리액트 CSS 사용하기 [react/css/typescript] (0) | 2023.03.17 |
리액트 Hooks 이해하기 [react/hooks/typescript] (0) | 2023.03.15 |