본문 바로가기

리액트 심화 스터디

[Week 3] Automatic batching, windowing, useMemo, useCallback

1. Automatic batching

Batching (일괄 처리)

리액트는 동일한 이벤트 핸들러 내에서 발생하는 state 업데이트를 묶어 한 번의 리렌더링만 발생시킨다.

이러한 방식으로 불필요한 렌더링을 줄이는 것을 Batching(일괄 처리)이라고 한다.

 

React 17에서의 Batching

React 18 이전까지, 리액트는 이벤트 핸들러 내부에서 발생하는 업데이트에만 배칭을 적용했다.

따라서, Promise나 setTimeout 같은 비동기 작업 내부에서 발생하는 업데이트는 배칭되지 않아,

setState 함수가 호출될 때마다 각각 리렌더링이 발생했다.

 

function handleClick() {
  fetchSomething().then(() => {
    setCount((c) => c + 1); 
    setFlag((f) => !f);
    // React 17에서는 두 번의 렌더링 발생
  });
}
 

위 예시 코드에서는 fetchSomething()의 비동기 작업이 완료된 후 setCount와 setFlag 함수가 연속으로 호출된다.

React 17에서는 이 두 상태 업데이트가 각각 별도의 리렌더링을 발생시키며, 배칭되지 않는다.

그 이유는 React 17 이전까지 배칭이 브라우저의 이벤트가 실행되는 도중에만 적용되었기 때문이다.

위 예시 코드는 이벤트가 종료된 후 state를 업데이트하고 있기 때문에 배칭이 이루어지지 않아 두 번의 리렌더링이 발생한 것이다.

 

React 18의 Automatic batching

하지만, React 18 이후부터는 createRoot 기능 덕분에 Promise나 setTimeout, Native Event Handler 등의 작업에서도 모든 상태 업데이트가 자동으로 배칭된다. 이를 Automatic batching이라고 하며, 덕분에 리렌더링을 자동으로 줄일 수 있게 되었다.

 

위에서 봤던 코드를 React 18에서 실행하면 다음과 같이 작동한다.

 

function handleClick() {
  fetchSomething().then(() => {
    setCount((c) => c + 1);
    setFlag((f) => !f);
    // React 18에서는 이 업데이트들이 배칭되어 한 번의 렌더링 발생
  });
}

 

Automatic batching을 통해 리액트는 비동기 함수 내에서 발생하는 state 업데이트를 묶어 단 한 번만 렌더링을 수행한다.

 

2. windowing

patterns.dev

windowing 사용자에게 보이는 부분만 렌더링하여 성능을 최적화하는 기법이다.

많은 양의 데이터를 렌더링해야 할 경우, 화면에 표시되는 항목만 렌더링하여 불필요한 렌더링을 줄이고 성능을 향상시킬 수 있다.

 

렌더링 영역의 크기를 사전에 알 수 있는 경우에는 react-window 라이브러리를 통해 windowing을 구현하는 것이 좋다.

하지만, 영역의 크기를 사전에 알 수 없다면 react-virtualized 라이브러리를 활용해 windowing을 구현하는 것이 적합하다.

(참고로 react-virtualized의 경량화된 버전이 react-window이다.)

react-window

yarn add react-window

 

react-window 사용 예시

import React from 'react';
import { FixedSizeList } from 'react-window';

const items = [...] // 대규모 데이터 리스트

const Row = ({ index, style }) => (
  <div style={style}>
  	{/* items[index]를 사용하여 항목 렌더링 */}
  </div>
);

const ListComponent = () => (
  <FixedSizeList
    height={500}
    width={500}
    itemSize={120}
    itemCount={items.length}
  >
    {Row}
  </FixedSizeList>
);

export default ListComponent;
  • FixedSizeList에서 height, width, itemSize를 지정하면 이 영역 내에서 windowing이 자동으로 이루어진다.

 

react-virtualized

yarn add react-virtualized

 

react-virtualized 사용 예시

import React from 'react';
import { List } from 'react-virtualized';

const items = [...]; // 대규모 데이터 리스트

// 각 항목의 높이를 동적으로 계산하는 함수
const getRowHeight = ({ index }) => {
  const item = data[index];
  return item.length * 5; 
};

const Row = ({ index, style }) => (
  <div style={style}>
    {/* items[index]를 사용하여 항목 렌더링 */}
    {items[index]}
  </div>
);

const ListComponent = () => (
  <List
    height={500}        
    width={500}     
    rowCount={data.length}      // 항목 개수
    rowRenderer={Row}           // 항목을 렌더링할 함수
    rowHeight={getRowHeight}    // 항목의 높이를 동적으로 결정
  />
);

export default ListComponent;
  • react-virtualized의 List 컴포넌트는 rowHeight, rowCount, rowRenderer를 설정하여 동적으로 항목을 렌더링한다.

 

3. useMemo

useMemo 비용이 큰 연산의 결과를 메모이제이션 하고 저장된 값을 반환하도록 하는 React hook이다.

이때, 메모이제이션이란 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로서 값을 재사용하는 기술을 뜻한다.

 

useMemo의 구조

const cachedValue = useMemo(calculateValue, dependencies)
  • calculateValue: 캐시하려는 값을 계산하는 함수
  • dependencies: 콜백 함수 내부에서 참조되는 의존성 배열

1. 초기 렌더링에서 useMemo는 인자 없이 calculateValue를 호출한 결과를 반환한다.
2. 이후에는 의존성 배열의 값이 변하지 않는 한 이전에 계산한 값을 재사용한다.

3. dependencies가 변경될 경우, calculateValue가 다시 호출되어 새로운 결과가 저장된다.

 

useMemo 예시: 복잡한 정렬 연산

function SortedList({ items, sortCriteria }) {
  // useMemo를 사용하여 정렬된 데이터를 캐시
  const sortedItems = useMemo(() => {
    console.log("Sorting items...");

    return [...items].sort((a, b) => {
      if (sortCriteria === 'ascending') {
        return a - b;
      } else if (sortCriteria === 'descending') {
        return b - a;
      } else {
        return 0;
      }
    });
  }, [items, sortCriteria]); // items나 sortCriteria가 변경될 때만 정렬 실행

 

만약 items 배열이 수천 개의 데이터를 포함하고 있고, 매 렌더링마다 이 배열을 정렬해야 한다면 굉장히 비용이 클 것이다.

useMemo는 items 배열과 sortCriteria 값이 변경될 때만 정렬을 수행하여, 불필요한 반복 연산을 방지한다.

 

언제 useMemo를 사용해야 할까?

useMemo를 최대한 많이 사용하면 할수록 성능이 좋아질 거라고 생각했는데 짧은 생각이었다!

useMemo는 메모리를 사용해 값을 저장하고, 조건을 검사하는 과정이 필요하기 때문에

모든 계산에 적용할 경우 오히려 복잡성을 높이고 성능을 저하시킬 수 있다고 한다.

따라서, 다음과 같은 경우에만 사용하는 것이 좋다.

  • 큰 배열의 정렬, 필터링 등 연산 비용이 높은 작업이 반복되는 경우
  • 특정 컴포넌트가 상태 업데이트와 관계없이 항상 동일한 결과를 반환하는 경우
  • 종속성 배열이 자주 변경되지 않는 경우

 

4. useCallback

useCallback 인자로 받은 함수를 메모이제이션하여, 리렌더링 시 이전에 저장했던 함수를 반환하는 React hook이다.

useMemo는 결과 값을 메모이제이션하고 useCallback은 함수 자체를 메모이제이션한다.

 

useCallback의 구조

const cachedFn = useCallback(fn, dependencies)
  • fn: 캐싱하려는 함수
  • dependencies: 콜백 함수 내부에서 참조되는 의존성 배열 (이 배열의 값이 변경될 때만 새로운 함수 생성)

 

함수 메모이제이션이 필요한 이유

자바스크립트에서 함수는 일급 객체이므로, 동일한 내용의 함수를 생성해도 메모리 주소가 다르면 별개의 함수로 인식된다.

const add1 = () => a + b; 
const add2 = () => a + b;

console.log(add1 === add2) // false

 

위 예시에서 동일한 내용의 함수를 정의하고 두 함수를 동등 비교 연산자로 비교해보면 false가 반환된다.

useCallback은 이러한 함수 동등성 문제 해결한다.
useCallback을 사용하면 컴포넌트가 렌더링될 때마다 새로운 함수가 생성되지 않고, 동일한 참조를 가진 함수가 반환된다.

 

useCallback 예시

  • useCallback을 사용하지 않은 경우
function Counter({ onIncrement }) {
  console.log('자식 컴포넌트 렌더링');

  return <button onClick={onIncrement}>Increment</button>;
}

function App() {
  const [count, setCount] = useState(0);

  function increment() {
    setCount(count + 1);
  }

  return (
    <div>
      <h1>{count}</h1>
      {/* 매 렌더링마다 새로운 increment 함수가 전달되어 Counter가 불필요하게 리렌더링된다. */}
      <Counter onIncrement={increment} />
    </div>
  );
}

 

위 코드에서, increment 함수는 매 렌더링마다 새로 정의된다.

이로 인해 Counter 컴포넌트는 onIncrement 함수가 변경되었다고 인식하고 매번 리렌더링된다.

 

  • useCallback을 사용한 경우
import { useState, useCallback } from 'react';

function Counter({ onIncrement }) {
  console.log('자식 컴포넌트 렌더링');

  return <button onClick={onIncrement}>Increment</button>;
}

function App() {
  const [count, setCount] = useState(0);

  // useCallback을 사용하여 함수를 메모이제이션
  const increment = useCallback(() => {
    setCount(count + 1);
  }, [count]);  // count가 변경될 때만 새로운 함수 생성

  return (
    <div>
      <h1>{count}</h1>
      {/* 동일한 props를 계속 받게 되어 Counter가 불필요하게 리렌더링되지 않는다. */}
      <Counter onIncrement={increment} />
    </div>
  );
}

 

위 예시에서 useCallback은 increment 함수를 메모이제이션하여 count가 변경되지 않는 한 동일한 함수 참조를 유지한다.

따라서, Counter 컴포넌트는 불필요하게 리렌더링되지 않는다.

 

 

 

https://web.dev/articles/virtualize-long-lists-react-window?hl=ko#react-window

https://ko.react.dev/reference/react/useMemo

https://ko.react.dev/reference/react/useCallback#usecallback