비동기 반복문에서 React 상태가 꼬이는 이유

reactstateasynceventloop

난이도: ★★★☆☆


핵심 요약

  1. 병렬 비동기 루프에서 setState(state + 1)을 쓰면 카운터가 1밖에 오르지 않는다.
  2. 원인은 Stale ClosureReact 공유 상태의 race condition이 겹치기 때문이다.
  3. 해결은 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.allPromise.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의 배경 지식

→ 확장 개념 (여기서 더 나아가면)

↔ 같은 원리가 적용되는 곳


참고