React의 상태 관리와 이벤트 루프

React는 각 컴포넌트를 Fiber Node라는 내부 자료구조로 관리하며, useState의 값은 Fiber Node의 memoizedState에 저장된다.

Memory Heap
├── React Fiber 트리 (React 내부 자료구조)
│   ├── FiberNode (컴포넌트 A)
│   │   └── memoizedState → { count: 0 }   ← useState의 state
│   ├── FiberNode (컴포넌트 B)
│   │   └── memoizedState → { name: 'hello' }
│   └── ...
├── 일반 변수, 객체
├── 클로저에 캡처된 값
└── ...

setState 호출 시 일어나는 일

setState는 즉시 state를 바꾸지 않는다. update queue에 요청을 쌓고, React 스케줄러가 macrotask로 처리한다.

setState 호출 (동기)
│
├── 1. Update 객체 생성 → Fiber Node의 update queue에 추가
│       { action: newValue } → queue에 enqueue
│
├── 2. React 스케줄러에 리렌더링 등록 (MessageChannel = macrotask)
│
└── 동기 코드 계속 실행 (setState 이후 코드)

        ↓ (현재 macrotask 종료 → microtask 소진 → 렌더링 → 다음 macrotask)

[스케줄러의 macrotask 실행]
│
├── 3. update queue에서 대기 중인 업데이트를 꺼냄
│      이전 state (memoizedState) + update → 새 state 계산
│
├── 4. 새 state로 컴포넌트 함수 재실행 (reconciliation)
│      이전 Virtual DOM vs 새 Virtual DOM 비교 (diffing)
│
└── 5. 변경된 부분만 실제 DOM에 반영 (commit)
       memoizedState를 새 state로 교체

setState 직후 state를 읽으면 이전 값인 이유

const [count, setCount] = useState(0);

const handleClick = () => {
    setCount(1); // update queue에 { action: 1 } 추가 + macrotask 등록
    console.log(count); // 0 (아직 macrotask 실행 전이므로 이전 값)
};

setState는 동기 코드에서 update queue에 요청만 쌓고, 실제 state 계산과 DOM 반영은 스케줄러의 macrotask에서 실행된다. 따라서 setState 직후에는 아직 이전 값이다.

이벤트 루프 관점

[사용자 클릭 (macrotask)]
    → setState → update queue에 추가 + macrotask 등록
    → [microtask 소진]
    → [브라우저 렌더링] ← 아직 state 변경 전이므로 이전 화면 유지

[React 스케줄러 macrotask 실행]
    → 3. 새 state 계산
    → 4. reconciliation (diffing)
    → 5. commit (실제 DOM 반영)
    → [microtask 소진]
    → [브라우저 렌더링] ← 여기서 변경된 DOM이 화면에 반영

React가 commit 단계에서 실제 DOM을 수정하고, 그 다음 브라우저 렌더링 단계에서 변경사항이 화면에 페인트된다. DOM 수정과 화면 페인트는 별개의 단계이다.

SPA 라우터 이동 시 과정

router.push도 내부적으로는 setState로 라우트 상태를 변경하는 것이므로 동일한 흐름을 따른다.

[사용자 클릭 (macrotask)]
    → router.push('/search')
    → history.pushState() 호출 (URL 변경, 동기)
    → React setState로 라우트 상태 업데이트 + macrotask 등록
    → [microtask 소진]
    → [브라우저 렌더링] ← 아직 이전 페이지 화면

[React 스케줄러 macrotask 실행]
    → 이전 페이지 컴포넌트 unmount
    → 새 페이지 컴포넌트 mount (컴포넌트 함수 실행)
    → reconciliation (diffing)
    → commit (실제 DOM 반영 — 새 페이지의 DOM으로 교체)
    → [microtask 소진]
    → [브라우저 렌더링] ← 새 페이지가 화면에 표시

[새 페이지의 useEffect (macrotask)]
    → autoFocus → .focus() 호출

사용자 클릭 시점과 새 페이지의 DOM 반영 사이에 최소 2번의 macrotask 경계가 있다.

정리

단계 실행 위치 설명
setState / router.push 동기 (현재 macrotask) update queue에 요청 추가, 스케줄러에 macrotask 등록
state 계산 + diffing + commit React 스케줄러 macrotask Heap의 Fiber Node에서 이전 state를 읽고 새 state 계산 → DOM 반영
화면 페인트 브라우저 렌더링 commit에서 수정된 DOM을 실제 화면에 그림
useEffect 별도 macrotask DOM 반영 이후 실행, 이미 여러 macrotask 경계를 지남