React의 잠재력 풀어내기: 성능 최적화 기법 심층 탐구
Source: Dev.to
React는 사용자 인터페이스를 구축하기 위한 인기 있는 JavaScript 라이브러리로, 개발자들이 동적이고 인터랙티브한 애플리케이션을 만들 수 있게 해줍니다. 하지만 애플리케이션이 복잡해지고 규모가 커짐에 따라 최적의 성능을 유지하는 것이 중요해집니다. 느린 UI는 사용자 경험을 저하시킬 뿐만 아니라 이탈률을 높이고 궁극적으로 애플리케이션의 영향력을 약화시킬 수 있습니다.
이 포스트에서는 React 애플리케이션의 성능을 최적화하기 위한 다양한 효과적인 기법들을 살펴보며, 부드럽고 반응성이 뛰어난 사용자 여정을 보장하는 방법을 제시합니다.
일반적인 성능 병목 현상
이는 종종 다음과 같은 원인에서 비롯됩니다:
- 불필요한 재렌더링 – React의 선언형 UI 업데이트는 컴포넌트의 상태나 props가 변경될 때 해당 컴포넌트와 그 자식들을 다시 렌더링한다는 의미입니다. 효율적으로 관리되지 않으면 중복된 계산과 DOM 조작이 발생할 수 있습니다.
- 큰 컴포넌트 트리 – 깊게 중첩된 계층 구조는 불필요한 재렌더링의 영향을 더욱 악화시킬 수 있습니다. 트리 깊숙한 곳에서의 변경이 루트까지 재렌더링을 일으킬 수 있습니다.
- 비싼 연산 – 매 렌더링마다 복잡한 계산, 데이터 가져오기 또는 조작을 수행하는 컴포넌트는 애플리케이션을 크게 느리게 만들 수 있습니다.
- 큰 번들 – JavaScript 번들의 크기는 초기 로드 시간에 직접적인 영향을 미칩니다. 큰 번들은 다운로드, 파싱, 실행에 더 많은 시간이 필요해 애플리케이션 렌더링이 지연됩니다.
- 비효율적인 데이터 가져오기 – 과도한 데이터 양을 가져오거나 너무 자주 가져오거나 최적이 아닌 방식으로 수행하면 지연이 발생하고 불필요한 리소스를 소비합니다.
실용적인 전략
메모이제이션
메모이제이션은 비용이 많이 드는 함수 호출 결과를 캐시하고 동일한 입력이 다시 발생하면 캐시된 결과를 반환합니다. React에서는 이것이 props가 변경되지 않은 경우 컴포넌트가 다시 렌더링되는 것을 방지하는 것으로 번역됩니다.
React.memo()
함수형 컴포넌트의 경우 React.memo()가 메모이제이션을 위한 기본 도구입니다. 이는 고차 컴포넌트(HOC)로, 여러분의 컴포넌트를 감싸고 렌더링된 출력을 메모이제이션합니다. React는 props가 이전 렌더와 동일하면 해당 컴포넌트의 렌더링을 건너뜁니다.
// MyExpensiveComponent.jsx
import React from 'react';
const MyExpensiveComponent = ({ data }) => {
console.log('MyExpensiveComponent rendered');
// Simulate an expensive computation
const processedData = data.map(item => item * 2);
return <div>{processedData.join(', ')}</div>;
};
export default React.memo(MyExpensiveComponent);
// ParentComponent.jsx
import React from 'react';
import MyExpensiveComponent from './MyExpensiveComponent';
const ParentComponent = () => {
const [value, setValue] = React.useState(10);
const sampleData = [1, 2, 3];
return (
<div>
<MyExpensiveComponent data={sampleData} />
<button onClick={() => setValue(value + 1)}>Increment</button>
<p>Current value: {value}</p>
</div>
);
};
export default ParentComponent;
이 예제에서 MyExpensiveComponent는 data prop이 실제로 변경될 때만 다시 렌더링됩니다. 부모가 다른 이유로 다시 렌더링되더라도 메모이제이션된 컴포넌트는 건너뛰어집니다.
useMemo() Hook
useMemo()는 비용이 많이 드는 계산을 메모이제이션합니다. 값을 계산하는 함수를 받고 의존성 배열을 받습니다. 의존성 중 하나가 변경될 때만 함수가 다시 실행됩니다.
import React, { useState, useMemo } from 'react';
const ExpensiveCalculationComponent = ({ list }) => {
const [filter, setFilter] = useState('');
// Memoize the filtered list to avoid recomputing on every render
const filteredList = useMemo(() => {
console.log('Filtering list...');
return list.filter(item => item.includes(filter));
}, [list, filter]); // Recompute only if `list` or `filter` changes
return (
<div>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter items"
/>
<ul>
{filteredList.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
};
export default ExpensiveCalculationComponent;
filteredList는 list 또는 filter가 변경될 때만 다시 계산됩니다. 다른 상태 업데이트는 비용이 많이 드는 필터 작업을 트리거하지 않습니다.
useCallback() Hook
useCallback()은 콜백 함수를 메모이제이션합니다. 이는 콜백을 메모이제이션된 자식 컴포넌트(React.memo)에 전달할 때 특히 유용합니다. 그렇지 않으면 매 렌더링마다 새로운 함수 인스턴스가 생성되어 자식의 메모이제이션이 깨집니다.
import React, { useState, useCallback } from 'react';
const Button = React.memo(({ onClick, label }) => {
console.log(`Button "${label}" rendered`);
return <button onClick={onClick}>{label}</button>;
});
const ParentComponent = () => {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// Memoize the handler for Count 1
const handleClick1 = useCallback(() => {
setCount1(prev => prev + 1);
}, []); // No dependencies – stable reference
// This handler is recreated on every render
const handleClick2 = () => {
setCount2(prev => prev + 1);
};
return (
<div>
<p>Count 1: {count1}</p>
<Button onClick={handleClick1} label="Increment Count 1" />
<p>Count 2: {count2}</p>
<Button onClick={handleClick2} label="Increment Count 2" />
</div>
);
};
export default ParentComponent;
Button "Increment Count 1"은 handleClick1이 메모이제이션되었기 때문에 count1이 변경될 때만 다시 렌더링됩니다. Button "Increment Count 2"는 매 부모 렌더링 시마다 핸들러가 새로 생성되므로 매번 다시 렌더링됩니다.
dler은 메모이제이션되지 않습니다.*
번들 크기 줄이기
대형 JavaScript 번들은 초기 로드 시간에 크게 영향을 미칠 수 있습니다. code splitting, dynamic imports, tree shaking과 같은 기술은 번들을 가볍게 유지하는 데 도움이 됩니다.
React.lazy와 Suspense를 이용한 코드 스플리팅
React의 lazy loading은 번들을 필요에 따라 로드되는 작은 청크로 나눌 수 있게 해줍니다.
React.lazy()– 컴포넌트를 동적으로 import하고 일반 컴포넌트처럼 렌더링합니다.Suspense– lazy‑loaded 컴포넌트를 가져오는 동안 대체 UI(예: 로더)를 제공합니다.
예시
import React, { lazy, Suspense } from 'react';
// Dynamically import the component
const LazyComponent = lazy(() => import('./LazyComponent'));
const App = () => {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
};
export default App;
Result:
LazyComponent.js는 실제로 필요할 때만 다운로드되고 파싱되어 초기 로드 성능을 향상시킵니다.
리스트 가상화 (윈도잉)
수천 개의 항목을 렌더링하는 것은 비용이 많이 들 수 있습니다. 가상화(또는 윈도잉)는 뷰포트에 보이는 항목만 렌더링하고, 사용자가 스크롤할 때 항목을 추가/제거합니다.
인기 라이브러리: react-window, react-virtualized.
예시 ( react-window 사용)
import React from 'react';
import { FixedSizeList as List } from 'react-window';
const Row = ({ index, style }) => (
<div style={style}>Row {index}</div>
);
const LongList = () => (
<List
height={400}
itemCount={1000}
itemSize={35}
width={300}
>
{Row}
</List>
);
export default LongList;
이점: 렌더링되는 DOM 요소 수를 크게 줄여 대규모 리스트에서 상당한 성능 향상을 제공합니다.
효율적인 상태 관리
React 전용 최적화는 아니지만, 상태 관리가 부실하면 불필요한 재렌더링을 초래할 수 있습니다.
- Local vs. Global State – 몇몇 컴포넌트만 상태를 필요로 할 때는 로컬 상태로 유지하고, 너무 높은 레벨로 끌어올리는 것을 피하세요.
- Context API – 자주 변하는 값에 대해서는 신중하게 사용하세요. 모든 소비자가 업데이트마다 재렌더링됩니다.
Solutions: 컨텍스트를 분리하거나 Zustand 또는 Jotai 같은 상태 관리 라이브러리를 도입해 더 세밀한 업데이트를 구현합니다. - Immutable Data Structures – 상태를 불변하게 업데이트하세요(특히 객체/배열). 그래야 React가 변경을 효율적으로 감지합니다.
Helper: Immer는 불변 업데이트를 간편하게 해줍니다.
Profiling Performance Bottlenecks
React DevTools에는 Profiler가 포함되어 있어 컴포넌트 렌더링 시간을 시각화합니다.
- Record interactions – 앱을 사용하는 동안 세션을 캡처합니다.
- Analyze commit times – 느린 컴포넌트를 식별합니다.
- Pinpoint re‑renders – 필요 이상으로 자주 재렌더링되는 컴포넌트를 확인합니다.
정기적인 프로파일링은 성능 문제가 사용자에게 영향을 주기 전에 포착하고 수정하는 데 도움이 됩니다.
요약
- 메모이제이션 (
React.memo,useMemo,useCallback) - 코드 스플리팅 (
React.lazy,Suspense) - 가상화 (
react-window,react-virtualized) - 효율적인 상태 관리 (로컬 상태, 적절한 컨텍스트 사용, 불변 업데이트)
- 지속적인 프로파일링 (React DevTools Profiler)
이러한 기술을 적용하고 앱을 정기적으로 프로파일링함으로써, 빠르고 반응성이 뛰어난 사용자 경험을 제공하여 제품의 전반적인 성공에 기여할 수 있습니다.