Automatic Batching 이해하기

Taemin Jang·2024년 2월 6일
0

Automatic Batching

React 18로 업데이트 되면서 새로운 기능들이 도입됐다.

특히 React 18에서 createRoot를 사용하면서 모든 업데이트는 Automatic Batching을 수행한다.

React 18 이전 - Legacy root API

import * as ReactDOM from 'react-dom';
import App from 'App';

const container = document.getElementById('app');

// 초기 렌더링
ReactDOM.render(<App tab="home" />, container);

// 업데이트하는 동안 React는
// DOM 엘리먼트의 root에 접근한다.
ReactDOM.render(<App tab="profile" />, container);

Legacy root API를 통한 리렌더링 동작

React 18 - New root API

import * as ReactDOMClient from 'react-dom/client';
import App from 'App';

const container = document.getElementById('app');

// root를 생성한다.
const root = ReactDOMClient.createRoot(container);

// 초기 렌더링: 요소를 root에 렌더링한다.
root.render(<App tab="home" />);

// 업데이트 중에는 container를 다시 전달할 필요가 없다.
root.render(<App tab="profile" />);

createRoot를 통한 batching

두 root API 차이는 클릭 이벤트 내에 2개 이상의 상태 업데이트가 일어날 때 하나의 리렌더링으로 처리해주는가?이다.

React의 업데이트가 발생하는 위치에 관계없이 Automatic Batching

다음 코드들은 동일하게 동작한다.

function handleClick() {
  setCount(c => c + 1);
  setFlag(f => !f);
  // Batching을 수행하여 한 번의 리렌더링이 일어난다.
}
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // Batching을 수행하여 한 번의 리렌더링이 일어난다.
}, 1000);
fetch(/*...*/).then(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // Batching을 수행하여 한 번의 리렌더링이 일어난다.
})
elm.addEventListener('click', () => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // Batching을 수행하여 한 번의 리렌더링이 일어난다.
});

Automatic Batching이 어떻게 동작하는가?

다음과 같은 counter 코드가 있을 때 클릭할 때마다 어떻게 동작될까요?

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

+1을 해주는 setNumber를 3번 했으니 3개씩 늘어난다고 생각이 들지만, 실제로 실행하면 클릭 할 때마다 1개씩 늘어나게 된다.

이렇게 동작하는 이유는 각 렌더링에 상태 값은 스냅샷으로 인해 고정적이다. 즉 number라는 값은 현재 render phase에서 0으로 고정되어 있으므로 아무리 +1을 한다하더라도 0이 된다.

따라서 위 코드는 아래와 같이 동작한다.

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(0 + 1);
        setNumber(0 + 1);
        setNumber(0 + 1);
      }}>+3</button>
    </>
  )
}

그리고 Automatic Batching으로 인해 각 동작마다 처리하고 렌더링하는 것이 아니라 이벤트 핸들러의 모든 코드가 실행되고나서 리렌더링하게 된다.

그러면 왜 그렇게 동작하는지 알아보기 전에 다른 코드 하나를 보자.

이번엔 어떻게 동작할까?

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}

아까와 달리 클릭마다 3번씩 동작하게 된다.

setter에는 2가지 방식으로 값을 전달할 수 있다.
1. setNumber(number + 1)과 같이 값만 전달하는 방식
2. setNumber((n) => n + 1)과 같이 updater function을 전달하는 방식

(prev) => prev + 1와 같은 업데이터 함수는 이전 상태 값에 접근하여 새로운 값으로 반환하도록 동작시킨다.

Automatic Batching을 하기 위해 setter안에 있는 값이나 함수를 react queue에 저장한다.

더이상 실행할 setter가 없다면 저장한 react queue를 한번에 처리한 뒤 변경된 상태 값으로 한 번만 렌더링된다.

처리하는 로직은 다음 코드처럼 동작한다.

export function getFinalState(baseState, queue) {
  let finalState = baseState;

  for (let update of queue) {
    if (typeof update === 'function') {
      // updater function 실행.
      finalState = update(finalState);
    } else {
      // 상태 값으로 변경.
      finalState = update;
    }
  }

  return finalState;
}

React에서 이렇게 하는 이유

여러 컴포넌트에서 여러 상태 변수를 업데이트하더라도 많은 리렌더링을 하지 않고도 업데이트할 수 있다.

Batching을 통해 여러 상태 업데이트를 하나의 렌더링으로 그룹화하여 React 앱의 성능이 개선되고, 빠르게 실행할 수 있다.

또한 이렇게 함으로써 불필요한 재렌더링을 피하고, 컴포넌트가 하나의 상태 변수만 업데이트되는 버그 유발을 방지할 수 있다.

flushSync

하지만, 다르게 말하면 이벤트 핸들러 안에 모든 코드가 완료될 때까지 UI가 업데이트 되지 않을 수 있다.

만약 Automatic Batching을 원하지 않는다면 ReactDOM.flushSync()를 사용하여 Batching을 해제할 수 있다.

import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // 즉시 돔을 업데이트한다.
  flushSync(() => {
    setFlag(f => !f);
  });
  // 즉시 돔을 업데이트한다.
}

하지만 이런 일이 자주 발생하지 않고, 앱의 성능을 저하시킬 수 있으니 사용할 때 주의해야한다.

퀴즈

해당 코드는 어떻게 동작하고 결과 값은 무엇일까요?

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Increase the number</button>
    </>
  )
}

참고

profile
하루하루 공부한 내용 기록하기

3개의 댓글

comment-user-thumbnail
2024년 2월 21일

좋은 포스팅 감사합니다.
추가로 찾아보면서 참고한 링크와 간단한 내용 정리 남겨요!
React 공식 개발 블로그
React Conf 21: React 18 for app developers

React 18의 'Automatic Batching'은 두 가지 관점에서 개선되었다고 해요.

  1. 성능(Performance)
    • 다수의 업데이트를 하나의 업데이트 그룹으로 묶어 처리하는 'Batching'을 자동적으로 수행한다는 'Automatic Batching', 이름 그 자체로 업데이트에 대한 렌더링 횟수를 줄여 성능을 개선시켜 줍니다.
  2. 일관성(Consistency)
    • 이전 버전에서 이벤트 핸들러 안에서만 동작했던 것과는 달리, 이젠 이벤트 핸들러 밖에서도 동작하며 일관성을 보장하게 되었다고 해요.

추가로 버튼을 한 번 이상 누르면, h1 헤더의 number 값은 42가 됩니다!

setNumber(42);
setNumber(number + 5);
setNumber(n => n + 1);

순서를 바꾸면 좀 더 재밌는 문제가 될 것 같아요
답은 무엇일까요?

1개의 답글