[React] useState의 비동기 처리와 Batching 개념 완벽 이해하기
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)"라고 부른다.
-
- 여러 상태 업데이트들을 모은다.
-
- 변경된 state로 새로운 UI를 만든다.
-
- 이전 UI와 비교하여 변경된 부분만 DOM에 반영한다 (Reconciliation).
-
- 한 번의 렌더링만 발생한다.
이런 구조 덕분에 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에서는 비동기 배치 처리의 범위가 넓어져서, 더 많은 경우에 자동으로 배치 처리가 이루어진다.