Frontend/🌐 React

useState는 어떻게 값을 기억하는가 (리액트 코드로 Fiber 구조와 렌더링 살펴보기)

haeunkim.on 2026. 5. 13. 13:29

TMI) 네이버랩스 인턴을 마치고 10일간 미국에서 쉬다왔다. 요즈음엔 개발 공부하는 것에 재미를 다시 붙이고 있다. 어쩌다보니 리액트 소스코드를 뜯어보는데에 재미를 붙여서, 동시에 이것저것 찾아보다보니 정보의 레이어가 쌓이는 장점도 있지만 그만큼 더 헷깔리는 것들이 생기는 것 같기도 하다. (특히 다양한 자료에서 용어를 혼용하는 경우가 많다..) 정보의 홍수와 AI로 인한 FOMO 속에서 혼란스러운 개발자 생태계 안에서 다음 스텝을 준비하는 입장에서, 리액트 소스코드를 뜯어본 경험은 안티프래질하다고 볼 수 있지 않을까 ..? 라는 생각이다.

 


Fiber

React는 컴포넌트 하나마다 Fiber 객체를 만든다. state는 React 내부의 Fiber 객체에 저장되는데, state와 관련된 필드만 보면

Fiber {
  memoizedState  → Hook 링크드 리스트 (useState 값들이 여기 저장됨)
  memoizedProps  → 가장 최근에 반영된 props
  pendingProps   → 이번 렌더링에서 새로 받은 props
  stateNode      → 실제 DOM 노드
}

 

Fiber는 컴포넌트와 1:1 대응한다.

<Board>       → Board Fiber
  <Square />  → Square Fiber
  <Square />  → Square Fiber
  ...

Hook Linked List 

useState를 여러 개 쓰면 React 내부에서 Hook 객체들이 Linked List로 연결된다.

function Board() {
  const [squares] = useState(Array(9).fill(null)); // Hook 1
  const [isX]     = useState(true);                // Hook 2
  const [count]   = useState(0);                   // Hook 3
}
Fiber.memoizedState
  ↓
Hook { memoizedState: [null,...], next → }
Hook { memoizedState: true,       next → }
Hook { memoizedState: 0,          next → null }

 

여기서 "Hook 객체"는 우리가 호출하는 useState 함수가 아니라, 그 값을 저장하는 내부 데이터 구조다.

React는 Hook을 이름이 아닌 호출 순서로만 구분한다. 변수명 squares, isX, count는 React 내부에서 보이지 않는다.

 

조건문 안에 Hook을 쓰면 안 되는 이유

// ❌
function Board() {
  const [squares] = useState(...); // Hook 1
  if (something) {
    const [isX] = useState(...);   // 어떨 땐 Hook 2, 어떨 땐 없음
  }
  const [count] = useState(...);   // Hook 2 또는 3으로 매칭됨 → 엉뚱한 값 반환
}

 

순서가 바뀌면 링크드 리스트 탐색 결과가 달라져서 잘못된 값이 반환된다.

 

Rules of Hooks

  1. 최상위에서만 호출 (조건문, 반복문, 중첩 함수 안에 넣지 말 것)
  2. React 함수 컴포넌트 안에서만 호출

단, 컴포넌트 단위 조건부 렌더링은 문제없다. 컴포넌트가 마운트/언마운트되면 그 Fiber 자체가 생겼다 사라지므로 다른 컴포넌트의 Hook 순서에 영향이 없다.


mountState vs updateState

React는 최초 렌더링인지 재렌더링인지에 따라 다른 함수를 실행한다.

// 최초 렌더링
function mountState(initialState) {
  const hook = mountWorkInProgressHook(); // 새 Hook 객체 생성
  hook.memoizedState = initialState;      // 초기값 저장
  return [hook.memoizedState, dispatch];
}

// 재렌더링
function updateState(initialState) {
  const hook = updateWorkInProgressHook(); // 기존 Hook 재사용
  // initialState는 무시됨
  const newState = processUpdateQueue(hook); // 큐 처리 → 최신값
  hook.memoizedState = newState;
  return [hook.memoizedState, dispatch];
}

 

dispatcher가 렌더링 단계에 따라 교체된다.

ReactCurrentDispatcher.current =
  isMount ? HooksDispatcherOnMount : HooksDispatcherOnUpdate;

업데이트 큐

setSquares(nextSquares)를 호출하면 React는 즉시 값을 바꾸지 않는다. 업데이트를 큐에 쌓고 리렌더링을 예약한다.

function dispatchSetState(fiber, queue, action) {
  const update = { action, next: null };
  enqueueUpdate(fiber, queue, update); // 큐에 등록
  scheduleUpdateOnFiber(fiber);        // 리렌더링 예약
}
Hook {
  memoizedState: [null, null, ...]          ← 아직 이전 값
  queue: Update { action: ['X', null, ...] }
}

 

재렌더링 시 processUpdateQueue가 큐를 처리해서 최신값을 계산하고 memoizedState에 반영한다. 여러 번의 setState를 한 번에 처리하는 배칭(batching)이 이 구조 덕분에 가능하다.


Element vs Fiber

 

  React Element Fiber
정체 JSX가 변환된 순수 객체 작업 단위 + 상태 저장소
생명주기 매 렌더링마다 새로 생성, 버려짐 재사용, 오래 유지됨
역할 "이런 컴포넌트를 그려줘" 설명서 실제 state, props, DOM을 들고 있음

 

재렌더링마다 새 Element 트리가 만들어지면, React는 기존 Fiber 트리와 비교한다. 이게 재조정(Reconciliation) 이다.

같은 자리 + 같은 타입  →  Fiber 재사용, memoizedProps 업데이트
같은 자리 + 다른 타입  →  기존 Fiber 버리고 새로 생성
자리가 없어짐          →  Fiber 소멸 (언마운트)