function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
분명히 세 번 불렀는데 count가 1밖에 안 올라간다. 처음엔 그냥 넘겼는데 이번에 제대로 파보기로 했다. 결론부터 말하면 state는 우리가 흔히 생각하는 "변수"가 아니다. 이걸 이해하려면 snapshot과 batching 개념을 알아야 한다.
useState는 어떻게 저장될까?
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
함수가 실행되고 나면 내부 변수는 사라져야 한다. 그런데 React 컴포넌트는 함수인데 왜 state 값이 유지되는지 궁금했다. 일반 변수였다면 함수 실행이 끝나는 순간 값이 사라져야 하는데 버튼을 눌러도 count 값은 계속 유지된다.
그 이유는 React가 state 값을 컴포넌트 함수 바깥에 저장하기 때문이다.
// 개념 이해용 코드 (실제 구현 아님)
let savedState = [];
function useState(initialValue) {
if (savedState[index] === undefined) {
savedState[index] = initialValue; // 처음 한 번만 초기화
}
function setState(newValue) {
savedState[index] = newValue;
rerender(); // 리렌더링 트리거
}
return [savedState[index], setState];
}
useState는 렌더링될 때마다 React가 저장해 둔 값을 꺼내서 반환한다. setState를 호출하면 저장된 state 값이 변경되고 React는 컴포넌트를 다시 실행(render) 한다.
즉, React 컴포넌트는 함수가 다시 실행되면서 최신 state 값을 새로 가져오는 방식으로 동작한다.
snapshot
React는 렌더링할 때마다 그 시점의 state 값을 기준으로 컴포넌트를 새로 실행한다. 이때 렌더링 시점의 state 값을 snapshot이라고 볼 수 있다. 중요한 건, 현재 실행 중인 렌더링의 state 값은 절대 바뀌지 않는다는 점이다.
1번째 렌더링 스냅샷 → count = 0, 화면 = <button>0</button>
↓
버튼 클릭 → setCount(1)
↓
2번째 렌더링 스냅샷 → count = 1, 화면 = <button>1</button>
각 렌더링은 자기만의 count를 가진다. 이전 렌더링의 count와 다음 렌더링의 count는 완전히 별개다.
그래서 이런 코드가 예상과 다르게 동작한다.
function handleClick() {
setCount(count + 1); // count = 0 → setCount(1)
setCount(count + 1); // count = 0 → setCount(1) ← 여전히 0
setCount(count + 1); // count = 0 → setCount(1) ← 여전히 0
}
handleClick은 count = 0인 렌더링의 snapshot 안에 있다. setCount를 호출해도 지금 이 렌더링의 count는 바뀌지 않는다. 다음 렌더링에서 새로 읽어올 뿐이다.
비동기에서도 똑같이 동작한다.
function handleClick() {
setCount(count + 1);
setTimeout(() => {
console.log(count); // 3초 뒤에도 0 출력
}, 3000);
}
3초 뒤에 count가 바뀌어 있어도, setTimeout 안의 count는 만들어진 시점(렌더링 시점)의 값인 0이다. 요약하자면 setCount는 지금 count를 바꾸는 것이 아니라 다음 렌더링 때 이 값으로 써 달라는 예약이다! 그렇다면 setCount를 여러 번 호출했는데 React는 왜 매번 렌더링 하지 않을까?
Queue & Batching
setCount를 호출하면 React는 그 값을 queue에 쌓는다.
function handleClick() {
setCount(count + 1); // queue: [1]
setCount(count + 1); // queue: [1, 1]
setCount(count + 1); // queue: [1, 1, 1]
}
근데 React는 이걸 하나하나 바로 처리하지 않는다. 이벤트 핸들러가 끝날 때까지 기다렸다가 한 번에 처리한다. 이걸 batching이라고 한다.
세 번 호출했는데 렌더링은 1번만 일어난다. 이게 React가 성능을 최적화하는 방식이다.
그럼 +3으로 만들려면 어떻게 해야 할까 의문이 들었다. 여기서 사용할 수 있는 게 함수형 업데이트이다.
// ❌ +1만 됨
setCount(count + 1); // queue: [1]
setCount(count + 1); // queue: [1, 1]
setCount(count + 1); // queue: [1, 1, 1] → 결국 1
// ✅ +3 됨
setCount(prev => prev + 1); // queue: [fn]
setCount(prev => prev + 1); // queue: [fn, fn]
setCount(prev => prev + 1); // queue: [fn, fn, fn] → 3
차이는 이거다.
- 값을 넘기면 → queue에 이 값으로 덮어 쌓임
- 함수를 넘기면 → queue에 이전 결과에 이 함수를 적용해서 쌓임
// 값을 넘긴 경우
queue = [1, 1, 1]
1 → count = 1
1 → count = 1 (덮어씀)
1 → count = 1 (덮어씀)
최종: 1
// 함수를 넘긴 경우
queue = [fn, fn, fn]
fn(0) → count = 1
fn(1) → count = 2 (prev = 직전 결과)
fn(2) → count = 3
최종: 3
함수형 업데이트는 snapshot에 묶이지 않고 queue 안에서 이전 결과를 이어받는다.
React 18에서의 batching
React 17까지는 이벤트 핸들러 안에서만 batching이 됐다.
// React 17 - batching 안됨 (렌더링 2번)
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
}, 1000);
// React 18 - 자동 batching (렌더링 1번)
setTimeout(() => {
setCount(c => c + 1); // 같이 묶임
setFlag(f => !f); // 같이 묶임
}, 1000);
React 18부터는 setTimeout, Promise 안에서도 자동으로 batching이 된다.
왜 이게 중요한가?
snapshot과 batching에 대해서 알아봤지만 왜 이게 필요한 지 궁금했다.
snapshot : 일관성 보장
렌더링 도중에 state가 막 바뀐다면 어떻게 될까?
function Order() {
const [price, setPrice] = useState(1000);
const [count, setCount] = useState(2);
return (
<div>
<p>가격: {price}</p>
<p>합계: {price * count}</p> {/* 렌더링 중간에 price가 바뀐다면? */}
</div>
);
}
같은 화면인데 위쪽은 price = 1000, 아래쪽은 price = 2000이 될 수도 있다. snapshot 덕분에 한 렌더링 안에서는 state가 절대 바뀌지 않고, 화면이 항상 그 시점 기준으로 일관성 있게 그려진다.
batching : 불필요한 렌더링을 막음(= 최적화)
function handleClick() {
setCount(c => c + 1);
setPrice(p => p + 100);
setFlag(f => !f);
}
// batching 없으면 → 렌더링 3번
// batching 있으면 → 렌더링 1번
실제 앱에서는 클릭 한 번에 state 여러 개를 동시에 바꾸는 경우가 많다. batching이 없으면 그게 매번 렌더링으로 이어져서 앱이 버벅거린다.
이 두 개념을 모르면 이런 상황에서 막힌다.
// 비동기 함수 안에서 state 읽기
async function handleSubmit() {
setLoading(true);
const result = await fetch('/api/submit');
setData(result);
setLoading(false);
console.log(loading); // 💀 여전히 false (snapshot!)
}
// 여러 필터 동시에 바꾸기
function resetFilters() {
setCategory('all');
setSort('latest');
setPage(1);
// batching 덕분에 렌더링 1번만 ✅
}
snapshot은 화면의 일관성을 보장하고, batching은 불필요한 렌더링을 막는다!
마무리
전체 흐름을 한 눈에 정리
setCount 호출
↓
queue에 추가 (지금 렌더링의 count는 그대로)
↓
이벤트 핸들러 끝날 때까지 기다림 (batching)
↓
queue 한 번에 처리
↓
렌더링 1번 실행 → 새 snapshot 생성
핵심 요약
- snapshot : 렌더링마다 state는 사진처럼 찍힌다. 그 렌더링이 끝나기 전까지 바뀌지 않는다.
- queue : setCount는 즉시 실행이 아니라 "다음 렌더링 때 이렇게 해줘"라는 예약이다.
- batching : React는 예약들을 모아서 한 번에 처리하고 렌더링도 1번만 한다.
연속 업데이트 필요 시
// ❌ snapshot에 묶여서 예상대로 안 됨
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// ✅ 함수형 업데이트로 queue를 이어받아야 함
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
'💻 STUDY > Frontend' 카테고리의 다른 글
| Storybook이란? — props가 많아질수록 개발이 느려진다고 느꼈다면 (0) | 2026.06.01 |
|---|---|
| React는 왜 Suspense를 만들었을까? (1) | 2026.05.21 |
| Pagination과 Infinite Scroll, React에서는 어떻게 구현할까? (0) | 2026.05.10 |
| Tailwind CSS가 가벼운 이유 (0) | 2026.05.05 |
| Three.js 정리하기 (0) | 2026.04.16 |