리액트에서 성능 최적화를 위해 적용할 수 있는 방법들을 설명해주세요.
리액트에서 성능 최적화를 위해 여러 가지 방법을 사용할 수 있는데요. 대표적으로 메모이제이션을 말씀 드릴 수 있겠습니다.
리액트의 memo를 사용하여 컴포넌트를 메모이제이션할 수 있습니다. 이는 컴포넌트의 props가 변경되지 않았을 때, 리렌더링을 방지하여 성능을 최적화합니다. 이는 특히 렌더링 비용이 큰 컴포넌트에서 유용합니다.
또한 useCallback과 useMemo를 활용할 수도 있습니다.
useCallback 은 함수를 메모이제이션하여 불필요한 함수 재생성을 방지하고, useMemo는 값의 재계산을 방지하여 성능을 최적화합니다. 이를 통해 자식 컴포넌트로 전달되는 함수나 값이 변경되지 않으면 리렌더링을 피할 수 있습니다.
마지막으로 코드 스플리팅을 활용해볼 수 있습니다. 코드 스플리팅은 큰 애플리케이션을 여러 개의 작은 청크로 나누어, 필요한 청크만 로드하게 하여 초기 로드 시간을 줄입니다. React.lazy와 Suspense를 사용하여 동적으로 컴포넌트를 로드할 수 있습니다.
코드 스플리팅은 어떤 경우에 사용해야 할까요? 🤔
첫번째로는 초기 로딩 시간이 길어지는 경우입니다. 애플리케이션이 커지면, 초기 로딩에 모든 코드를 로드하는 것이 비효율적일 수 있습니다. 코드 스플리팅을 사용해 초기 로드 시 필요한 핵심 코드만 로드하고, 이후 추가적인 기능은 필요할 때 로드하도록 하면 초기 로딩 속도를 크게 개선할 수 있습니다.
두번째로는 라우트별 코드 분할이 필요한 경우입니다. SPA에서는 각 페이지가 별도의 기능과 UI를 가지므로, 라우트별로 필요한 코드만 분리하여 로드할 수 있습니다. 이 방식은 리액트의 React.lazy와 Suspense를 사용하여 라우트별 컴포넌트를 동적으로 불러올 때 유용합니다.
리액트 성능 최적화 방법: 초보자를 위한 가이드
리액트(React)는 사용자 인터페이스를 구축하기 위한 강력한 자바스크립트 라이브러리입니다. 하지만 애플리케이션이 커지고 복잡해질수록 성능 문제가 발생할 수 있습니다. 이 글에서는 리액트 애플리케이션의 성능을 최적화하는 다양한 방법을 초보자도 이해하기 쉽게 설명해 드리겠습니다.
1. 메모이제이션을 활용한 최적화
메모이제이션은 비용이 많이 드는 함수 호출의 결과를 저장하고, 동일한 입력이 다시 발생할 때 재계산 없이 저장된 결과를 반환하는 기술입니다.
React.memo
React.memo는 컴포넌트를 메모이제이션하여 props가 변경되지 않으면 리렌더링을 방지합니다.
import React from 'react';
const ExpensiveComponent = ({ name, count }) => {
console.log('ExpensiveComponent 렌더링:', name);
// 비용이 많이 드는 렌더링 작업 가정
const items = [];
for (let i = 0; i < count; i++) {
items.push(<div key={i}>Item {i}</div>);
}
return (
<div>
<h2>Hello, {name}!</h2>
{items}
</div>
);
};
// React.memo로 컴포넌트 감싸기
export default React.memo(ExpensiveComponent);
이제 name이나 count props가 변경되지 않는 한, 부모 컴포넌트가 리렌더링되더라도 ExpensiveComponent는 다시 렌더링되지 않습니다.
useCallback
함수를 메모이제이션하여 불필요한 재생성을 방지합니다. 특히 자식 컴포넌트에 함수를 props로 전달할 때 유용합니다.
import React, { useState, useCallback } from 'react';
import ExpensiveButton from './ExpensiveButton';
const ParentComponent = () => {
const [count, setCount] = useState(0);
// useCallback 없이 매 렌더링마다 새로운 함수 생성
const badHandleClick = () => {
console.log('버튼 클릭됨');
};
// useCallback을 사용하여 함수 메모이제이션
const goodHandleClick = useCallback(() => {
console.log('버튼 클릭됨');
}, []); // 의존성 배열이 비어있으므로 함수는 한 번만 생성됨
return (
<div>
<h1>카운트: {count}</h1>
<button onClick={() => setCount(count + 1)}>카운트 증가</button>
{/* 매 렌더링마다 재생성되는 함수를 전달하여 불필요한 리렌더링 발생 */}
<ExpensiveButton onClick={badHandleClick} label="좋지 않은 예" />
{/* 메모이제이션된 함수를 전달하여 불필요한 리렌더링 방지 */}
<ExpensiveButton onClick={goodHandleClick} label="좋은 예" />
</div>
);
};
// ExpensiveButton.js
import React from 'react';
const ExpensiveButton = ({ onClick, label }) => {
console.log(`${label} 버튼 렌더링`);
return <button onClick={onClick}>{label}</button>;
};
export default React.memo(ExpensiveButton);
useCallback 훅을 사용하면 의존성 배열의 값이 변경되지 않는 한 동일한 함수 인스턴스를 재사용합니다. 이렇게 하면 React.memo로 감싼 자식 컴포넌트의 불필요한 리렌더링을 방지할 수 있습니다.
useMemo
계산 비용이 큰 값을 메모이제이션하여 불필요한 재계산을 방지합니다.
import React, { useState, useMemo } from 'react';
const ExpensiveCalculation = ({ numbers }) => {
// 비용이 많이 드는 계산 (예: 소수 찾기)
const findPrimes = (nums) => {
console.log('소수 계산 중...');
return nums.filter(num => {
if (num <= 1) return false;
if (num <= 3) return true;
if (num % 2 === 0 || num % 3 === 0) return false;
for (let i = 5; i * i <= num; i += 6) {
if (num % i === 0 || num % (i + 2) === 0) return false;
}
return true;
});
};
// useMemo 없이 매 렌더링마다 재계산
// const primes = findPrimes(numbers);
// useMemo를 사용하여 numbers가 변경될 때만 재계산
const primes = useMemo(() => findPrimes(numbers), [numbers]);
return (
<div>
<h2>소수 목록:</h2>
<ul>
{primes.map(prime => <li key={prime}>{prime}</li>)}
</ul>
</div>
);
};
useMemo는 종속성 배열의 값이 변경될 때만 값을 다시 계산합니다. 이를 통해 계산 비용이 큰 연산의 불필요한 반복을 방지할 수 있습니다.
2. 불필요한 렌더링 최적화
상태 업데이트 최적화
상태 업데이트 방식에 따라 불필요한 렌더링이 발생할 수 있습니다. 다음과 같은 방법으로 최적화할 수 있습니다.
함수형 업데이트 사용
// 좋지 않은 예
const [count, setCount] = useState(0);
const increment = () => {
setCount(count + 1);
setCount(count + 1); // 실제로는 한 번만 증가
};
// 좋은 예
const [count, setCount] = useState(0);
const increment = () => {
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1); // 두 번 증가
};
객체 상태 업데이트 최적화
// 좋지 않은 예
const [user, setUser] = useState({ name: '홍길동', age: 30 });
const updateName = (newName) => {
// 매번 새 객체를 생성하여 불필요한 렌더링 발생 가능성
setUser({ name: newName, age: user.age });
};
// 좋은 예
const updateName = (newName) => {
// 스프레드 연산자를 사용하여 이전 상태 유지
setUser(prevUser => ({ ...prevUser, name: newName }));
};
컴포넌트 분할
큰 컴포넌트를 작은 컴포넌트로 분할하면 상태 변경 시 필요한 부분만 리렌더링할 수 있습니다.
// 좋지 않은 예: 하나의 큰 컴포넌트
const UserProfile = () => {
const [user, setUser] = useState({ name: '홍길동', age: 30 });
const [posts, setPosts] = useState([]);
// user나 posts 상태가 변경되면 전체 컴포넌트가 리렌더링됨
return (
<div>
<h1>{user.name}의 프로필</h1>
<p>나이: {user.age}</p>
<div>
<h2>작성한 글</h2>
{posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.content}</p>
</div>
))}
</div>
</div>
);
};
// 좋은 예: 작은 컴포넌트로 분할
const UserInfo = React.memo(({ user }) => {
return (
<div>
<h1>{user.name}의 프로필</h1>
<p>나이: {user.age}</p>
</div>
);
});
const UserPosts = React.memo(({ posts }) => {
return (
<div>
<h2>작성한 글</h2>
{posts.map(post => (
<div key={post.id}>
<h3>{post.title}</h3>
<p>{post.content}</p>
</div>
))}
</div>
);
});
const UserProfile = () => {
const [user, setUser] = useState({ name: '홍길동', age: 30 });
const [posts, setPosts] = useState([]);
// user가 변경되면 UserInfo만 리렌더링
// posts가 변경되면 UserPosts만 리렌더링
return (
<div>
<UserInfo user={user} />
<UserPosts posts={posts} />
</div>
);
};
3. 코드 스플리팅(Code Splitting)
코드 스플리팅은 애플리케이션을 여러 개의 작은 청크로 나누어 필요할 때만 로드하는 기술입니다. 이를 통해 초기 로딩 시간을 크게 줄일 수 있습니다.
React.lazy와 Suspense
import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Loading from './Loading';
// 동적으로 컴포넌트 import
const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));
const UserProfile = lazy(() => import('./routes/UserProfile'));
const App = () => {
return (
<Router>
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/profile/:id" element={<UserProfile />} />
</Routes>
</Suspense>
</Router>
);
};
이 예제에서는 React.lazy를 사용하여 각 라우트 컴포넌트를 동적으로 로드합니다. Suspense 컴포넌트는 lazy 컴포넌트가 로드되는 동안 폴백 UI(예: 로딩 스피너)를 표시합니다.
코드 스플리팅은 언제 사용해야 할까?
1. 초기 로딩 시간이 길어지는 경우
대규모 애플리케이션에서는 초기에 모든 코드를 로드하면 사용자가 첫 화면을 보기까지 오랜 시간이 걸릴 수 있습니다. 코드 스플리팅을 사용하면 필요한 핵심 기능만 먼저 로드하고, 나머지는 필요할 때 로드할 수 있습니다.
// 조건부로 컴포넌트 로드하기
import React, { useState, lazy, Suspense } from 'react';
const LargeDataChart = lazy(() => import('./LargeDataChart'));
const Dashboard = () => {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>대시보드</h1>
<button onClick={() => setShowChart(!showChart)}>
{showChart ? '차트 숨기기' : '차트 보기'}
</button>
{showChart && (
<Suspense fallback={<div>차트 로딩 중...</div>}>
<LargeDataChart />
</Suspense>
)}
</div>
);
};
이 예제에서는 사용자가 "차트 보기" 버튼을 클릭할 때만 차트 컴포넌트를 로드합니다. 이렇게 하면 초기 페이지 로드 시간을 크게 줄일 수 있습니다.
2. 라우트별 코드 분할이 필요한 경우
단일 페이지 애플리케이션(SPA)에서는 각 페이지가 서로 다른 기능과 UI를 가질 수 있습니다. 라우트별로 코드를 분할하면 사용자가 방문한 페이지의 코드만 로드하여 효율성을 높일 수 있습니다.
앞서 본 라우팅 예제와 같이 React.lazy와 Suspense를 사용하여 각 라우트의 컴포넌트를 필요할 때만 로드할 수 있습니다.
4. 가상화(Virtualization) 기법
대량의 데이터를 표시할 때는 가상화 기법을 사용하여 화면에 보이는 항목만 렌더링할 수 있습니다. react-window나 react-virtualized 같은 라이브러리를 사용하면 됩니다.
import React from 'react';
import { FixedSizeList } from 'react-window';
const VirtualizedList = ({ items }) => {
const Row = ({ index, style }) => (
<div style={style}>
<p>Item {items[index]}</p>
</div>
);
return (
<FixedSizeList
height={400}
width={300}
itemCount={items.length}
itemSize={50}
>
{Row}
</FixedSizeList>
);
};
// 사용 예
const LargeList = () => {
// 10,000개의 항목이 있는 배열 생성
const items = Array.from({ length: 10000 }, (_, i) => i);
return <VirtualizedList items={items} />;
};
이 예제에서는 10,000개의 항목이 있지만, 실제로는 화면에 보이는 항목만 렌더링되므로 성능이 크게 향상됩니다.
5. 웹 워커(Web Worker) 활용
무거운 계산은 메인 스레드가 아닌 웹 워커에서 수행하여 UI 렌더링을 방해하지 않도록 할 수 있습니다.
// worker.js
self.addEventListener('message', e => {
if (e.data.command === 'calculate') {
// 무거운 계산 수행
const result = performHeavyCalculation(e.data.numbers);
self.postMessage({ result });
}
});
function performHeavyCalculation(numbers) {
// 무거운 계산 로직
return numbers.reduce((sum, num) => sum + Math.pow(num, 2), 0);
}
// React 컴포넌트
import React, { useState, useEffect } from 'react';
const HeavyCalculationComponent = ({ numbers }) => {
const [result, setResult] = useState(null);
const [calculating, setCalculating] = useState(false);
useEffect(() => {
const worker = new Worker('./worker.js');
worker.onmessage = e => {
setResult(e.data.result);
setCalculating(false);
};
setCalculating(true);
worker.postMessage({
command: 'calculate',
numbers
});
return () => worker.terminate();
}, [numbers]);
return (
<div>
<h2>계산 결과</h2>
{calculating ? <p>계산 중...</p> : <p>결과: {result}</p>}
</div>
);
};
웹 워커를 사용하면 복잡한 계산을 백그라운드에서 처리하여 UI의 반응성을 유지할 수 있습니다.
결론
리액트 애플리케이션의 성능을 최적화하는 방법은 다양합니다. 메모이제이션(React.memo, useCallback, useMemo), 불필요한 렌더링 방지, 코드 스플리팅, 가상화, 웹 워커 등의 기법을 상황에 맞게 적절히 활용하면 사용자 경험을 크게 향상시킬 수 있습니다.
하지만 성능 최적화는 항상 필요한 것은 아닙니다. "조기 최적화는 모든 악의 근원"이라는 말이 있듯이, 실제로 성능 문제가 발생했을 때 적절한 측정과 분석을 통해 최적화하는 것이 중요합니다. React DevTools의 Profiler를 사용하여 성능 병목 현상을 식별하고, 필요한 부분만 선택적으로 최적화하는 것이 좋은 접근 방식입니다.
최적화는 지속적인 과정이며, 애플리케이션의 특성과 사용자의 요구에 맞게 적절한 전략을 선택해야 합니다.