2주차 과제로 React를 활용해 1 to 50 숫자 클릭 게임을 구현했다.
게임을 만들면서 현재 클릭해야 할 숫자와 숫자판의 상태 그리고 타이머와 게임 종료 여부 등 여러 데이터를 useState로 관리했다. 숫자를 클릭하면 다음 숫자와 숫자판의 배열이 함께 변경되고 게임 진행 상황에 따라 타이머나 기록도 변경되도록 구현했다.
과제를 진행할 때는 각각의 state가 올바르게 변경되는지에만 집중했다. 세미나에서 state가 변경되면 컴포넌트가 다시 렌더링된다고 배웠지만 여러 상태를 연속으로 변경할 때 React가 이를 어떻게 처리하는지는 생각해 본 적이 없었다.
그렇다면 하나의 이벤트에서 상태 업데이트가 여러 번 발생하면 React도 그만큼 여러 번 렌더링할까? 아니면 여러 업데이트를 한 번에 처리할까?
이러한 궁금증을 계기로 React의 Automatic Batching에 대해 알아보고 직접 코드를 만들어 확인해 보기로 했다. 추가로 React가 렌더링 작업을 어떤 방식으로 조절하는지도 함께 살펴보았다.
React에서 useState를 사용하면 상태를 변경하는 함수를 통해 화면을 업데이트할 수 있다. 그런데 상태 업데이트 함수를 여러 번 호출했다고 해서 반드시 그 횟수만큼 렌더링이 발생하는 것은 아니다.
React에는 여러 상태 업데이트를 하나로 묶어서 처리하는 Batching이라는 방식이 있기 때문이다.
예를 들어 버튼을 클릭했을 때 count를 증가시키고 message도 변경한다고 가정해 보자. 두 상태를 각각 변경하더라도 React는 이를 하나로 묶어 화면을 업데이트할 수 있다.
만약 상태가 변경될 때마다 렌더링한다면 하나의 클릭으로 화면을 여러 번 다시 계산해야 한다. 하지만 두 상태를 함께 처리하면 불필요한 렌더링을 줄일 수 있고 일부 상태만 변경된 중간 화면이 나타나는 것도 줄일 수 있다.
처음에는 상태 업데이트 함수를 호출하면 그때마다 렌더링이 발생한다고 막연히 생각했는데 실제로는 React가 업데이트를 모아서 처리할 수 있다는 점이 흥미로웠다.
Automatic Batching에 대해 찾아보면서 React 18을 기준으로 배칭의 적용 범위가 달라졌다는 사실도 알게 되었다.
React 17까지는 주로 React 이벤트 핸들러 내부에서 발생한 상태 업데이트가 자동으로 묶였다. 버튼의 onClick 안에서 여러 상태를 변경하는 경우가 대표적이다.
반면 setTimeout이나 Promise 내부에서 발생한 업데이트는 기본적으로 자동 배칭되지 않아 각각 별도의 렌더링이 발생할 수 있었다.
React 18부터는 이러한 비동기 콜백 내부에서도 Automatic Batching이 적용된다. 즉 React 이벤트 핸들러뿐 아니라 setTimeout이나 Promise에서도 여러 상태 업데이트를 하나로 묶을 수 있게 된 것이다.
다만 React 18에서 확장된 자동 배칭은 createRoot를 사용하는 환경을 기준으로 한다. 이번에 진행한 게임 프로젝트도 createRoot를 사용하고 있었기 때문에 자동 배칭이 적용되는 환경이었다.
설명만 읽었을 때는 두 버전의 차이가 크게 와닿지 않았다. 그래서 실제로 상태 업데이트가 어떻게 처리되는지 확인해 보고 싶었다.
특히 일반적인 클릭 이벤트뿐 아니라 setTimeout이나 Promise 내부에서도 상태 업데이트가 묶인다는 점이 궁금했다. 이를 확인하기 위해 기존 게임 코드를 수정하는 대신 별도의 React 프로젝트를 만들어 실행해봤다.
먼저 count와 message라는 두 개의 state를 만들었다.
각 버튼을 클릭하면 count를 1 증가시키고 message를 변경하도록 작성했다. 차이점은 상태 업데이트가 실행되는 위치이다.
첫 번째 버튼은 일반적인 클릭 이벤트에서 상태를 변경하고 두 번째 버튼은 setTimeout 내부에서 변경한다. 마지막 버튼은 Promise의 then 내부에서 상태를 변경하도록 했다.
그리고 화면 업데이트가 완료된 후 콘솔에 로그가 출력되도록 useEffect를 사용했다.
import { useState, useEffect } from 'react';
function App() {
const [count, setCount] = useState(0);
const [message, setMessage] = useState('시작');
useEffect(() => {
console.log('렌더링 완료!');
});
const handleClick = () => {
setCount((prev) => prev + 1);
setMessage('일반 이벤트');
};
const handleTimeout = () => {
setTimeout(() => {
setCount((prev) => prev + 1);
setMessage('setTimeout');
}, 1000);
};
const handlePromise = () => {
Promise.resolve().then(() => {
setCount((prev) => prev + 1);
setMessage('Promise');
});
};
return (
<div>
<h1>React Automatic Batching 실험</h1>
<h2>Count: {count}</h2>
<p>Message: {message}</p>
<button onClick={handleClick}>일반 이벤트</button>
<button onClick={handleTimeout}>setTimeout</button>
<button onClick={handlePromise}>Promise</button>
</div>
);
}
export default App;
여기서 useEffect에 의존성 배열을 전달하지 않았기 때문에 컴포넌트의 화면 업데이트가 커밋된 후마다 로그가 출력된다.
이 로그는 컴포넌트 함수가 호출된 횟수가 아니라 Effect가 실행된 횟수를 확인하는 용도이다. 이번 실험에서는 상태 업데이트가 묶여 하나의 화면 업데이트로 처리되는지 관찰하기 위해 사용했다.
코드를 실행하기 전에 StrictMode라는 기능도 확인했다.
Vite로 React 프로젝트를 생성하면 기본적으로 main.jsx에 StrictMode가 포함되어 있다. 개발 환경에서 StrictMode는 잠재적인 문제를 발견하기 위해 렌더링 함수나 Effect를 추가로 실행할 수 있다고 한다.
따라서 초기 실행 시 콘솔 로그가 예상보다 많이 출력되더라도 이것이 Automatic Batching이 적용되지 않았다는 의미는 아니다.
이번에는 로그를 조금 더 명확하게 관찰하기 위해 실험용 프로젝트에서만 StrictMode를 제외하고 진행했다.
실제 프로젝트에서는 오류를 발견하는 데 도움이 되는 기능이므로 단순히 로그가 추가로 출력된다는 이유로 제거할 필요는 없을 것 같다.
먼저 일반 이벤트 버튼을 클릭했다.
count가 증가하고 message가 변경되었지만 콘솔에는 렌더링 완료!가 한 번만 출력되었다. 두 상태를 연속으로 변경했는데도 하나의 화면 업데이트로 처리된 것이다.

다음으로 setTimeout 버튼을 클릭했다.
1초가 지난 뒤 두 상태가 함께 변경되었고 이번에도 콘솔 로그는 한 번만 출력되었다.

마지막으로 Promise 버튼을 클릭했을 때도 같은 결과를 확인할 수 있었다.

세 가지 경우 모두 상태 업데이트 함수는 두 번 호출했지만 화면 업데이트 후 실행되는 Effect 로그는 한 번만 출력되었다.
특히 Automatic Batching에 대해 조사하면서 React 18 이전에는 setTimeout이나 Promise 내부의 상태 업데이트가 기본적으로 별도로 처리되었다는 점을 알게 되었기 때문에 이번 결과가 더 흥미로웠다.
React 17 환경을 직접 실행해 비교한 것은 아니지만 이번 실험을 통해 현재 React에서는 이벤트 핸들러 외부에서도 Automatic Batching이 적용된다는 것을 확인할 수 있었다.
결국 상태 업데이트 함수를 여러 번 호출한다고 해서 반드시 그 횟수만큼 화면이 업데이트되는 것은 아니었다!!
Automatic Batching에 대해 알아보면서 React가 단순히 여러 업데이트를 묶는 것 외에도 렌더링 작업을 조절하는 기능을 제공한다는 사실을 알게 되었다.
처음에는 모두 렌더링을 효율적으로 처리하기 위한 기능이니 비슷한 개념이라고 생각했다. 하지만 자세히 살펴보니 해결하려는 문제가 달랐다.
Batching은 여러 상태 업데이트를 하나로 묶어 불필요한 렌더링을 줄이는 방식이다. 반면 Scheduling은 업데이트의 중요도와 상황을 고려해 렌더링 작업을 언제 처리할지 결정하는 것과 관련이 있다.
예를 들어 검색창에 글자를 입력하는 상황을 생각해 보자.
사용자가 입력한 글자는 화면에 바로 나타나는 것이 중요하다. 하지만 입력한 검색어에 따라 많은 검색 결과를 다시 표시하는 작업은 상대적으로 덜 긴급할 수 있다.
이런 상황에서 React가 제공하는 기능 중 하나가 startTransition이다.
startTransition은 특정 상태 업데이트를 긴급하지 않은 작업으로 표시하는 기능이다.
검색창을 예로 들면 입력창의 글자를 변경하는 업데이트는 일반적으로 처리하고 검색 결과를 갱신하는 업데이트는 Transition으로 표시할 수 있다.
이렇게 하면 검색 결과를 렌더링하는 도중 더 긴급한 입력 업데이트가 발생했을 때 React가 기존 작업을 중단하고 입력에 반응하는 작업을 우선 처리할 수 있다.
처음에는 이름만 보고 단순히 업데이트를 늦게 실행하는 기능이라고 생각했는데 실제로는 일정 시간 지연시키는 것이 아니라 렌더링 작업의 우선순위를 구분하는 기능에 가깝다.
startTransition이 무거운 JavaScript 계산 자체를 빠르게 만들지는 않는다. 계산 속도를 높이는 것이 아니라 렌더링 작업의 우선순위를 조절해 사용자 입력에 더 빠르게 반응할 수 있도록 돕는 기능이다.
반대로 상태를 변경한 직후 DOM 업데이트가 완료되어 있어야 하는 상황도 있다.
React는 일반적으로 상태 업데이트를 모아서 처리하지만 외부 라이브러리나 브라우저 API와 연동할 때는 변경된 DOM을 즉시 읽어야 할 수도 있다.
이때 사용할 수 있는 기능이 flushSync이다.
flushSync는 필요한 상태 업데이트를 동기적으로 반영하도록 React에 요청한다. 다만 DOM이 업데이트되었다고 해서 이미 실행 중인 함수가 참조하는 state 값까지 즉시 바뀌는 것은 아니다.
flushSync를 자주 사용하면 성능이 저하될 수 있다. 따라서 일반적인 상태 변경에서는 React의 자동 배칭을 활용하고 꼭 필요한 상황에서만 사용하는 게 좋다.
두 기능은 이번에 직접 실험하지는 않았지만 Automatic Batching을 공부하면서 렌더링 작업을 다루는 방식이 생각보다 다양하다는 점을 알 수 있었다.
이번 아티클은 1 to 50 게임에서 여러 state를 관리했던 경험에서 시작했다.
과제를 진행할 때는 상태가 올바르게 변경되는지만 확인했는데 이번에는 그 업데이트를 React가 내부적으로 어떻게 처리하는지 살펴볼 수 있었다.
특히 일반 이벤트와 setTimeout 그리고 Promise를 직접 비교하면서 두 상태를 변경해도 Effect 로그가 한 번만 출력되는 결과를 확인했다. 공식 문서에서 읽은 Automatic Batching의 동작을 실제 코드로 확인하니 개념이 더 명확하게 이해되었다.
업데이트를 하나로 묶는 Batching과 렌더링의 우선순위를 조절하는 Scheduling이 서로 다른 문제를 해결한다는 점도 알게 되었다.
처음에는 useState를 단순히 화면에 표시할 값을 변경하는 기능으로만 생각했는데 이번 탐구를 통해 상태 업데이트가 실제 화면에 반영되기까지 React가 여러 작업을 처리한다는 사실을 알 수 있었다.
앞으로 React 프로젝트를 구현할 때는 화면에 나타나는 결과뿐 아니라 그 과정에서 React가 상태 업데이트와 렌더링을 어떻게 처리하는지도 함께 생각해 보고 싶다. 🤓🤓🧐
1 to 50 게임 과제에서 출발해서 Automatic Batching까지 직접 확인해 보신 흐름이 좋네요. 여러 state를 연속으로 바꿀 때 렌더링 횟수를 직접 찍어보면 확실히 기억에 오래 남는 것 같아요.