072DATA

React의 성능 최적화 Hook -> useMemo & useCallback ( 깨알 감자 디바운스 ) 본문

FrontEnd/React

React의 성능 최적화 Hook -> useMemo & useCallback ( 깨알 감자 디바운스 )

0720 2024. 11. 7. 04:18

목표

  • useMemo useCallback의 동작 원리 이해
  • 두 Hook의 차이점과 각각의 사용 의도 파악 (!)
  • 실제 프로젝트에서의 적용 방법 습득
  • 성능 최적화 관점에서의 이해

 

React의 렌더링 특성

React에서 컴포넌트는 다음과 같은 경우에 리렌더링됨

  1. state가 변경될 때
  2. props가 변경될 때
  3. 부모 컴포넌트가 리렌더링될 때

이러한 리렌더링은 때때로 불필요한 계산이나 렌더링을 발생시킬 수 있음

 

Memoization이란?

  • 이전에 계산한 값을 저장해두고 동일한 입력이 들어올 때 재사용하는 기법
  • 컴퓨터 프로그래밍에서 속도를 향상시키는 최적화 기술 중 하나

 

그럼 이제 useMemouseCallback 알아보겠음

 

useMemo

 

사실 useMemo 관련해서 포스팅을 한 적이 있긴한데 다 까먹고 제대로 정리된 것 같지도 않음..

운좋게 useCallbak 관련해서 학습하던 중에 같이 정리하면 좋을 것 같아서 이녀석도 껴줬음 허허

 

기본 문법

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

주요 특징

  1. 값의 메모이제이션
    • 계산 비용이 큰 값을 !!!캐싱!!!
    • 의존성 배열의 값이 변경될 때만 !!!!재계산!!!!
  2. 사용해야 하는 경우
    • 복잡한 계산이 필요한 경우
    • Deep comparison이 필요한 객체를 생성하는 경우
    • 자식 컴포넌트의 props로 전달되는 값이 자주 변경되는 경우

 

실제 사용 예시 ( 주석 확인 )

function ProductList({ category, search }) {
  // 비용이 큰 필터링 및 정렬 연산
  const filteredProducts = useMemo(() => {
    console.log("Filtering products..."); // 이 연산이 언제 발생하는지 확인
    return products
      .filter(product => 
        product.category === category &&
        product.name.toLowerCase().includes(search.toLowerCase())
      )
      .sort((a, b) => b.price - a.price);
  }, [category, search]); // category나 search가 변경될 때만 재계산

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

 

코드에서 ProductList 컴포넌트는 상품 목록을 필터링하고 정렬하는 기능을 하는데

category나 search 값이 변경될 때만 필터링/정렬을 다시 수행하여 불필요한 계산을 방지하는 역할임

 

따라서 useMemo는 복잡한 계산을 하거나 값이 자주 변경될 때 캐싱하거나

재계산하여 성능을 최적화 시키는 hook이라고 정리하면 될듯

 

 

그럼 이제 useCallback

 

useCallback

 

기본 문법

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

 

주요 특징

  1. 함수의 메모이제이션 -> useMemo는 값의 메모이제이션이었는데 (?)
    • 함수 자체를 캐싱
    • 의존성 배열의 값이 변경될 때만 새로운 함수 생성
  2. 사용해야 하는 경우
    • 자식 컴포넌트에 콜백 함수를 props로 전달할 때
    • React.memo()로 감싼 컴포넌트에 함수를 전달할 때
    • 함수가 다른 Hook의 의존성으로 사용될 때

useMemo랑 진짜 비슷한데 계산하는 느낌이라던가 

가장 중요한 "함수" 를 캐싱한다는 걸 기억해야 할듯

 

실제 예시 ( 주석 확인 )

function SearchComponent() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  // API 호출 함수를 메모이제이션
  const handleSearch = useCallback(async (searchQuery) => {
    try {
      const response = await api.search(searchQuery);
      setResults(response.data);
    } catch (error) {
      console.error("Search failed:", error);
    }
  }, []); // 의존성이 없으므로 컴포넌트가 마운트될 때만 생성

  // 디바운스된 검색 함수
  useEffect(() => {
    const timeoutId = setTimeout(() => {
      if (query) {
        handleSearch(query);
      }
    }, 500);

    return () => clearTimeout(timeoutId);
  }, [query, handleSearch]); // handleSearch가 메모이제이션되어 있어 무한 루프 방지

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <SearchResults results={results} />
    </div>
  );
}

 

 

검색어(query)를 입력하면 API를 호출하여 결과를 가져오는 검색 컴포넌트이며

handleSearch 함수를 메모이제이션하여 useEffect의 무한 루프를 방지하고, 디바운스 처리로 API 호출을 최적화시킴

 

꺠알 지식 디바운스

디바운스라는 건 연속으로 발생되는 이벤트를 그룹화해서 마지막 이벤트만 처리하는 기법임 (디바운싱ㅇ)

검색 기능 같은 곳에서 유용해서 위 코드처럼 사용자가 입력할 때 타이핑이 멈추면 지정된 시간 후 실제 검색이 실행됨

다시 새로운 타이핑이 시작되면 이전 타이머를 취소하고 다시 시작해서 결과값을 다시 처리함

 

디바운스 처리가 없다면?

r → API 호출
re → API 호출
rea → API 호출
reac → API 호출
react → API 호출

 

디바운스 처리를 해준다면 !?

r (대기)
re (대기)
rea (대기)
reac (대기)
react → 500ms 후 API 호출 1번만 실행

 

이렇게 서버 부하를 감소시키고 성능을 향상 시키는 기법임 ( 예시가 다 했네 )

 

 

각 Hook의 실제 사용 사례

 

useMemo와 useCallback의 예시를 보고 솔직히 아직 와닿진 않아서

실제로 어디에 사용되는지도 알아 봤는데, 

데이터 뿌려주는 그리드 형태의 컴포넌트나 폼 제출 컴포넌트에도 사용을 하는듯

 

그리드 형태의 컴포넌트

function DataGrid({ data, onSort, onFilter }) {
  // 정렬된 데이터 계산
  const sortedData = useMemo(() => {
    return [...data].sort((a, b) => (a.value - b.value));
  }, [data]);

  // 정렬 핸들러
  const handleSort = useCallback((column) => {
    onSort(column);
  }, [onSort]);

  return (
    <table>
      <thead>
        <tr>
          {columns.map(column => (
            <th key={column.id} onClick={() => handleSort(column.id)}>
              {column.name}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {sortedData.map(row => (
          <DataRow key={row.id} data={row} />
        ))}
      </tbody>
    </table>
  );
}

 

테이블 형태의 데이터를 표시하고 정렬하는 그리드 컴포넌트임

데이터 정렬 결과와 정렬 함수를 메모이제이션하여 불필요한 재계산과 리렌더링을 방지하는 코등미

실제로 데이터를 map으로 뿌려주는 경우가 되게 많은데 지금 진행하는 최종 프로젝트에서도 적용하면 좋겠네


폼 컴포넌트

function ComplexForm() {
  const [formData, setFormData] = useState({});

  // 폼 데이터 유효성 검사
  const validationErrors = useMemo(() => {
    return validateFormData(formData);
  }, [formData]);

  // 폼 제출 핸들러
  const handleSubmit = useCallback(async (e) => {
    e.preventDefault();
    if (Object.keys(validationErrors).length === 0) {
      await submitForm(formData);
    }
  }, [formData, validationErrors]);

  return (
    <form onSubmit={handleSubmit}>
      {/* 폼 필드들 */}
    </form>
  );
}

 

복잡한 폼 데이터를 관리하고 유효성 검사를 수행하는 컴포넌트임

폼 데이터가 변경될 때만 유효성 검사를 다시 수행하고, 제출 함수를 메모이제이션하여 성능을 최적화함

이런 폼 제출 함수는 굳이 새로 읽어서 처리할 필요가 없으니까 캐싱해두고 필요할 때마다  사용하는 것 같음

성능 최적화 팁

다소 신뢰도가 떨어질 수 있는 클로드의 증언이니 가볍게 보면 될듯

그래도 나보단 똑똑한 녀석이 팁을 준거니 관련해서 찾아보고,

학습하면 도움이 될 것 같아 기록함

1. React DevTools 활용

  • Components 탭에서 리렌더링 확인
  • Profiler를 사용하여 성능 병목 지점 파악

2. 최적화 체크리스트

  • 실제로 성능 개선이 필요한가?
  • 메모이제이션 비용이 재계산 비용보다 적은가?
  • 의존성 배열이 올바르게 설정되었는가?

 

주의사항

 

1. 과도한 사용 피하기

  • 단순한 연산에는 메모이제이션이 불필요 ( 굳이 싶은듯 )
  • 모든 컴포넌트에 적용하면 오히려 성능 저하 ( 알짜배기만 쏙 뽑아서 사용하면 도움될듯)

2. 의존성 배열 관리

  • 빈 배열: 마운트 시에만 계산
  • 잘못된 의존성: 메모리 누수나 버그 발생 가능 ( 이거 ㄹㅇ 의존성 배열은 독이 될 수도 있기 때문에 중요한듯)

 

그리고 마지막으로 일반적으로  사용할 떄 실수 하는 경우가 있는데 코드 예시 가져옴

// ❌ 잘못된 사용
const Component = ({ data }) => {
  // 매 렌더링마다 새로운 객체 생성
  const options = useMemo(() => ({ key: "value" }), []);
  
  // 단순한 연산에 불필요한 메모이제이션
  const count = useMemo(() => data.length, [data]);
  
  return <Child options={options} count={count} />;
};

// ✅ 올바른 사용
const Component = ({ data }) => {
  // 복잡한 객체나 계산에만 사용
  const processedData = useMemo(() => 
    data.map(item => complexCalculation(item))
  , [data]);
  
  return <Child data={processedData} count={data.length} />;
};

 

 

마치며

useMemouseCallback성능 최적화를 위한 강력한 도구로 사용하지만

무분별한 사용은 피하고 필요한 경우에만 알짜배기만 뽑아서 적용시키면 베스트인듯

항상 사용할 때 성능 측정을 통해 최적화 효과 확인 필요하다고 하는데

사용해놓고 얼마나 최적화 된지도 모르고 쓰면 의미가 있나 싶음

그리고 코드의 가독성과 유지보수성도 함께 고려해서 적절하게 써서 의도를 잘 전달하면 좋을듯 (거의 안성재)

 

자러가자.. ㅈㅈ요