Automatic Batching과 flushSync
React로 개발을 하다 보면 아래와 같이 상태를 업데이트한 직후에 값을 확인했을 때, 이전 값이 출력되는 경험을 해본 적이 있을 것이다.
const [count, setCount] = useState(0);
const increment = () => {
setCount((prev) => prev + 1);
console.log(count) // 기대한 값: 1, 실제 출력 값: 0
}분명 setCount 를 호출했는데 왜 0이 출력 될까? 이는 React의 상태 업데이트를 비동기적으로 동작하게 만드는 Automatic Batching이라는 중요한 최적화 기술이 있기 때문이다.
Automatic Batching이란?
Automatic Batching은 특정 스코프 내에서 다중 상태 업데이트가 발생할 때, 매 업데이트마다 리렌더링을 하는 대신 모든 상태 변경을 묶어 단 한 번의 리렌더링으로 처리하는 최적화 기법이다.
만약 배칭이 없다면 아래 코드처럼 상태를 연속으로 업데이트할 때마다 리렌더링이 발생해 성능 저하를 유발할 것이다.
const [count, setCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const handleClick = () => {
setCount((prev) => prev + 1);
setIsOpen((prev) => !prev);
// 위 두 상태 업데이트를 하나로 묶어(Batching)
// 컴포넌트를 단 한 번만 리렌더링함
}React18에서 무엇이 바뀌었나?
Batching 자체는 React 18 이전에도 존재했다. 하지만 React 17를 포함한 그 이전 버전에서는 오직 React의 onClick, onChange와 같은 이벤트 핸들러 내부에서만 배칭이 동작했다.
React 18부터는 한 한계 더 개선되어 Promise, setTimeout, Native Event handler 등 어떤 스코프에서든 다중 상태 업데이트가 발생한다면 자동으로 배칭을 처리한다.
// React 17: setTimeout 내부에서는 배칭이 풀려 리렌더링이 2번 발생함
// React 18: setTimeout 내부에서도 배칭이 적용되어 리렌더링이 1번만 발생함
setTimeout(() => {
setCount((prev) => prev + 1);
setIsOpen((prev) => !prev);
}, 1000);flushSync
Automatic Batching은 대부분의 상황에서 훌륭하게 최적화하지만, DOM이 즉시 업데이트 되어야만 하는 특수한 상황에서는 방해가 될 수 있다.
상태를 업데이트한 직후에 변경된 DOM의 크기를 측정하거나, 스크롤 위치를 조정해야 할 때가 그 예시다. 이 때 react-dom에서 제공하는 flushSync를 사용하면 배칭을 무시하고 동기적으로 강제 리렌더링을 발생시킬 수 있다.
기본 동작
flushSync 콜백 함수 내에서 상태를 업데이트하면, React는 해당 업데이트를 즉시 화면에 반영한다.
import { flushSync } from 'react-dom';
const increment = () => {
flushSync(() => {
setCount((prev) => prev + 1);
}); // 즉시 리렌더링 발생 (count: 1)
flushSync(() => {
setCount((prev) => prev + 1);
}); // 즉시 리렌더링 발생 (count: 2)
}사용 예시
가장 대표적인 사용 사례는 "새로운 항목을 리스트에 추가하고, 즉시 그 항목으로 스크롤을 이동해야 할 때"
일반적인 방식으로 상태를 업데이트 후 즉시 DOM 요소에 접근하면, 아직 React가 리렌더링을 마치지 않아 이전 DOM 상태를 참조하게 된다. 이 때 flushSync를 활용한다.
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';
function TodoList() {
const [todos, setTodos] = useState(['초기 할 일']);
const listRef = useRef<HTMLUListElement>(null);
const handleAddTodo = () => {
const newTodo = `새로운 할 일 ${todos.length + 1}`;
// flushSync를 사용해 상태 업데이트와 DOM 반영을 즉시 동기화
flushSync(() => {
setTodos([...todos, newTodo]);
});
// flushSync 덕분에 이 시점에는 이미 새로운 할 일이 DOM에 렌더링되어 있다.
// 따라서 스크롤을 맨 아래로 정확하게 이동시킬 수 있다.
if (listRef.current) {
listRef.current.lastElementChild?.scrollIntoView({ behavior: 'smooth' });
}
};
return (
<div>
<ul ref={listRef} style={{ maxHeight: '100px', overflowY: 'auto' }}>
{todos.map((todo, idx) => (
<li key={idx}>{todo}</li>
))}
</ul>
<button onClick={handleAddTodo}>할 일 추가</button>
</div>
);
}flushSync 는 강력하지만 애플리케이션의 성능을 크게 저하할 수 있다. 가급적 사용을 지양한다.