비동기 반복문에서 React 상태가 꼬이는 이유
난이도: ★★★☆☆
핵심 요약
- 병렬 비동기 루프에서
setState(state + 1)을 쓰면 카운터가 1밖에 오르지 않는다. - 원인은 Stale Closure와 React 공유 상태의 race condition이 겹치기 때문이다.
- 해결은
setState(prev => prev + 1)— functional update form 하나로 끝난다.
1. Stale Closure
React state는 렌더 시점에 스냅샷으로 캡처된다.
비동기 콜백이 클로저로 그 값을 들고 있으면, 이후 state가 바뀌어도 캡처된 값은 업데이트되지 않는다.
// completedBatches = 0인 시점에 handleSend가 실행됨
// 이후 비동기 콜백들은 모두 completedBatches = 0을 클로저로 캡처
for (let i = 0; i < 3; i++) {
await fetch(...)
setCompletedBatches(completedBatches + 1) // 항상 0+1=1
}
// 3번 돌아도 completedBatches는 1에서 멈춤
발생 조건: 비동기 콜백 내에서 state 값을 직접 읽을 때.
2. Race Condition (공유 상태 경쟁)
순차 루프에서는 stale closure만 문제가 된다.
병렬화(Promise.allSettled)를 추가하면 두 번째 문제가 생긴다.
배치 A ──await 완료──> completedBatches 읽음 (= 0) → setState(0+1=1)
배치 B ──await 완료──> completedBatches 읽음 (= 0) → setState(0+1=1)
→ 결과: 카운터가 2가 아닌 1
JS는 단일 스레드라 true race는 아니지만, 이벤트 루프 재진입 타이밍에 따라 여러 콜백이 같은 시점의 state를 읽고 같은 값으로 덮어쓴다.
React state는 이 관점에서 “공유 상태”처럼 작동한다.
엄밀히는 “완료 순서에 따라 결과가 달라지는 비결정적 상태 업데이트”가 맞는 표현.
3. 해결: Functional Update Form
setState(prev => ...) 형태는 React 내부 업데이트 큐에 함수 자체를 등록한다.
React가 순서대로 꺼내 실행하면서 prev에 항상 최신 state를 주입한다.
// 클로저로 캡처한 값이 아닌, React가 보장하는 최신 prev를 받음
setCompletedBatches(prev => prev + 1)
// A, B가 동시에 완료되어도:
// A: prev=0 → 1
// B: prev=1 → 2 (React가 순서 보장)
stale closure 해결: 직접 캡처한 값을 쓰지 않음
race condition 해결: React 업데이트 큐가 직렬화해서 처리
4. 왜 for…await는 순차인데 setState가 stale해지는가
직접 질문이 나왔던 부분:
“for문에서 i=1의 경우 await fetch가 끝나지 않았는데 i=2가 돌 수 있어?”
for…await 루프 자체는 순차다. 각 iteration은 await가 resolve될 때까지 기다린다.
문제는 completedBatches가 루프가 시작된 렌더 시점의 값으로 고정된다는 것.
렌더 → completedBatches = 0 캡처 → handleSend 실행
i=0: await fetch() → 완료 → setCompletedBatches(0+1=1) → re-render
i=1: await fetch() → 완료 → setCompletedBatches(0+1=1) ← 여전히 캡처된 0을 씀
i=2: await fetch() → 완료 → setCompletedBatches(0+1=1)
re-render가 됐어도 handleSend 클로저는 처음 캡처한 completedBatches=0을 계속 들고 있다.
5. 마이크로태스크 vs 매크로태스크 (메커니즘)
위 현상이 왜 발생하는지의 배경 지식.
| 큐 | 예시 | 처리 시점 |
|---|---|---|
| 마이크로태스크 | Promise.then, await 이후 재개 | Call Stack 비는 즉시, 매크로태스크보다 먼저 |
| 매크로태스크 | setTimeout, MessageChannel | 마이크로태스크 큐 완전히 빈 후 하나씩 |
왜 둘이 다른가: 마이크로태스크는 “현재 작업의 연장”이라 중간에 끼어들 수 없다. 매크로태스크는 “완전히 새로운 작업”으로 이벤트 루프가 한 턴을 마친 뒤에야 실행된다.
React와의 연결: React 스케줄러는 MessageChannel (매크로태스크)을 사용한다.
→ await fetch() 완료는 마이크로태스크로 재개
→ setState 호출 시 React는 MessageChannel에 렌더 등록 (매크로태스크)
→ 매 await 경계마다 React re-render가 끼어들 수 있다
await fetch() 완료 → 마이크로태스크 재개
setState(...) 호출 → MessageChannel 등록 (매크로태스크)
마이크로태스크 종료 → 큐 비었는가? YES
MessageChannel 실행 → React re-render
다음 await fetch() → 1번부터 반복
setState 배치 타이밍:
// 배치 O — 같은 마이크로태스크 내, await 경계 없음
setA(1)
setB(2)
// → 렌더 1번
// 배치 X — await 경계 존재
await fetch()
setA(1) // MessageChannel #1 등록
await fetch()
setB(2) // MessageChannel #2 등록
// → 렌더 2번 (타이밍에 따라 합쳐질 수 있으나 보장 불가)
부록: Promise.allSettled vs Promise.all
Promise.all | Promise.allSettled | |
|---|---|---|
| 실패 시 | 즉시 reject, 나머지 중단 | 나머지 계속 진행 |
| 반환값 | 성공 배열 또는 에러 | {status, value/reason}[] 항상 반환 |
| 쓰는 경우 | 전체 성공이 전제 | 부분 실패 허용, 결과 개별 처리 |
부록: HTTP 커넥션 풀 & CORS Preflight
커넥션 풀: same-origin이어도 브라우저 per-origin 연결 제한을 따른다.
- HTTP/1.1: 최대 6개 동시 연결
- HTTP/2 (Vercel 기본): multiplexing으로 제한 없음
CORS Preflight: CORS 여부가 아니라 요청 종류에 따라 결정됨.
- same-origin → CORS 정책 미적용, preflight 없음
- cross-origin +
Content-Type: application/json→ 비단순 요청 → OPTIONS preflight 발생
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- JavaScript Closure — state가 렌더 시점에 스냅샷으로 캡처되는 이유의 뿌리. 클로저가 값을 들고 있는 메커니즘을 모르면 “왜 항상 0이지?”가 이해되지 않는다
- JavaScript Event Loop — 마이크로태스크/매크로태스크 분리, Call Stack → Task Queue 흐름. 섹션 5의 배경 지식
→ 확장 개념 (여기서 더 나아가면)
- 입력창이 사라질 때 값이 멋대로 저장되는 이유 — setState 비동기성이 만드는 또 다른 버그 패턴 (blur-on-unmount)
- sessionStorage 대신 URL에 상태를 담는 이유 — setState가 즉시 반영되지 않는다는 개념이 hydration flash의 뿌리로 이어짐
- A/B 실험 노출이 버튼 클릭이 아닌 페이지 렌더에 발화되는 이유 — side effect 있는 함수를 잘못된 위치에서 두 번 호출하는 실수, 렌더 타임 vs 클릭 타임 제어의 같은 맥락
- sessionStorage에서 읽어온 값이 useCallback 안에서 이전 값을 보여주는 이유 — 이 노트의 stale closure 원리가 React 외부 store 파생값에 적용된 케이스
- React Concurrent Mode (useTransition, useDeferredValue) — React 18에서 렌더 우선순위를 제어하는 확장. 이 노트의 MessageChannel 기반 스케줄러 이해가 전제
↔ 같은 원리가 적용되는 곳
- 함수 컴포넌트가 옛날 값을 보는 이유 — 이 노트의 stale closure를 fiber(유지) vs 클로저(스냅샷) 관점으로 일반화한 노트
- 왜 서버는 이전 요청을 기억하지 않는가 — “상태를 누가 들고 있는가” — 레이어는 달라도 같은 질문
- Debounce / Throttle — 비동기 이벤트 흐름을 제어하는 패턴. Race condition의 또 다른 해결 접근
참고
- Jake Archibald: Tasks, microtasks, queues and schedules — 마이크로/매크로 타이밍을 인터랙티브 시각화로 설명. 이 주제 최고의 레퍼런스.
- MDN: Event Loop
- MDN: Microtask guide
- React: functional update
- React Scheduler 소스 (MessageChannel 확인)
- MDN: Promise.allSettled