Skip to main content

React Performance Optimization

Untitled

목적

현업에서 신규 웹 개발을 동시 다발적으로 시작하는 중이고 모두 react를 기반으로 개발이 진행되고 있는데 동료들과 이야기 하다 보면 어떻게 사용하는 것이 성능적 이슈를 줄일 수 있는지에 대해 논의하게 되는데 인터넷으로 성능을 좋게 하는 방법들에 대해 여러 개를 읽어 봤지만 일회성으로 끝날 거 같아 해당 내용들을 이해한 바 대로 정리해 두려 함.(React Functional Component 방식만)

방법

WebWorker(react-native의 경우 https://github.com/joltup/react-native-threads 를 사용할 수 있음)

위의 waitSync는 3초를 기다려주는 가상의 함수인데 기존의 코드를 보면 3초를 기다린 후 90을 곱해 리턴하는데 input에서 카운트 입력이 변경될 때마다 해당 함수가 호출되면서 매번 렌더링을 기다렸다가 진행해야 한다.

useMemo(()=> func, [input_dependency]) 는 왼쪽과 같은 구조를 지니는데 함수 실행 시 입력된 값들을 배열로 캐싱해두고 해당 값이 들어오면 이미 산출했던 결과값을 리턴해주고 다른 값이 들어올 때만 함수를 실행시켜 캐싱해주는 방식이다. 자주 사용하는 경우는 const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); 와 같이 값이 잘 바뀌지 않는 비싼 함수를 실행 시 값의 리렌더링을 위한 동작들을 방지.

자바스크립트는 싱글 쓰레드에서 동작하는데 동일한 쓰레드 내에서 비용이 큰 함수를 실행시키면 렌더링 코드에 큰 영향을 끼칠 수 있으므로 다른 쓰레드로 동작을 옮겨 실행하는 방식이 있는데 이 방법으로 WebWorker를 사용하는 방식이 있다. React에서 공식적으로 지원하는 방식은 아니지만 아래와 같이 사용할 수 있다.(new Worker() 를 통해 직접 구현할 수 있지만 여러 라이브러리들이 있어 이를 이용함.)

const bubleSort = (input) => {
let swap;
let n = input.length - 1;
const sortedArray = input.slice();
do {
swap = false;
for (let index = 0; index < n; index += 1) {
if (sortedArray[index] > sortedArray[index + 1]) {
const tmp = sortedArray[index];
sortedArray[index] = sortedArray[index + 1];
sortedArray[index + 1] = tmp;
swap = true;
}
}
n -= 1;
} while (swap);

return sortedArray;
};

export default bubleSort;
import React from "react";
import { useWorker, WORKER_STATUS } from "@koale/useworker";
import { useToasts } from "react-toast-notifications";
// 사용할 비용이 큰 알고리즘
import bubleSort from "./algorithms/bublesort";
// 알고리즘으로 sorting 할 랜덤 리스트
const numbers = [...Array(50000)].map(() =>
Math.floor(Math.random() * 1000000)
);
...
const App = () => {
const { addToast } = useToasts();
const [sortStatus, setSortStatus] = React.useState(false);
// bubbleSort 함수 worker에 등록시 실행함수, 현재상태함수, 킬 함수를 얻어 사용한다.
const [
sortWorker,
{ status: sortWorkerStatus, kill: killWorker }
] = useWorker(bubleSort);

console.log("WORKER:", sortWorkerStatus);

const onSortClick = () => {
setSortStatus(true);
const result = bubleSort(numbers);
setSortStatus(false);
addToast("Finished: Sort", { appearance: "success" });
console.log("Buble Sort", result);
};

const onWorkerSortClick = () => {
sortWorker(numbers).then(result => {
console.log("Buble Sort useWorker()", result);
addToast("Finished: Sort using useWorker.", { appearance: "success" });
});
};

return (
<div>
<section className="App-section">
<button
type="button"
disabled={sortStatus}
className="App-button"
onClick={() => onSortClick()}
>
{sortStatus ? `Loading...` : `Buble Sort`}
</button>
<button
type="button"
disabled={sortWorkerStatus === WORKER_STATUS.RUNNING}
className="App-button"
onClick={() => onWorkerSortClick()}
>
{sortWorkerStatus === WORKER_STATUS.RUNNING
? `Loading...`
: `Buble Sort useWorker()`}
</button>
{sortWorkerStatus === WORKER_STATUS.RUNNING ? (
<button
type="button"
className="App-button"
onClick={() => killWorker()}
>
Kill Worker
</button>
) : null}
</section>
<section className="App-section">
<span style={{ color: "white" }}>
Open DevTools console to see the results.
</span>
</section>
</div>
);
}

위와 같이 worker를 사용할 때와 사용하지 않을 때를 비교해 실행하면 아래와 같은데 이처럼 비용이 큰 함수는 UI 동작을 방해할 수 있으므로 sort와 같은 큰 동작들은 아래와 같이 worker를 사용하는 것이 좋다.

records.mov

Lazy Loading

부하를 단축하기 위해 자주 사용되는 최적화 기법 중 하나. 성능 문제의 위험을 최소화 하는데 도움이 되고 React에서도 lazy load를 이용하기 위해 React.lazy() API를 제공한다.

React.lazy는 코드분할을 통해 앱을 지연 로딩하게 도와주는 방식인데 유저가 앱에 들어올 때 초기화 로딩에 필요한 비용을 줄여준다.

아래의 예시는 여러 개의 이미지를 불러올 때 lazyloading을 구현한 사례입니다.

import React from "react";
import { Iprops } from "./LazyItem";
const LazyImage = ({ src, name }: Iprops) => {
return <img src={src} alt={name} />;
};
export default LazyImage;

우선 위와 같이 image url을 받아 이미지를 보여주는 컴포넌트를 생성합니다.

import React, { lazy, Suspense } from "react";
export interface Iprops {
src: string;
name: string;
}
const LazyImage = lazy(() => import("./LazyImage"));
const LazyItem = ({ src, name }: Iprops) => {
return (
<div>
<Suspense fallback={<div>...loading</div>}>
{" "}
<LazyImage src={src} name={name} />
</Suspense>
{name}
</div>
);
};
export default LazyItem;

이미지를 렌더링해주는 컴포넌트를 lazy 시켜주고 불러오는 동안 loading으로 표현하게 설정합니다.

import React from "react";
import { images } from "../data/images";
import LazyItem from "./LazyItem";
const LazyWrapper = () => {
return (
<div>
{images.map((image) => (
<LazyItem key={image.id} src={image.src} name={image.name} />
))}
</div>
);
};
export default LazyWrapper;

많은 이미지들을 image url 을 통해 가져올 시 초기화에 있어 많은 비용이 드는 문제를 어느정도는 해결할 수 있을 것으로 보임.

React.useMemo()

react hook 사용 시 사용하는 함수로서 함수의 결과값이 동일한데 비용이 큰 함수에 대해 결과값을 캐싱해 해당 함수를 매번 동작시키지 않고 결과값을 바로 리턴해주는 방식의 최적화.

const App = () => {
const [count, setCount] = useState(0);
const expFunc = (count) => {
waitSync(3000);
return count * 90;
};
const resCount = expFunc(count);
return (
<>
Count: {resCount}
<input
type="text"
onChange={(e) => setCount(e.target.value)}
placeholder="Set Count"
/>
</code>
);
};
const App = () => {
const [count, setCount] = useState(0);
const expFunc = (count) => {
waitSync(3000);
return count * 90;
};
const resCount = useMemo(() => {
return expFunc(count);
}, [count]);
return (
<>
Count: {resCount}
<input
type="text"
onChange={(e) => setCount(e.target.value)}
placeholder="Set Count"
/>
</>
);
};

React.memo()

함수 컴포넌트를 캐시하는데 사용.

function My(props) {
return <div>{props.data}</div>;
}
function App() {
const [state, setState] = useState(0);
return (
<>
<button onClick={() => setState(0)}>Click</button>
<My data={state} />
</>
);
}
function My(props) {
return <div>{props.data}</div>;
}
const MemoedMy = React.memo(My);
function App() {
const [state, setState] = useState(0);
return (
<>
<button onClick={() => setState(0)}>Click</button>
<MemeodMy data={state} />
</>
);
}

기존을 보면 App컴포넌트의 state를 My에서 props로 받아서 컴포넌트가 생겨나는데 onClick을 할 때마다 state를 리렌더링하고 state값이 동일해도 계속 리렌더링 된다. 불필요한 리렌더링을 줄이기 위해 My 컴포넌트를 변경과 같이 memo를 이용해 캐시해서 사용하면 동일한 입력에 대해서는 캐싱된 결과를 리턴해 주므로 지속적인 리렌더링을 막을 수 있다.(React class component의 React.PureComponent와 비슷한 역할)

React.useCallback()

useMemo와 비슷하지만 함수 선언을 memoize 하는데 사용된다는 차이점이 있다. 컴포넌트 내부 로직에서 쓰이는 함수의 리렌더링을 막는다.

const Test = () => {
const [text, setText] = React.useState("");
const handleText = (e) => {
setText(e.target.value);
};
return <input name="user-id" value={id} onChange={handleText} required />;
};
const Test = () => {
const [text, setText] = React.useState("");
const handleText = React.useCallback((e) => {
setText(e.target.value);
}, []);
return <input name="user-id" value={id} onChange={handleText} required />;
};

캐싱 방식 비교

React.MemoReact.useMemoReact.useCallback
종류HOChookhook
사용범위class, functional componentfunctional componentfunctional component
메모이징컴포넌트 자체콜백함수의 값콜백함수 자체

Remove Extra Node

const App = () => {
return (
<div>
<h1>hello!</h1>
<h1>hello~~!</h1>
</div>
);
};
const App = () => {
return (
<>
<h1>hello!</h1>
<h1>hello~~!</h1>
</>
);
};

React 에서는 기존에서처럼 컴포넌트를 리턴 시 closing tag 가 반드시 필요한데 이 때 div 를 추가하면 쓸데없는 node 가 지속적으로 추가되고 DOM 컨트롤이나 css 조정 등 성능을 감소시키고 에러 확률도 높이게 된다.

그래서 React.Fragment<></> 와 같은 추가적인 노드를 추가하지 않는 방법을 사용하는 것이 적절하다.

렌더 내에서 함수 선언, Css 선언 피하기

const Test = () => {
return (
<div style={{ marginTop: 8 }} onClick={() => console.log(1)}>
test
</div>
);
};
import styled from "styled-component";
const divWrapper = stlyed.div`
margin-top: 8px;
`;

const Test = () => {
const handleClick = () => {
console.log(1);
};
return (
<>
<divWrapper style={{ marginTop: 8 }} onClick={handleClick}>
test
</divWrapper>
</>
);
};

렌더되는 부분에서 함수 또는 Css 선언을 하게 되면 react virtualDOM을 활용해 변경 부분만 리렌더링 해주는 방식에서 변경부를 검색할 때 렌더 부의 object 형태로 들어가 있다면 object 끼리의 비교는 무조건 다르다고 판단하기 때문에 항상 리렌더링 시키는 비효율이 발생한다. 이를 해결하기 위해 변경과 같이 기존을 변경시킬 필요가 있다.(styled component를 사용하는 것은 하나의 방식이다.)

map 사용 시 index를 key로 사용하는 것 피하기

Issues.map((issue,index) => <Issue key={index} data={issue} />
Issues.map((issue,index) => <Issue key={issue.id} data={issue} />

key를 index로 사용하게 되면 key가 DOM 요소를 식별 시 사용되기 때문에 잘못된 데이터가 표시되는 경우가 생길 수 있다. 예를 들어 리스트 중 항목을 추가하거나 삭제할 때 React 는 동일 키인 경우 DOM 요소가 이전과 동일한 구성요소라고 판단해 오류를 일으키기도 합니다. 목록이 모두 정적으로 변화가 없는 경우 목록의 id 나 순서가 필요 없고 변경되지 않는 경우는 index를 그대로 사용해도 무방합니다.

소감

개념적으로 완전히 이해하지 못했고 사용에 적절한 상황을 경험하지는 않았지만 간단한 소개와 사용 예시를 정리해 봤습니다. lazy loading, 캐싱 방식(memoized) 등 좋은 최적화 방법들이 많았고 정리된 최적화 방법을 참고해 필요한 상황들에 적재적소에 조금씩 도입하기 시작한다면 좀 더 코드 규칙도 잡히고 성능도 향상시킬 수 있는 일석이조의 효과를 얻지 않을까 기대해 봅니다.