본문 바로가기
IT/React

[React] React 18 - Automatic Batching

by 무녈 2022. 1. 12.

새로운 React, React 18

2021년 6월 8일 리액트 팀에서 리액트 18 버전에 관한 사항들을 발표한 이후 7개월의 시간이 흘렀다. 

Alpha와 Beta 버전을 거쳐 현재 RC 단계로, 한달 내에 온전한 React 18 버전을 사용할 수 있을 것으로 기대된다.

(리액트 공식 홈페이지 React 18 안내)

React 18 TimeLine

 

최초 알파 버전에서 소개된 내용과 현재 RC 버전에 소개된 내용에서 큰 차이는 없으며, 대부분의 기능이 확정되었다고 보아도 무방하다.

추가 기능이 도입이 없을 것이라는 공식 설명

리액트 18의 개선사항들은 아래와 같다.

  • 즉시 사용 가능한 개선 (out-of-the-box improvements)
  • 동시성 특성 (Concurrent features)
    • startTransition
    • useDeferredValue
    • SuspenseList
    • Streaming SSR with selective hydration
  • 추후 변경될 예정
    • Server Components

개선 사항 중 가장 기대되는 기능 중 하나로, 렌더링 최적화와 관련된 automatic batching과 관련된 내용을 번역 및 이해해보고자 한다.

추후 나머지 기능들에 대해서 원문서를 읽으며 다루어보고자 한다.


Automatic Batching

(원글: https://github.com/reactwg/react-18/discussions/21)

Batching

automatic batching에 들어가기 앞서 batching이 무엇인지 알아야 한다.

배칭(batching)은 리액트가 더 나은 성능을 위해 여러개의 state(상태) 업데이트를  하나의 re-render가 발생하도록 그룹화 하는 것을 의미한다.

 

예를 들어, 하나의 클릭 이벤트 안에 두 개의 state 업데이트를 가지는 경우, React는 항상 이러한 작업을 하나의 렌더링으로 일괄 처리하였다. 다음 코드를 실행할 경우, state를 두 번 변경하지만, React는 한 번의 렌더링만 수행한 것을 확인할 수 있다.

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

  function handleClick() {
    setCount(c => c + 1); // 아직 리렌더링 X
    setFlag(f => !f); // 아직 리렌더링 X
    // React는 함수가 끝나면 리렌더링 할 것이다 (-> 이것이 배칭)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
    </div>
  );
}
import { useState, useLayoutEffect } from "react";
import * as ReactDOM from "react-dom";

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

  function handleClick() {
    console.log("=== click ===");
    setCount((c) => c + 1); // Does not re-render yet
    setFlag((f) => !f); // Does not re-render yet
    // React will only re-render once at the end (that's batching!)
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
      <LogEvents />
    </div>
  );
}

function LogEvents(props) {
  useLayoutEffect(() => {
    console.log("Commit");
  });
  console.log("Render");
  return null;
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Demo 컴포넌트 실행 시 한 번의 렌더링만 발생

이 과정은 불필요한 리렌더링을 줄이기 때문에 성능에 아주 좋다. 또한, 컴포넌트가 '반만 완료된' state를 렌더링하는 방지하여 버그의 발생을 예방할 수 있다. 비유를 하자면 레스토랑의 웨이터가, 요리를 고를 때 마다 주방으로 달려가지 않고, 모든 주문을 마칠 때까지 대기하는 것과 같다.

 

하지만, React는 업데이이트 배칭 시점에 대한 일관성이 없었다. 예를 들어, 데이터터를 가져온 다음 handleClick 함수 내부에서 state를 업데이트를 할 경우, React는 업데이트를 배칭(일괄 처리)하지 않고, 두 개의 독립적인 업데이트를 수행했다.

 

일관적이지 못한 이유는 React가 클릭과 같은 브라우저 이벤트의 업데이트만 배칭을 사용해왔기 때문이고, 이 경우 fetch callback에서 이벤트가 처리된 후 state를 업데이트하기 때문에 배칭이 적용되지 않은 것이다.

  • 🟡 Demo: React 17 does NOT batch outside event handlers. (클릭시 렌더가 두번 콘솔에 찍히는 것을 확인)
    import { useState, useLayoutEffect } from "react";
    import * as ReactDOM from "react-dom";
    
    function App() {
      const [count, setCount] = useState(0);
      const [flag, setFlag] = useState(false);
    
      function handleClick() {
        console.log("=== click ===");
        fetchSomething().then(() => {
          // React 17 and earlier does NOT batch these:
          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>
          <LogEvents />
        </div>
      );
    }
    
    function LogEvents(props) {
      useLayoutEffect(() => {
        console.log("Commit");
      });
      console.log("Render");
      return null;
    }
    
    function fetchSomething() {
      return new Promise((resolve) => setTimeout(resolve, 100));
    }
    
    const rootElement = document.getElementById("root");
    ReactDOM.render(<App />, rootElement);

Demo 컴포넌트 실행 시 두 번의 렌더링 발생

React 18 이전까지, React 이벤트 핸들러 내부에서 발생하는 업데이트만 배칭했다. Promise, setTimeout, native event handlers, 다른 모든 이벤트 내부에서 발생하는 업데이트들은 React에서 배칭되지 않았다.

automatic batching이란 무엇인가?

React 18부터 createRoot를 통해, 모든 업데이트들은 어디서 왔는가와 무관하게 자동으로 배칭된다.

timeout, promise, mative event handler와 모든 다른 이벤트는 React 이벤트 내부의 업데이트와 동일한 방식으로 state 업데이트를 배칭한다. 따라서 작업 렌더링을 최소화하여, 애플리케이션의 성능 향상을 기대한다.
function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  function handleClick() {
    fetchSomething().then(() => {
      // React 18과 이후 버전에서는 아래 항목들을 배칭
      setCount((c) => c + 1);
      setFlag((f) => !f);
      // React는 이 콜백이 끝났을 때만 리렌더링을 하게 된다 (이제 여기도 배칭이 들어간다!)
    });
  }

  return (
    <div>
      <button onClick={handleClick}>Next</button>
      <h1 style=>{count}</h1>
    </div>
  );
}

한 번의 렌더링 발생
import { useState, useLayoutEffect } from "react";
import * as ReactDOM from "react-dom";

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

  function handleClick() {
    console.log("=== click ===");
    fetchSomething().then(() => {
      // React 18 WITHOUT createRoot does not batches these:
      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>
      <LogEvents />
    </div>
  );
}

function LogEvents(props) {
  useLayoutEffect(() => {
    console.log("Commit");
  });
  console.log("Render");
  return null;
}

function fetchSomething() {
  return new Promise((resolve) => setTimeout(resolve, 100));
}

const rootElement = document.getElementById("root");
// This keeps the old behavior:
ReactDOM.render(<App />, rootElement);
두 번의 렌더링이 발생
 Note: React 18을 도입할 때 createRoot로 업그레이드 하는 것을 권장. render를 통해 확인 가능하도록 한 유일한 이유는 프로덕션 환경에서 테이팅이 용이하기 때문

React는 업데이트의 발생지점과 무관하게 자동으로 업데이트를 배칭한다. 아래 이 예제는, 

function handleClick() {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React는 이 함수가 끝날 때만 리렌더링을 한다 (배칭이다!)
}

아래 예제와 동일하게 동작하고

setTimeout(() => {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React는 이 함수가 끝날 때만 리렌더링을 한다 (배칭이다!)
}, 1000);

아래 예제와 동일하게 동작하고,

fetch(/*...*/).then(() => {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React는 이 함수가 끝날 때만 리렌더링을 한다 (배칭이다!)
});

아래 항목과도 동일하게 동작

elm.addEventListener("click", () => {
  setCount((c) => c + 1);
  setFlag((f) => !f);
  // React는 이 함수가 끝날 때만 리렌더링을 한다 (배칭이다!)
});

뱃칭을 원하지 않는 경우

일반적으로 뱃칭은 안전한 절차지만, 일부 코드는 state 변경 후 즉시 DOM으로부터 값을 가져오는 것에 의존한다. 이러한 경우, ReactDOM.flushSync()를 사용하여 배칭하지 않을 수 있다.

import { flushSync } from "react-dom"; // Note: react가 아닌 react-dom이다

function handleClick() {
  flushSync(() => {
    setCounter((c) => c + 1);
  });
  // 이 과정이 끝났을 때 React는 DOM을 업데이트한 상태
  flushSync(() => {
    setFlag((f) => !f);
  });
  // 이 과정이 끝났을 때 React는 DOM을 업데이트한 상태
}

일반적인 과정은 아닐 것이다.

 


참고

 

The Plan for React 18 – React Blog

Update Nov. 15th, 2021 React 18 is now in beta. More information about the status of the release is available in the React 18 Working Group post. The React team is excited to share a few updates: We’ve started work on the React 18 release, which will be

ko.reactjs.org

 

Introducing React 18 · Discussion #4 · reactwg/react-18

Overview Welcome to the first post in the React 18 workgroup! This post is intended to provide an overview for the plan for React 18 and serve as a jumping point to other topics in the discussion. ...

github.com

 

반응형

댓글