카테고리 없음

[ je 개발 공유 ] React 최적화하기 (청자 : Vue 사용자)

Je-chan 2024. 3. 12. 21:51

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 (
https://image.tmdb.org/t/p/original/${movie.backdrop_path}`} alt="img" />
  );
};

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 되는가?
  1. 컴포넌트의 최초 렌더링
  1. 조상(부모 포함) 컴포넌트의 리렌더링
  2. 부모 컴포넌트로부터 넘겨 받은 props 값 변화
  3. 컴포넌트 내부의 state 변화
  4. 중앙값(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 를 사용할 때 점검 리스트

  1. 위의 사례처럼 단순히 props, state 의 변경에 따라 다른 state 의 변경만을 초래하는가?
  2. 이벤트 핸들러에서 담당해야할 것을 useEffect 로 처리하고 있지는 않은가?

 

  • 특히 가장 많이 빈번하게 발생하는 케이스는 위의 경우다.
  • 위 케이스에서는 두 가지 문제점이 존재한다.
    • (1) useEffect 가 단순히 state 의 변경만을 초래하고 있다.
    • (2) 이벤트 핸들러에서 한 번에 담당할 수 있는 로직을 구태여 useEffect 로 처리하고 있다.
  • 이벤트 핸들러에서 한 번에 처리하면 Render 되는 횟수가 획기적으로 줄어든다

 

  1. 의존성 배열이 너무 많은가?
    • useEffect 가 하나의 일이 아닌 여러 일을 한 꺼 번에 담당하진 않은지 확인해봐야 한다.
    • 만약 여러 일을 담당하고 있다면, “코드가 지저분해보이거나”, “의미 없는 함수 호출” 이 이뤄지고 있다는 것을 뜻한다.
  2. 단순 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 는 아래의 상황에 사용하면 적합하다.
    1. 계산 비용이 많이 드는 함수를 사용할 때 (예를 들면, Highcharts 의 Option 을 만드는 함수 등)
    2. 자식 컴포넌트로 넘기는 props 가 참조형 자료일 때

3-3) useCallback

  • useCallback(캐싱할_함수, [의존성_배열])
  • useCallback 은 useMemo 와 동일한 메커니즘을 갖고 있지만, 그 역할은 함수를 캐싱한다는 점에 있다.
    • useMemo 에서 첫 번째 인자로 들어가는 값이 “함수를 리턴하는 함수” 인 경우, useCallback 으로 바꿀 수 있다는 것이다.
  • useCallback 은 아래의 상황에 사용하면 적합하다.
    1. useEffect 에 들어갈 함수(보통, useEffect 와 의존성 배열이 동일한 경우)
    2. 자식 컴포넌트로 넘기는 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버전이 나오지 않았기에 단정할 수는 없겠지만)