본문 바로가기

React.memo와 성능 최적화

4. React.memo와 성능 최적화

React 애플리케이션이 커질수록 성능 최적화가 중요해집니다. React.memo는 컴포넌트의 불필요한 리렌더링을 방지하여 성능을 향상시키는 고차 컴포넌트(Higher-Order Component)입니다.

React.memo란?

기본 동작 원리

import { memo, useState } from 'react';

// 일반 컴포넌트 (매번 리렌더링됨)
function RegularChild({ name }) {
  console.log('RegularChild 렌더링:', name);
  
  return (
    <div>
      <h3>일반 컴포넌트</h3>
      <p>이름: {name}</p>
    </div>
  );
}

// memo로 최적화된 컴포넌트 (props가 같으면 리렌더링 안됨)
const MemoizedChild = memo(function MemoizedChild({ name }) {
  console.log('MemoizedChild 렌더링:', name);
  
  return (
    <div>
      <h3>최적화된 컴포넌트</h3>
      <p>이름: {name}</p>
    </div>
  );
});

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('김개발');
  
  return (
    <div>
      <h1>React.memo 예제</h1>
      
      <div>
        <button onClick={() => setCount(count + 1)}>
          카운트: {count}
        </button>
        <button onClick={() => setName(name === '김개발' ? '이개발' : '김개발')}>
          이름 변경
        </button>
      </div>
      
      <RegularChild name={name} />
      <MemoizedChild name={name} />
    </div>
  );
}

위 예제에서 카운트 버튼을 누르면:

  • RegularChild는 항상 리렌더링됩니다
  • MemoizedChild는 name이 변경되지 않았으므로 리렌더링되지 않습니다

얕은 비교(Shallow Comparison)

React.memo의 기본 비교 방식

import { memo, useState } from 'react';

const UserCard = memo(function UserCard({ user, theme }) {
  console.log('UserCard 렌더링');
  
  return (
    <div>
      <h3>{user.name}</h3>
      <p>나이: {user.age}</p>
      <p>이메일: {user.email}</p>
    </div>
  );
});

function ShallowComparisonExample() {
  const [count, setCount] = useState(0);
  
  // ❌ 매번 새로운 객체를 생성 - 불필요한 리렌더링
  const user = { name: '김개발', age: 25, email: 'kim@example.com' };
  const theme = { background: '#f5f5f5', text: '#333' };
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        카운트: {count}
      </button>
      <UserCard user={user} theme={theme} />
    </div>
  );
}

위 예제는 매번 새로운 객체를 생성하므로 memo의 효과가 없습니다.

해결 방법: 객체 안정화

import { memo, useState, useMemo } from 'react';

const UserCard = memo(function UserCard({ user, theme }) {
  console.log('UserCard 렌더링');
  
  return (
    <div>
      <h3>{user.name}</h3>
      <p>나이: {user.age}</p>
      <p>이메일: {user.email}</p>
    </div>
  );
});

function OptimizedExample() {
  const [count, setCount] = useState(0);
  
  // ✅ useMemo로 객체를 메모이제이션
  const user = useMemo(() => ({
    name: '김개발',
    age: 25,
    email: 'kim@example.com'
  }), []);
  
  const theme = useMemo(() => ({
    background: '#f5f5f5',
    text: '#333'
  }), []);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        카운트: {count}
      </button>
      <UserCard user={user} theme={theme} />
    </div>
  );
}

커스텀 비교 함수

areEqual 함수 사용하기

import { memo, useState } from 'react';

// 커스텀 비교 함수
function areEqual(prevProps, nextProps) {
  // 특정 props만 비교
  return (
    prevProps.user.id === nextProps.user.id &&
    prevProps.user.name === nextProps.user.name
  );
}

const UserProfile = memo(function UserProfile({ user, lastUpdated }) {
  console.log('UserProfile 렌더링');
  
  return (
    <div>
      <h3>{user.name}</h3>
      <p>ID: {user.id}</p>
      <p>마지막 업데이트: {lastUpdated}</p>
    </div>
  );
}, areEqual);

function CustomComparisonExample() {
  const [user, setUser] = useState({
    id: 1,
    name: '김개발',
    email: 'kim@example.com'
  });
  
  const [lastUpdated, setLastUpdated] = useState(new Date().toLocaleString());
  
  const updateTimestamp = () => {
    setLastUpdated(new Date().toLocaleString());
  };
  
  const updateEmail = () => {
    setUser(prev => ({
      ...prev,
      email: prev.email === 'kim@example.com' ? 'new@example.com' : 'kim@example.com'
    }));
  };
  
  return (
    <div>
      <button onClick={updateTimestamp}>
        타임스탬프 업데이트 (리렌더링 안됨)
      </button>
      <button onClick={updateEmail}>
        이메일 변경 (리렌더링 안됨)
      </button>
      <UserProfile user={user} lastUpdated={lastUpdated} />
    </div>
  );
}

실습: 할 일 목록 최적화

최적화 전: 모든 아이템이 리렌더링

import { useState } from 'react';

function TodoItem({ todo, onToggle, onDelete }) {
  console.log(`TodoItem ${todo.id} 렌더링`);
  
  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => onToggle(todo.id)}
        />
        <span>
          {todo.text}
        </span>
      </label>
      <button onClick={() => onDelete(todo.id)}>삭제</button>
    </div>
  );
}

function UnoptimizedTodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '리액트 공부하기', completed: false },
    { id: 2, text: '점심 먹기', completed: true },
    { id: 3, text: '운동하기', completed: false }
  ]);
  
  const [newTodo, setNewTodo] = useState('');
  
  const addTodo = () => {
    if (newTodo.trim()) {
      setTodos(prev => [...prev, {
        id: Date.now(),
        text: newTodo,
        completed: false
      }]);
      setNewTodo('');
    }
  };
  
  const toggleTodo = (id) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };
  
  const deleteTodo = (id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  };
  
  return (
    <div>
      <h2>할 일 목록 (최적화 전)</h2>
      
      <div>
        <input
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="새 할 일"
        />
        <button onClick={addTodo}>추가</button>
      </div>
      
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={toggleTodo}
          onDelete={deleteTodo}
        />
      ))}
    </div>
  );
}

최적화 후: 변경된 아이템만 리렌더링

import { useState, memo, useCallback } from 'react';

// memo로 최적화된 TodoItem
const TodoItem = memo(function TodoItem({ todo, onToggle, onDelete }) {
  console.log(`TodoItem ${todo.id} 렌더링`);
  
  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={todo.completed}
          onChange={() => onToggle(todo.id)}
        />
        <span>
          {todo.text}
        </span>
      </label>
      <button onClick={() => onDelete(todo.id)}>삭제</button>
    </div>
  );
});

function OptimizedTodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: '리액트 공부하기', completed: false },
    { id: 2, text: '점심 먹기', completed: true },
    { id: 3, text: '운동하기', completed: false }
  ]);
  
  const [newTodo, setNewTodo] = useState('');
  
  const addTodo = useCallback(() => {
    if (newTodo.trim()) {
      setTodos(prev => [...prev, {
        id: Date.now(),
        text: newTodo,
        completed: false
      }]);
      setNewTodo('');
    }
  }, [newTodo]);
  
  // useCallback으로 함수를 메모이제이션
  const toggleTodo = useCallback((id) => {
    setTodos(prev => prev.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  }, []);
  
  const deleteTodo = useCallback((id) => {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }, []);
  
  return (
    <div>
      <h2>할 일 목록 (최적화 후)</h2>
      
      <div>
        <input
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="새 할 일"
        />
        <button onClick={addTodo}>추가</button>
      </div>
      
      {todos.map(todo => (
        <TodoItem
          key={todo.id}
          todo={todo}
          onToggle={toggleTodo}
          onDelete={deleteTodo}
        />
      ))}
    </div>
  );
}

복잡한 컴포넌트 최적화

상품 카드 컴포넌트 예제

import { memo, useState, useMemo, useCallback } from 'react';

// 가격 계산 함수 (비용이 큰 계산 시뮬레이션)
function calculateDiscountPrice(price, discountRate) {
  console.log('가격 계산 중...');
  
  // 복잡한 계산 시뮬레이션
  for (let i = 0; i < 100000; i++) {
    Math.random();
  }
  
  return price * (1 - discountRate / 100);
}

const ProductCard = memo(function ProductCard({ 
  product, 
  onAddToCart, 
  onToggleFavorite,
  isFavorite 
}) {
  console.log(`ProductCard ${product.id} 렌더링`);
  
  // 계산 결과를 메모이제이션
  const discountedPrice = useMemo(() => 
    calculateDiscountPrice(product.price, product.discountRate),
    [product.price, product.discountRate]
  );
  
  return (
    <div>
      <h3>{product.name}</h3>
      <p>원가: {product.price.toLocaleString()}원</p>
      {product.discountRate > 0 && (
        <p>할인가: {Math.round(discountedPrice).toLocaleString()}원</p>
      )}
      <p>할인율: {product.discountRate}%</p>
      
      <button onClick={() => onAddToCart(product)}>
        장바구니 추가
      </button>
      <button 
        onClick={() => onToggleFavorite(product.id)}
      >
        {isFavorite ? '좋아요 ❤️' : '좋아요'}
      </button>
    </div>
  );
});

function ProductList() {
  const [products] = useState([
    { id: 1, name: '노트북', price: 1500000, discountRate: 10 },
    { id: 2, name: '마우스', price: 50000, discountRate: 20 },
    { id: 3, name: '키보드', price: 100000, discountRate: 15 }
  ]);
  
  const [favorites, setFavorites] = useState(new Set());
  const [cart, setCart] = useState([]);
  
  // 장바구니 추가 함수
  const handleAddToCart = useCallback((product) => {
    setCart(prev => {
      const existingItem = prev.find(item => item.id === product.id);
      if (existingItem) {
        return prev.map(item =>
          item.id === product.id 
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      } else {
        return [...prev, { ...product, quantity: 1 }];
      }
    });
  }, []);
  
  // 좋아요 토글 함수
  const handleToggleFavorite = useCallback((productId) => {
    setFavorites(prev => {
      const newFavorites = new Set(prev);
      if (newFavorites.has(productId)) {
        newFavorites.delete(productId);
      } else {
        newFavorites.add(productId);
      }
      return newFavorites;
    });
  }, []);
  
  return (
    <div>
      <h2>상품 목록</h2>
      <p>장바구니 아이템 수: {cart.reduce((sum, item) => sum + item.quantity, 0)}</p>
      
      {products.map(product => (
        <ProductCard
          key={product.id}
          product={product}
          onAddToCart={handleAddToCart}
          onToggleFavorite={handleToggleFavorite}
          isFavorite={favorites.has(product.id)}
        />
      ))}
    </div>
  );
}

React.memo 사용 시 주의사항

언제 사용해야 할까?

import { memo, useState } from 'react';

// ❌ memo를 사용하면 안 되는 경우
// 1. props가 자주 변경되는 컴포넌트
const FrequentlyChangingComponent = memo(function FrequentlyChangingComponent({ timestamp }) {
  return <div>현재 시간: {timestamp}</div>;
});

// 2. 렌더링 비용이 낮은 간단한 컴포넌트
const SimpleComponent = memo(function SimpleComponent({ text }) {
  return <span>{text}</span>;
});

// ✅ memo를 사용하면 좋은 경우
// 1. 렌더링 비용이 높은 컴포넌트
const ExpensiveChart = memo(function ExpensiveChart({ data }) {
  // 복잡한 차트 렌더링 로직
  const processedData = data.map(item => ({
    ...item,
    calculated: item.value * Math.random() // 비용이 큰 계산
  }));
  
  return (
    <div>
      {processedData.map(item => (
        <div key={item.id}>{item.calculated}</div>
      ))}
    </div>
  );
});

// 2. 자식이 많은 컴포넌트
const LargeList = memo(function LargeList({ items, onItemClick }) {
  return (
    <div>
      {items.map(item => (
        <div key={item.id} onClick={() => onItemClick(item)}>
          {item.name}
        </div>
      ))}
    </div>
  );
});

function MemoGuideExample() {
  const [count, setCount] = useState(0);
  const [timestamp, setTimestamp] = useState(Date.now());
  const [chartData] = useState([
    { id: 1, value: 100 },
    { id: 2, value: 200 },
    { id: 3, value: 150 }
  ]);
  
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>
        카운트: {count}
      </button>
      <button onClick={() => setTimestamp(Date.now())}>
        시간 업데이트
      </button>
      
      {/* 자주 변경되는 컴포넌트 - memo 효과가 낮음 */}
      <FrequentlyChangingComponent timestamp={timestamp} />
      
      {/* 간단한 컴포넌트 - memo 오버헤드가 더 클 수 있음 */}
      <SimpleComponent text="안녕하세요" />
      
      {/* 비용이 큰 컴포넌트 - memo 효과가 높음 */}
      <ExpensiveChart data={chartData} />
    </div>
  );
}

성능 측정하기

React DevTools Profiler 사용법

import { memo, useState, Profiler } from 'react';

const ProfiledComponent = memo(function ProfiledComponent({ data }) {
  return (
    <div>
      <h3>프로파일링 대상 컴포넌트</h3>
      <p>데이터: {JSON.stringify(data)}</p>
    </div>
  );
});

function PerformanceExample() {
  const [count, setCount] = useState(0);
  const [data, setData] = useState({ value: 1 });
  
  // Profiler 콜백 함수
  const onRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
    console.log('Profiler 결과:', {
      id,           // 컴포넌트 ID
      phase,        // 'mount' 또는 'update'
      actualDuration, // 렌더링에 걸린 시간
      baseDuration,   // 메모이제이션 없이 걸릴 예상 시간
      startTime,      // 렌더링 시작 시간
      commitTime      // 커밋 시간
    });
  };
  
  return (
    <div>
      <h2>성능 측정 예제</h2>
      
      <button onClick={() => setCount(count + 1)}>
        카운트 증가 (컴포넌트 리렌더링 안됨): {count}
      </button>
      
      <button onClick={() => setData({ value: data.value + 1 })}>
        데이터 변경 (컴포넌트 리렌더링됨)
      </button>
      
      <Profiler id="ProfiledComponent" onRender={onRenderCallback}>
        <ProfiledComponent data={data} />
      </Profiler>
    </div>
  );
}

정리

React.memo와 성능 최적화의 핵심 개념을 학습했습니다:

  1. React.memo 기본 사용법: props가 변경되지 않으면 리렌더링 건너뛰기
  2. 얕은 비교: 객체와 배열 props 처리 시 주의사항
  3. 커스텀 비교 함수: areEqual로 특정 props만 비교
  4. 함수 메모이제이션: useCallback과 함께 사용하여 최적화
  5. 복잡한 계산 최적화: useMemo와 조합하여 성능 향상
  6. 사용 시점 판단: 비용과 효과를 고려한 적절한 사용
  7. 성능 측정: React DevTools Profiler로 효과 확인

React.memo는 성능 최적화의 강력한 도구지만, 모든 컴포넌트에 적용할 필요는 없습니다. 실제로 성능 문제가 있는 부분을 식별하고, 측정 가능한 성능 향상이 있을 때만 사용하는 것이 좋습니다.

다음 장에서는 Suspense와 lazy loading에 대해 알아보겠습니다.