• useState의 비동기 처리

    2022. 7. 26.

    by. 쩸

    배칭 (Batching)

     

    const [value, setValue] = useState(0);
    
    const handleClick = () => {
      setValue(value + 1);
      setValue(value + 1);
    }

     

    handleClick은 value를 두 번 변경하기 위한 핸들러 함수이다. 하지만 막상 실행된 뒤 값을 확인하면 예상했던 값인 2가 아니라 1로 설정된 것을 확인할 수 있을 것이다. 

     

    위처럼 상태를 변경하는 세터 함수가 예상했던 것처럼 동작하지 않는 이유는 리액트가 세터 함수들을 모아 일괄적으로 처리(Batch Update)했기 때문이다.

     

    일반적으로 리액트는 상태가 변경되면 변경된 내용을 반영하기 위해 컴포넌트를 다시 렌더링한다. 그런데 만약 페이지 내에 존재하는 수많은 상태값 하나하나가 바뀔 때마다 화면을 리렌더링한다면 성능상 매우 좋지 않을 것이다. 때문에 리액트는 더 나은 성능을 위해 위의 핸들러 함수처럼 상태를 여러 번 업데이트하는 경우 한꺼번에 처리하여 한번만 리렌더링되도록 했다. 이렇게 여러 상태 업데이트를 그룹화하여 한번만 업데이트하는 것을 배칭(Batching)이라고 한다. 리액트는 16ms 간격으로 배칭을 한다. 다시 말해 16ms 동안 발생한 상태의 변경을 묶어서 한번에 처리한다는 것이다.

     

    이러한 특징 덕에 handleClick 함수의 두 세터 함수는 묶여서 처리될 것이다. 정확하게 어떤 원리인지는 모르겠지만 동일한 세터 함수가 여러 개 있다면 그 중 가장 마지막으로 받은 함수만을 실행하는 것 같다. 참고로 클래스 컴포넌트에서는 state가 객체이므로 동일한 setState 호출이 여러 번 반복된다면 setState 호출에 전달된 모든 객체를 추출하여 하나로 묶는 배칭을 수행하고, 이를 머지하여 단일 객체로 만든 뒤 해당 객체를 사용하여 setState를 수행한다고 한다.

     

    state = {
      count: 0
    };
    
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    
    // 객체를 머지하여 단일 객체를 형성
    const newState = Object.assign(
      {},
      objectFromSetState1,
      objectFromSetState2,
      objectFromSetState3,
    );

     

    위처럼 객체를 머지하는 것을 오브젝트 컴포지션(Object Composition)이라고 한다. 이렇게 Object.assign()에 동일한 키를 가진 객체가 전달된다면 가장 마지막으로 전달된 객체의 키 값이 적용되게 된다. 비슷한 맥락으로 useState의 setState 함수도 가장 마지막에 전달된 함수가 실행되는 것이 아닐까 싶다.

     

     

    함수형 업데이트

     

    It is safe to call setState with a function multiple times. Updates will be queued and later executed in the order they were called. 
    — дэн (@dan_abramov) January 25, 2017

     

    이를 해결하기 위해서는 세터 함수에 변경할 state 값을 직접 지정하지 않고 이전 state를 받아 변경된 state를 반환하는 함수를 전달해주면 된다. 이렇게 함수를 인자로 전달하면 리액트는 전달된 함수들을 큐에 넣은 뒤 순차적으로 함수를 호출해 상태를 업데이트하고 첫번째 setState인 경우에는 setState를 호출하기 이전의 상태를, 두번째부터는 이전의 setState 호출로 인해 갱신된 최신의 상태를 전달하게 된다. 

     

    const [value, setValue] = useState(0);
    
    const handleClick = () => {
      setValue(prevValue => prevValue + 1);
      setValue(prevValue => prevValue + 1);
    }

     

    단순히 state 값을 전달할 때와 달리 이전 setState 변경까지 반영하여 갱신된 상태를 바탕으로 state를 변경하게 되므로 처음에 작성했던 handleClick 함수도 의도했던 대로 동작시킬 수 있다.

     

     

    자동 배칭 (Automatic Batching)

     

    현재 setState 는 이벤트 핸들러 내에서 비동기적입니다.
    — 리액트 공식문서 - 컴포넌트 State

     

    하지만 이러한 세터 함수의 비동기적인 동작은 이벤트 핸들러 안에서만 발생했다. 리액트 이벤트 핸들러 내에서 호출된 setState의 경우에만 비동기적으로 처리되었고, 이외에 Promise, setTimeout 등의 경우에는 동기적으로 처리되었던 것이다. 아래의 코드를 실행하면 배칭이 되지 않아서 두 번의 로그가 찍히는 것을 확인할 수 있다. 

     

    function App() {
      const [count, setCount] = useState(0);
      const [flag, setFlag] = useState(false);
    
      function handleClick() {
        fetchSomething().then(() => {
          // React 17 and earlier does NOT batch these because
          // they run *after* the event in a callback, not *during* it
          setCount(c => c + 1); // Causes a re-render
          setFlag(f => !f); // Causes a re-render
        });
      }
    
      return (
        <div>
          <button onClick={handleClick}>Next</button>
          <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
        </div>
      );
    }

     

    React 18에서는 자동 배칭(Automatic Batching)이 도입되어 이러한 부분을 걱정하지 않아도 되게 되었다. 기존에 리액트 이벤트 핸들러에서만 가능했던 배칭이 Promise, Timeouts, 네이티브 이벤트 핸들러 등에서도 가능하도록 변경되었다. 이로 인해 불필요한 렌더링 작업을 줄이고 성능도 더욱 향상시킬 수 있게 됐다.

     

    import ReactDOM from 'react-dom/client';
    
    const root = ReactDOM.createRoot(document.getElementById('root'));
    
    root.render(
      <React.StrictMode>
        <App />
      </React.StrictMode>
    );

     

    자동 배칭을 사용하려면 ReactDOM.render 대신 ReactDOM.createRoot를 사용하면 된다. 메소드를 적용하면 업데이트가 발생하는 위치에 상관없이 자동 배칭이 이루어지게 될 것이다.

     

    function handleClick() {
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    }
    setTimeout(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    }, 1000);
    fetch(/*...*/).then(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    })
    elm.addEventListener('click', () => {
      setCount(c => c + 1);
      setFlag(f => !f);
      // React will only re-render once at the end (that's batching!)
    });

     

    위 코드 모두 한번의 리렌더링만 발생하게 된다. 만약 자동 배칭을 적용하고 싶지 않다면 flushSync를 사용하면 된다.

     

    import { flushSync } from 'react-dom';
    
    function handleClick() {
      flushSync(() => {
        setCounter(c => c + 1);
      });
      // React has updated the DOM by now
      flushSync(() => {
        setFlag(f => !f);
      });
      // React has updated the DOM by now
    }

    참고 & 출처

     

     

     

    댓글