React 성능 최적화: memo, useMemo, useCallback 완벽 가이드

2025-08-01


React 개발에서 성능 최적화는 항상 중요한 주제입니다. 특히 memo, useMemo, useCallback은 React의 핵심 최적화 도구들이지만, 잘못 사용하면 오히려 성능을 저하시킬 수 있습니다. 이 글에서는 각 도구의 정확한 사용법과 주의사항을 깊이 있게 다뤄보겠습니다.

React 렌더링 메커니즘 이해하기

최적화를 제대로 하려면 먼저 React의 렌더링 메커니즘을 이해해야 합니다.

React 렌더링 과정

React 렌더링 과정
1. Props/State 변경
트리거
2. 컴포넌트 렌더링
함수 실행
3. Virtual DOM 생성
JSX → 객체
4. DOM 업데이트
Diffing & Patch

불필요한 렌더링이 성능 저하의 주요 원인

불필요한 렌더링의 문제점

// 문제가 되는 예시
const ParentComponent = () => {
  const [count, setCount] = useState(0);
 
  // 매번 새로운 객체 생성
  const expensiveObject = {
    data: "some data",
    timestamp: Date.now(),
  };
 
  // 매번 새로운 함수 생성
  const handleClick = () => {
    console.log("clicked");
  };
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <ExpensiveChild data={expensiveObject} onAction={handleClick} />
    </div>
  );
};

이 예시에서 count가 변경될 때마다:

  1. expensiveObject가 새로 생성됨
  2. handleClick 함수가 새로 생성됨
  3. ExpensiveChild가 불필요하게 리렌더링됨

1. React.memo - 컴포넌트 메모이제이션

React.memo의 동작 원리

React.memo는 컴포넌트를 메모이제이션하여 props가 변경되지 않으면 리렌더링을 방지합니다.

const MemoizedComponent = React.memo(MyComponent, (prevProps, nextProps) => {
  // 커스텀 비교 함수 (선택사항)
  return prevProps.value === nextProps.value;
});

올바른 사용법

// ✅ 좋은 예시
const UserCard = React.memo(({ user, onEdit }) => {
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      <button onClick={() => onEdit(user.id)}>Edit</button>
    </div>
  );
});
 
// 부모 컴포넌트
const UserList = () => {
  const [users, setUsers] = useState([]);
 
  // useCallback으로 함수 메모이제이션
  const handleEdit = useCallback((userId) => {
    // 편집 로직
  }, []);
 
  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} onEdit={handleEdit} />
      ))}
    </div>
  );
};

주의사항

// ❌ 잘못된 예시
const UserCard = React.memo(({ user, onEdit }) => {
  return (
    <div className="user-card">
      <h3>{user.name}</h3>
      <p>{user.email}</p>
      {/* 매번 새로운 함수가 전달됨 */}
      <button onClick={() => onEdit(user.id)}>Edit</button>
    </div>
  );
});
 
const UserList = () => {
  const [users, setUsers] = useState([]);
 
  // 매번 새로운 함수 생성
  const handleEdit = (userId) => {
    // 편집 로직
  };
 
  return (
    <div>
      {users.map((user) => (
        <UserCard
          key={user.id}
          user={user}
          onEdit={handleEdit} // 매번 새로운 함수
        />
      ))}
    </div>
  );
};

언제 사용해야 할까?

사용해야 하는 경우:

  • 컴포넌트가 자주 리렌더링됨
  • 렌더링 비용이 높음
  • props가 안정적임

사용하지 말아야 하는 경우:

  • 컴포넌트가 거의 리렌더링되지 않음
  • 렌더링 비용이 낮음
  • props가 자주 변경됨

2. useMemo - 값 메모이제이션

useMemo의 동작 원리

useMemo는 계산 비용이 높은 값을 메모이제이션합니다.

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

올바른 사용법

// ✅ 좋은 예시
const ExpensiveComponent = ({ items, filter }) => {
  // 계산 비용이 높은 필터링
  const filteredItems = useMemo(() => {
    return items.filter((item) =>
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [items, filter]);
 
  // 계산 비용이 높은 정렬
  const sortedItems = useMemo(() => {
    return [...filteredItems].sort((a, b) => a.name.localeCompare(b.name));
  }, [filteredItems]);
 
  return (
    <div>
      {sortedItems.map((item) => (
        <ItemCard key={item.id} item={item} />
      ))}
    </div>
  );
};

성능 비교 예시

// ❌ 성능이 나쁜 예시
const ExpensiveComponent = ({ items, filter }) => {
  // 매번 새로운 계산
  const filteredItems = items.filter((item) =>
    item.name.toLowerCase().includes(filter.toLowerCase())
  );
 
  const sortedItems = [...filteredItems].sort((a, b) =>
    a.name.localeCompare(b.name)
  );
 
  return (
    <div>
      {sortedItems.map((item) => (
        <ItemCard key={item.id} item={item} />
      ))}
    </div>
  );
};
 
// ✅ 성능이 좋은 예시
const ExpensiveComponent = ({ items, filter }) => {
  const filteredItems = useMemo(() => {
    return items.filter((item) =>
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [items, filter]);
 
  const sortedItems = useMemo(() => {
    return [...filteredItems].sort((a, b) => a.name.localeCompare(b.name));
  }, [filteredItems]);
 
  return (
    <div>
      {sortedItems.map((item) => (
        <ItemCard key={item.id} item={item} />
      ))}
    </div>
  );
};

언제 사용해야 할까?

사용해야 하는 경우:

  • 계산 비용이 높은 연산
  • 객체나 배열의 참조 동일성 유지
  • 렌더링 성능에 직접적인 영향

사용하지 말아야 하는 경우:

  • 계산 비용이 낮은 연산
  • 단순한 값 변환
  • 의존성 배열이 자주 변경됨

3. useCallback - 함수 메모이제이션

useCallback의 동작 원리

useCallback은 함수를 메모이제이션하여 불필요한 리렌더링을 방지합니다.

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

올바른 사용법

// ✅ 좋은 예시
const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState([]);
 
  // 안정적인 함수 참조
  const handleUserUpdate = useCallback((userId, newData) => {
    setUsers((prev) =>
      prev.map((user) => (user.id === userId ? { ...user, ...newData } : user))
    );
  }, []);
 
  const handleUserDelete = useCallback((userId) => {
    setUsers((prev) => prev.filter((user) => user.id !== userId));
  }, []);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      <UserList
        users={users}
        onUpdate={handleUserUpdate}
        onDelete={handleUserDelete}
      />
    </div>
  );
};

성능 비교 예시

// ❌ 성능이 나쁜 예시
const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState([]);
 
  // 매번 새로운 함수 생성
  const handleUserUpdate = (userId, newData) => {
    setUsers((prev) =>
      prev.map((user) => (user.id === userId ? { ...user, ...newData } : user))
    );
  };
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {/* count가 변경될 때마다 UserList가 리렌더링됨 */}
      <UserList users={users} onUpdate={handleUserUpdate} />
    </div>
  );
};
 
// ✅ 성능이 좋은 예시
const ParentComponent = () => {
  const [count, setCount] = useState(0);
  const [users, setUsers] = useState([]);
 
  // 함수 참조가 안정적
  const handleUserUpdate = useCallback((userId, newData) => {
    setUsers((prev) =>
      prev.map((user) => (user.id === userId ? { ...user, ...newData } : user))
    );
  }, []);
 
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {/* count가 변경되어도 UserList는 리렌더링되지 않음 */}
      <UserList users={users} onUpdate={handleUserUpdate} />
    </div>
  );
};

언제 사용해야 할까?

사용해야 하는 경우:

  • 함수를 자식 컴포넌트에 props로 전달
  • 함수가 다른 Hook의 의존성으로 사용됨
  • 함수 참조의 안정성이 중요함

사용하지 말아야 하는 경우:

  • 함수가 컴포넌트 내부에서만 사용됨
  • 함수가 매번 다른 의존성을 가짐
  • 성능상 이점이 미미함

성능 측정과 최적화 전략

React DevTools Profiler 사용

import { Profiler } from "react";
 
const App = () => {
  const onRenderCallback = (id, phase, actualDuration) => {
    console.log(`${id} took ${actualDuration}ms to ${phase}`);
  };
 
  return (
    <Profiler id="App" onRender={onRenderCallback}>
      <YourComponent />
    </Profiler>
  );
};

성능 측정 예시

// 성능 측정을 위한 커스텀 Hook
const useRenderCount = (componentName) => {
  const renderCount = useRef(0);
 
  useEffect(() => {
    renderCount.current += 1;
    console.log(`${componentName} rendered ${renderCount.current} times`);
  });
 
  return renderCount.current;
};
 
// 사용 예시
const ExpensiveComponent = React.memo(({ data }) => {
  const renderCount = useRenderCount("ExpensiveComponent");
 
  return (
    <div>
      <p>Render count: {renderCount}</p>
      {/* 복잡한 렌더링 로직 */}
    </div>
  );
});

최적화 패턴과 안티패턴

좋은 패턴들

// 1. 조건부 렌더링 최적화
const ConditionalComponent = ({ condition, data }) => {
  const expensiveData = useMemo(() => {
    if (!condition) return null;
    return processExpensiveData(data);
  }, [condition, data]);
 
  if (!condition) return null;
 
  return <div>{expensiveData}</div>;
};
 
// 2. 리스트 렌더링 최적화
const UserList = ({ users }) => {
  const sortedUsers = useMemo(() => {
    return [...users].sort((a, b) => a.name.localeCompare(b.name));
  }, [users]);
 
  return (
    <div>
      {sortedUsers.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
};
 
// 3. 이벤트 핸들러 최적화
const FormComponent = () => {
  const [formData, setFormData] = useState({});
 
  const handleInputChange = useCallback((field, value) => {
    setFormData((prev) => ({ ...prev, [field]: value }));
  }, []);
 
  const handleSubmit = useCallback(
    (e) => {
      e.preventDefault();
      // 제출 로직
    },
    [formData]
  );
 
  return (
    <form onSubmit={handleSubmit}>
      <input onChange={(e) => handleInputChange("name", e.target.value)} />
      <button type="submit">Submit</button>
    </form>
  );
};

피해야 할 패턴들

// ❌ 불필요한 메모이제이션
const SimpleComponent = ({ text }) => {
  // 단순한 문자열 변환은 메모이제이션 불필요
  const processedText = useMemo(() => {
    return text.toUpperCase();
  }, [text]);
 
  return <div>{processedText}</div>;
};
 
// ❌ 잘못된 의존성 배열
const BadExample = ({ user }) => {
  const userInfo = useMemo(() => {
    return {
      name: user.name,
      email: user.email,
      // user 객체 전체를 의존성으로 사용
    };
  }, [user]); // user 객체가 매번 새로 생성되면 의미없음
 
  return <div>{userInfo.name}</div>;
};
 
// ❌ 과도한 최적화
const OverOptimizedComponent = ({ data }) => {
  // 모든 것을 메모이제이션하는 것은 오히려 성능 저하
  const processedData = useMemo(() => data, [data]);
  const handleClick = useCallback(() => console.log("clicked"), []);
 
  return <div onClick={handleClick}>{processedData}</div>;
};

실제 프로젝트에서의 적용

1. 대시보드 컴포넌트 최적화

const Dashboard = ({ data, filters }) => {
  // 복잡한 데이터 처리
  const processedData = useMemo(() => {
    return data
      .filter((item) => filters.status.includes(item.status))
      .filter((item) => item.date >= filters.startDate)
      .map((item) => ({
        ...item,
        calculatedValue: item.value * item.multiplier,
      }));
  }, [data, filters]);
 
  // 차트 데이터 계산
  const chartData = useMemo(() => {
    return processedData.reduce((acc, item) => {
      const key = item.category;
      acc[key] = (acc[key] || 0) + item.calculatedValue;
      return acc;
    }, {});
  }, [processedData]);
 
  // 안정적인 이벤트 핸들러
  const handleFilterChange = useCallback((newFilters) => {
    // 필터 변경 로직
  }, []);
 
  return (
    <div>
      <FilterPanel onFilterChange={handleFilterChange} />
      <DataTable data={processedData} />
      <Chart data={chartData} />
    </div>
  );
};

2. 폼 컴포넌트 최적화

const ComplexForm = ({ initialData, onSubmit }) => {
  const [formData, setFormData] = useState(initialData);
  const [errors, setErrors] = useState({});
 
  // 유효성 검사 (비용이 높은 연산)
  const validationErrors = useMemo(() => {
    const newErrors = {};
 
    if (!formData.name.trim()) {
      newErrors.name = "이름은 필수입니다.";
    }
 
    if (formData.email && !isValidEmail(formData.email)) {
      newErrors.email = "올바른 이메일 형식이 아닙니다.";
    }
 
    // 복잡한 비즈니스 로직 검증
    if (formData.age < 18 && formData.requiresAdult) {
      newErrors.age = "성인 인증이 필요합니다.";
    }
 
    return newErrors;
  }, [formData]);
 
  // 안정적인 이벤트 핸들러들
  const handleInputChange = useCallback((field, value) => {
    setFormData((prev) => ({ ...prev, [field]: value }));
  }, []);
 
  const handleSubmit = useCallback(
    (e) => {
      e.preventDefault();
      if (Object.keys(validationErrors).length === 0) {
        onSubmit(formData);
      }
    },
    [formData, validationErrors, onSubmit]
  );
 
  return (
    <form onSubmit={handleSubmit}>
      <Input
        value={formData.name}
        onChange={(e) => handleInputChange("name", e.target.value)}
        error={validationErrors.name}
      />
      <Input
        value={formData.email}
        onChange={(e) => handleInputChange("email", e.target.value)}
        error={validationErrors.email}
      />
      <button type="submit">Submit</button>
    </form>
  );
};

결론

React의 성능 최적화는 신중하게 접근해야 합니다:

핵심 원칙

  1. 측정 후 최적화: 성능 문제가 실제로 있는지 먼저 확인
  2. 적절한 사용: 모든 것을 최적화하려 하지 말고 필요한 곳에만 적용
  3. 의존성 관리: 의존성 배열을 정확히 관리하여 불필요한 재계산 방지
  4. 코드 가독성: 과도한 최적화로 인한 코드 복잡성 증가 주의

체크리스트

  • [ ] 실제 성능 문제가 있는가?
  • [ ] 최적화 도구가 적절한 곳에 사용되었는가?
  • [ ] 의존성 배열이 정확한가?
  • [ ] 코드 가독성이 유지되는가?
  • [ ] 테스트가 통과하는가?

성능 최적화는 마지막 단계에서 해야 할 일이지, 처음부터 고려할 사항이 아닙니다.

먼저 기능을 완성하고, 실제 성능 문제가 발생했을 때 적절한 도구를 사용하여 최적화하세요.


참고 자료:

댓글

GitHub 계정으로 댓글을 남겨보세요!