목록으로
React

React 성능 최적화 기법

불필요한 리렌더링을 줄이고 React 앱을 빠르게 만드는 방법을 알아봅니다.

React 앱이 느려졌다면 불필요한 리렌더링이 원인인 경우가 많습니다. 성능을 개선하는 실전 기법들을 알아봅니다.

리렌더링 이해하기

컴포넌트가 리렌더링되는 조건은 세 가지입니다.

  1. 자신의 state가 변경될 때
  2. 부모 컴포넌트가 리렌더링될 때
  3. Context 값이 변경될 때

불필요한 리렌더링을 줄이는 것이 최적화의 핵심입니다.

React.memo

props가 변경되지 않으면 리렌더링을 건너뜁니다.

const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
  // 복잡한 렌더링 로직
  return <div>{/* ... */}</div>;
});

// 커스텀 비교 함수
const ListItem = React.memo(
  function ListItem({ item, onSelect }) {
    return <div onClick={() => onSelect(item.id)}>{item.name}</div>;
  },
  (prevProps, nextProps) => {
    // true 반환 시 리렌더링 스킵
    return prevProps.item.id === nextProps.item.id;
  }
);

memo가 효과 없는 경우

// 매번 새 객체/배열을 전달하면 memo가 무의미
function Parent() {
  return (
    <Child
      style={{ color: 'red' }}  // 매번  객체
      items={[1, 2, 3]}         // 매번  배열
      onClick={() => {}}         // 매번 새 함수
    />
  );
}

useMemo

계산 비용이 큰 값을 메모이제이션합니다.

function ProductList({ products, filter }) {
  // filter가 변경될 때만 재계산
  const filteredProducts = useMemo(() => {
    return products.filter(p => p.category === filter);
  }, [products, filter]);

  // 정렬된 결과 메모이제이션
  const sortedProducts = useMemo(() => {
    return [...filteredProducts].sort((a, b) => a.price - b.price);
  }, [filteredProducts]);

  return (
    <ul>
      {sortedProducts.map(product => (
        <ProductItem key={product.id} product={product} />
      ))}
    </ul>
  );
}

언제 useMemo를 사용할까

// 필요한 경우
const expensiveValue = useMemo(() => {
  return heavyCalculation(data); // 계산 비용이 큼
}, [data]);

// 불필요한 경우
const simpleValue = useMemo(() => {
  return a + b; // 단순 연산
}, [a, b]);

단순한 연산에는 useMemo가 오히려 오버헤드입니다.

useCallback

함수를 메모이제이션합니다. 자식 컴포넌트에 콜백을 전달할 때 유용합니다.

function TodoList() {
  const [todos, setTodos] = useState([]);

  // todos가 변경될 때만 새 함수 생성
  const handleToggle = useCallback((id) => {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );
  }, []); // setTodos는 안정적이므로 의존성 불필요

  return (
    <ul>
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={handleToggle}
        />
      ))}
    </ul>
  );
}

const TodoItem = React.memo(function TodoItem({ todo, onToggle }) {
  return (
    <li onClick={() => onToggle(todo.id)}>
      {todo.text}
    </li>
  );
});

상태 구조 최적화

상태 분리

// 나쁜 예 - 하나의 큰 상태
const [state, setState] = useState({
  user: null,
  posts: [],
  comments: [],
  isLoading: false
});

// 좋은 예 - 관련된 상태끼리 분리
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [comments, setComments] = useState([]);
const [isLoading, setIsLoading] = useState(false);

상태를 분리하면 해당 상태를 사용하는 컴포넌트만 리렌더링됩니다.

상태 끌어내리기

// 나쁜 예 - 상태가 너무 높은 위치
function App() {
  const [inputValue, setInputValue] = useState('');

  return (
    <div>
      <Header />
      <SearchInput value={inputValue} onChange={setInputValue} />
      <ExpensiveList />  {/* inputValue 변경마다 리렌더링 */}
    </div>
  );
}

// 좋은 예 - 상태를 사용하는 곳 가까이
function App() {
  return (
    <div>
      <Header />
      <SearchSection />  {/* 상태가 내부에 있음 */}
      <ExpensiveList />  {/* 영향 없음 */}
    </div>
  );
}

function SearchSection() {
  const [inputValue, setInputValue] = useState('');
  return <SearchInput value={inputValue} onChange={setInputValue} />;
}

가상화 (Virtualization)

긴 목록은 화면에 보이는 항목만 렌더링합니다.

import { FixedSizeList } from 'react-window';

function VirtualList({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].name}
    </div>
  );

  return (
    <FixedSizeList
      height={400}
      itemCount={items.length}
      itemSize={50}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

10,000개 항목도 부드럽게 렌더링됩니다.

코드 스플리팅

import { lazy, Suspense } from 'react';

// 동적 import
const AdminDashboard = lazy(() => import('./AdminDashboard'));
const UserProfile = lazy(() => import('./UserProfile'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/admin" element={<AdminDashboard />} />
        <Route path="/profile" element={<UserProfile />} />
      </Routes>
    </Suspense>
  );
}

성능 측정

React DevTools Profiler

컴포넌트별 렌더링 시간을 측정합니다. 어떤 컴포넌트가 자주 리렌더링되는지 확인할 수 있습니다.

개발 중 리렌더링 확인

function useRenderCount(componentName) {
  const renderCount = useRef(0);
  renderCount.current++;

  useEffect(() => {
    console.log(`${componentName} 렌더링 횟수: ${renderCount.current}`);
  });
}

function MyComponent() {
  useRenderCount('MyComponent');
  // ...
}

마무리

성능 최적화는 측정 먼저, 최적화는 나중입니다. 실제로 문제가 되는 부분을 찾고 그 부분만 최적화합니다.

모든 곳에 memo, useMemo, useCallback을 붙이는 것은 좋지 않습니다. 오히려 코드만 복잡해집니다. 필요한 곳에만 적용하세요.