[React] useState의 비동기 처리와 Batching 개념 완벽 이해하기

ByEunwoo
react

React에서 useState의 상태 업데이트는 비동기적으로 처리된다.
이는 곧 setState 함수(예: setNum)가 호출되더라도 즉시 상태값이 변경되지 않음을 의미한다.
그렇다면 왜 React는 이렇게 비동기적으로 상태를 처리할까?

Q. 왜 직접 상태값을 바꾸지 않고 setState를 사용해야 할까?

React는 가상 DOM(Virtual DOM) 을 기반으로 작동한다. 만약 상태가 직접 변경된다면 어떤 상태가 바뀌었는지 추적하기 어려워진다.
반면 setState를 통해 상태를 바꾸면, React는 내부적으로 이를 감지해 변경 전후의 차이를 비교하고, 오직 변경된 부분만 리렌더링 하게 된다.

  • 즉, setState는 상태 변경을 감지하고, 리렌더링 최적화를 위한 트리거 역할을 한다.

상태 업데이트가 비동기적인 이유

React는 여러 개의 상태 업데이트가 동시에 발생하더라도, 렌더링을 매번 하지 않고 한 번에 처리하려 한다.
이 과정을 "배치(Batching)"이라고 부른다.

import { useState } from 'react';
 
export default function Example1() {
  const [num, setNum] = useState(0);
 
  const numPlus = () => {
    setNum(num + 1);
    setNum(num + 1);
  };
 
  return (
    <div style={{ display: 'flex', flexDirection: 'column' }}>
      <span>useState 비동기 처리 예시</span>
      {num}
      <button onClick={numPlus}>+</button>
      <button onClick={() => setNum(0)}> reset </button>
    </div>
  );
}

위 코드에서 기대와 달리 num은 2가 아니라 1만 증가한다. 왜일까?
React는 위의 setNum(num + 1) 호출들을 같은 타이밍에 일어난 것으로 보고, 첫 번째 상태 기준인 num = 0을 기준 값으로 두 번 계산했기 때문이다.

우리의 의도대로 하려면 아래와 같이 callback함수를 setNum에 넘겨줘야 한다.

import { useState } from 'react';
 
//  2. setState의 인자로 콜백함수 사용 예시
 
export default function Example2() {
  const [num, setNum] = useState(0);
 
  const numPlus = () => {
    setNum((prevNum) => prevNum + 1);
    setNum((prevNum) => prevNum + 1);
  };
 
  return (
    <div style={{ display: 'flex', flexDirection: 'column', marginBottom: '10px' }}>
      <span>2. setState의 인자로 콜백함수 사용</span>
      {num}
      <button onClick={numPlus}>+</button>
      <button onClick={() => setNum(0)}> reset </button>
    </div>
  );
}

위 코드에서는 setNum에 넘겨준 콜백 함수가 이전 상태값을 인자로 받아서 처리하기 때문에, num은 2로 증가하게 된다.

이렇게 하면 prev는 항상 최신 상태를 참조하게 되어 의도한 대로 num이 2씩 증가한다.

setNum((prev) => prev + 1);
setNum((prev) => prev + 1);

왜 이렇게 동작할까?

리액트 내부적으로 setState를 비동기로 처리하기 때문이다.
하나의 어플리케이션, 하나의 페이지나 컴포넌트에도 수많은 State가 있을텐데, State가 각각 바뀔 때마다 화면을 리렌더링 해야한다면 문제(일관성, 성능)가 생길 수 있다.
그래서 리액트는 이런 문제를 효율적으로 해결하기 위해 setState를 연속 호출하면 setState를 모두 취합(batch)한 후에 한 번에 렌더링하도록 한다.
아무리 많은 setState가 연속적으로 사용되었어도 배치 처리에 의해서 한 번의 렌더링으로 최신 상태를 유지한다.

Batching이란?

React는 상태 업데이트가 여러 번 발생할 경우, 16ms 단위로 이들을 하나로 묶어 처리한다. 이걸 "배치 처리(Batching)"라고 부른다.

    1. 여러 상태 업데이트들을 모은다.
    1. 변경된 state로 새로운 UI를 만든다.
    1. 이전 UI와 비교하여 변경된 부분만 DOM에 반영한다 (Reconciliation).
    1. 한 번의 렌더링만 발생한다.

이런 구조 덕분에 setState는 항상 비동기적이며, 실행 순서와 상관없이 최신 상태를 반영할 수 있는 구조로 설계되었다.

import { useState, useEffect } from 'react';
 
export default function Example3() {
  const [num, setNum] = useState(0);
 
  const handlePlus = () => {
    setNum(num + 1);
    console.log(num); // 0이 출력됨
  };
  return (
    <div>
      <p>{num}</p>
      <button onClick={handlePlus}>plus</button>
    </div>
  );
}

위의 코드에서 console log를 찍어보면, 1이 나올거라 생각할 수 있지만, 0이 찍힌다.
이 코드는 많은 사람들이 헷갈리는 부분이다. setNum이 비동기적으로 처리되기 때문에, 상태가 바뀌기 전에 콘솔 로그가 실행되어 0이 출력된다.

setter 함수인 setNum은 비동기적으로 작동하기 때문에, num의 값이 업데이트 되기 전에 console.log(num)이 실행된다.
그래서 num의 값은 여전히 0으로 찍히게 된다.

useState를 동기적으로 처리할 순 없을까?

setState의 인자로 value => value + 1 와 같이 callback 함수를 사용하면, 비동기적으로 처리되는 setState를 동기적으로 처리할 수 있다.
setState는 비동기적으로 처리되지만, callback 함수를 사용하면 이전 상태를 인자로 받아서 처리하기 때문에, 항상 최신 상태를 참조할 수 있다.

import { useState } from 'react';
 
export default function Example3() {
  const [num, setNum] = useState(0);
 
  const numPlus = () => {
    setNum((prevNum) => prevNum + 1);
    setNum((prevNum) => prevNum + 1);
  };
 
  return (
    <div style={{ display: 'flex', flexDirection: 'column', marginBottom: '10px' }}>
      <span>2. setState의 인자로 콜백함수 사용</span>
      {num}
      <button onClick={numPlus}>+</button>
      <button onClick={() => setNum(0)}> reset </button>
    </div>
  );
}

React 18에서 바뀐 점: Automatic Batching

React 18부터는 자동 배치(Auto Batching) 기능이 추가되어, 이벤트 핸들러, useEffect, setTimeout, fetch 콜백 등에서 발생한 상태 업데이트까지도 묶어서 처리한다.

즉, 동기처럼 보이지만 실제로는 더 넓은 범위에서 비동기 배치가 일어난다. 개발자는 더욱 적은 렌더링으로 더 효율적인 코드 작성이 가능해진다.
위의 예시에서 React 18에서는 setNum을 두 번 호출해도, 같은 이벤트 루프 안에서 실행되면 자동으로 묶여서 하나의 리렌더로 처리된다.

예를 들자면, React 18 이전에는

setNum((prev) => prev + 1);
setNum((prev) => prev + 1);
// → 리렌더 두 번

그러나 React 18에서는:

setNum((prev) => prev + 1);
setNum((prev) => prev + 1);
// → 내부적으로 하나로 묶여서 리렌더 한 번만 함

이런 식으로 동작한다.
이렇게 React 18에서는 비동기 배치 처리의 범위가 넓어져서, 더 많은 경우에 자동으로 배치 처리가 이루어진다.

Posted inreact
Written byEunwoo