React 최적화, 상태관리하기.pdf
1.86MB
1. 무엇을 최적화해야 하는가?
- 프론트엔드에서 가장 비용이 많이 드는 것은 DOM 에 화면을 그리는 Layout(혹은 Reflow), Paint(Repaint) 하는 과정이다.
- 물론, 이미지 압축, 네트워크 요청 최적화 등의 작업들도 존재하지만 이 장에서는 “React 시작하기” 에 이어 가장 기초적인 React 라이브러리의 최적화 방안에 대해서 알아보고자 한다.
- 흔히 프론트엔드에서 최적화한다고 말하면 이 화면을 그리는 작업을 최소화하는 것을 뜻한다.
- 그렇다면 React 에서는 어떤 방식으로 DOM 에 화면을 그리는 지를 알아야 한다.
1-1) React 의 Trigger, Render, Commit
- React 에서 클래스형 컴포넌트가 주류였던 시기에는 Vue 와 마찬가지로 “Life Cycle” 이 중요했던 시기가 있었으나, 현재 공식 문서에서는 “Life Cycle” 이 아닌 “Render” 와 “Commit”, 두 가지 과정으로 설명한다.
- Trigger 란, Redner 를 촉발하는 요인을 의미하고,
- Render 란, Virtual DOM 을 작성하는 것을 의미하고
- Commit 이란, Virtual DOM 을 실제 Real DOM 에 반영하는 것을 의미한다.
- 즉, 컴포넌트를 화면에 그리는 과정은 Trigger ⇒ Render ⇒ Commit 순이다.
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import axios from '../../api/axios';
const Detail = () => {
let { movieId } = useParams();
const [movie, setMovie] = useState({});
useEffect(() => {
async function fetchData() {
const response = await axios.get(`/movie/${movieId}`);
// console.log('response',response);
setMovie(response.data);
}
fetchData();
}, [movieId]);
if (!movie) return null;
return (
);
};
export default Detail;
- 위 코드가 있다고 했을 때, Virtual DOM 을 만드는 것은 함수의 return 되는 JSX 다.
- 즉, 우리가 React 에서 렌더링이라고 표현하는 것은, 함수 Detail 이 실행되어 함수(컴포넌트) 몸체에 있는 코드들 모두 실행하고 return 까지 되는 JSX 가 반영되는 과정을 의미한다.
- Commit 은 이렇게 return 된 JSX 문이 Virtual DOM 에 반영이 되고, Virtual DOM 이 완성되면 그 완성된 내용을 DOM 에 반영하는 과정이다.
- 우리가 리렌더링이라고 표현하는 것은, 이미 Virtual DOM 이 존재하지만 데이터가 변경되어 Virtual DOM 을 새롭게 업데이트 해야 하는 경우, (예를 들어 위에서라면 movieId 값이 바뀌어 movie State 가 변경된 경우) Detail 함수를 다시 호출하고, 실행해 새롭게 Return 된 JSX 를 Virtual DOM 에 반영하는 과정이다
- 그렇다면 언제 Trigger 되는가?
- 컴포넌트의 최초 렌더링
- 조상(부모 포함) 컴포넌트의 리렌더링
- 부모 컴포넌트로부터 넘겨 받은 props 값 변화
- 컴포넌트 내부의 state 변화
- 중앙값(Redux, Recoil, ContextAPI 등에서 내려주는 State)의 변화
1-2) 최적화를 위해 고려할 것들
- 프론트엔드에서 가장 비용이 많이 드는 것은 실제 DOM 을 그리는 과정이고,
- React 에서 이에 밀접한 관계가 있는 것은 Render, Commit 과정이고,
- 이 Render, Commit 과정을 촉발시키는 것이 Trigger 라면
- 우리가 React 라이브러리를 사용해 최적화할 때 가장 고려해야 할 것은 Trigger 를 최소한으로 발생시키는 것이다.
- 위 Trigger 시키는 다섯 가지 요인들을 참조하여 최적화를 위해 신경 써야 할 것들을 정리해보면
(1) 컴포넌트 내부 State 의 변화
(2) 컴포넌트 외부 리렌더링
(3) 컴포넌트 외부에서 내려주는 Props 의 변화
- 위 세 가지에 초점을 맞추면 된다. 그리고 React 는 위 세 가지에 맞게 적절한 Hooks 를 제공한다.
2. useEffect 를 명확하게 알기
- 구글링을 하면 보통 React 최적화 방안으로 React.memo, useMemo, useCallback 을 얘기한다.
- 하지만 React 최적화에 있어 첫 시작은 위의 Hooks 를 사용하는 것이 아니다. 사람들이 가장 많이 잘못 사용하고 있는 useEffect 를 제대로 사용한다는 것에서부터 시작해야 한다.
- 이는 “최적화를 위해 고려할 것들” 의 첫 번째인 “컴포넌트 내부 State 의 변화” 와 연관되는 내용이다.
2-1) useEffect 란 무엇인가?
- 이전 “React 시작하기” 에서도 언급했듯, useEffect 는 React 에서 사이드 이펙트를 발생시키는 것들을 명시하고, 동기화하는 Hook 이다.
- 네트워크 호출, 써드 파트 라이브러리와의 연결 등 컴포넌트를 순수하게 유지할 수 없도록 하는 코드들을 명시한다.
- 이후, 의존성 배열의 값이 변경될 때마다 함수를 실행시켜 리턴받은 값으로 React 와 동기화하는 작업을 진행한다.
- useEffect 코드의 실행 시점은 컴포넌트가 Commit 된 다음이다. 즉, Render 중에는 이 코드가 실행되지 않고, Virtual DOM 이 Real DOM 에 모두 반영되고 난 다음에 실행된다.
2-2) Vue 의 watch 와 무엇이 다른가?
- 먼저, Vue 와 React 의 본질적인 차이점을 알아야 한다.
- React 의 함수형 컴포넌트는 Render, Commit 의 단계로 나뉘어 있으며 Render 를 위해서 컴포넌트라는 이름의 함수를 “호출” 한다.
- 즉, 리렌더링이 됐을 때도 함수 몸체에 있는 코드가 사용된다
- Vue 의 <script setup> 방식은 라이프 사이클을 사용하며, script 에 작성하는 코드들은 라이프 사이클이 “created” 일 때 코드를 사용한다.
- 즉, 최초의 렌더링을 위해서만 함수 몸체에 있는 코드가 사용된다.
왼쪽은 React, 오른쪽은 Vue 로 작성한 코드다. 둘 다, dummy 라는 string 데이터를 받아 오고 있으며 부모 컴포넌트에서는 3초 후에 dummy 데이터를 업데이트 한다.
- 왼쪽이 React, 오른쪽이 Vue 로 작성된 코드다.
- React 에서 작성된 코드를 보면, console.log 로 최신 업데이트 된 props 의 데이터를 console.log 를 호출하여 콘솔창에 띄웠다.
- 반면, Vue 에서는 최신 업데이트된 props 의 데이터를 console 로 찍지 못했다
- 이는, React 에서는 WatchTest 컴포넌트를 한 번 더 호출했기 때문에 console.log 가 호출된 것이고, vue 에서는 따로 호출하지 않았기 때문에 변경되지 않았던 것이다.
- 이는 state 를 업데이트 했을 때도 마찬가지다.
Vue 에서 이런 state 나 props 의 변경을 감지하는 대표적인 방법은 Life Cycle 중 하나인 onUpdated 혹은 watch 를 활용하는 방법이다.
- Vue 코드에는 watch, onUpdated 를 활용하고
- React 에는 useEffect 코드를 사용해보았다.
- 결과적으로, Vue 에서는 watch, 혹은 onUpdated 를 통해서 값의 변화를 감지하였고 (watch 가 라이프사이클인 onUpdated 보다 먼저 감지하는 것을 추가로 확인할 수 있다)
- React 의 useEffect 도 값의 변화를 감지해 콘솔을 찍은 것을 확인할 수 있었다.
- 하지만, 이런 useEffect 의 활용은 매우 비효율적이다.
- state 값이 변화되면 컴포넌트 함수가 재 호출되기 때문에 아무런 Hooks 를 사용하지 않아도 변화된 state 의 값을 추적할 수 있다. 하지만 여기에 useEffect 라는 코드를 추가했기 때문에 무의미한 값을 계속 추적해야 하는 상황이 일어난 것이다.
- 결론 : useEffect 는 사이드이펙트가 발생할 코드에만 적용할 것
- useEffect 를 Vue 에 빗대어서 표현하면 라이프사이클이 onUpdated 인 상황에서, watch 처럼 감시한 데이터가 변경됐을 경우 실행하는 Hook
2-3) useEffect 를 사용할 때 점검 리스트
- 위의 사례처럼 단순히 props, state 의 변경에 따라 다른 state 의 변경만을 초래하는가?
- 이벤트 핸들러에서 담당해야할 것을 useEffect 로 처리하고 있지는 않은가?
- 특히 가장 많이 빈번하게 발생하는 케이스는 위의 경우다.
- 위 케이스에서는 두 가지 문제점이 존재한다.
- (1) useEffect 가 단순히 state 의 변경만을 초래하고 있다.
- (2) 이벤트 핸들러에서 한 번에 담당할 수 있는 로직을 구태여 useEffect 로 처리하고 있다.
- 이벤트 핸들러에서 한 번에 처리하면 Render 되는 횟수가 획기적으로 줄어든다
- 의존성 배열이 너무 많은가?
- useEffect 가 하나의 일이 아닌 여러 일을 한 꺼 번에 담당하진 않은지 확인해봐야 한다.
- 만약 여러 일을 담당하고 있다면, “코드가 지저분해보이거나”, “의미 없는 함수 호출” 이 이뤄지고 있다는 것을 뜻한다.
- 단순 POST 요청은 useEffect 에서 담당하지 않아도 된다.
- GET 요청의 경우, 사용자로부터 입력 받는 값이 계속 변화함에 따라 이를 추적해 네트워크 요청을 할 수 있다.
- 이런 경우, 사이드 이펙트 발생과 사용자로부터 입력 받은 값을 React 와 동기화 시킨다는 점에서 useEffect 의 적절한 사용방법이 된다.
- 하지만, POST 요청의 경우, 요청해서 받은 값과 요청한 컴포넌트의 React 의 화면 상태를 동기화하지 않을 수 있다.
- 이런 케이스에는 useEffect 를 따로 할 필요는 없다
3. React 의 Memoziation
- React 는 사용자가 원하는 컴포넌트, 값, 함수 등을 React 에서 기억하도록 만들어 주는 메모화 Hooks 를 제공하고 있다.
3-1) React.memo
- React.memo 는 컴포넌트를 캐싱하는 방법이다.
- “최적화를 위해 고려해야할 것” 의 (2), (3) 을 해결하기 위한 방법으로 React.memo 로 감싸여 있는 컴포넌트는 부모가 리렌더링되든, 안 되든 상관하지 않고 props 의 값이 변경되면 리렌더링, 변경되지 않았다면 리렌더링 하지 않는다.
- useMemo, useCallback 등을 사용한 값을 자식 컴포넌트에 넘겨줄 때, 자식 컴포넌트가 React.memo 로 감싸 있어야 원하는 최적화를 이뤄낼 수 있다.
3-2) useMemo
- useMemo(값을_리턴하는_함수, [의존성_배열])
- useMemo 는 값을 값을 리턴하는 함수를 첫 번째 인자로 받고, 두 번째 인자로 의존성 배열을 갖는다.
- useMemo 는 첫 번째 인자로 넘겨받은 함수가 리턴한 값을 React 메모리에 저장한다.
- 이후, 의존성 배열에 있는 요소가 바뀌지 않으면 값을 리턴하는 함수가 동작하지 않고, 요소의 값이 바뀌면 값을 리턴하는 함수를 호출해 새로운 값을 받고 그 값을 다시 React 메모리에 저장한다.
- useMemo 는 아래의 상황에 사용하면 적합하다.
- 계산 비용이 많이 드는 함수를 사용할 때 (예를 들면, Highcharts 의 Option 을 만드는 함수 등)
- 자식 컴포넌트로 넘기는 props 가 참조형 자료일 때
3-3) useCallback
- useCallback(캐싱할_함수, [의존성_배열])
- useCallback 은 useMemo 와 동일한 메커니즘을 갖고 있지만, 그 역할은 함수를 캐싱한다는 점에 있다.
- useMemo 에서 첫 번째 인자로 들어가는 값이 “함수를 리턴하는 함수” 인 경우, useCallback 으로 바꿀 수 있다는 것이다.
- useCallback 은 아래의 상황에 사용하면 적합하다.
- useEffect 에 들어갈 함수(보통, useEffect 와 의존성 배열이 동일한 경우)
- 자식 컴포넌트로 넘기는 props 가 함수일 때
4. 실습
4-1) 예제 코드
- TodoList.tsx
// TodoList.tsx
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
import { TodoData, todoList } from "../data";
import TodoItem from "./TodoItem";
import TodoSummary from "./TodoSummary";
import TodoTitle from "./TodoTitle";
const TodoList = () => {
const [todoListState, setTodoListState] = useState(todoList);
const [todoTitle, setTodoTitle] = useState("TODO TITLE");
const [date, setDate] = useState(new Date());
const [checkDate, setCheckDate] = useState(true);
const checkTodoList = (todoId: TodoData["id"]) => {
const newTodoList = todoListState.map((todo) => ({
...todo,
isDone: todo.id === todoId ? !todo.isDone : todo.isDone,
}));
setTodoListState(newTodoList);
};
const doneTodoList = todoListState.filter((todo) => todo.isDone);
useEffect(() => {
const currentTime = checkDate
? setInterval(() => {
setDate(new Date());
}, 1000)
: null;
return () => {
if (checkDate) {
clearInterval(currentTime!);
}
};
}, [checkDate]);
return (
<div className="todo-list">
<div>
현재 시간:{" "}
{`${date.getFullYear()}년 ${
date.getMonth() + 1
}월 ${date.getDate()}일 ${date.getHours()}시 ${date.getMinutes()}분 ${date.getSeconds()}초 `}
</div>
<div>
<TodoTitle title={todoTitle} />
<input
value={todoTitle}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setTodoTitle(e.target.value);
}}
/>
</div>
<br />
<div>
<TodoSummary label={"오늘 해야할 일"} todoList={todoListState} />
</div>
<br />
<div>
<TodoSummary label={"모두 완료한 일"} todoList={doneTodoList} />
</div>
<br />
<br />
<div>
{todoListState.map((todo) => (
<TodoItem key={todo.id} todo={todo} checkTodoList={checkTodoList} />
))}
</div>
{
<button onClick={() => setCheckDate(!checkDate)}>
{checkDate ? "STOP" : "START"}
</button>
}
</div>
);
};
export default TodoList;
- TodoTitle.tsx
interface Props {
title: string;
}
const TodoTitle = ({ title }: Props) => {
return <h3>{title}</h3>;
};
export default TodoTitle;
- TodoSummary.tsx
import { TodoData } from "../data";
interface Props {
label: string;
todoList: TodoData[];
}
const TodoSummary = ({ label, todoList }: Props) => {
return (
<div>
{label} : {todoList.map((todo) => todo.todo).join(", ")}
</div>
);
};
export default TodoSummary;
- TodoItem.tsx
import type { TodoData } from "../data";
interface Props {
todo: TodoData;
checkTodoList: (todoId: TodoData["id"]) => void;
}
const TodoItem = ({ todo, checkTodoList }: Props) => {
return (
<div className="todo-item" style={{ display: "flex", gap: "0.25rem" }}>
<input
type="checkbox"
onChange={() => checkTodoList(todo.id)}
checked={todo.isDone}
/>
<p>{todo.todo}</p>
</div>
);
};
export default TodoItem;
- todoList.ts
export interface TodoData {
id: number;
todo: string;
isDone: boolean;
}
export const todoList: TodoData[] = [
{
id: 0,
todo: "관리 페이지 개발",
isDone: true,
},
{
id: 1,
todo: "다국어 적용",
isDone: true,
},
{
id: 2,
todo: "React 발표",
isDone: false,
},
{
id: 3,
todo: "데이터 시각화 발표",
isDone: false,
},
{
id: 4,
todo: "집 가기",
isDone: false,
},
];
- 위의 실습 예제는 현재 그 어떤 최적화도 이루어지지 않은 상태.
- 이 코드 실행을 보면 각 자식 컴포넌트들이 활용하고 있지 않은 데이터가 변경됐음에도 불구하고 모든 자식 컴포넌트가 렌더링되고 있음을 확인할 수 있다.
- 예를 들어 시간이 바뀌었을 뿐인데, 시간과 무관한 TodoItem 컴포넌트가 리렌더링되고 있다
- 이 컴포넌트들을 React.memo, useMemo, useCallback 등을 활용해서 사용하고 있지 않은 데이터가 변경돼도 리렌더링되지 않게 하는 것이 실습
4-2) 최적화 예시
- TodoList.tsx
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from "react";
import { TodoData, todoList } from "../data";
import TodoItem from "./TodoItem";
import TodoSummary from "./TodoSummary";
import TodoTitle from "./TodoTitle";
const TodoList = () => {
const [todoListState, setTodoListState] = useState(todoList);
const [todoTitle, setTodoTitle] = useState("TODO TITLE");
const [date, setDate] = useState(new Date());
const [checkDate, setCheckDate] = useState(true);
const checkTodoList = (todoId: TodoData["id"]) => {
const newTodoList = todoListState.map((todo) => ({
...todo,
isDone: todo.id === todoId ? !todo.isDone : todo.isDone,
}));
setTodoListState(newTodoList);
};
const doneTodoList = useMemo(
() => todoListState.filter((todo) => todo.isDone),
[todoListState]
);
const checkTodoListMemo = useCallback(checkTodoList, [todoListState]);
useEffect(() => {
const currentTime = checkDate
? setInterval(() => {
setDate(new Date());
}, 1000)
: null;
return () => {
if (checkDate) {
clearInterval(currentTime!);
}
};
}, [checkDate]);
return (
<div className="todo-list">
<div>
현재 시간:{" "}
{`${date.getFullYear()}년 ${
date.getMonth() + 1
}월 ${date.getDate()}일 ${date.getHours()}시 ${date.getMinutes()}분 ${date.getSeconds()}초 `}
</div>
<div>
<TodoTitle title={todoTitle} />
<input
value={todoTitle}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setTodoTitle(e.target.value);
}}
/>
</div>
<br />
<div>
<TodoSummary label={"오늘 해야할 일"} todoList={todoListState} />
</div>
<br />
<div>
<TodoSummary label={"모두 완료한 일"} todoList={doneTodoList} />
</div>
<br />
<div>
{todoListState.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
checkTodoList={checkTodoListMemo}
/>
))}
</div>
{
<button onClick={() => setCheckDate(!checkDate)}>
{checkDate ? "STOP" : "START"}
</button>
}
</div>
);
};
export default TodoList;
- TodoTitle, TodoSummary, TodoItem 은 코드 변경 없이 컴포넌트를 React.memo 로 감싸 주기만 하면 된다.
import { memo } from "react";
import type { TodoData } from "../data";
interface Props {
todo: TodoData;
checkTodoList: (todoId: TodoData["id"]) => void;
}
const TodoItem = ({ todo, checkTodoList }: Props) => {
return (
<div className="todo-item" style={{ display: "flex", gap: "0.25rem" }}>
<input
type="checkbox"
onChange={() => checkTodoList(todo.id)}
checked={todo.isDone}
/>
<p>{todo.todo}</p>
</div>
);
};
// 이렇게 아래처럼 memo 로
export default memo(TodoItem);
이제 useMemo, useCallback, React.memo 는 필요 없다?
이번에 React 19 버전에서 메모이제이션과 관련된 함수들을 자동으로 최적화해준다는 React 공식 발표가 있었다.
https://react.dev/blog/2024/02/15/react-labs-what-we-have-been-working-on-february-2024
React Labs: What We've Been Working On – February 2024 – React
The library for web and native user interfaces
react.dev
그러므로 hooks 를 사용하는 최적화는 React 18 까지만이라 생각하면 좋을 것 같다. (아직 19버전이 나오지 않았기에 단정할 수는 없겠지만)