React로 상태를 업데이트하다 보면 한 번쯤 이런 코드를 써본 적이 있을 것이다.
setState(state + 1);
setState(state + 1);
두 번 더했으니까 2가 오르겠지? 라고 기대하지만, 실제로는 그렇지 않은 경우가 많다.
이 글에서는 React의 batching(배칭) 특성과 그로 인해 생기는 stale state 문제, 그리고 이를 해결하는 함수형 업데이트(prev) 패턴을, 실무에서 겪은 필터 UI 예제를 통해 정리한다.
예제 시나리오: 필터 자동 선택 + 자동 pin
다음과 같은 필터 리스트가 있다고 하자.
type Filter = {
id: number;
label: string;
selected: boolean;
pinned: boolean;
};
const initialFilters: Filter[] = [
{ id: 1, label: 'Material', selected: false, pinned: false },
{ id: 2, label: 'Plant', selected: false, pinned: false },
{ id: 3, label: 'Vendor', selected: false, pinned: false },
{ id: 4, label: 'Region', selected: false, pinned: false },
];
요구사항은 다음과 같다.
- 버튼을 누르면
- label이 Plant, Vendor인 필터를 자동으로 selected=true로 만든다.
- 그 뒤에
- selected=true인 필터만 pinned=true로 만든다.
즉, 버튼 한 번에 자동 선택 → 선택된 필터 pin 처리를 하고 싶은 상황이다.
전체 컴포넌트 코드
'use client';
import { useState } from 'react';
type Filter = {
id: number;
label: string;
selected: boolean;
pinned: boolean;
};
const initialFilters: Filter[] = [
{ id: 1, label: 'Material', selected: false, pinned: false },
{ id: 2, label: 'Plant', selected: false, pinned: false },
{ id: 3, label: 'Vendor', selected: false, pinned: false },
{ id: 4, label: 'Region', selected: false, pinned: false },
];
export default function FilterBatchingPage() {
const [attributeFilters, setAttributeFilters] = useState<Filter[]>(initialFilters);
// prev 안 쓰는 실무 버그 버전
const badAutoSelectAndPin = () => {
// 1) 자동 선택
setAttributeFilters(
attributeFilters.map((f) =>
f.label === 'Plant' || f.label === 'Vendor'
? { ...f, selected: true }
: f
)
);
// 2) 선택된 것 pinned 처리
// ⚠️ 여기서 attributeFilters는 "업데이트 전(old) 값"
setAttributeFilters(
attributeFilters.map((f) =>
f.selected ? { ...f, pinned: true } : f
)
);
};
// prev 쓰는 정상 버전
const goodAutoSelectAndPin = () => {
// 1) 자동 선택
setAttributeFilters((prev) =>
prev.map((f) =>
f.label === 'Plant' || f.label === 'Vendor'
? { ...f, selected: true }
: f
)
);
// 2) 선택된 것 pinned 처리
setAttributeFilters((prev) =>
prev.map((f) =>
f.selected ? { ...f, pinned: true } : f
)
);
};
const reset = () => setAttributeFilters(initialFilters);
return (
<div className="p-6 space-y-4">
<h1 className="text-xl font-bold">
실무형 batching/stale state 테스트(필터 자동 선택 + pin)
</h1>
<div className="flex gap-2">
<button onClick={badAutoSelectAndPin} className="px-3 py-1 border rounded">
prev 없이 자동선택+핀
</button>
<button onClick={goodAutoSelectAndPin} className="px-3 py-1 border rounded">
prev로 자동선택+핀
</button>
<button onClick={reset} className="px-3 py-1 border rounded">
reset
</button>
</div>
<ul className="space-y-2">
{attributeFilters.map((f) => (
<li
key={f.id}
className={`p-2 border rounded flex justify-between ${
f.pinned ? 'bg-yellow-50' : ''
}`}
>
<span>{f.label}</span>
<span className="text-sm text-gray-600">
selected: {String(f.selected)} / pinned: {String(f.pinned)}
</span>
</li>
))}
</ul>
<p className="text-sm text-gray-600">
기대값: Plant, Vendor가 selected=true 되고, 그 둘만 pinned=true가 되어야 한다.
</p>
</div>
);
}
기대 결과
버튼 클릭 시 기대하는 최종 상태는 다음과 같다.
- Plant, Vendor
→ selected: true, pinned: true - Material, Region
→ selected: false, pinned: false
이제 bad / good 두 버전을 비교해 보자.
1. prev 없이 자동선택 + pin: 왜 틀리는가?
문제 버전의 핵심 코드는 다음과 같다.
const badAutoSelectAndPin = () => {
setAttributeFilters(
attributeFilters.map(/* 1) 자동 선택 */)
);
setAttributeFilters(
attributeFilters.map(/* 2) 선택된 것 pinned 처리 */)
);
};
여기서 놓치기 쉬운 점이 하나 있다. attributeFilters는 이미 렌더링에 사용된, 과거의 state 스냅샷이다.
React는 성능을 위해 같은 이벤트 안에서 일어난 여러 setState를 batching(배칭) 한다. 즉, 두 번의 setAttributeFilters가 한 번에 모여 처리될 수 있다.
React 입장에서는 이렇게 보인다.
- attributeFilters(old)를 기준으로 첫 번째 업데이트 계산
- 여전히 attributeFilters(old)를 기준으로 두 번째 업데이트 계산
- 두 결과를 섞거나, 마지막 것을 적용
그 결과:
- 1단계에서 Plant, Vendor를 selected=true로 바꾸는 로직은 존재하지만
- 2단계에서 참조하는 attributeFilters는 여전히 기존 값(old) 이기 때문에
f.selected는 여전히 false로 남아 있다. - 따라서 pin 로직이 기대대로 동작하지 않고
아무 것도 pinned 되지 않거나, 엉뚱한 결과가 나올 수 있다.
이게 바로 stale state 문제다.
2. prev로 자동선택 + pin: batching 속에서도 항상 최신 상태
const goodAutoSelectAndPin = () => {
setAttributeFilters((prev) =>
prev.map(/* 1) 자동 선택 */)
);
setAttributeFilters((prev) =>
prev.map(/* 2) 선택된 것 pinned 처리 */)
);
};
여기서 중요한 것은 함수형 업데이트를 쓰고 있다는 점이다.
setAttributeFilters((prev) => {
// prev는 이 시점에서 React가 가진 "가장 최신 상태"
});
React는 여러 업데이트를 배칭하더라도, 각 호출마다 순서대로 prev를 전달한다.
흐름을 살펴보면:
- 첫 번째 setAttributeFilters(prev => ...) 실행
- prev는 기존 state
- 결과: Plant, Vendor가 selected=true가 된 새 state A
- 두 번째 setAttributeFilters(prev => ...) 실행
- 이때 prev는 이미 A 상태
- 즉, selected=true가 반영된 상태를 기준으로 pinned 계산
- 최종 state는
- Plant, Vendor: selected=true, pinned=true
- 나머지: 그대로
prev를 사용함으로써, 업데이트가 몇 번 배칭되든 항상 그 시점 기준의 최신 state에 대해 계산할 수 있게 된다.
3. React의 batching은 왜 이런 문제를 만들까?
정리해 보면:
- React의 setState는 즉시 state를 바꾸는 함수가 아니다.
- 변경 요청을 스케줄링하고, React가 적절한 시점에 모아서(batching) 처리한다.
- 같은 이벤트 루프 안에서 여러 번 setState를 호출하면 React는 이를 한 번의 렌더링으로 합쳐 처리하려고 한다.
이때 다음과 같은 코드가 문제를 일으킨다.
setAttributeFilters(attributeFilters.map(...));
setAttributeFilters(attributeFilters.map(...));
그래서 React는 함수형 업데이트를 제공한다.
setAttributeFilters(prev => prev.map(...));
setAttributeFilters(prev => prev.map(...));
여기서 prev는 바로 직전 업데이트까지 모두 반영한 최신 값이다. React가 배칭을 하더라도 연속 업데이트를 안전하게 처리할 수 있다.
4. 숫자/불린 같은 Primitive도 예외가 아니다.
이 문제는 객체 배열에서만 생기는 게 아니다.
number, boolean 같은 primitive state에서도 동일하게 나타난다.
// 원하는 만큼 올라가지 않을 수 있다.
setCount(count + 1);
setCount(count + 1);
// 항상 2가 증가한다.
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// 연달아 토글할 때 꼬일 수 있다.
setOpen(!open);
setOpen(!open);
// 항상 현재 값을 기준으로 토글된다.
setOpen(prev => !prev);
setOpen(prev => !prev);
정리: prev 업데이트는 언제 써야 할까?
- 같은 state를 연속으로 여러 번 업데이트할 때
- 이전 state 값을 계산에 참고할 때
- 이벤트 핸들러 안에서 setState를 두 번 이상 호출할 때
- 비동기, 배칭 환경에서도 항상 최신 state를 기준으로 동작해야 할 때
'Frontend' 카테고리의 다른 글
| 표출할 데이터가 많은 경우 drag 버벅거리는 이슈 (0) | 2025.12.07 |
|---|---|
| key로 컴포넌트 안의 state를 초기화 하기 (0) | 2025.12.07 |
| React Query에서 두 번째 요청이 더 빠르게 느껴지는 이유 (0) | 2025.12.07 |
| React-Arborist 트리에서 한글 입력 오류 해결 (3) | 2025.08.25 |
| React-Arborist 트리 UI에서 Input 포커스 문제 해결 (0) | 2025.08.23 |