컴포넌트 최적화
컴포넌트 최적화란 리액트 애플리케이션에서 성능을 높이기 위한 기술과 전략을 의미합니다. 그 주된 목적은 렌더링 속도를 개선하고, 불필요한 리렌더링을 줄이는 것입니다.
성능을 최적화 하는 방법에는 웹 최적화와 리액트 특화 최적화가 있는데, 자습서에서는 리액트 특화 최적화 방법을 다룬다. 리액트를 최적화하는 방법은 크게 메모이제이션, 로딩 성능 최적화(코드 스플리팅), 렌더링 우선순위 조정 세 가지가 있다.
메모이제이션
메모이제이션(memoization)은 이미 계산한 결과를 저장해두고 같은 계산을 반복해야 할 때 저장된 값을 다시 사용하는 최적화 기법입니다.
메모이제이션은 이전의 결과를 저장해 두고 다시 쓰는 것을 말한다. 리액트에서는 컴포넌트, 함수, 계산된 값에 대해 메모이제이션을 사용할 수 있다.
메모이제이션 도구
- 컴포넌트 재사용: React.memo
- 함수 참조 고정: useCallback
- 무거운 연산 결과 저장: useMemo
컴포넌트 재사용(React.memo)
리액트는 부모가 리렌더링되면 그 아래의 자식들도 같이 리렌더링된다. 자식들이 이전과 계속 같은 형상일 경우, 불필요한 리렌더링이 계속 일어나는 문제가 있다. 그래서 컴포넌트를 메모이제이션해서 리렌더링을 막을 수 있다. 컴포넌트를 메모이제이션하면 props나 상태가 바뀌기 전까지는 리렌더링이 일어나지 않는다.
import { memo } from 'react';
import B from './B';
export default memo(function A()
{
console.log('A render')
return (
<>
<h1>A component</h1>
<B />
</>
);
});
함수 참조 고정(useCallback)
자식 컴포넌트에 props로 함수를 전달할 경우, 부모 컴포넌트를 React.memo로 메모이제이션했어도 자식 컴포넌트가 다시 리렌더링된다. 왜냐하면 자바스크립트의 함수는 참조 자료형인데, 부모 컴포넌트의 상위 컴포넌트가 리렌더링되며 함수가 다시 정의 되면 함수의 참조 값이 달라져 props가 변경된 것으로 판단하기 때문이다. 따라서 자식 컴포넌트에 props로 함수를 전달할 경우에는 함수를 메모지에이션해서 리렌더링을 막는다. 이 때 함수는 콜백 함수 형태로 써서 클로저에 따른 변수값 고정을 막는다.
import { useCallback, useState } from 'react';
import A from './A';
export default function App()
{
console.log('App render');
const [ count, setCount ] = useState(0);
const increment = useCallback(() => setCount((count) => count + 1), []);
return (
<>
<h1>App Count: {count}</h1>
<A increment={increment} />
</>
);
}
무거운 연산 결과 저장(useMemo)
함수형 컴포넌트에서는 리렌더링이 발생할 때마다 컴포넌트 전체 함수가 다시 실행된다고 볼 수 있습니다. 이런 구조에서 컴포넌트 내부에 연산 비용이 큰 작업이 포함되어 있다면 리렌더링이 발생할 때마다 해당 연산이 반복 실행되어 애플리케이션의 성능 저하로 이어질 수 있습니다.
계산된 값이 너무 크고 많을 경우 값을 메모이제이션해서 내용을 저장할 수 있다. 이 때 사용하는 useMemo 훅으로 함수를 감싸지 않게 주의한다(철학 유지 및 가독성 향상 목적).
import { useMemo, useState } from 'react';
const initialItems = new Array(29_999_999).fill(0).map((_, i) => {
return {
id: i,
selected: i === 29_999_998,
};
});
export default function App() {
const [count, setCount ] = useState(0);
const selectedItems = useMemo(() => initialItems.find((item) => item.selected), [])
return (
<>
<h1>Count: {count}</h1>
<button onClick={()=>setCount((count)=>count+1)}>증가</button>
<p>{selectedItems?.id}</p>
</>
);
}
로딩 성능 최적화
로딩 성능 최적화는 특히 초기 로딩 시에 속도를 빠르게 하는 방법에 대해 다룬다. 웹은 초기 로딩 시 사용자가 이탈하지 않기 위해 서비스 속도가 빠른 것이 중요하기 때문이다.
로딩 성능 최적화 도구
- 필요한 것만 로딩: React.lazy(Code Splitting)
- 로딩 경험 최적화: React.Suspense
- 에러 처리: react-error-boundary
필요한 것만 로딩(React.lazy)
코드 스플리팅은 웹 브라우저의 청크를 분리해 필요한 부분 먼저 띄워주는 것을 말한다. 예를 들어 사용자가 접근한 이후에 띄워줘도 되는 컴포넌트의 경우에는 코드 스플리팅을 이용해서 사용자가 접근한 뒤에 띄워 초반 성능을 높인다.
// LazyComponent.tsx
export default function LazyComponent() {
return(
<div>LazyComponent</div>
);
}
// App.tsx
import { lazy, useState } from 'react';
const LazyComponent = lazy(() => import('./components/LazyComponent'));
export default function App() {
const [ isShow, setIsShow ] = useState(false);
return (
<>
<button onClick={()=>(setIsShow(!isShow))}>Toggle</button>
{isShow && <LazyComponent />}
</>
);
}
로딩 경험 최적화(React.Suspense)
Suspense는 비동기 로딩이 완료될 때까지 fallback 속성으로 지정한 UI를 대신 보여주는 역할을 합니다.
React.lazy 등으로 동적으로 컴포넌트를 불러올 때, 컴포넌트의 로드가 느릴 경우 해당 컴포넌트 자리에 스켈레톤 ui를 띄워 사용자에게 표시할 수 있다. React.lazy와 짝꿍으로 많이 쓴다.
// LazyComponent.tsx
export default function LazyComponent() {
return(
<div>LazyComponent</div>
);
}
// App.tsx
import { lazy, Suspense, useState } from 'react';
const LazyComponent = lazy(() =>
new Promise(resolve => setTimeout(resolve, 2000))
.then(() => import('./components/LazyComponent'))
);
export default function App() {
const [ isShow, setIsShow ] = useState(false);
return(
<>
<button onClick={()=>setIsShow(!isShow)}>Toggle</button>
{ isShow && (<Suspense fallback={<div>Loading...</div>}>
<LazyComponent></LazyComponent>
</Suspense>) }
</>
);
}
에러 처리(react-error-boundary)
컴포넌트 트리 내부에서 에러가 발생해도, 앱 전체가 멈추지 않도록 처리하고 다른 UI를 띄워줄 수 있다. 이를 위해 리액트에서 에러 복구 패턴인 ErrorBoundary를 추가하였다. ErrorBoundary를 사용하기 위해서는 별도의 클래스 컴포넌트를 만들고 오류를 감지해 처리해야하지만, react-error-boundary 라이브러리를 사용하면 컴포넌트를 만들지 않고 오류 처리를 쉽게 구현할 수 있다.
라이브러리 설치 $ npm install react-error-boundary
// LazyComponent.tsx
export default function LazyComponent() {
const random = Math.floor(Math.random() * 2) + 1;
if (random === 1) {
throw new Error('random number is 1');
}
return(
<div>LazyComponent</div>
);
}
// App.tsx
import { lazy, Suspense, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
const LazyComponent = lazy(() =>
new Promise(resolve => setTimeout(resolve, 2000))
.then(() => import('./components/LazyComponent'))
);
export default function App() {
const [ isShow, setIsShow ] = useState(false);
return(
<>
<button onClick={()=>setIsShow(!isShow)}>Toggle</button>
{ isShow && (
<ErrorBoundary fallback={<div>오류 발생</div>}>
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent></LazyComponent>
</Suspense>
</ErrorBoundary>
) }
</>
);
}
ErrorBoundary에 fallback 속성이 아닌 FallbackComponent 속성을 써서 컴포넌트를 props로 보내면 더 정교한 에러 처리(ex. 재시도 버튼)을 할 수 있다.
에러는 ErrorBoundary에 잡힐 수도 있고 잡히지 않을 수도 있는데, 각각의 경우에 리액트에서 제공하는 createRoot()를 이용해 콜백 함수를 설정하면 디버깅에 용이하다.
전용 콜백 함수
- onCaughtError: ErrorBoundary에 잡힌 오류 발생 시 호출
- onUncaughtError: ErrorBoundary에 잡히지 않은 오류 발생 시 호출
- onRecoverableError: ErrorBoundary로 오류를 처리한 후 컴포넌트를 리렌더링할 때 호출
렌더링 우선순위 조정
사용자 타이핑 등으로 상태가 빠른 시간에 많이 업데이트 될 경우, 리렌더링이 많이 일어나 앱의 성능이 떨어질 수 있다. 이 같은 렌더링 성능 저하는 상태 업데이트를 지연시키거나 작업을 백그라운드로 처리하여 렌더링 우선순위를 조정해 완화할 수 있다.
렌더링 우선순위 조정 도구
- 상태 업데이트 지연: useDeferredValue
- 작업 백그라운드 처리: useTransition
상태 업데이트 지연(useDeferredValue)
deferredValue는 렌더링에 부담을 주지 않는 범위 내에서 가능한 늦게 업데이트되도록 리액트가 자동으로 조절합니다. 즉, 사용자가 빠르게 입력값을 바꾸더라도 렌더링은 그중 일부만 선택적으로 수행합니다.
useDeferredValue를 props로 전달하는 값에 사용하면 props 업데이트가 지연된다. 이 때 주의할 점은 상태 업데이트 시점이 지연되는 것이지, 컴포넌트의 리렌더링이 지연되는 것은 아니라는 것이다. useDeferredValue를 사용하면 props 값은 바뀌지 않지만, 입력값을 바꿀 때 마다 부모 컴포넌트는 리렌더링되어 자식 컴포넌트도 함께 리렌더링된다. 따라서 props 값이 바뀌지 않을 때 자식 컴포넌트의 리렌더링을 막기 위해서는 React.memo를 함께 사용해야한다.
// SlowList.tsx
import { memo } from "react";
export default memo(function SlowList({ query }: { query: string }) {
const items = [];
for (let i = 0; i < 500; i++) {
items.push(<SlowItem key={i} query={query} />);
}
return(
<ul>{items}</ul>
);
});
function SlowItem({ query }: { query: string }) {
const startTime = performance.now();
while (performance.now() - startTime < 1) { }
return <li>query: { query }</li>
}
// App.tsx
import { useDeferredValue, useState } from "react";
import SlowList from "./components/SlowList";
export default function App() {
const [query, setQuery] = useState('');
const deferredValue = useDeferredValue(query);
return(
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)}></input>
<SlowList query={deferredValue}></SlowList>
</div>
);
}
작업 백그라운드 처리(useTransition)
useTransition을 사용하면 작업을 백그라운드로 처리할 수 있고, 작업의 처리 상태를 확인할 수 있다(예제의 isPending). useDeferredValue와 다른 점은 값 하나가 아닌 함수 단위의 작업을 지연시킬 수 있다는 점이다.
// SlowList.tsx
import { memo } from "react";
export default memo(function SlowList({ query }: { query: string }) {
const items = [];
for (let i = 0; i < 500; i++) {
items.push(<SlowItem key={i} query={query} />);
}
return(
<ul>{items}</ul>
);
});
function SlowItem({ query }: { query: string }) {
const startTime = performance.now();
while (performance.now() - startTime < 1) { }
return <li>query: { query }</li>
}
// App.tsx
import { useState, useTransition } from "react";
import SlowList from "./components/SlowList";
export default function App() {
const [query, setQuery] = useState('');
const [deferredValue, setDeferredValue] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setQuery(newQuery);
startTransition(() => setDeferredValue(newQuery));
}
return(
<div>
<input value={query} onChange={handleChange}></input>
{ isPending ? <div>Loading</div> : <SlowList query={deferredValue}></SlowList> }
</div>
);
}
리소스 로딩 최적화
리액트 19에서는 로딩 지연 문제를 해결하기 위해 웹 브라우저가 필요한 리소스를 미리 불러올 수 있도록 도와주는 API를 새롭게 제공합니다.
리소스 로딩을 위한 새로운 API
- preconnect()
- prefetchDNS()
- preinit()
- preinitModule()
- preload()
- preloadModule()